Untrusted CAD: Running Customer (and Model-Generated) Geometry Scripts in a MicroVM
There is a family of products that all converge on the same uncomfortable line of code. A product configurator where the customer drags sliders and gets a solid back. A 3D-print shop that accepts a parametric model instead of a finished STL. A hardware startup whose internal tool regenerates a bracket family from a spreadsheet. And, increasingly, an AI agent that writes parametric geometry on demand, because "model a wall mount for this router" is now a reasonable prompt. Every one of them ends up executing a script that produces a solid: OpenSCAD's own language, CadQuery or build123d, a FreeCAD headless macro, a JSCAD file.
I'm Ajay; I built PandaStack, which is a Firecracker microVM sandbox platform. This post is about where that script runs. The short version: a geometry script is a program, two of the four toolchains above are literally general-purpose interpreters, and even the one that genuinely is not can burn every core and all of your RAM with four lines that look like a design decision.
A geometry script is a program that happens to return a solid. Treat it as the former and the latter takes care of itself.
Two of these are a language. Two of them are Python
The reason teams get this wrong is that the category name — "geometry script" — flattens four very different threat models into one word.
- OpenSCAD — its own functional language. No variables you can reassign in a loop, no shelling out, no `import os`, no network. Genuinely not a general-purpose interpreter, and the closest thing to a safe-by-construction option on this list.
- CadQuery and build123d — plain Python on top of OCCT via its Python bindings. A `.py` file. `import os` is right there, and so is `subprocess`, `socket`, and reading whatever the process can read. There is no sandbox in the library, because it was never a library's job.
- FreeCAD macros — also Python, executed with the privileges of the FreeCAD process, which in headless mode (`FreeCADCmd`, or `freecad --console`) is whatever user you launched it as. The macro system exists specifically so that people can automate FreeCAD, which means it exists specifically so that people can do anything.
- JSCAD — JavaScript, run by Node through the CLI. Node is not a sandbox either; `require('fs')` is a module away unless you have gone out of your way to restrict it.
So "we only accept geometry scripts, not code" is true for exactly one of the four, and it is the one your power users will complain about first, because OpenSCAD's language is deliberately restrictive and CadQuery is Python with a mature ecosystem behind it. The pressure to support the Python-shaped ones is real and you will give in. Plan for that instead of discovering it.
Even OpenSCAD is not inert. `include <>` and `use <>` pull in another file by path, `import()` reads an STL, DXF or SVG, and `surface(file=…)` reads a heightmap. Those are file-read primitives. They cannot write and they cannot dial out, but they can read whatever the process can reach, and `echo()` plus the exit code is a perfectly serviceable one-bit-at-a-time exfiltration channel if you are patient and the thing you want is short. In a per-job microVM with nothing in it but the job, that primitive reads a lot of empty directories. On a long-lived worker that has processed four hundred other customers' models, it reads something considerably more interesting.
The "safe" language is still a denial-of-service engine
Forget code execution for a moment. The much more frequent incident is the honest customer whose honest model eats a machine.
In OpenSCAD, `$fn` sets the facet count for curved primitives. A sphere at `$fn=1000` is not a slightly heavier sphere; the facet count drives the triangle count roughly as the square, and then you difference it against another one. A CSG tree with a few hundred booleans over high-resolution primitives is a few keystrokes to write and genuinely minutes-to-forever to evaluate. The same applies with different spelling in OCCT: a fillet on a self-intersecting edge, a boolean between two solids that share a face exactly, a shell operation on a body with a degenerate face. These are not exotic. They are what happens when a slider reaches the end of its range, or when a language model picks plausible numbers.
Then there is the export. Tessellating a smooth solid at a tight linear deflection produces however many triangles it produces, and nothing in the pipeline objects. I have watched a single innocuous-looking parameter change take a mesh from a few megabytes to the kind of file where the interesting question is whether your disk or your HTTP layer gives up first. A watertight ninety-million-triangle STL is a perfectly valid mesh and a denial-of-service attack against your slicer, your viewer and your S3 bill.
None of that requires malice, which is why the defence cannot be a review process. It has to be limits.
The real resource profile: one core, lots of RAM
Geometry kernels are, in the paths you actually hit, largely single-threaded. OCCT's boolean algorithms have an opt-in parallel mode in recent versions and OpenSCAD's newer Manifold backend parallelises some work, so check what your build does rather than taking my word for it — but the default experience is one core pegged at 100% for the duration of one boolean, and sixteen idle cores watching.
This has a direct consequence for how you size a sandbox. On PandaStack every template gets 8 burstable vCPUs, and those 8 vCPUs will do nothing whatsoever for a single boolean. What they are excellent at is a batch: a configurator sweep of twenty-four variants, a tessellation at four deflections, an export to STL and STEP and 3MF in parallel. Parallelism lives at the job level, not inside the kernel. Design your fan-out accordingly and stop waiting for the geometry library to use your cores.
Memory is the binding constraint, and on a snapshot-based platform it is also the one you cannot change late. Firecracker cannot alter guest RAM at snapshot restore, so RAM is chosen when the template is built (`--memory-mb`) and baked into the snapshot. Pass `memory_mb` on a create and our agent silently corrects it to the baked value — which is honest behaviour but reads like a lie in your code, so leave it out. `--cpu` is deprecated and ignored. The practical upshot: a CAD template is a sizing decision you make once, up front, by measuring your worst real model and then adding headroom for the one you have not seen. Our `base` template is 4 GiB; a template that has to survive customer assemblies probably wants more, and that is a different template, not a different request.
Hard limits belong in the shell, not in the API call
This is the part people skip, and it is the part that matters. The sandbox boundary stops a script from reaching your other customers. It does not stop a script from consuming the whole sandbox, and if the guest OOM killer gets involved it will make its own choices about what to reap — possibly sshd, at which point your job fails in a way that looks like a platform bug rather than a bad model.
So the only thing the API ever calls is a wrapper. Everything expensive happens behind it, under limits the guest kernel enforces.
#!/bin/bash
# /usr/local/bin/run-geometry -- the ONLY entry point the API calls. Every
# limit below is enforced by the guest kernel, which is the one participant in
# this story that a script cannot argue with.
set -euo pipefail
JOB_DIR=${1:?usage: run-geometry <job-dir>}
OUT="$JOB_DIR/out"; mkdir -p "$OUT"; cd "$JOB_DIR"
# Address space, inherited by everything this shell spawns. A runaway boolean
# then dies with a bad_alloc or a Python MemoryError instead of waking the
# guest OOM killer, which does not share your priorities about what to reap.
# Caveat worth knowing: -v bounds VIRTUAL address space, not RSS. Allocators
# that reserve large arenas (and anything built with a sanitiser) can trip it
# while barely touching memory. If cgroup v2 is mounted in your guest,
# memory.max on a per-job cgroup is the sharper instrument; -v is the portable
# one, and it is what I reach for first.
ulimit -v $((3 * 1024 * 1024)) # 3 GiB inside a 4 GiB guest
# CPU seconds: SIGXCPU at the soft limit, SIGKILL at the hard one. This is
# what catches a tessellation that spins without allocating -- the case the
# memory limit above cannot see.
ulimit -t 120
# File size: SIGXFSZ fires on the write that would cross the line, so a 40 GiB
# STL becomes a failed job at a quarter of a gigabyte instead of a full disk.
# -f counts 512-byte blocks on most shells -- run `ulimit -a` on yours rather
# than trusting my arithmetic.
ulimit -f $((256 * 1024))
# Processes, because "geometry script" and "fork bomb" are not mutually
# exclusive categories.
ulimit -u 64
# ulimit -t counts CPU time, so a script that blocks on a socket forever is
# still comfortably within its limits. Wall clock needs an external executioner.
run() { timeout --signal=TERM --kill-after=10s 150s "$@"; }
# -D takes an EXPRESSION, not a number. If you interpolate unvalidated input
# into it you have handed the caller the language, which rather defeats the
# point of choosing the restrictive one.
numeric() { case "$1" in ''|*[!0-9.]*) echo "bad parameter: $1" >&2; exit 64;; esac; }
numeric "$P_WIDTH"; numeric "$P_HEIGHT"; numeric "$P_FACETS"
case "${JOB_KIND:?}" in
openscad)
# model.scad does `$fn = facets;` at the top rather than taking $fn on the
# command line, which keeps a dollar sign out of the shell quoting.
run openscad --hardwarnings --render \
-D "width=$P_WIDTH" -D "height=$P_HEIGHT" -D "facets=$P_FACETS" \
-o "$OUT/model.stl" model.scad
# Preview PNG. There is no GPU in here, so image export needs a software
# GL stack: Xvfb plus Mesa's llvmpipe, or a build linked against OSMesa.
# Confirm which one your binary wants BEFORE you bake it into a template.
run xvfb-run -a openscad --render \
--imgsize=1024,768 --camera=0,0,0,55,0,25,320 \
-D "width=$P_WIDTH" -D "height=$P_HEIGHT" -D "facets=$P_FACETS" \
-o "$OUT/preview.png" model.scad
;;
cadquery)
# Plain Python on OCCT. `import os` is legal in here and always was; the
# limits above plus the microVM around them are the entire defence.
run python3 /job/build.py --params "$JOB_DIR/params.json" --out "$OUT"
;;
*) echo "unknown JOB_KIND" >&2; exit 64 ;;
esac
# Sizes before anything leaves. The real mesh check runs next, in Python.
find "$OUT" -type f -printf '%s\t%p\n'
Three exit codes are worth recognising in your caller, because they are the system working rather than failing. 124 is GNU `timeout` giving up. 137 is a SIGKILL, so either `--kill-after` fired or the guest OOM killer did. 153 is SIGXFSZ, which means the output-size guard caught an export before it caught you.
Bake the toolchain, because installing it is the slow part
OCCT, CadQuery or build123d, OpenSCAD, a software GL stack, and the fonts are not a small install, and "pip install cadquery" on the hot path is how a 4-second job becomes a 90-second job. This is the workload a baked template snapshot was made for: the entire toolchain is identical on every single job, and the only thing that varies is a few hundred bytes of parameters and a script.
So you bake once and restore per job. On PandaStack a create that restores a baked snapshot lands at p50 179 ms, p99 203 ms — the `/snapshot/load` step itself is roughly 49–80 ms of that. The first spawn of a freshly built template, before a snapshot exists, is a real cold boot at around 3 seconds; after that you are on the fast path. Against a boolean that takes twenty seconds, a 179 ms machine-per-job is close enough to free that the isolation argument stops having a cost side. (That calculus inverts for 5-millisecond jobs. CAD is not that.)
Two things to bake that people forget. First, fonts. OpenSCAD's `text()` resolves through fontconfig, and a missing font does not fail — it substitutes. In a CAD pipeline that is not a cosmetic difference, it is a different solid, so install the fonts in the template, pin them, and have the job assert with `fc-list` that the one it wants is actually present. Second, the binary itself. Recent OpenSCAD snapshots ship a Manifold backend that is dramatically faster than the old CGAL path for the same tree, and the two can hand you subtly different meshes for the same file. If you promise customers reproducibility, the version of OpenSCAD is part of the contract and belongs pinned in the template, not resolved from a repo at build time.
The driver: STEP out, a PNG out, and a mesh you have actually checked
Validate inside the sandbox, before the bytes are worth trusting and while you can still fail the job cheaply. A non-manifold mesh that reaches a slicer becomes a support ticket; one that reaches a print becomes a refund. `trimesh` answers the two questions a slicer cares about in about four lines.
import json, pathlib
from pandastack import Sandbox
# The template was baked once, with the numbers that cannot change later:
#
# pandastack template build -f templates/cad/Dockerfile -n cad-occt \
# --size-mb 8192 \ # OCCT + CadQuery + OpenSCAD + a software GL
# # stack + fonts is not a small rootfs.
# --memory-mb 6144 # BAKED INTO THE SNAPSHOT. Firecracker cannot
# # change guest RAM at restore, so this is the
# # only place a boolean's ceiling gets chosen.
# # --cpu is deprecated/ignored; every template
# # gets 8 burstable vCPUs regardless.
#
# memory_mb= is deliberately absent from create(): the agent would silently
# correct it to the baked value, so passing it only misleads the next reader.
PARAMS = {"width": 120.0, "height": 40.0, "fillet": 3.0, "holes": 6}
CHECK = r"""
import json, sys, trimesh
m = trimesh.load(sys.argv[1])
print(json.dumps({
"triangles": int(m.faces.shape[0]),
"watertight": bool(m.is_watertight),
"winding_consistent": bool(m.is_winding_consistent),
"euler_number": int(m.euler_number),
"volume_mm3": float(m.volume) if m.is_volume else None,
"bbox_mm": [float(v) for v in (m.bounds[1] - m.bounds[0])],
}))
"""
with Sandbox.create(template="cad-occt", ttl_seconds=600,
metadata={"job": "configurator", "tenant": "acme"}) as sbx:
sbx.filesystem.write("/job/build.py", pathlib.Path("build.py").read_text())
sbx.filesystem.write("/job/params.json", json.dumps(PARAMS))
sbx.filesystem.write("/job/check.py", CHECK)
# One call, into the wrapper that owns the limits. timeout_seconds here is
# a CLIENT deadline -- it is the number that stops THIS request hanging,
# not the number that stops the geometry kernel. That one lives in the
# shell, in `timeout` and `ulimit`.
r = sbx.exec("JOB_KIND=cadquery P_WIDTH=120 P_HEIGHT=40 P_FACETS=96 "
"/usr/local/bin/run-geometry /job", timeout_seconds=300)
if r.exit_code != 0:
# 124 = timeout gave up. 137 = SIGKILL (kill-after, or the guest OOM
# killer). 153 = SIGXFSZ, the output-size guard. All three are the
# design working; only the stderr tail tells you which model did it.
raise RuntimeError(f"geometry failed ({r.exit_code}): {r.stderr[-2000:]}")
chk = sbx.exec("python3 /job/check.py /job/out/model.stl", timeout_seconds=120)
report = json.loads(chk.stdout)
if not report["watertight"] or not report["winding_consistent"]:
raise ValueError(f"non-manifold mesh, refusing to ship it: {report}")
if report["triangles"] > 2_000_000:
raise ValueError(f"mesh too heavy for the viewer: {report}")
step = sbx.filesystem.read("/job/out/model.step")
png = sbx.filesystem.read("/job/out/preview.png")
# Out here the sandbox is gone, and so is anything the script wrote, imported,
# or went looking for in /etc. filesystem.read() handles one file and returns
# bytes -- there is no directory copy, so pull the artefacts you named.
pathlib.Path("model.step").write_bytes(step)
pathlib.Path("preview.png").write_bytes(png)
A note on the geometry you ship. STEP is the one to keep if the customer might ever open the model again, because it is a boundary representation — real surfaces, not a triangle soup — and it survives a round trip through someone else's CAD package. STL is what a slicer wants and what your WebGL viewer wants, and it is lossy by construction. 3MF is the better modern choice where the toolchain on both ends supports it, since it carries units and metadata that STL simply does not have; check what your exporter and your slicer actually implement rather than assuming. Export both the B-rep and the mesh, validate the mesh, and never let the mesh be your only copy of the model.
Fan-out: one warm sandbox, forked per variant
A configurator sweep is the case where this architecture starts paying for itself twice. Twenty-four variants of the same model means twenty-four imports of CadQuery, twenty-four OCCT initialisations and twenty-four font-cache warm-ups — or one, if you warm a base sandbox and then fork it.
import json
from concurrent.futures import ThreadPoolExecutor
from pandastack import Sandbox
VARIANTS = [{"width": w, "height": h, "facets": 96}
for w in (80, 100, 120, 140) for h in (20, 30, 40, 50, 60, 70)]
base = Sandbox.create(template="cad-occt", ttl_seconds=1800,
metadata={"role": "sweep-base"})
base.filesystem.write("/job/build.py", BUILD_PY)
# Warm the expensive, identical-for-every-variant part ONCE: import cadquery,
# initialise OCCT, populate the fontconfig cache. Then snapshot it by forking.
base.exec("python3 -c 'import cadquery' && fc-list >/dev/null", timeout_seconds=120)
# A fork is a disk+memory snapshot restore of the parent. Same-host forks land
# in roughly 400-750 ms each; one that has to cross hosts is 1.2-3.5 s, because
# the memory image has to travel before it can be restored.
kids = base.fork_tree(len(VARIANTS), metadata={"sweep": "bracket-v7"})
# THE FORK CAVEAT, and it bites CAD specifically: every child resumes from the
# parent's exact memory, so the guest's clock and RNG state come back
# IDENTICAL in all 24 of them. Consequences you will actually hit:
# * STEP writes a timestamp into its header -- 24 files, one timestamp.
# * OpenSCAD's rands() without an explicit seed can repeat across children,
# so your "randomised" surface texture is the same surface texture.
# * Any job id derived from time.time() collides. Pass ids in; never mint
# them in a forked guest.
for sbx, params in zip(kids, VARIANTS):
sbx.filesystem.write("/job/params.json", json.dumps(params))
sbx.exec("command -v chronyc >/dev/null && chronyc -a makestep || true")
def build(pair):
sbx, params = pair
env = (f"JOB_KIND=cadquery P_WIDTH={params['width']} "
f"P_HEIGHT={params['height']} P_FACETS={params['facets']}")
r = sbx.exec(f"{env} /usr/local/bin/run-geometry /job", timeout_seconds=300)
try:
if r.exit_code != 0:
return params, None, r.stderr[-500:]
return params, sbx.filesystem.read("/job/out/model.stl"), None
finally:
sbx.kill()
# The 8 burstable vCPUs do nothing for one boolean. THIS is what they are for.
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(build, zip(kids, VARIANTS)))
base.kill()
One more fork caveat specific to this workload: our `fork()` restores the parent's disk and memory, which is exactly what makes it cheap and exactly what makes a half-written output file in the parent show up in all twenty-four children. Fork from a clean, warmed, idle parent — not from one that is mid-export.
Four places you can run this, honestly compared
| Approach | What stops a hostile script | What stops a heavy one | State between customers | Honest verdict |
|---|---|---|---|---|
| In-process (import CadQuery, exec the script) | Nothing. It is your interpreter, your file descriptors, your credentials in the environment | Nothing, and an OCCT segfault takes your API server down with it | All of it. Same process, same memory, same `/tmp` | Don't. This is the version that ends up in an incident write-up |
| Container per job | A shared kernel and a namespace boundary. Fine against mistakes, thinner than people assume against intent | cgroups, which work well — set them, because the defaults are unlimited | Clean per job if you build the image carefully and never mount anything writable across jobs | Reasonable for first-party scripts. The OCCT segfaults are the least of your problems with model-generated code |
| Pool of long-lived workers | Whatever the worker runtime enforces, plus hope | Limits you set per job, if the worker remembers to reset them | This is the failure mode. Caches, temp files, leaked file handles, and an OpenSCAD `include <>` that can read all of it | Fastest per job, and the one that leaks customer A's model into customer B's render. Flush state or do not use it |
| MicroVM per job | A separate kernel and a hardware virtualisation boundary. The script gets a whole machine and the machine is disposable | The baked guest RAM is a hard ceiling, plus `ulimit`/`timeout` inside | None. The machine is destroyed, and the next job restores a pristine snapshot | The right default when the script came from a customer or a model. Costs you ~179 ms against jobs that take seconds |
Containers and worker pools are not strawmen — if every script in your system was written by your own engineers and reviewed, a container with sensible cgroups is a perfectly defensible answer and considerably less machinery. The calculus changes the moment the script's author is a customer or a language model, because at that point you are not defending against bugs, you are defending against intent, and a shared kernel is a much larger piece of attack surface than a virtio device model. Other sandbox vendors in this space solve the same problem with their own isolation models and some are genuinely excellent at the parts I am not describing here; verify any of this against their current docs rather than my summary of them, because everybody's boundary moves between releases.
Where this stops working
There is no GPU in a Firecracker guest, so your preview PNG is rendered by a software GL stack. At 1024×768 for a mechanical part that is fine; for a marketing-quality render with materials and ambient occlusion it is not, and you want a different pipeline for that job. The single-threaded boolean does not get faster because you chose a better hypervisor — a microVM bounds the damage, it does not make OCCT clever. Baked RAM means an assembly that needs 24 GiB is a new template and a re-bake, not a bigger request, and discovering that at 2am is unpleasant, so measure your worst real model before you promise anything. Our guest kernel is 5.10 with Ubuntu 24.04 userspace, one kernel per host, not swappable per template; if a toolchain you want needs something newer, check before you build on it. And a cross-host fork at 1.2–3.5 s will quietly dominate a sweep of short jobs, so keep a sweep's children on one host where you can.
The thing a microVM genuinely buys you is that the worst case stops being interesting. A model-generated script that tries `rm -rf /`, reads `/etc/shadow`, allocates until the kernel panics, or finds a fresh way to segfault OCCT produces the same outcome as a model that just builds a bracket slightly wrong: one failed job, one destroyed machine, one error in a log, and the next customer's request restoring a clean snapshot 179 milliseconds later.
Frequently asked questions
If OpenSCAD can't `import os`, do I really need a VM for it?
You need something, and the reason is resource exhaustion rather than code execution. OpenSCAD's language genuinely cannot shell out or open a socket, which makes it the safest of the common options — but `$fn` on a sphere drives triangle count roughly as the square, a deep CSG tree of booleans over high-resolution primitives can evaluate for a very long time, and a mesh export has no inherent size bound. Any of those will consume a whole core and as much RAM as you let it, which on a shared worker means you have taken out everyone else's jobs with a model that contains no malice at all. There is also a quieter issue: `include <>`, `import()` and `surface(file=…)` read files by path, so an OpenSCAD script has an arbitrary-file-read primitive scoped to whatever that process can see. On a per-job microVM, that reads an empty filesystem. On a long-lived worker that has handled four hundred other customers, it reads their models. Limits plus a disposable machine answers both problems with one mechanism.
How much RAM should the CAD template get, and can I raise it per job?
You cannot raise it per job, and this is the single most important constraint to internalise before designing anything. Firecracker cannot change guest RAM at snapshot restore, so the number is chosen at template build time with `--memory-mb` and baked into the snapshot. On PandaStack, passing `memory_mb` on a create is silently corrected to the baked value, which means the field reads like a knob and is not one — leave it out of your code so the next reader is not misled. `--cpu` is likewise deprecated and ignored; every template gets 8 burstable vCPUs. Practically: take your worst real customer model, measure peak RSS during the boolean and the tessellation, and size the template above that with headroom for the model you have not seen yet. Our `base` template is 4 GiB for reference; a serious CAD template usually wants more. If you need two very different ceilings — a cheap configurator path and a heavy assembly path — build two templates and route between them, because that is the only mechanism there is.
Do the 8 burstable vCPUs make a boolean operation faster?
No, and it is worth being blunt about it because the sizing dialog implies otherwise. The geometry paths you hit in OCCT and in OpenSCAD's CSG evaluation are largely single-threaded; OCCT's boolean algorithms have an opt-in parallel mode in recent versions and OpenSCAD's newer Manifold backend parallelises some work, so check what your specific build does — but the normal experience is one core at 100% and the rest idle. Where the vCPUs earn their keep is at the job level: a configurator sweep across parameter sets, exports to several formats at once, tessellation at multiple deflections, or a batch of independent models. So structure the work as many small jobs rather than one big one, and use a thread pool in your driver to keep the sandbox busy. If you have one enormous boolean and a deadline, the fix is the model or the kernel's tolerances, not more cores — a microVM bounds the blast radius of a slow operation, it does not make the operation clever.
Why validate the mesh inside the sandbox instead of after I download it?
Because inside the sandbox, failure is cheap and the evidence is still there. A non-manifold or non-watertight mesh is not a cosmetic defect — a slicer will either refuse it, silently repair it into something you did not design, or produce a print that fails halfway up. Checking in the guest with `trimesh` (`is_watertight`, `is_winding_consistent`, `euler_number`, and a volume that only means anything if the mesh is a closed volume) lets you fail the job before any bytes reach your storage, your queue or your customer, and lets you log the parameters and the stderr that produced it while the sandbox still exists. Do the triangle-count and file-size gates there too: a watertight ninety-million-triangle STL is a completely valid mesh and a denial-of-service attack against your viewer. The one thing worth adding on your side after download is a cheap integrity check — size and hash — so a truncated transfer never looks like a geometry bug. Validate where the context is; verify where the bytes land.
Keep reading
- Sandboxing AI-agent 3D rendering and asset pipelines — The downstream half of this pipeline: a .blend file is a program too, and asset parsers are a large pile of C reached by attacker-chosen bytes.
- Running an optimization solver as a service — The same resource profile from another angle — single-threaded, memory-hungry, and fundamentally unbounded by the time a customer writes the model.
- Monte Carlo fan-out with microVM forks — The fork pattern in depth, including why identical RNG state across children is the first thing that surprises everybody.
- The guest OOM killer and Firecracker memory — What actually happens when a boolean eats the guest, and why ulimit before the OOM killer is the better plan.
- Running untrusted code safely — The general version of this argument: which boundaries hold against intent rather than against mistakes.
Related posts
- Build a Headless Browser Screenshot Service on MicroVMs
A headless browser is a loaded gun that renders JavaScript. Run each screenshot/PDF/OG-render job in a throwaway Firecracker microVM with locked-down egress, and let it die after the shot.
- Running npm install on untrusted code in a microVM
`npm install` is arbitrary code execution with a friendly progress bar. A malicious postinstall can read your SSH keys and tokens the second you install. Here's how to make that safe.
- On-prem without regrets: shipping your product as a microVM
The bank has the budget and the bank will not send you its data. So you ship your software into their datacentre instead, where you are the untrusted party and they are the untrusted party, and nobody gets a shell. A microVM image is a surprisingly good answer to both halves of that.
- How AI Agent Sandboxes Work
An AI agent decides to run some code. The safe move is to not run it on your machine. This is the mechanism of an agent sandbox — how a model's action becomes a command inside a disposable Firecracker microVM and comes back as a result, without ever touching your host kernel.
- Per-Tenant LLM Fine-Tuning Jobs in Isolated microVMs
You let customers upload their own training data and run fine-tuning jobs. That job holds a private dataset, a customer-supplied training script, and a credential. Run each one in its own Firecracker microVM so a poisoned script can't read another tenant's data or walk off with your keys.
More in AI agent sandboxes · See AI agent sandboxes on PandaStack
49ms p50 cold start. Fork, snapshot, and scale to zero.