Packages: the Roots package manager
roots add, roots install and friends install npm packages into a project: from the npm registry (or a private one), into an isolated node_modules, recorded in a text lockfile, with a 7-day cooldown on new versions and no dependency code running at install. It is written in Common Lisp inside the toolchain, including its TLS client (OpenSSL 3 through sb-alien): there is no Node.js or npm involved.
roots add vue # newest vue that is at least 7 days old; writes "^3.5.x"
roots add -D vite@^6 # a dev dependency with a range
roots install # what package.json says, exactly as roots.lock pins it
roots install --frozen # CI: fail unless roots.lock is already exactly right
roots install --frozen --verify-age # CI: and ask the registry how old each locked version is
roots install --verify # rebuild node_modules from the re-hashed store
roots update # re-resolve within the ranges, with a review of what changed
roots remove express
roots why debug # who needs it, and the chain from your project
roots outdated # newer versions, and which ones are still in the cooldown
roots approve-builds esbuild # allow one package's install scripts (pinned, sandboxed)
roots store verify # re-hash the shared store
roots import-lock # roots.lock from npm's package-lock.json (v2/v3)
roots publish --dry-run # the tarball and provenance plan (staged + OIDC)
roots doctor --ci # also checks CI workflows, MCP servers and hooksWhere things are declared #
| File | What it holds | Why there |
|---|---|---|
package.json | dependencies, devDependencies, optionalDependencies | npm compatibility: editors, tsc, Vite and npm itself read it. Roots writes it like npm does (same indentation, sorted dependency lists, ^version on add). |
roots.lisp :packages | the policy: cooldown, exemptions, overrides, hoisting, registries — every exception with a :reason | Roots's one config file (SC-15). It is data, read with the safe reader of roots build and never evaluated, validated against a schema with coded errors and "did you mean". Comments are allowed, so the reason for an exception can live next to it. |
roots.lock | the resolved graph, approved builds, overrides in effect, provenance (attribution) | generated; review it like code (see what roots.lock is trusted with). |
.npmrc (project, then ~/.npmrc) | only registry=, @scope:registry=, and //host/:_authToken= / _auth / username+_password | tokens belong in the user's home, not in the project. Every other npm setting is ignored with a warning (PM171), so nothing in it can turn a check off (strict-ssl=false does nothing). |
The project's .npmrc comes with the checkout, so it is not trusted with your environment: ${VAR} is expanded there only in a credential value (the usual //registry.npmjs.org/:_authToken=${NPM_TOKEN}), and when the host is not the public npm registry a warning (PM171) names the variable and the host it is sent to. A ${VAR} in a registry URL of a project .npmrc is an error (PM170). ~/.npmrc (or $NPM_CONFIG_USERCONFIG) is yours and expands everywhere, like npm. A credential key covers one host: //npm.corp/ matches https://npm.corp/… (the scheme's default port), not npm.corp.evil.example and not npm.corp:8443 (write //npm.corp:8443/ for that); a path in the key matches whole segments. A key with an @ in its host part (//registry.npmjs.org@other.example/) covers nothing.
package.json "overrides" is refused (PM174): an override changes what everybody gets, so Roots wants the reason written down. The equivalent:
(:name "my-app"
:packages (:cooldown 7 ; days; 0 turns the cooldown off
:exempt ((:package "vite" :reason "we follow vite releases and review them"))
:overrides ((:package "lodash" :version "4.17.21" :reason "CVE-2021-23337"))
:hoist ((:package "foo" :reason "bar 1.x imports foo without declaring it"))
:registry "https://registry.npmjs.org/"
:registries ((:scope "@corp" :url "https://npm.corp.example/"))
:strict nil ; t: a packument/tarball mismatch fails
:builds ((:package "esbuild" :reason "downloads its binary")) ; who may be approved at all
:verify-age t ; ask the registry how old locked versions are
:script-timeout 300)) ; seconds for an approved build script:builds and :verify-age are off unless you write them; they are what makes a change to roots.lock alone harmless (below). A :builds entry may pin a :version; :builds () means no package may run install scripts.
A project does not need a roots.lisp to use the package manager; without one the defaults apply. With one, roots build validates the same :packages section.
How versions are chosen #
For each dependency range, in this order:
- an override from
roots.lispreplaces the range everywhere in the graph; - a version already in
roots.lockthat still satisfies the range is kept (adding one package does not move the others); - otherwise, among the versions that satisfy the range and were published at least 7 days ago: the
latesttag if it fits, else the highest, non-deprecated first — the same rule as npm (npm-pick-manifest), so graphs match npm's with the cooldown off.
Ranges are node-semver's full grammar (^ ~ x-ranges, hyphen ranges, ||, prereleases only when a comparator names the same major.minor.patch), tested against the real semver package on 155,000 version/range pairs. The same version is shared wherever its ranges allow (one debug@4.4.3 for everybody), and several versions coexist when they must.
Cooldown (SC-3). Most malicious versions are found and removed within hours or days; a 7-day window would have blocked most 2025–2026 attacks. When nothing old enough matches:
roots: PM131 lib@^1.2.0: every matching version is younger than the 7-day cooldown (needed by your project)
newest matching: 1.2.0, published 2 days ago, eligible on 2026-11-13
newest eligible: 1.1.0
hint: wait, use a range that allows 1.1.0, run with --allow-young lib (logged in roots.lock), or
exempt it in roots.lisp: (:packages (:exempt ((:package "lib" :reason "…"))))--allow-young <pkg> (on add, install, update) is for urgent security fixes: the exception is printed and recorded on the package's block in roots.lock (cooldown-override "--allow-young on 2026-11-08"), so it shows up in review. Publish times come from the registry; the abbreviated metadata npm clients use has none, so Roots fetches the full document only for packages modified inside the window, keeps only the times, and caches it with its ETag.
Peer dependencies are resolved across the graph: an existing version that satisfies the peer range is used, otherwise one is installed; optional peers are not installed. Singletons: a package with "roots": {"singleton": true} in its package.json exists once in the graph. A conflict is never papered over with a "legacy" switch (SC-13); it is explained with both chains and one fix:
roots: PM134 peer dependency conflict on host: plugin@1.0.0 needs host ^2.0.0, but the graph has 1.5.0
your project needs host ^1
your project → plugin@1.0.0 needs peer host ^2.0.0
hint: no version of host satisfies every requirement; force 2.4.0, the newest that fits the peer
range, with a reason: (:packages (:overrides ((:package "host" :version "2.4.0" :reason "…"))))Platforms. os, cpu and libc (glibc/musl) follow npm's rules, !negations included. Optional dependencies for other platforms (esbuild's and Rollup's binaries) are resolved into the lockfile, so it is portable, but not downloaded or linked here. A required package for another platform is an error (PM137).
Sources. Registry packages and npm: aliases everywhere. Git, tarball-URL and file: dependencies only as direct dependencies of your project (SC-4), and pinned: GitHub dependencies need the full commit SHA (github:user/repo#<40 hex>), URLs must be https, file: must be a .tgz; their integrity is recorded on first install and verified after (trust on first use: nothing but roots.lock records it, so a change of that integrity in a review deserves a question). In roots.lock their resolved can only be what the specifier in package.json says: the commit's archive, the URL, the file (PM142 otherwise). Git dependencies are fetched as GitHub archives and never run prepare. Workspaces, link: and directories are not supported in 0.1 (PM136).
The store and node_modules #
Every file of every package is stored once per machine in ~/.roots/store ($ROOTS_STORE to move it), named by its SHA-512, read-only, and hard-linked into projects:
node_modules/
vue -> .roots/vue@3.5.13/node_modules/vue your dependencies, and nothing else
.bin/vite -> ../vite/bin/vite.js
.roots/
vue@3.5.13/node_modules/
vue/… hard links into the store
@vue/shared -> ../../@vue+shared@3.5.13/node_modules/@vue/shared
node_modules/foo -> ../foo@1.0.0/node_modules/foo only for :hoist entriesEach package sees exactly what it declares, so nothing can rely on a dependency it forgot to list, and versions never clash. Node, Vite and other tools resolve this layout as is (symlinks are resolved to real paths). Installs into a node_modules made by another package manager are refused (PM152): delete it first.
node_modules, node_modules/.roots, .bin and every package directory must be real directories. A symbolic link in their place (git stores links, so a repository can ship node_modules/.roots -> ../../something) is refused with PM152 and never followed: the linker deletes what is not in the graph, and must not do that where a link points. This includes a node_modules that is itself a symbolic link to a shared directory. Two names that differ only in letter case (JSONStream and jsonstream with the same version, or two such dependency names of one package) are refused on every platform (PM153): on macOS and Windows they are one directory and one would replace the other.
The store is for one user. A new store's v1/ is created with mode 0700 (cached registry metadata is inside it, in v1/metadata/); do not point $ROOTS_STORE at a directory several users write. Hard links share the file with the store: if you make a file in node_modules writable and edit it, every project sees the edit until roots store verify finds it. roots install trusts a package directory in node_modules whose marker matches roots.lock and does nothing when the whole layout does; roots install --verify rebuilds every package directory from the re-hashed store (use it when something that ran in the project may have changed node_modules).
Security model #
| What Roots does | Requirement | |
|---|---|---|
| Install scripts | Never run: no preinstall/install/postinstall, no implicit node-gyp rebuild, no git prepare. Packages that declare them are listed (PM161); in CI ($CI set) that is an error (PM160). | SC-1 |
| Approvals | roots approve-builds [pkg…] [--all] records build name@version with integrity and source in roots.lock. A new version, another tarball or another source is not approved and asks again. With :builds in roots.lisp an approval counts only for a package listed there (PM163 when you try to approve another), so roots.lock alone cannot approve a script. The commands shown for approval are the ones that run (from the verified tarball's package.json), printed without control characters. | SC-1 |
| Approved scripts | Run with an empty environment (only PATH, HOME, TMPDIR, npm_package_*: no NPM_TOKEN, GITHUB_TOKEN, cloud keys), HOME/TMPDIR in a fresh temporary directory, the package's own writable copy as working directory (never the store), a timeout (:script-timeout), and no network on Linux (unshare -n). Elsewhere the network is not isolated (printed when the script runs). Files outside the package are not protected from an approved script. | SC-2 |
| Cooldown | 7 days by default; exemptions need a reason; --allow-young is logged in roots.lock. A version that is already in roots.lock is used without asking the registry (that is what makes installs work offline); roots install --verify-age or :verify-age t checks every locked version's publish time (PM131), taking exemptions only from roots.lisp. | SC-3 |
| Exotic sources | Refused below the root (PM132, with the path); pinned at the root. | SC-4 |
| Manifests | Dependencies, bin, scripts, os/cpu/libc and exports come from the tarball's package.json, not the registry's metadata; a disagreement warns (PM123), and fails with :strict t. The package.json Roots reads is the one that lands on disk: a tarball has one file per path (the last entry wins, hard links included), and a second package.json in another letter case is refused (Unicode's idea of case included: a name that macOS would open as package.json). | SC-5 |
| Names | A dependency name is used as a directory name and in a registry URL, so every name is checked before anything is fetched: in a package's package.json (PM138), in yours (PM172), in roots.lock (PM140). ../../src, a/b, a name with a space or a line break never reach the linker or a request. | |
| Lockfile | Untrusted input, checked on every install before anything is fetched: every entry has an integrity; resolved must be exactly that name and version's tarball on the registry the policy maps it to (no commander → commander2, no foreign host, no http); scope mappings hold; every edge points at a block and every block is reachable. Then each package is checked against its tarball: name and version, and every dependency edge must be declared by that package.json with a range the locked version satisfies (or a recorded override), in the same list (dependencies or optionalDependencies). A package roots.lock marks as "for another platform" is only skipped when nothing requires it (PM137) and, if its tarball is in the store, when that tarball agrees (PM146). Git, URL and file entries must resolve to what their specifier says (PM142). | SC-6 |
| Scoped registries | A scope mapped to a registry is only ever fetched from it; a package missing there is an error (PM113), never a fallback to the public registry. | SC-7 |
| Update review | roots update and roots add print what changed in roots.lock and stop on red flags until you pass --reviewed: a patch release that adds dependencies (the axios pattern), a package that newly declares install scripts, a different publisher than the previous version, a lost provenance attestation. Provenance is shown as attribution (who built it), never as "safe". | SC-8 |
| Typos | roots add of a name that does not exist suggests close popular names (PM110); a name one or two typos away from a popular package (PM181), a package created in the last 30 days or with fewer than 3 versions (PM182) get a warning. | SC-9 |
| Store | Files are read-only and re-hashed before every link; a modified file stops the install (PM150). roots store verify [--fix] re-hashes everything. Root ignores file modes, so the store is tamper-evident, not tamper-proof, against root. Tamper-evident means file contents: an index (which files a tarball had) is read as untrusted input (a malformed one, or one with a path like ../x, counts as missing and the tarball is fetched again), but the store does not keep tarballs, so it cannot prove that an index lists the right files. A process of your own user that can write the store can therefore still swap one valid file for another. | SC-10 |
| Build | roots build reads package code as data (it is parsed, never run, SC-11). A package's Lisp (a .lisp file, or a .vue with <script lang="lisp">) is refused with B129, because compiling Lisp runs its macros; there is no opt-in. A node_modules/<name> that links outside the project is B125, and a package whose package.json is missing, does not parse or has no valid name and version is B128 (build.md). An import of a package your code does not declare is an error naming how it is reachable (SC-12, below). | SC-11, SC-12 |
| TLS | OpenSSL 3 required; certificate verification and host-name verification always on (no --insecure, no strict-ssl); TLS 1.2 minimum; CA bundle $SSL_CERT_FILE or the system one. Plain http only to localhost (always 127.0.0.1, never looked up) and 127.x.x.x addresses; a name that starts with 127. is not one. A response without a length must end with a TLS close. Certificate revocation (CRL, OCSP) is not checked and nothing is pinned, as in other package managers. Tested against a local server with an untrusted CA, a wrong host name, an expired certificate and a TLS 1.1-only server. | CL-3 |
| Libraries | OpenSSL and zlib are the system's, loaded when roots starts. On macOS they are loaded only by absolute path (Homebrew's and MacPorts' directories, /usr/lib for zlib, or $ROOTS_OPENSSL_DIR, which must be absolute), because a bare name would be searched in the working directory first: a checkout could ship its own libz.1.dylib. On Linux a library path variable (LD_LIBRARY_PATH) with an empty or relative element is refused for the same reason (PM101). roots doctor prints the files in use. | CL-3 |
| HTTP | Bounded redirects, https only, the Authorization header never follows a redirect to another origin (host, port or scheme); a URL with a space, a control character or a non-ASCII character in its path is not requested (PM106), so neither a name nor a redirect can add a line to a request; size limits on headers, bodies and decompression; timeouts on every connect, read and write. HTTPS_PROXY is honoured with a CONNECT tunnel (TLS end to end), except hosts in NO_PROXY; the proxy itself is spoken to over plain http, an https:// proxy URL is refused (PM107). Cached registry metadata is kept per registry URL, not per host. | |
| Tarballs | A path that is absolute, contains .., a backslash or control characters, a link leaving the package, a device file, a path that is both a file and a directory, an over-long name, an oversized entry (512 MB unpacked per package) or too many entries refuses the whole package (PM121). The gzip trailer's announced size is not believed beyond what deflate can produce, and running out of memory while unpacking is an error (PM122), not a crash. | |
| Publishing | roots publish --dry-run builds the exact tarball (deterministic), lists it, refuses secrets (.env, keys), and prints the provenance and staging plan; it refuses to run when a long-lived npm token is next to OIDC trusted publishing (the axios compromise, PM190). Uploading is not implemented in 0.1.x. | SC-17 |
| CI and agents | roots doctor lists pull_request_target workflows that check out PR code, actions not pinned to a commit SHA, caches shared between PR jobs and a publishing workflow, npm tokens next to id-token: write, MCP servers and hooks in .mcp.json, .claude/, .cursor/, .vscode/, and a global git core.hooksPath. --ci exits 1 when there are findings. | SC-18 |
What Roots does not protect against: malicious code that runs when you require() or import a package (the node-ipc 2026 case) other than by the cooldown; a compromised registry serving a malicious tarball and matching metadata for a version you lock for the first time; root on your machine, or another process of your own user writing the store. Git tags matching releases (ChainDrop) are not checked yet.
What roots.lock is trusted with #
roots.lock is checked as untrusted input for everything a tarball or the policy can prove: where a package comes from, what it is, what it depends on. Two things only the lockfile says, and by default Roots takes its word for them:
- that a locked version is old enough. A locked version that satisfies the range is used without a metadata request, so a roots.lock that pins a version published an hour ago installs it, in CI with
--frozentoo. - that an install script is approved. The
buildblocks live in roots.lock.
So a pull request that changes only roots.lock can do both, and such a change deserves the review a change to the CI configuration gets. To stop relying on that review, put the policy where roots.lock cannot reach it:
:packages (:builds ((:package "esbuild" :reason "downloads its binary")) ; or :builds () for none
:verify-age t) ; or: roots install --frozen --verify-age in CIWith :builds, an approval in roots.lock counts only for a listed package (unlisted ones are reported like unapproved scripts: PM161, PM160 in CI). With :verify-age (or the flag), every install asks the registry for the publish time of each locked registry version (one cached metadata request per package; it needs the network on a cold cache) and fails with PM131 for one inside the cooldown. Exemptions for this check come from :exempt in roots.lisp and --allow-young on the command line; a cooldown-override line in roots.lock is not accepted as one, so a version installed with --allow-young needs an :exempt entry until it is old enough. Git, URL and file dependencies have no cooldown.
Speed #
Metadata and tarballs are fetched in parallel (up to 16 threads, started as work appears; keep-alive connections, gzip). A lockfile that matches package.json is used as is — no metadata request at all — and with a warm store an install needs no network (SC-16): it works during a registry outage. When node_modules already matches roots.lock, roots install does nothing.
Imports in roots build #
An app can import the npm packages it declares. Entry points follow Node: "exports" (subpaths, * patterns, conditions in the order the package lists them, with browser, import, module, default for DOM targets and node, import, module, default for term), then "module", then "main". Package modules are written to npm/<name>@<version>/… in the output, and their own imports are resolved the same way, inside the package or to its declared dependencies. CommonJS-only entries are an error (B128): the build ships ES modules.
src/App.ts:3: B123 imports lodash, which is not in your dependencies; it is reachable only via
your project → express@4.21.2 → body-parser@1.20.3 → lodash@4.17.21
hint: add it: roots add lodashA package that imports something it does not declare gets the same error with a :hoist suggestion; hoisting makes it visible from node_modules/.roots/node_modules and needs a reason.
Error codes #
Package-manager errors print roots: PMxxx message and usually a hint. Build errors for imports keep the build's B1xx codes (build.md).
| Code | Meaning |
|---|---|
| PM100 | command-line usage |
| PM101 | OpenSSL 3 or zlib missing, or too old; a library path variable with an empty or relative element |
| PM102 | TLS: certificate or host-name verification failed, handshake refused, CA file unusable |
| PM103 | network: cannot resolve, connect, or a timeout (GETs are retried) |
| PM104 | the registry answered an unexpected HTTP status |
| PM105 | malformed or oversized HTTP response |
| PM106 | not an http(s) URL (a space, control or non-ASCII character in the path included); refused or too many redirects; a header with a line break |
| PM107 | the proxy (HTTPS_PROXY) refused or failed, or is an https:// proxy |
| PM110 | package or tarball not found (with "did you mean" for roots add) |
| PM111 | malformed JSON (packument, package.json, store index) |
| PM112 | plain http to a non-local registry or tarball URL |
| PM113 | a scoped package is not in the registry its scope is mapped to (no fallback, SC-7) |
| PM114 | the registry refused access (401/403): a token is needed |
| PM120 | integrity mismatch: the bytes are not the ones recorded |
| PM121 | unsafe or malformed tarball (path traversal, links, devices, sizes, a path that is a file and a directory, a second package.json in another letter case) |
| PM122 | corrupt, truncated or oversized gzip; out of memory while unpacking |
| PM123 | the packument and the tarball's package.json disagree (warning; error with :strict) |
| PM124 | the tarball has no valid package.json |
| PM130 | no version satisfies the range |
| PM131 | every matching version is inside the cooldown; with --verify-age, a locked version that is (or cannot be shown not to be) |
| PM132 | a git/URL/file dependency below the root (SC-4) |
| PM133 | a direct git dependency not pinned to a commit SHA |
| PM134 | peer dependency conflict (both chains and a fix) |
| PM135 | singleton conflict |
| PM136 | unsupported specifier (workspace:, link:, directories, non-GitHub git) |
| PM137 | a required package is for another platform |
| PM138 | a dependency name that is not a package name (../x, a/b, spaces, line breaks) |
| PM140 | roots.lock (or package-lock.json) cannot be read: syntax, unknown field, duplicate, a name that is not a package name |
| PM141 | an entry without a usable integrity hash |
| PM142 | an entry resolved from a host that is not a configured registry, or a git/URL/file entry resolved from somewhere else than its specifier says |
| PM143 | a tarball that is another package or version than its entry says |
| PM144 | a scoped entry resolved outside the scope's registry |
| PM145 | --frozen: roots.lock is missing, does not match package.json, or would be rewritten |
| PM146 | an entry's edges, install scripts or platform do not match its package.json (invented, missing, redirected or re-labelled edges) |
| PM147 | an entry's tarball URL is not that name and version's tarball |
| PM150 | a store file does not match its hash, or a store index names a path outside the package (tampering); run roots store verify --fix |
| PM151 | the store lock (v1/locks) cannot be taken |
| PM152 | node_modules was made by another package manager, or has a symbolic link (or a file) where Roots expects a directory |
| PM153 | two packages, or two dependency names of one package, that differ only in letter case |
| PM160 | unapproved install scripts in CI (or approve-builds in CI) |
| PM161 | warning: install scripts were not run |
| PM162 | an approved script failed or timed out |
| PM163 | approve-builds for a package that roots.lisp :builds does not list |
| PM170 | invalid :packages policy (missing reason, bad name, bad URL); ${VAR} in a URL of the project's .npmrc |
| PM171 | warning: ignored .npmrc settings; an environment variable the project's .npmrc sends to a host as a credential |
| PM172 | package.json missing or invalid (a dependency name that is not a package name included) |
| PM173 | a scope mapped to two registries |
| PM174 | package.json overrides (use roots.lisp, with a reason) |
| PM175 | not a dependency of this project |
| PM180 | update review red flags (accept with --reviewed) |
| PM181 | warning: a name close to a popular package |
| PM182 | warning: a very new package, or one with very few versions |
| PM190 | publish refused (token next to OIDC, private, no name/version) |
| PM191 | publish without --dry-run (not implemented in 0.1.x) |
| PM192 | secrets in the files to publish |
| PM195 | doctor: pull_request_target checking out PR code |
| PM196 | doctor: an action not pinned to a commit SHA |
| PM197 | doctor: a cache shared by pull-request jobs and a publishing workflow |
| PM198 | doctor: an npm token next to OIDC |
| PM199 | doctor: an MCP server or hook to review |
Tests #
bin/test-system roots-pm-tests # everything (about 15 s; uses the network)
bin/test-system roots-pm-tests pm-semver # the node-semver differential oracle
ROOTS_PM_REALWORLD=1 bin/test-system roots-pm-tests pm-realworld # graphs and timings vs npmA local fixture registry (loopback HTTP with real packuments and tarballs) drives the behaviour tests; tests/fixtures/tls/ has certificates made to be refused. tests/hardening.lisp holds one test per finding of the security review (hostile checkouts, package names, lockfile edits, store indexes, redirects); on macOS one of them compiles a marker library with cc and starts bin/roots next to it.
roots doctor prints the OpenSSL and zlib files in use before its other checks. Tests that need registry.npmjs.org or Node print PENDING and pass when those are missing; ROOTS_E2E_REQUIRE=1 makes them fail instead.
Limits in 0.1 #
Workspaces and catalogs, link:, directory dependencies, non-GitHub git hosts, publishing (only --dry-run), roots run/x for package scripts, IPv6-only registries, and checking release tags against source repositories are not implemented yet. Engines (engines.node) are not used to pick versions, so the choice does not depend on the machine.