TL;DR
A customer's procurement team asked us for a license inventory of our product, and I discovered our npm lockfile contained 1,400+ dependencies nobody had ever audited. I used Claude Code to classify every license, dig into the sketchy ones, trace how the risky packages got into the tree, and wire a CI gate so this never silently regresses. Total time: about two days instead of the two weeks I'd budgeted. π‘
The Problem
Here's an email you never want to receive: "Before we can renew, our legal team needs a complete inventory of third-party licenses in your product, including transitive dependencies."
Our package.json listed 62 direct dependencies. Innocent enough. Then I ran:
npm ls --all --parseable | wc -l
# 1417
1,417 packages. Every single one of them ships with a license, and every single one of those licenses is a tiny legal contract we had implicitly agreed to. Nobody on the team β including me β had ever read more than a handful of them.
The scary part isn't the well-behaved majority. Something like 90% of the npm ecosystem is MIT/ISC/Apache-2.0 and genuinely fine. The scary part is the tail:
- Copyleft licenses (GPL, AGPL) hiding three levels deep in the tree
- Packages whose
licensefield says one thing while the bundledLICENSEfile says another - Packages with
"license": "SEE LICENSE IN LICENSE.txt"β which tells you exactly nothing - Packages with no license metadata at all, which legally defaults to "all rights reserved"
Reading 1,400 license files by hand is exactly the kind of soul-crushing, judgment-requiring-but-barely, high-volume work that I now refuse to do myself. So I opened Claude Code (v2.x, on Node.js 22.x) and made it a compliance intern for two days.
How I Solved It
Step 1: Get the raw inventory mechanically
First rule: don't make the AI do work a deterministic tool does better. The initial sweep came from license-checker-rss, which reads the metadata for the whole installed tree:
npx license-checker-rss --json --production > licenses.json
jq 'length' licenses.json
# 1417
Then I asked Claude Code to summarize the distribution before doing anything fancy:
Read licenses.json. Group packages by license identifier and give me
a count per license, sorted descending. Flag anything that is not
MIT, ISC, BSD-2-Clause, BSD-3-Clause, Apache-2.0, or 0BSD.
The distribution came back looking like most JS projects:
| Bucket | Count |
|---|---|
| MIT / ISC / BSD / Apache-2.0 | 1,361 |
| MPL-2.0, LGPL (weak copyleft) | 19 |
| GPL family flagged | 4 |
UNKNOWN, SEE LICENSE IN, custom |
33 |
So 1,361 packages needed zero further attention, and 56 packages needed actual eyes. That's the whole game: use cheap tools to shrink the haystack, use the agent on what's left.
Step 2: Let the agent read the weird ones
The 33 "unknown/custom" packages are where metadata-only scanners give up β and where an agent that can read files shines. Every one of those packages has something in node_modules: a LICENSE file, a license section in the README, a header comment in the source.
I gave Claude Code a classification rubric up front, in the project's instruction file, so it wouldn't improvise legal opinions:
## License classification rules
- Classify as exactly one of: PERMISSIVE / WEAK_COPYLEFT /
STRONG_COPYLEFT / PROPRIETARY / CANNOT_DETERMINE
- Quote the exact sentence from the license text that justifies
the classification. No quote, no classification.
- If the package.json license field and the LICENSE file disagree,
report BOTH and classify by the LICENSE file.
- Never guess. CANNOT_DETERMINE is a valid and welcome answer.
Then the sweep itself:
For each package in unknowns.txt, read its files under node_modules/,
find the actual license text, and classify it per the rules in
CLAUDE.md. Output one JSON line per package: name, version,
classification, evidence quote, file path.
That last rule β quote the sentence or don't classify β is the single most important line. On my first attempt without it, the agent confidently labeled a package MIT because the README "looked standard." With the evidence-quote requirement, every classification came back verifiable, and I spot-checked them in minutes instead of re-reading everything.
Results from the 33 unknowns: 26 were permissive licenses with lazy metadata, 4 were custom-but-clearly-permissive texts, 2 were CANNOT_DETERMINE (dead packages with no license text anywhere β we replaced them), and 1 was a genuine surpriseβ¦
Step 3: The AGPL in the basement π±
One of the four GPL-family flags turned out to be AGPL-3.0, sitting four levels deep under a charting library. If you're not familiar: AGPL is the license where even network use can trigger source-disclosure obligations. For a proprietary SaaS product, that's not a footnote β that's a drop-everything finding.
The immediate question was: how did this get here, and can it go away? This is where npm why plus an agent that can read changelogs saved me hours:
npm why aggressively-licensed-package
Ask: which of our direct dependencies ultimately pulls this in,
is it actually imported at runtime or only used at build time,
and does a newer version of the parent drop this dependency?
Claude Code traced the chain, checked our bundle output to confirm the package actually shipped to production (it did β build-time-only would have been a much easier conversation), and then found that the parent library had replaced this dependency two major versions ago, precisely because of the license. The fix was a scheduled upgrade we'd been putting off anyway. One npm install later, the AGPL was gone.
I want to be honest: an experienced engineer with npm why and patience would have found the same answer. The agent didn't do anything superhuman. It just did 45 minutes of archaeology in 4, and it read the parent library's changelog so I didn't have to.
Step 4: Make it impossible to regress
An audit that runs once is a snapshot, not a control. The lockfile changes every week; next month's npm install can quietly reintroduce the exact problem. So the last step was a CI gate β and this part I had Claude Code write end to end:
// scripts/check-licenses.mjs β runs in CI on every lockfile change
import { execSync } from "node:child_process";
const ALLOWED = new Set([
"MIT", "ISC", "0BSD", "BSD-2-Clause", "BSD-3-Clause",
"Apache-2.0", "CC0-1.0", "Unlicense",
]);
// Reviewed one-by-one; each entry links to the review note.
const EXCEPTIONS = new Map([
["some-mpl-package@4.2.1", "MPL-2.0 ok: unmodified, dynamically linked"],
]);
const raw = execSync("npx license-checker-rss --json --production");
const tree = JSON.parse(raw);
const violations = Object.entries(tree).filter(([pkg, info]) => {
const license = String(info.licenses ?? "UNKNOWN");
return !ALLOWED.has(license) && !EXCEPTIONS.has(pkg);
});
if (violations.length > 0) {
console.error("License gate failed for:");
for (const [pkg, info] of violations) {
console.error(` ${pkg} β ${info.licenses}`);
}
process.exit(1);
}
console.log(`License gate passed: ${Object.keys(tree).length} packages checked β
`);
The design choice that matters: it's an allowlist, not a blocklist. A blocklist of "bad" licenses silently passes anything new or weird. An allowlist fails closed β a never-before-seen license identifier stops CI and forces a human (or an agent with a rubric) to look at it once, add it to ALLOWED or EXCEPTIONS with a written reason, and move on.
The whole flow now looks like this:
flowchart LR
A[lockfile change] --> B[license-checker in CI]
B --> C{all in allowlist?}
C -- yes --> D[merge β
]
C -- no --> E[CI fails]
E --> F[agent-assisted review]
F --> G[allowlist or replace]
G --> D
Lessons Learned
Shrink the haystack with dumb tools before you deploy the smart one. Metadata scanning removed 96% of the work for free. Pointing an LLM at all 1,417 packages would have been slower, pricier, and noisier. The agent earns its keep on the ambiguous residue, not the bulk.
Demand evidence quotes, not conclusions. "Classify this license" gets you plausible answers. "Classify it and quote the sentence that proves it" gets you checkable answers. This one prompt rule turned spot-checking from re-doing the work into skimming it.
CANNOT_DETERMINEis a feature. Explicitly telling the agent that "I don't know" is a welcome answer is the difference between an audit and a hallucination generator. The two packages it refused to classify were exactly the two that deserved human escalation.The license field lies more often than you'd think. Out of 33 manually-reviewed packages, several had metadata that disagreed with the shipped license text. If your compliance story is built purely on
package.jsonfields, it's built on the honor system.An audit without a CI gate is theater. The one-time sweep satisfied procurement. The allowlist gate is what makes the answer stay true. Fail closed on anything unrecognized, and make every exception carry a written justification.
β οΈ Obligatory disclaimer: I'm an engineer, not a lawyer, and neither is Claude. The agent did the reading, sorting, and evidence-gathering; the actual risk calls on the flagged packages went through humans (and for the AGPL one, actual counsel). Use AI to prepare the decision, not to make it.
What's Next
Two things on my list: generating a proper SBOM (CycloneDX format) from the same pipeline so the next procurement request is a one-command answer, and running the identical playbook against our Python service β pip has its own flavor of license chaos, and I suspect its tail is even weirder than npm's.
Wrap-up
Dependency license auditing is the perfect AI-agent workload: massive volume, mostly mechanical, occasionally requiring real judgment β and an agent with file access plus a strict rubric handles the volume while surfacing exactly the cases that need your brain.
If you've never looked at your own lockfile's license tail, run npx license-checker-rss --summary today. It takes thirty seconds, and you might meet your own AGPL in the basement.
Have you found something scary in your dependency tree? Tell me your worst license surprise in the comments β and follow me here on Dev.to π for more write-ups on putting coding agents to work on the jobs nobody wants.
Top comments (1)
@yureki_lab, using a deterministic inventory to shrink the set before asking an agent to inspect ambiguous evidence is exactly the right division of labor. Iβd bind every exception to the package version, lockfile integrity hash, dependency path, and reviewed license-file digest; then an unchanged name/version cannot inherit approval if the evidence artifact or how it ships has changed. CI can also verify that the stored evidence quote still exists verbatim before accepting the exception. Did you keep the agentβs
CANNOT_DETERMINEresult and the later human disposition as separate machine-readable records?