I Banned Package Managers From My Laptop (and Then Caught Them Sneaking Back In)
I locked every package manager in a container so a malicious dependency could never touch my credentials.
Then I sat down to write a blog post about how well it worked, and found out it didn't.
This is that blog post. It's a bit different from the one I planned.
The Rule
On my current project, one rule sits above almost everything else: never run go, pnpm, uv or buf on the host. Not once. Not "just this time."
Every toolchain runs inside a pinned Docker image, driven by a make target. Want to lint a Go service? make -C services/whatever lint. Want to install the dashboard's dependencies? make -C panels/admin install. The only things on my actual laptop are docker, make and direnv.
The reason is one sentence long: installing a package runs a stranger's code.
npm install runs postinstall scripts. pip install can run setup.py. Every one of those scripts runs as you, with your home directory, which means ~/.ssh, ~/.aws, ~/.npmrc, your GitHub token, and whatever else you've got lying around. The event-stream incident, ua-parser-js, the steady drip of typosquatted packages on npm and PyPI: none of those needed a zero-day. They needed you to type install.
So the rule came with a threat model, written right at the top of the tooling docs:
A compromised dependency running apostinstall/go generate/pip installscript must not be able to exfiltrate host credentials.
And a runner that looked like this:
TOOL_RUN := docker run --rm -i \
-u $$(id -u):$$(id -g) \
-v $$(pwd):/work \
-w /work \
-e HOME=/tmp
Run as my user so files don't come back owned by root. Mount only the current directory. Point HOME at /tmp, so $HOME never gets mounted and there are no dotfiles to steal.
There was one more rule: the GitHub token (needed for our private Go modules) goes to the Go image only. Never to Node, never to Python. The comment in the Makefile was delightfully stern about it:
Go has no automatic install-time code execution —go mod downloadonly fetches files. npm/pnpmpostinstall, pipsetup.py, etc. DO run arbitrary code on install […] Keep this split intact — do not "DRY out" by lifting the github vars into TOOL_RUN.
Airtight. I was very proud of it.
"Why Not Just Audit Your Dependencies?"
I can already hear you typing.
Sure, run npm audit. Pin your lockfile. Read your diffs. I do all of that. But npm audit tells you about vulnerabilities someone has already reported. A malicious package is dangerous precisely during the window before anyone has noticed, and it only needs one install inside that window.
The container approach doesn't care whether a package is good or evil. It just makes sure that when the evil one runs, there's nothing worth stealing in the room.
That was the theory, anyway.
Fact-Checking My Own Blog Post
I went back through the repo to grab some nice quotable snippets for this post. The rule, the runner, the threat model. Then I looked at how the runners were actually defined.
TOOL_RUN mounted $(pwd): just the unit's folder, exactly as designed.
The Go runner mounted $(REPO_ROOT): the whole repo.
So did the dashboard runners. And the TypeScript package runners. And the protobuf runner, the sqlc runner, and the little dev server for the widget playground.
Of the runners actually in use, only the Python one still mounted just its own folder.
Nobody made a bad decision here. Each widening was completely reasonable on its own:
- Go uses a
go.workworkspace, so the Go runner needs to see sibling modules. Mount the whole repo. - pnpm uses a workspace too, so it needs the root manifest and every sibling package. Mount the whole repo.
- Codegen writes to
proto/gen/andsqlc/gen/, whichever directory you run it from. Mount the whole repo.
Every change got reviewed. Every one had a comment explaining why. And the rule that said "only the working directory" was enforced by exactly one mechanism: someone noticing in code review.
Nobody noticed, because each individual change was fine.
Now, the whole repo is mostly code, so what's the harm? Well. Here's what else lives in a repo when you run a local dev stack:
.envrc, direnv's file, holding my GitHub token, my Hugging Face token and my LLM provider's API key. In plain text. Gitignored, so it felt safe..cache/, also gitignored, holding the dev stack's JWT signing key, a client-auth CA key, a pile of database credentials, a client certificate or two, and the private key of my local mkcert root CA.
That last one deserves its own section. We'll get there.
$HOME was locked tight. The spare key was under the doormat.
Every pnpm install for the dashboards, which absolutely does run postinstall scripts, could read all of it. With full internet access, because no runner ever set --network. Which brings us neatly to the GitHub token that "only ever reaches the Go image": it was sitting in .envrc, readable by every Node container in the repo.
Oops.
It Gets Worse: Reading Is the Polite Attack
Stealing secrets is bad. But a whole-repo mount isn't just readable. It's writable.
Think about what's inside a repo that later runs on your host, outside any container:
.git/hooks/. Drop in apre-commithook and it runs the next time you commit..git/config. Setcore.fsmonitorto a script and it runs on a plaingit status. You don't even have to commit.- The Makefiles themselves.
makeparses them on the host, and one of mine had a$(shell …)line that runs on every single invocation. - Your AI coding agent's config directory, if it supports hooks. (Mine does.)
So the real attack isn't "a postinstall script steals your token from inside the container." It's "a postinstall script writes one line into .git/config, and a week later your own laptop runs it for you, with your real $HOME, your SSH keys and every token direnv has loaded."
The container was a sandbox with a door in the back wall.
And Worse: "Go Has No Install-Time Code Execution"
That comment in the Makefile? Technically true. go mod download just fetches files.
But the Go runner doesn't only run go mod download. It also runs:
go generate, which in my repo meantgo run go.uber.org/mock/mockgenacross 84 directives. That's compiling and executing a dependency.go test, which runs every imported package'sinit()function.golangci-lint, and its plugins.
All of those ran inside the one container that held the GitHub token.
And here's the chain that genuinely made my stomach drop. The Go module cache lived at .cache/go/. Inside the repo. Inside every whole-repo mount. Including the Node ones.
Go verifies module downloads against checksums. It doesn't re-verify source that's already been unpacked into the cache. So:
- A malicious npm package's
postinstalleditsmockgen's source in the Go module cache. - Some time later, I run
make mocks. - The Go runner compiles the tampered
mockgenand runs it. With the GitHub token. With network access.
An npm package never touches a Go credential directly. It just leaves a note for the Go runner, which reads it and runs it for them.
The Scariest One Wasn't a Dependency at All
Remember the mkcert root key?
mkcert exists to solve exactly one problem: making your browser trust your local dev stack. Run mkcert -install and it adds a root CA to your operating system's trust store. Any certificate signed by that root is trusted by your Mac, for any domain.
My stack also runs a forward proxy that intercepts TLS, so a headless browser can crawl customer sites through it. An intercepting proxy needs a CA of its own to mint certificates on the fly. And somewhere along the way, the proxy was handed... the mkcert root.
Those are opposite directions of trust. One says "my laptop trusts my dev stack." The other says "this proxy speaks for the entire internet." And the key that does both was sitting inside a proxy whose whole job is to parse TLS from arbitrary websites. One parser bug, and someone can mint a certificate for your bank that your browser will happily accept.
While I'm confessing: the little dev server for the widget playground served the repo root, bound to 0.0.0.0. So http://<my-laptop>:4173/.envrc worked from anywhere on the café Wi-Fi.
Cool. Cool cool cool.
The Fix: Four Rules Instead of One
The original rule, "run package managers in containers," was never wrong. It was just one rule standing in for four, and the other three had never been written down. So now they are.
1. No secret lives in the repo tree. Not in .envrc, not in .cache/, not anywhere a mount might reach. Secrets moved to a directory outside the repo that no runner ever mounts, and .envrc reads from there instead of holding literals. This single change makes every read attack pointless: the playground, the wide mounts, the throwaway scripts. There's nothing to read.
2. Runners can't write anything the host later executes. Mounts are now an allow-list, not "the whole repo." Source is mounted read-only, and only the unit's own output directories are writable. .git, the Makefiles and editor/agent config are simply not there. The Go runner gets go.work, the Go modules and the generated Go code. pnpm gets the workspace manifests, the lockfile and the workspace packages. Nobody gets .git.
3. Different trust levels don't share writable state. Each runner gets its own caches, in named Docker volumes outside the repo. The Node runner can't reach the Go module cache because the Go module cache isn't anywhere the Node runner can see.
4. Credentials and network only where they're actually needed. The Go runner is split in two. Fetching modules gets the token and the network. Everything else (generate, test, lint, build) runs with --network none, GOPROXY=off and no token. If mockgen ever does go rogue, it has nothing to steal and nowhere to send it.
The new shape looks roughly like this:
# Everything the Go toolchain needs to SEE: read-only.
GO_SRC := \
-v $(REPO_ROOT)/go.work:/work/go.work:ro \
-v $(REPO_ROOT)/services:/work/services:ro \
-v $(REPO_ROOT)/shared/go:/work/shared/go:ro \
-v $(REPO_ROOT)/proto/gen/go:/work/proto/gen/go:ro
# The ONE thing it's allowed to CHANGE: the unit being built.
GO_OUT := -v $(CURDIR):/work/$(UNIT):rw
GO_RUN := docker run --rm -i \
--network none \
-u $$(id -u):$$(id -g) \
$(GO_SRC) $(GO_OUT) \
-v go-mod-cache:/go/pkg/mod:ro \
-e GOPROXY=off -e HOME=/tmp \
$(GOLANG_IMAGE)
Look for :ro and :rw. The whole fix for the write attacks lives in those three letters: four mounts the container can look at, one it can touch. No .git, no Makefiles, no .envrc. They aren't hidden or denied. They just aren't there.
More typing? Yes. But every line is a decision someone had to make on purpose, not a default that quietly grew.
And the proxy got its own dedicated CA, trusted only inside the two images that actually need it and nowhere else. The mkcert root went back to doing its one job, and got re-minted for good measure. So did every token that had been sitting in .envrc. There was no evidence anything had been taken, but "no evidence" is doing a lot of work in that sentence.
Guarding the Guardrail
Here's the real lesson, though. The original rule didn't fail because it was a bad rule. It failed because the only thing enforcing it was a human reading diffs, and each diff was individually fine.
So now a machine enforces it. A make check runs over every runner definition and fails the build if anything mounts the repo root wholesale, passes a credential to a runner that executes code, or publishes a port on anything other than 127.0.0.1. It's about thirty lines of script, and it would have caught every single one of these problems the day it was introduced.
A rule that isn't checked isn't a rule. It's a suggestion with good intentions.
What's Still Not Perfect
This isn't a victory lap.
- Language servers still run on the host. This is the big one. My editor doesn't go through
make. It runsgopls,tsserverand friends directly on my laptop, to index, format and type-check, and it never asks my carefully crafted wrappers for permission. The ESLint extension is the best (worst?) example: it loadseslintand every plugin straight out ofnode_modulesand runs them on my laptop. The verynode_modulesI was so careful to install inside a container. So every rule in this post applies to my terminal and none of it applies to my IDE. The fix is devcontainers, and I haven't made the jump yet. - Allow-lists are more config. A new top-level directory now means updating a mount list. That's the point, but it's also friction.
- It's slower. Containers, volumes and split fetch/run steps all add up. I've made peace with that.
- Docker isn't a security boundary in the way a VM is. This setup raises the bar a lot. It doesn't make a determined attacker's day impossible.
Why This Works for Me
- Nothing to steal. Secrets live outside every mount, so the read attacks are dead by construction.
- Nowhere to hide. No runner can write to
.git, a Makefile or my editor config, so nothing gets planted for later. - No cross-contamination. Each trust level has its own caches, so an npm package can't leave presents for the Go toolchain.
- Drift gets caught. The check fails the moment someone "just mounts the whole repo for now."
When I started this post, I thought the lesson was "run your package managers in containers." It isn't. Plenty of people do that and are exactly as exposed as I was.
The container wasn't the boundary. The -v flag was the door right through it.