Building untrusted Go code in a microVM
You clone a Go repo — one an agent picked, a pull request from a fork, a project a user submitted — and you run `go build ./...`. It feels like a compile. A compiler reads source and emits a binary; what could it execute? Quite a lot, as it turns out. Go's toolchain runs code at build time by design: `go generate` runs arbitrary shell commands, cgo hands module source to a C compiler on your machine, module resolution reaches out over the network, and `go test` runs whatever is in the test files. Building an untrusted Go repository on your host is running its author's code, full stop. This post is about that gap: why the Go build path is code execution, where the module supply chain leaks in, and the microVM pattern that lets you build anything without betting your host on it.
I'm Ajay — I built PandaStack, a Firecracker microVM platform for exactly this kind of untrusted execution. I'll be concrete about the threat and honest about where Go's built-in guardrails (the checksum database, `GOFLAGS`, vendoring) help versus where you actually need an isolation boundary.
The Go build path runs code, not just a compiler
The mental model that gets people hurt is "build compiles source, `run` executes it." Wrong on the first half. Several steps of an ordinary Go build execute arbitrary code from the repo and its dependencies, as your user, with your environment, your network, and your credentials — before you ever run the produced binary. There is nothing to review after the fact, because the interesting behavior fired during the build.
- go generate — runs the shell commands named in `//go:generate` directives verbatim. It is, quite literally, a feature for running someone else's arbitrary commands on your laptop. It is not run by `go build`, but every "just set up the repo" script and plenty of Makefiles run it, and once you do, the directive author picked the command.
- cgo — a package with `import "C"` invokes the host C compiler on the module's C source during the build. `#cgo` directives can pass flags, and the C code (plus a `#cgo LDFLAGS` linker step) runs a full toolchain over attacker-authored source. A malicious module can execute at build time through cgo without you ever calling generate.
- go test — compiles and runs the test binary, which is arbitrary Go the author wrote. `TestMain`, an `init()` in a `_test.go` file, or a plain test function runs whatever it contains. Running an untrusted repo's tests is running the untrusted repo.
- The module toolchain — `go mod download` / `go build` resolve and fetch dependencies over the network (GOPROXY), and Go may even download a different toolchain version named in `go.mod`. Fetching is network I/O you didn't audit, feeding source into the build steps above.
The module supply chain: what fetching dependencies actually pulls in
Go's dependency handling is better than most — but "better" isn't "inert." When your build resolves a module, it fetches source from a proxy and, unless you've vendored, that source flows straight into the cgo and test paths above. The checksum database (`sum.golang.org`) and your `go.sum` verify that you got the same bytes everyone else got for a given version — genuinely useful, and it stops a proxy silently swapping a tarball out from under you. What it does not do is judge whether those bytes should run. A module can be perfectly, verifiably itself and still ship a hostile `//go:generate` line or cgo that phones home the moment it compiles.
- GOPROXY is network access — resolving a fresh dependency graph reaches out to a proxy (default the public one). A repo's `go.mod` chooses which modules, and thus which authors' source, enters your build.
- Checksums verify identity, not intent — `go.sum` and the checksum db answer "are these the bytes for v1.4.2?" not "is v1.4.2 safe to compile." A typosquatted or freshly-malicious module passes checksum verification against itself just fine.
- A module's build can touch the network — cgo source, a `go generate` step, or a build-tagged file can open a socket during the build. If the build has open egress and an inherited credential, that's your exfil path.
- Transitive reach — you read your one direct dependency; its transitive graph each also gets to contribute cgo and generate directives, and any one of them is enough.
The checksum database is a seatbelt, not a cage. It guarantees you compiled the same module everyone else did. It says nothing about whether compiling that module was a good idea — and compiling it is the whole point.
Why an agent or a fork makes this sharply worse
A developer who runs `go build` on a repo they cloned deliberately at least chose the repo. An AI agent that clones and builds whatever a task points it at did not — and it will happily run `go generate ./...` because a README said to, or `go test ./...` to "check the repo works," on code it has never seen. The repo author chose the generate directives and the test bodies; the agent just executed them as you. Under prompt injection it's worse: text in a README, an issue, or a tool result an attacker controls can steer the agent toward "run `go generate` first," and the model obliges.
The CI-on-a-fork case is the one that quietly burns people. If your pipeline runs `go build` or `go test` on a contributor's pull request, the PR author chose the dependencies, the cgo, the generate directives, and the test code — and merging isn't required. The build on your runner already executed their code against your CI secrets. You've turned a pull request into a code-execution channel, and the only thing between that and your host is whatever isolation wraps the build.
Why a shared-kernel container is a weak boundary here
The next instinct is "run the build in a container." Better than your host, and fine for code you trust — but for genuinely untrusted build-time code it's a soft boundary. A container shares the host kernel. The generate step or cgo compile runs as a normal process against that shared kernel, and a kernel bug or a container-escape primitive puts it on the host and, in a multi-tenant setup, into other tenants. That's not theoretical enough to ignore: AWS, Google, and others moved untrusted workloads off plain containers onto microVMs or gVisor precisely because a shared kernel is a large, attackable surface.
There's a second, more mundane failure that catches more people. CI containers are usually built with exactly the things a build step should never see — cloud credentials and a `GITHUB_TOKEN` in the environment, the host's Docker socket bind-mounted in, cloud metadata at 169.254.169.254 reachable, an SSH agent forwarded. A malicious `//go:generate` line doesn't need a kernel exploit to ruin your day if it can just read a token that was sitting right there in the environment. The most common real-world leak is boring: ambient network plus an inherited secret, not an exotic escape.
The options, ranked for untrusted builds
Where you run an untrusted `go build` / `go test`, from least to most contained:
- Host go (in your dev machine or CI runner) — no boundary at all. generate, cgo, and test code run as you, see every secret in your environment, read your keys and tokens. Never do this with a repo you didn't choose and trust.
- Host go with GOFLAGS=-mod=vendor and no generate — narrows what fetches and skips the generate step, but cgo and test code still execute as you if you build or test at all. Good hygiene, not a boundary for untrusted code.
- A container — real process isolation and good hygiene, but a shared kernel and often ambient network/credentials (GITHUB_TOKEN, Docker socket, metadata endpoint). Fine for trusted builds; a soft boundary for untrusted build-time code.
- A disposable microVM — the build runs behind a hardware virtualization boundary with its own guest kernel, no host secrets present, and egress you control. If the repo is hostile, the blast radius is one VM you were going to delete anyway.
The pattern: build inside a disposable microVM
The durable shape has four properties: a hardware isolation boundary (a microVM with its own kernel, not a shared one), an ephemeral environment (fresh per build, destroyed after, so nothing persists forward), no host credentials in the guest, and controlled egress so a build step can't quietly exfiltrate. On PandaStack you get the first two by construction and enforce the rest per sandbox. Here's the loop with the Python SDK — create a throwaway VM on the `base` (Go-capable) template, clone the untrusted repo into the guest, run the full build and tests inside it with a timeout, then pull the built binary back out and destroy the VM:
from pandastack import Sandbox
# An untrusted Go repo — an agent picked it, or it came in from a fork.
# It may have a hostile //go:generate line or cgo that runs at build time.
# We are going to build and test it anyway, safely.
repo = "https://github.com/example/untrusted-service"
# One disposable microVM for this build. ttl reaps it if we crash.
with Sandbox.create(template="base", ttl_seconds=600) as sbx:
c = sbx.exec(f"git clone --depth 1 {repo} /workspace/app", timeout_seconds=120)
if c.exit_code != 0:
raise RuntimeError(f"clone failed:\n{c.stderr}")
# `go build` compiles CGO and downloads modules over GOPROXY; `go test`
# RUNS the repo's test code. Both are third-party code execution, here,
# inside the guest kernel, with no host secrets and egress locked down.
# GOFLAGS=-mod=mod lets modules resolve; drop network entirely with
# GOFLAGS=-mod=vendor if the repo vendors its deps.
env = "GOFLAGS=-mod=mod GOTOOLCHAIN=local CGO_ENABLED=1"
b = sbx.exec(
f"cd /workspace/app && {env} go build -o /workspace/app/bin/app ./...",
timeout_seconds=300,
)
if b.exit_code != 0:
raise RuntimeError(f"build failed / blocked:\n{b.stderr}")
# Run the (untrusted) tests in the same contained VM.
t = sbx.exec(
f"cd /workspace/app && {env} go test ./...",
timeout_seconds=300,
)
print("test exit:", t.exit_code)
# Pull the built binary back through the API, not a host mount.
binary = sbx.filesystem.read("/workspace/app/bin/app")
with open("app", "wb") as f:
f.write(binary)
print(f"pulled {len(binary)} bytes")
# VM and everything the build did to it are gone hereThe equivalent commands, if you want to see the build path plainly — a `go generate` step is the one you should be most nervous about, because it runs whatever the directives say:
# All of these execute third-party code and belong inside the sandbox.
go mod download # network fetch over GOPROXY
go generate ./... # runs the //go:generate shell commands verbatim
go build ./... # compiles cgo (invokes the host C compiler)
go test ./... # compiles AND RUNS the repo's test binaries
# Tighter posture inside the guest:
export GOFLAGS=-mod=vendor # compile only vendored source, no network
export GOTOOLCHAIN=local # don't auto-download a toolchain named in go.mod
export GOPROXY=https://proxy.internal # or your own mirror, egress-allowlistedIf any package in that graph had a hostile cgo step or a `//go:generate` that tried to read credentials and phone home, here's what it found when it ran: no cloud credential files, no `GITHUB_TOKEN` or cloud tokens in the environment, no metadata endpoint reachable, and egress restricted so the exfil never leaves. Whatever it wrote to the filesystem dies when the `with` block exits. The worst case is a wasted sandbox — which is the whole point of making sandboxes cheap.
That cheapness is what makes per-build isolation practical rather than a nice idea. On PandaStack every create restores a baked snapshot on demand — p50 179ms, p99 ~203ms, with the snapshot-restore step itself around 49ms (a true first cold boot, before any snapshot exists, is ~3s). So spinning up a fresh VM just to build one sketchy repo isn't a boot-time tax you avoid by reusing environments across trust domains; it's fast enough to do every time. If you want each build to start from an identical known-good base (Go toolchain and module cache already warm), snapshot a configured sandbox once and fork it — a same-host fork is 400–750ms (cross-host 1.2–3.5s) and shares memory copy-on-write. And there's plenty of address space to do this at scale: each agent pre-allocates 16,384 /30 subnets, so per-VM network isolation isn't the bottleneck.
Controlling egress so a build step can't phone home
Isolation contains a compromised guest; egress control decides whether it can talk to the outside while contained. The tension is obvious: `go mod download` needs to reach a proxy, but a malicious cgo step or generate directive wants to reach an attacker's server. You can't naively block all egress or the module fetch fails. The workable posture is default-deny with a narrow allowlist for the module proxy, so the build resolves but arbitrary build-time code can't open a socket to somewhere you didn't sanction.
- Point `GOPROXY` at a proxy you control (a private Athens/JFrog mirror or `GOPROXY=off` with vendored deps) and allow egress only to that host — the build resolves, everything else is denied.
- Set `GOTOOLCHAIN=local` so a repo's `go.mod` can't make Go auto-download a different toolchain version over the network mid-build.
- Deny the cloud metadata endpoint (169.254.169.254) unconditionally; nothing an untrusted build has any business there.
- Keep the guest's outbound default-deny; open only what the build genuinely needs, per sandbox, and close it when the sandbox dies — and treat DNS as an exfil channel too, so an allowlist that only names your proxy shuts down encoding a stolen token into a lookup.
Extracting the binary without trusting what built it
The point of the build was usually to produce something — a compiled binary, a set of artifacts. The safe way to get it out is through the filesystem API, not a shared host mount, so the untrusted build never had a writable path into your host in the first place. Have `go build` write a known output path, check the exit code, then `filesystem.read` the bytes back on the host side (as in the loop above). If you need a directory of outputs, `tar` it inside the guest and read the single archive out.
Treat the extracted binary with appropriate suspicion, too: it was produced by code you didn't trust, by a toolchain fed attacker-authored source. If you're going to run that binary, run it in another sandbox, not on a box with secrets. The microVM contained the build; it doesn't launder the output into something trustworthy. What it buys you is that the dangerous step — resolving, compiling, and executing arbitrary third-party build code — happened somewhere disposable, and only inert bytes crossed back to you.
Better: don't build untrusted things live when you can avoid it
The safest untrusted build is the one you scope down before you run it. PandaStack's `base` template pre-warms the Go toolchain (alongside Node, Python, and Bun via mise), so the compiler is already there on a fresh VM with zero setup and you're not fetching a toolchain over the network at build time. For a repo you're forced to build from a fork or an agent, the two useful levers before you even hit go are: never run `go generate` unless you've read every directive, and prefer `GOFLAGS=-mod=vendor` with `GOTOOLCHAIN=local` so the build compiles only what's checked in and can't reach out for more.
For everything that genuinely has to compile untrusted source — cgo, tests, modules you didn't vendor — you still fall back to the pattern above: build live, but inside the disposable, credential-free, egress-controlled VM, so the fallback is contained rather than dangerous. The two-tier posture — pre-warmed toolchain and tightened flags for the common case, isolated live-build for the tail — is what lets you say yes to "the agent can build whatever the repo needs" without that being a synonym for "the agent can run whatever the repo's author wrote, as you."
When is none of this necessary? If you fully control the repo and its vendored dependencies and neither is agent- or contributor-influenced, a normal `go build` into your usual environment is fine — don't spin up a VM to compile code you wrote and trust. The whole apparatus here is for the case that actually bites: a repo that arrived at build time, from a model or a fork, possibly steered by an attacker. For that case, treat `go build` and `go test` as the hostile code execution they are, run them in a VM you're happy to throw away, and let the blast radius be a sandbox instead of your fleet.
Frequently asked questions
Does building Go code actually run code, or just compile it?
It runs code. `go generate` runs the shell commands named in `//go:generate` directives verbatim; a package that uses cgo invokes the host C compiler on the module's C source during `go build`; and `go test` compiles and runs the repo's test binaries, which are arbitrary Go the author wrote. All of that executes as your user, with your environment and network, before you ever run the produced binary. So building or testing an untrusted Go repo is code execution, not a passive compile, which is why isolating the build matters.
Doesn't Go's checksum database make module dependencies safe?
No — it verifies identity, not intent. `go.sum` and the checksum database (sum.golang.org) confirm you got the same bytes everyone else got for a given module version, which usefully stops a proxy silently swapping a tarball. But they say nothing about whether compiling that module is safe. A typosquatted or freshly-malicious module with a hostile cgo step or `//go:generate` line passes checksum verification against itself just fine. Integrity checking answers 'are these the bytes I resolved?'; isolation answers 'what happens if they're hostile anyway?'
Why is it risky to let an agent or a pull request trigger a Go build?
Because whoever wrote the repo chose the code that runs at build time. An agent that clones and runs `go generate` or `go test` on code it has never seen is executing the author's directives and test bodies as you, and under prompt injection attacker-controlled text can steer it there. CI that runs `go build` or `go test` on a contributor's fork executes that contributor's cgo, generate steps, and test code against your CI secrets — no merge required. Assume any build triggered by untrusted input might run hostile code, and isolate it.
Isn't a Docker container enough to sandbox an untrusted Go build?
For trusted builds, usually. For genuinely untrusted build-time code it's a soft boundary: a container shares the host kernel, so a kernel bug or container escape can reach the host and other tenants, and CI containers often carry ambient network access and inherited credentials (GITHUB_TOKEN, a mounted Docker socket, cloud metadata) that a malicious generate step or cgo build can abuse. A hardware-isolated microVM with its own guest kernel, no host secrets, and controlled egress is the stronger backstop — which is why cloud providers moved untrusted workloads off plain containers.
How do I safely build untrusted Go code with PandaStack?
Create a disposable microVM (the Go-capable base template) with a ttl, clone the untrusted repo into the guest, run `go build ./...` and `go test ./...` inside it with a timeout, then read the built binary back through the filesystem API and delete the sandbox. The guest has no host secrets, egress is default-deny with a narrow allowlist to your module proxy, GOTOOLCHAIN=local stops a mid-build toolchain download, and everything the build did dies with the VM. Because a create restores a baked snapshot in ~49ms (p50 179ms), a fresh VM per build is practical, and the base template pre-warms the Go toolchain so you're not fetching one over the network.
49ms p50 cold start. Fork, snapshot, and scale to zero.