Quarantining the Dependencies Your Agent Adds, Before They Install
When an agent adds a dependency overnight, nobody reviews the lifecycle scripts that run at install time. Here is how I turned the default off and built a quarantine score to let the safe ones through.
The tasks I had queued for the agent overnight were done by morning. Tests passing. Scrolling through the diff, I noticed one new line in package.json: a small library for parsing CLI arguments. Four-digit weekly downloads. Published eleven days ago.
Nothing was wrong with it functionally. But the moment that dependency unpacked into node_modules, a postinstall script had run once on my machine. The agent had done exactly the job it was given — make the tests pass. Reading that script was a job nobody had been assigned.
As an indie developer running several apps alongside a set of Stripe-billed sites, the only thing that quietly grows is the number of times a dependency decision gets made. The same machine also builds what I ship to the App Store. That is why one added line bothered me.
npm install is not a download. It is an execution. Back when I added dependencies by hand, there was a beat before I typed that line — enough to glance at the name, maybe search it. Hand the work to an agent and that beat disappears.
What npm install Actually Runs
Of npm's lifecycle scripts, three can fire from a dependency: preinstall, install, and postinstall. The part that matters is that this is not limited to your direct dependencies. Scripts belonging to a transitive dependency six levels down run with the same permissions, as the same user.
So the boundary you are defending is not "the packages I chose." It is "the entire tree the packages I chose dragged along." When an agent is adding dependencies, that boundary can widen overnight.
Hook
When it fires
Typical legitimate use
preinstall
Before the package unpacks
Environment checks (rarely needed)
install
Right after unpacking
Building native addons
postinstall
After install
Fetching binaries, applying patches
The legitimate uses are real. Native addons and tools that fetch platform-specific binaries do not work without them. Which means "ban everything" is not a policy anyone sticks to. The policy that survives is: off by default, allowed by name.
Flipping the Default
Defaults and config locations differ by package manager. Here is what I actually set across four repositories.
Tool
Dependency scripts by default
How to block or allow
npm
Run
ignore-scripts=true in .npmrc; run allowed ones manually
pnpm (v10+)
Do not run
Only packages listed in pnpm.onlyBuiltDependencies run
Yarn (Berry)
Do not run
enableScripts / dependenciesMeta in .yarnrc.yml
If you are on pnpm, the safe default is already yours. You only need to be explicit about the allowlist.
The honest problem: some packages break under ignore-scripts=true. Anything that fetches a binary — sharp, esbuild — will fail. And it fails in an unhelpful way. The install succeeds. Then the app starts and tells you a binary is missing.
So reverse the order. Instead of hunting for breakage after the fact, count what will stop first. Across four repositories, the steps that worked for me were these three:
Inventory every dependency in the tree that carries a script (Implementation 1)
Sort that list into an allowlist and a drop-candidate list
Flip the default and let only the allowlist through
Done in this order, you know the blast radius before you start. The first time I did it backwards, isolating the cause alone cost me half a day.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦The .npmrc and pnpm settings that stop install-time scripts by default, and how to find what breaks when you do
✦A working script that scores new dependencies on age, weekly downloads, name edit distance, and install scripts
✦A gate for unattended runs, plus one month of real numbers (87% auto-passed, 13% held, and where the false positives landed)
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Implementation 1: Inventory Which Dependencies Carry Scripts
npm's abbreviated registry metadata includes a hasInstallScript flag. Walk the tree and pull out only the packages that have one.
// scripts/list-install-scripts.mjs// Usage: node scripts/list-install-scripts.mjsimport { readFile } from "node:fs/promises";const ABBREV = "application/vnd.npm.install-v1+json";async function hasInstallScript(name, version) { const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, { headers: { accept: ABBREV }, }); if (!res.ok) return { name, version, unknown: true }; const doc = await res.json(); const v = doc.versions?.[version]; return { name, version, hasInstallScript: Boolean(v?.hasInstallScript) };}// Feed it the output of: npm ls --all --json > tree.jsonfunction flatten(node, out = new Map()) { for (const [name, dep] of Object.entries(node.dependencies ?? {})) { if (dep.version && !out.has(`${name}@${dep.version}`)) { out.set(`${name}@${dep.version}`, { name, version: dep.version }); flatten(dep, out); } } return out;}const tree = JSON.parse(await readFile("tree.json", "utf8"));const all = [...flatten(tree).values()];const results = [];for (const { name, version } of all) { results.push(await hasInstallScript(name, version));}const withScripts = results.filter((r) => r.hasInstallScript);console.log(`Total dependencies: ${all.length} / with install scripts: ${withScripts.length}`);for (const r of withScripts) console.log(` ${r.name}@${r.version}`);
The first time I ran this, one repository had 612 dependencies. Nine of them carried install-time scripts. That is 1.5%.
That number decided the approach. Nine is a list you can name. Given the choice between trusting 612 packages and reading 9, the second one is tractable.
Implementation 2: Score New Dependencies
An allowlist does not help when the agent adds something new tomorrow. What you need is a way to look at additions mechanically.
I used four signals. All of them come from public APIs.
Signal
Source
What it questions
Days since first publish
time.created in the packument
Freshly created packages
Weekly downloads
npm downloads API
Packages nobody uses
Name edit distance
Compared against a local list of well-known names
Typosquatting
Install-time script
hasInstallScript
Code that runs on unpack
// scripts/quarantine-score.mjs// Usage: node scripts/quarantine-score.mjs lodash-es minimistconst ABBREV = "application/vnd.npm.install-v1+json";// Names you actually use, plus widely-used ones: the typosquat comparison setconst KNOWN = ["lodash", "react", "next", "express", "axios", "zod", "minimist", "chalk", "dotenv"];function editDistance(a, b) { const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)), ); for (let i = 1; i <= a.length; i++) { for (let j = 1; j <= b.length; j++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost); } } return d[a.length][b.length];}function nearestKnown(name) { let best = { name: null, distance: Infinity }; for (const k of KNOWN) { if (k === name) return { name: k, distance: 0 }; const distance = editDistance(name, k); if (distance < best.distance) best = { name: k, distance }; } return best;}async function inspect(name) { const [full, abbrev, dl] = await Promise.all([ fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`).then((r) => r.json()), fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, { headers: { accept: ABBREV }, }).then((r) => r.json()), fetch(`https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(name)}`) .then((r) => r.json()) .catch(() => ({ downloads: 0 })), ]); const latest = full["dist-tags"]?.latest; const ageDays = Math.floor((Date.now() - Date.parse(full.time?.created ?? 0)) / 86400000); const weekly = dl.downloads ?? 0; const near = nearestKnown(name); const scripted = Boolean(abbrev.versions?.[latest]?.hasInstallScript); // Additive. Higher means a human should look. let score = 0; const reasons = []; if (ageDays < 30) { score += 3; reasons.push(`published ${ageDays} days ago`); } else if (ageDays < 180) { score += 1; reasons.push(`published ${ageDays} days ago`); } if (weekly < 1000) { score += 3; reasons.push(`${weekly} weekly downloads`); } else if (weekly < 50000) { score += 1; reasons.push(`${weekly} weekly downloads`); } if (near.distance > 0 && near.distance <= 2) { score += 4; reasons.push(`edit distance ${near.distance} from "${near.name}"`); } if (scripted) { score += 2; reasons.push("has an install-time script"); } return { name, version: latest, score, reasons };}const targets = process.argv.slice(2);let blocked = 0;for (const name of targets) { const r = await inspect(name); const verdict = r.score >= 5 ? "HOLD" : "PASS"; if (r.score >= 5) blocked++; console.log(`[${verdict}] ${r.name}@${r.version} score=${r.score}`); for (const reason of r.reasons) console.log(` - ${reason}`);}process.exit(blocked > 0 ? 1 : 0);
The threshold of 5 is deliberate. "Just new" (3 points) or "just unpopular" (3 points) does not hold anything on its own. Solo development routinely means reaching for niche, legitimate, small packages. What I want to catch is the overlap: new and unused (6 points), or one character off a well-known name (4 points) and carrying a script (2 points). That combination earns a pause.
Typosquatting gets the heaviest weight at 4 because a one-character difference rarely happens by accident, and it is the worst thing on the list when it does.
I would recommend filling KNOWN with the names you actually use, not just the famous ones. What you are protecting is not the general public's popular packages — it is the names already sitting in your own package.json.
Implementation 3: Wire It Into Unattended Runs
The quarantine has to fire when the agent commits, not when a human reads a diff. Pull new dependency names out of the staged package.json diff and score them.
#!/usr/bin/env bash# scripts/gate-new-deps.shset -euo pipefail# Extract only added dependency names from the staged package.json diffADDED=$(git diff --cached -U0 -- package.json \ | grep -E '^\+\s+"[^"]+":' \ | sed -E 's/^\+\s+"([^"]+)".*/\1/' \ | grep -vE '^(name|version|description|scripts|type|private|license)$' || true)if [ -z "$ADDED" ]; then echo "No new dependencies" exit 0fiecho "Quarantining new dependencies:"echo "$ADDED" | sed 's/^/ /'# shellcheck disable=SC2086if ! node scripts/quarantine-score.mjs $ADDED; then echo "" echo "Quarantine held one or more packages. An agent does not get to clear this." echo "Review them, then re-run with QUARANTINE_ACK=1 to pass deliberately." [ "${QUARANTINE_ACK:-0}" = "1" ] || exit 1 echo "Manually cleared via QUARANTINE_ACK"fi
Unattended runs never get QUARANTINE_ACK. That is the whole design. The agent can only add dependencies that clear the quarantine. When it meets one that does not, the task stops and the decision lands on my desk in the morning.
Automate the stop; keep the pass. This asymmetry earns its keep almost everywhere in agent operations. The point of a gate like this is that there is no path by which the agent can declare itself fine.
One Month In
Across four repositories, one month:
Item
Count
Notes
New dependencies added by agents
31
Four repos combined
Auto-passed (score < 5)
27 (87%)
No interruption
Held for review (score ≥ 5)
4 (13%)
—
Held, then cleared
3
Niche but legitimate small packages
Held, then swapped out
1
9 days old, ~200 weekly downloads, had a script
Three of the four holds turned out fine. Three false positives out of four sounds like poor precision. It works anyway because the hold rate is four per month. If four reviews a month narrow down where to look across 31 unattended decisions, the trade is worth making.
Tighten the rules to cut false positives and the threshold rises, which means missing the thing you actually built this for. I have come to think the right question here is not precision but whether the number of reviews lands somewhere a person will keep doing.
Metric
Before
After
Dependencies running install-time scripts
9
3 (allowlist only)
Human looks before a new dependency lands
0
1, when score ≥ 5
Gate runtime
—
~0.4s per dependency
Six dependencies broke under ignore-scripts=true, including sharp and esbuild. Three went on the allowlist. The other three I dropped entirely — two of them turned out to duplicate functionality I already had elsewhere. Installing the quarantine ended up being a dependency cleanup.
Where the Line Sits
This catches code that runs at install time, and packages with confusingly similar names. What it does not catch is equally clear: code that misbehaves only once imported at runtime, a legitimate package that gets compromised later, tampering upstream of the registry. Those need other layers.
I still think this layer earns its place. The thing that thins out most when you delegate to an agent is the pause. A human typing npm install reads the name once, even unconsciously. An agent has no such step. This design is really just an attempt to rebuild a step that went missing, in machine form.
Start by running just the Implementation 1 inventory on one of your own repositories. Total dependencies, and how many carry a script. Once those two numbers sit next to each other, what to do next tends to decide itself. Mine were 612 and 9.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.