Skip to content

Commit 5966a2b

Browse files
committed
feat(pnpm-policy): derive pnpm supply-chain policy from what you publish
Adds pnpm-policy: a maintainer can't use minimumReleaseAge today, because quarantining their own releases makes their workspaces un-installable, so the cooldown gets set to 0 and the protection is lost entirely. This resolves the exemption list from the npm maintainer search instead, and patches it into pnpm-workspace.yaml. Also teaches yamlize to attach comments, so the generated block can carry its own rationale.
1 parent 9ae46e6 commit 5966a2b

34 files changed

Lines changed: 5642 additions & 5286 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ jobs:
5858
- komoji
5959
- nested-obj
6060
- node-api-client
61+
- pnpm-policy
6162
- schema-sdk
6263
- schema-typescript
6364
- strfy-js

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ A comprehensive collection of TypeScript utilities for working with schemas, JSO
2727
| **jsonldjs** | [![npm](https://img.shields.io/npm/v/jsonldjs.svg)](https://www.npmjs.com/package/jsonldjs) | [GitHub](./packages/jsonld-tools) | Powerful JSON-LD builder with comprehensive filtering and subgraph extraction |
2828
| **komoji** | [![npm](https://img.shields.io/npm/v/komoji.svg)](https://www.npmjs.com/package/komoji) | [GitHub](./packages/komoji) | the tiny case transformer — effortlessly transform strings between naming conventions |
2929
| **nested-obj** | [![npm](https://img.shields.io/npm/v/nested-obj.svg)](https://www.npmjs.com/package/nested-obj) | [GitHub](./packages/nested-obj) | Safely access and modify nested object properties using string paths |
30+
| **pnpm-policy** | [![npm](https://img.shields.io/npm/v/pnpm-policy.svg)](https://www.npmjs.com/package/pnpm-policy) | [GitHub](./packages/pnpm-policy) | pnpm supply-chain policies for npm maintainers — derive release-age exemptions from what you publish |
3031
| **schema-sdk** | [![npm](https://img.shields.io/npm/v/schema-sdk.svg)](https://www.npmjs.com/package/schema-sdk) | [GitHub](./packages/schema-sdk) | Convert JSON Schema OpenAPI Spec to TypeScript Clients |
3132
| **schema-typescript** | [![npm](https://img.shields.io/npm/v/schema-typescript.svg)](https://www.npmjs.com/package/schema-typescript) | [GitHub](./packages/schema-typescript) | Convert JSON Schema to TypeScript Definitions |
3233
| **strfy-js** | [![npm](https://img.shields.io/npm/v/strfy-js.svg)](https://www.npmjs.com/package/strfy-js) | [GitHub](./packages/strfy-js) | Stringify JSON as JavaScript with extended serialization capabilities |

packages/pnpm-policy/README.md

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
# pnpm-policy
2+
3+
<p align="center" width="100%">
4+
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
5+
</p>
6+
7+
<p align="center" width="100%">
8+
<a href="https://github.com/constructive-io/dev-utils/actions/workflows/ci.yml">
9+
<img height="20" src="https://github.com/constructive-io/dev-utils/actions/workflows/ci.yml/badge.svg" />
10+
</a>
11+
<a href="https://github.com/constructive-io/dev-utils/blob/main/LICENSE">
12+
<img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/>
13+
</a>
14+
<a href="https://www.npmjs.com/package/pnpm-policy"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/dev-utils?filename=packages%2Fpnpm-policy%2Fpackage.json"></a>
15+
</p>
16+
17+
**pnpm supply-chain policies for npm maintainers** — especially those running many pnpm workspaces and publishing many npm modules.
18+
19+
## The maintainer's problem
20+
21+
`minimumReleaseAge` is the single most effective supply-chain control pnpm ships: a package must have existed for N days before it can be installed, and most compromised releases are caught and yanked well inside that window. It is also the one control a package maintainer cannot turn on.
22+
23+
You publish `@acme/parser` at 2pm and consume it in three workspaces at 2:05pm. A 14-day quarantine means your own release is unusable for two weeks, in every repo you own. So the cooldown gets set to `0`, and the protection that would have stopped a compromised transitive dependency is gone — not because you decided the risk was acceptable, but because the tool could not tell your packages from everyone else's.
24+
25+
`pnpm-policy` makes that distinction. It asks npm what your maintainer accounts publish, and writes the answer into `pnpm-workspace.yaml` as an exemption list:
26+
27+
```yaml
28+
minimumReleaseAge: 20160 # 14 days, for everything third-party
29+
minimumReleaseAgeExclude:
30+
- "@acme/*" # a scope you own
31+
- my-unscoped-package # a package you publish
32+
```
33+
34+
Your releases install immediately. Everything else waits.
35+
36+
At Constructive that is 1104 published packages behind one maintainer account, fetched in 5 requests and under two seconds, compressing to 5 scope globs plus 85 individual names for a workspace with ~1800 resolved dependencies.
37+
38+
## Installation
39+
40+
```bash
41+
npm install --save-dev pnpm-policy
42+
```
43+
44+
## Quick start
45+
46+
```bash
47+
npx pnpm-policy init # write a starter pnpm-policy.yaml
48+
# edit it: add your npm accounts under `maintainers`
49+
npx pnpm-policy inventory # ask npm what you publish → pnpm-policy.inventory.json
50+
npx pnpm-policy generate # patch the policy into pnpm-workspace.yaml
51+
npx pnpm-policy check # CI: fail if the file drifted or a waiver expired
52+
```
53+
54+
Commit `pnpm-policy.yaml`, `pnpm-policy.inventory.json`, and the generated `pnpm-workspace.yaml`. Add `pnpm-policy check` to CI.
55+
56+
## Configuration
57+
58+
`pnpm-policy.yaml` (or `.yml`, or `.json`) at the workspace root:
59+
60+
```yaml
61+
# How old a third-party release must be before it may be installed.
62+
# Accepts 14d / 2w / 36h / 90m, or a bare number of minutes (what pnpm stores).
63+
minimumReleaseAge: 14d
64+
65+
# Transitive dependencies must resolve from the registry, not from git or a URL.
66+
blockExoticSubdeps: true
67+
68+
# The npm accounts YOU publish under.
69+
maintainers:
70+
- your-npm-username
71+
- your-ci-account
72+
73+
# Scopes you own outright — emitted as `@scope/*`.
74+
scopes:
75+
- "@acme"
76+
77+
# Written by `pnpm-policy inventory`. Commit it; review its diffs.
78+
inventory: ./pnpm-policy.inventory.json
79+
80+
# Dependencies allowed to run install scripts. The value is the reason.
81+
allowBuilds:
82+
esbuild: native binary, downloaded at install time
83+
sharp: libvips bindings
84+
"@swc/core": native binary
85+
86+
# Third-party escape hatches — see below.
87+
exceptions:
88+
- package: some-lib
89+
versions: ['4.17.21']
90+
reason: CVE-2026-1234 fix, published hours ago
91+
until: 2026-10-01
92+
93+
# Anything else you want in the managed block, passed through verbatim.
94+
settings:
95+
strictDepBuilds: true
96+
```
97+
98+
| Key | Type | Default | Meaning |
99+
| --- | --- | --- | --- |
100+
| `minimumReleaseAge` | duration | `14d` | Quarantine applied to everything not exempted. |
101+
| `blockExoticSubdeps` | boolean | `false` | Refuse transitive deps from git/URL sources. |
102+
| `maintainers` | string[] | `[]` | **Your own** npm accounts. See the warning below. |
103+
| `scopes` | string[] | `[]` | Scopes you own, emitted as globs. |
104+
| `inventory` | path or package | – | Where the generated inventory lives. |
105+
| `intersect` | boolean | `true` | Only emit names this workspace actually resolves. |
106+
| `allowBuilds` | map or list | `{}` | Dependencies permitted to run install scripts. |
107+
| `exceptions` | list | `[]` | Third-party bypasses, each with a reason. |
108+
| `settings` | map | `{}` | Extra pnpm settings to include in the managed block. |
109+
110+
### `maintainers` is your own identity, not a trust list
111+
112+
Every package published by a listed account bypasses the release-age quarantine. That is the point — waiting on your own release protects nothing — but it means the entry is a delegation of trust as wide as the account itself.
113+
114+
**List only accounts you control.** Adding a colleague's or a vendor's account exempts everything they will ever publish, including a release made by whoever compromises their credentials. If you want to trust a specific third-party package, that is what `exceptions` is for.
115+
116+
The same reasoning applies to a shared publishing account: if five people can publish under it, the exemption covers all five.
117+
118+
### `exceptions`: the urgent-fix escape hatch
119+
120+
A security fix published an hour ago is exactly the case where a 14-day cooldown is wrong. `exceptions` lets you take one package out of quarantine without lowering it for everything:
121+
122+
```yaml
123+
exceptions:
124+
- package: some-lib
125+
versions: ['4.17.21'] # optional: exempt only these versions
126+
reason: CVE-2026-1234, no backport available
127+
until: 2026-10-01 # optional: waiver expires
128+
```
129+
130+
- `reason` is **required**. A bypass whose justification is not written down cannot be reviewed later.
131+
- `versions` pins the waiver to exact versions (pnpm matches `name@1.0.0||1.0.1`), so a *later* release of the same package still waits. A name pattern like `@acme/*` cannot be version-pinned, and `pnpm-policy` rejects the combination rather than emitting something pnpm will not match.
132+
- `until` makes the waiver expire: after that date `pnpm-policy check` fails, so the exception has to be renewed or removed instead of quietly becoming permanent.
133+
134+
The reason travels into the generated file, beside the line it explains:
135+
136+
```yaml
137+
minimumReleaseAgeExclude:
138+
- "@acme/*"
139+
- some-lib@4.17.21 # CVE-2026-1234, no backport available (expires 2026-10-01)
140+
```
141+
142+
### `allowBuilds`
143+
144+
Install scripts are arbitrary code execution at install time, so pnpm blocks them by default and needs an explicit list. Give each entry a reason:
145+
146+
```yaml
147+
allowBuilds:
148+
esbuild: native binary, downloaded at install time
149+
core-js: polyfill postinstall
150+
```
151+
152+
That becomes pnpm's `allowBuilds` map (pnpm ≥ 10.16), with the reasons as inline comments. For older pnpm, `--builds-key onlyBuiltDependencies` emits the array form instead.
153+
154+
Unlike the release-age exemptions, this list is **not** derived from anything: a package that runs install scripts is a deliberate trust decision, whoever published it.
155+
156+
## The inventory
157+
158+
`pnpm-policy inventory` queries `registry.npmjs.org` for `maintainer:<account>`, paginates, and writes:
159+
160+
```json
161+
{
162+
"generatedAt": "2026-01-01T00:00:00.000Z",
163+
"maintainers": ["your-npm-username"],
164+
"scopes": ["@acme"],
165+
"packages": ["my-unscoped-package", "another-one"]
166+
}
167+
```
168+
169+
Two rules govern how it compresses:
170+
171+
**Scope globs are opt-in.** `@acme/*` also exempts a package published into `@acme` by somebody else — so a scope is only globbed when you claim it in `scopes:`. npm has no "list a scope" API, and its search index is incomplete enough to report zero packages for a scope that plainly has some, so the registry cannot prove exclusivity for you. `--verify-scopes` will consult the index anyway and glob scopes it finds no foreign packages in; finding one *is* proof a scope is shared, but finding none is not proof that it is yours.
172+
173+
**Unscoped names are listed individually**, which is exact: they come from your own maintainer query.
174+
175+
**Intersection.** By default only names this workspace actually resolves (read from `pnpm-lock.yaml`) are written out — 1104 published packages becomes the ~85 that appear in this repo. Scope globs are never intersected: nobody else can publish into a scope you own, so the glob stays correct when a new package lands there tomorrow. Pass `--no-intersect` to emit everything.
176+
177+
Commit the inventory and review its diffs. It is an exemption list, so a name appearing in it is a name that stops being quarantined — worth one human glance, which is also why refreshing it should open a pull request rather than run silently in an install hook.
178+
179+
### Sharing an inventory across repos
180+
181+
If you run many workspaces, publish the config and inventory as a small data-only package and point `inventory:` at it:
182+
183+
```yaml
184+
inventory: "@acme/pnpm-policy/inventory.json"
185+
```
186+
187+
Pin it exactly (a pnpm `configDependency` gives you version + integrity), so the exemption list cannot change under a repo without a visible bump.
188+
189+
## Generated output
190+
191+
`generate` **patches** `pnpm-workspace.yaml` rather than rewriting it: your `packages:`, `catalog:`, and hand-written comments survive untouched, and only the policy keys are managed. Output is deterministic, so a second run produces no diff.
192+
193+
```yaml
194+
packages:
195+
- packages/*
196+
197+
# Managed by pnpm-policy — run `pnpm-policy generate` after editing pnpm-policy.yaml.
198+
199+
# A third-party release must be 2w old before it can be installed.
200+
# Most malicious releases are found and yanked well inside that window.
201+
minimumReleaseAge: 20160
202+
# Exempt from the wait: 1 scope glob(s), 2 first-party package(s).
203+
# First-party membership comes from what your-npm-username publishes on npm — waiting on your own release protects nothing.
204+
minimumReleaseAgeExclude:
205+
- "@acme/*"
206+
- my-unscoped-package
207+
# The only dependencies permitted to run install scripts.
208+
allowBuilds:
209+
esbuild: true # native binary, downloaded at install time
210+
```
211+
212+
Managed keys are `minimumReleaseAge`, `minimumReleaseAgeExclude`, the builds key, and `blockExoticSubdeps`, plus anything under `settings`. A managed key the policy no longer sets is removed, so dropping `blockExoticSubdeps` from the config stops enforcing it rather than leaving a stale rule behind.
213+
214+
## Drift checking in CI
215+
216+
```yaml
217+
- run: npx pnpm-policy check
218+
```
219+
220+
`check` fails when the workspace file no longer matches what the config would generate — someone hand-edited `minimumReleaseAge: 0`, or the inventory was refreshed without regenerating — and when a waiver's `until` date has passed. The error says which, and how to fix it.
221+
222+
## CLI
223+
224+
```
225+
pnpm-policy <command> [options]
226+
227+
Commands:
228+
init Write a starter pnpm-policy.yaml
229+
inventory Query npm for what your maintainers publish, and write the export
230+
generate Patch the policy into pnpm-workspace.yaml
231+
check Fail if the workspace file drifted or a waiver expired
232+
233+
Options:
234+
--cwd <dir> Workspace root (default: current directory)
235+
--config <path> Config file, or a directory holding one
236+
--out <path> inventory: where to write the export
237+
--builds-key <key> allowBuilds (default) or onlyBuiltDependencies, for pnpm < 10.16
238+
--no-intersect Emit every first-party name, not just the ones this workspace resolves
239+
--verify-scopes inventory: also glob a scope the registry shows nobody else publishing into
240+
--registry <url> inventory: registry to query (default: https://registry.npmjs.org)
241+
--throttle <ms> inventory: pause between registry requests (default: 1000)
242+
--json Print machine-readable output
243+
-q, --quiet Only print errors
244+
```
245+
246+
## Library
247+
248+
The CLI is a thin wrapper; everything is available programmatically.
249+
250+
```ts
251+
import { buildInventory, check, generate, loadConfig, resolvePolicy } from 'pnpm-policy';
252+
253+
const { report, changed } = generate({ cwd: process.cwd() });
254+
console.log(`${report.scopes.length} scopes, ${report.firstPartyPackages.length} packages exempt`);
255+
256+
const result = check({ cwd: process.cwd() });
257+
if (!result.ok) process.exit(1);
258+
```
259+
260+
| Export | Purpose |
261+
| --- | --- |
262+
| `loadConfig(pathOrDir)` | Find, read, and validate a policy config. |
263+
| `buildInventory(maintainers, options)` | Query npm and compress the result. |
264+
| `readInventory` / `writeInventory` | Inventory JSON I/O. |
265+
| `resolvePolicy({ config, inventory, resolved })` | Config + inventory → pnpm settings, with rationale. |
266+
| `applyPolicy(source, policy)` | Patch a policy into a `pnpm-workspace.yaml` string. |
267+
| `writeWorkspacePolicy` / `workspaceDrift` | Write, or diff without writing. |
268+
| `generate` / `check` | The two CLI commands, end to end. |
269+
| `readLockfilePackages(path)` | Every package name a lockfile resolves. |
270+
| `packagesByMaintainer(name, options)` | Paginated, throttled maintainer search. |
271+
| `parseDuration` / `formatDuration` | `14d``20160`. |
272+
273+
Registry calls throttle (1s between requests by default) and retry on 429 and 5xx, honouring `Retry-After` — npm's search endpoint rate-limits bursts. Pass `fetchImpl` to supply your own client.
274+
275+
## What this does not do
276+
277+
- It does not verify package *contents* — no signature checking, no provenance attestation. It decides who waits, not who is trustworthy.
278+
- It cannot prove you own a scope. See the inventory section.
279+
- It does not make `allowBuilds` decisions for you.
280+
- Exempting your own packages means a compromise of *your* npm account is not slowed down by the quarantine. Protect the account: 2FA, granular tokens, trusted publishing.
281+
282+
## Related
283+
284+
- [pnpm `minimumReleaseAge`](https://pnpm.io/settings#minimumreleaseage)
285+
- [pnpm `allowBuilds`](https://pnpm.io/settings#allowbuilds)
286+
- [`yamlize`](https://github.com/constructive-io/dev-utils/tree/main/packages/yamlize) — the comment-preserving YAML layer this uses
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { mkdtempSync, readFileSync, writeFileSync } from 'fs';
2+
import { tmpdir } from 'os';
3+
import { join } from 'path';
4+
5+
import { parseArgs, run } from '../src/cli';
6+
7+
describe('parseArgs', () => {
8+
it('reads a command and its options', () => {
9+
const parsed = parseArgs(['generate', '--cwd', '/tmp/ws', '--json']);
10+
expect(parsed).toMatchObject({ command: 'generate', cwd: '/tmp/ws', json: true });
11+
});
12+
13+
it('accepts --flag=value as well as --flag value', () => {
14+
expect(parseArgs(['check', '--config=./policy.yaml']).config).toBe('./policy.yaml');
15+
});
16+
17+
it('leaves scope verification off unless asked for', () => {
18+
expect(parseArgs(['inventory']).verifyScopes).toBe(false);
19+
expect(parseArgs(['inventory', '--verify-scopes']).verifyScopes).toBe(true);
20+
});
21+
22+
it('rejects a builds key pnpm does not have', () => {
23+
expect(() => parseArgs(['generate', '--builds-key', 'allowScripts'])).toThrow(
24+
/--builds-key must be/
25+
);
26+
});
27+
28+
it('rejects an unknown option instead of ignoring it', () => {
29+
expect(() => parseArgs(['generate', '--yolo'])).toThrow(/Unknown option/);
30+
});
31+
});
32+
33+
describe('run', () => {
34+
const log = jest.spyOn(console, 'log').mockImplementation(() => undefined);
35+
const error = jest.spyOn(console, 'error').mockImplementation(() => undefined);
36+
37+
afterEach(() => {
38+
log.mockClear();
39+
error.mockClear();
40+
});
41+
42+
afterAll(() => {
43+
log.mockRestore();
44+
error.mockRestore();
45+
});
46+
47+
function workspace(): string {
48+
const dir = mkdtempSync(join(tmpdir(), 'pnpm-policy-cli-'));
49+
writeFileSync(join(dir, 'pnpm-lock.yaml'), "lockfileVersion: '9.0'\npackages:\n yanse@1.0.0: {}\n");
50+
return dir;
51+
}
52+
53+
it('init writes a starter config, and refuses to overwrite one', async () => {
54+
const dir = workspace();
55+
expect(await run(['init', '--cwd', dir])).toBe(0);
56+
expect(readFileSync(join(dir, 'pnpm-policy.yaml'), 'utf-8')).toContain('minimumReleaseAge');
57+
expect(await run(['init', '--cwd', dir])).toBe(1);
58+
});
59+
60+
it('generate then check round-trips to a clean exit', async () => {
61+
const dir = workspace();
62+
writeFileSync(join(dir, 'pnpm-policy.yaml'), 'minimumReleaseAge: 7d\n');
63+
64+
expect(await run(['generate', '--cwd', dir])).toBe(0);
65+
expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf-8')).toContain(
66+
'minimumReleaseAge: 10080'
67+
);
68+
expect(await run(['check', '--cwd', dir])).toBe(0);
69+
});
70+
71+
it('check exits non-zero on drift and says how to fix it', async () => {
72+
const dir = workspace();
73+
writeFileSync(join(dir, 'pnpm-policy.yaml'), 'minimumReleaseAge: 7d\n');
74+
writeFileSync(join(dir, 'pnpm-workspace.yaml'), 'minimumReleaseAge: 0\n');
75+
76+
expect(await run(['check', '--cwd', dir])).toBe(1);
77+
expect(error).toHaveBeenCalledWith(expect.stringContaining('pnpm-policy generate'));
78+
});
79+
80+
it('reports a config error without a stack trace', async () => {
81+
const dir = workspace();
82+
expect(await run(['generate', '--cwd', dir])).toBe(1);
83+
expect(error).toHaveBeenCalledWith(expect.stringContaining('No policy config found'));
84+
});
85+
86+
it('shows usage for no command, and for an unknown one', async () => {
87+
expect(await run([])).toBe(2);
88+
expect(await run(['frobnicate'])).toBe(2);
89+
});
90+
91+
it('prints help on request', async () => {
92+
expect(await run(['--help'])).toBe(0);
93+
expect(log).toHaveBeenCalledWith(expect.stringContaining('pnpm-policy <command>'));
94+
});
95+
});

0 commit comments

Comments
 (0)