@pipobscure/bundle 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/HISTORY.md +1924 -0
  2. package/README.md +623 -0
  3. package/bundle.run +0 -0
  4. package/dist/api.d.ts +147 -0
  5. package/dist/api.d.ts.map +1 -0
  6. package/dist/api.js +174 -0
  7. package/dist/api.js.map +1 -0
  8. package/dist/archive.d.ts +115 -0
  9. package/dist/archive.d.ts.map +1 -0
  10. package/dist/archive.js +188 -0
  11. package/dist/archive.js.map +1 -0
  12. package/dist/audit.d.ts +78 -0
  13. package/dist/audit.d.ts.map +1 -0
  14. package/dist/audit.js +119 -0
  15. package/dist/audit.js.map +1 -0
  16. package/dist/cli.d.ts +23 -0
  17. package/dist/cli.d.ts.map +1 -0
  18. package/dist/cli.js +555 -0
  19. package/dist/cli.js.map +1 -0
  20. package/dist/files.d.ts +53 -0
  21. package/dist/files.d.ts.map +1 -0
  22. package/dist/files.js +118 -0
  23. package/dist/files.js.map +1 -0
  24. package/dist/index.d.ts +10 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +35 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/launch.d.ts +97 -0
  29. package/dist/launch.d.ts.map +1 -0
  30. package/dist/launch.js +267 -0
  31. package/dist/launch.js.map +1 -0
  32. package/dist/main.d.ts +3 -0
  33. package/dist/main.d.ts.map +1 -0
  34. package/dist/main.js +19 -0
  35. package/dist/main.js.map +1 -0
  36. package/dist/manifest.d.ts +139 -0
  37. package/dist/manifest.d.ts.map +1 -0
  38. package/dist/manifest.js +504 -0
  39. package/dist/manifest.js.map +1 -0
  40. package/dist/oidc.d.ts +40 -0
  41. package/dist/oidc.d.ts.map +1 -0
  42. package/dist/oidc.js +320 -0
  43. package/dist/oidc.js.map +1 -0
  44. package/dist/preload.d.ts +14 -0
  45. package/dist/preload.d.ts.map +1 -0
  46. package/dist/preload.js +38 -0
  47. package/dist/preload.js.map +1 -0
  48. package/dist/provider.d.ts +83 -0
  49. package/dist/provider.d.ts.map +1 -0
  50. package/dist/provider.js +206 -0
  51. package/dist/provider.js.map +1 -0
  52. package/dist/record.d.ts +2 -0
  53. package/dist/record.d.ts.map +1 -0
  54. package/dist/record.js +23 -0
  55. package/dist/record.js.map +1 -0
  56. package/dist/recorder.d.ts +64 -0
  57. package/dist/recorder.d.ts.map +1 -0
  58. package/dist/recorder.js +111 -0
  59. package/dist/recorder.js.map +1 -0
  60. package/dist/register.d.ts +2 -0
  61. package/dist/register.d.ts.map +1 -0
  62. package/dist/register.js +28 -0
  63. package/dist/register.js.map +1 -0
  64. package/dist/sea.d.ts +97 -0
  65. package/dist/sea.d.ts.map +1 -0
  66. package/dist/sea.js +220 -0
  67. package/dist/sea.js.map +1 -0
  68. package/dist/sigstore.d.ts +112 -0
  69. package/dist/sigstore.d.ts.map +1 -0
  70. package/dist/sigstore.js +385 -0
  71. package/dist/sigstore.js.map +1 -0
  72. package/dist/skill.d.ts +36 -0
  73. package/dist/skill.d.ts.map +1 -0
  74. package/dist/skill.js +108 -0
  75. package/dist/skill.js.map +1 -0
  76. package/package.json +84 -0
  77. package/shell-base +2 -0
  78. package/skills/audit-bundle/SKILL.md +271 -0
  79. package/src/api.ts +293 -0
  80. package/src/archive.ts +312 -0
  81. package/src/audit.ts +206 -0
  82. package/src/cli.ts +575 -0
  83. package/src/files.ts +156 -0
  84. package/src/index.ts +114 -0
  85. package/src/launch.ts +336 -0
  86. package/src/main.ts +20 -0
  87. package/src/manifest.ts +615 -0
  88. package/src/oidc.ts +372 -0
  89. package/src/preload.ts +40 -0
  90. package/src/provider.ts +270 -0
  91. package/src/record.ts +25 -0
  92. package/src/recorder.ts +166 -0
  93. package/src/register.ts +30 -0
  94. package/src/sea.ts +341 -0
  95. package/src/sigstore.ts +492 -0
  96. package/src/skill.ts +132 -0
  97. package/src/types/node-vfs.d.ts +90 -0
  98. package/src/types/node-zip.d.ts +85 -0
package/README.md ADDED
@@ -0,0 +1,623 @@
1
+ # @pipobscure/bundle
2
+
3
+ Ship a Node.js application as **one signed file** that the runtime refuses to run if it has
4
+ been tampered with.
5
+
6
+ ```sh
7
+ bundle create --base ./app --files app.manifest --output app.bundle # archive it
8
+ bundle sign --launcher --output app.run app.bundle # sign, via sigstore
9
+ ./app.run # and it is a program
10
+ ```
11
+
12
+ The archive is a real ZIP with a signature over the whole file. Mounting it through
13
+ `node:vfs` is what enforces that signature: the provider verifies before it returns a
14
+ filesystem, so an archive that does not check out never becomes one and its entry point
15
+ never runs. Every member is re-hashed against its signed digest as it is read, for the life
16
+ of the process.
17
+
18
+ > **Requires Node 26.10 or later**, run with `--experimental-vfs`. Every piece this needs is
19
+ > in a released Node; the last, the `--vfs-load` loader, shipped in v26.10.0. Building a
20
+ > single executable additionally needs one open pull request. See
21
+ > [Requirements](#requirements). Everything here is experimental.
22
+
23
+ ---
24
+
25
+ ## Contents
26
+
27
+ - [Install](#install) · [The four steps](#the-four-steps) · [CLI](#cli)
28
+ - [Using it from code](#using-it-from-code) · [Exports](#exports)
29
+ - [Executables that verify before they run](#executables-that-verify-before-they-run)
30
+ - [How it works](#how-it-works) · [What it does and does not prove](#what-it-does-and-does-not-prove)
31
+ - [Requirements](#requirements) · [Development](#development) · [Reading further](#reading-further)
32
+
33
+ ---
34
+
35
+ ## Install
36
+
37
+ ```sh
38
+ npm install @pipobscure/bundle # the library
39
+ npx @pipobscure/bundle --help # the CLI, without installing
40
+ ```
41
+
42
+ The `bundle` command npm installs **is** the signed archive. `bin` points straight at
43
+ `bundle.run` — the CLI, its skill and its whole dependency tree in one file, behind a two-line
44
+ `#!/bin/sh` prefix that mounts it and runs it. There is no wrapper script in between, which
45
+ is the point: nothing unsigned stands between you and the artifact, and
46
+
47
+ ```sh
48
+ head -c 100 "$(npm root)/@pipobscure/bundle/bundle.run" # what it will do
49
+ unzip -l "$(npm root)/@pipobscure/bundle/bundle.run" # everything it contains
50
+ bundle verify "$(npm root)/@pipobscure/bundle/bundle.run" # who signed it
51
+ ```
52
+
53
+ answer every question about it without running anything. See
54
+ [the tool as a bundle of itself](HISTORY.md#where-this-ends-up-the-tool-as-a-bundle-of-itself).
55
+
56
+ Running it by name does not verify it — the kernel gives a `#!` launcher no preload to carry
57
+ a provider, and this package says so rather than pretending otherwise. Verification is a
58
+ separate act, done with a copy of `bundle` you already trust: `bundle verify bundle.run` to
59
+ check it, or `bundle run bundle.run -- <args>` to execute it through the verifying mount.
60
+
61
+ ---
62
+
63
+ ## The four steps
64
+
65
+ Building a bundle is four steps, in this order:
66
+
67
+ ```
68
+ 1. observe run the application, and write down every file it actually reads
69
+ 2. create archive exactly that list, unsigned
70
+ 3. audit review it — against the last release, if there is one
71
+ 4. sign only if step 3 came back clean
72
+ ```
73
+
74
+ ### 1. Observe
75
+
76
+ Static analysis is perennially wrong about dynamic `require`, data files and conditional
77
+ imports. So the file list comes from running the thing:
78
+
79
+ ```sh
80
+ BUNDLE_MANIFEST=app.manifest node --experimental-vfs \
81
+ -r @pipobscure/bundle/record --vfs-load=./app -- <args>
82
+ ```
83
+
84
+ Every file read through the mount is appended to `app.manifest`, one path per line, as it is
85
+ read — so a killed process still leaves a usable list. Read-only `open()`s count too, which
86
+ catches streamed files that a `readFile` hook would miss.
87
+
88
+ Observation has one blind spot worth knowing: code on a path the run never took. For a
89
+ dependency tree, pair it with a computed closure — see [`moduleFiles`](#using-it-from-code).
90
+
91
+ ### 2. Create
92
+
93
+ ```sh
94
+ bundle create --base ./app --files app.manifest --output app.bundle
95
+ ```
96
+
97
+ Unsigned, and deliberately so. This is the single input to every shape you ship.
98
+
99
+ ### 3. Audit
100
+
101
+ A signature is a claim about bytes you stand behind, so the review belongs *before* it:
102
+
103
+ ```sh
104
+ bundle audit app.bundle # what is about to be reviewed, and how
105
+ bundle skill # install the audit skill into .claude/skills/
106
+ claude "/audit-bundle app.bundle" # verify, extract, read every member
107
+ bundle audit --check app.bundle # exits non-zero without a clean verdict
108
+ ```
109
+
110
+ `bundle audit` does the two mechanical halves around the review. On its own it reports the
111
+ archive's hash, its members and — with `--baseline <previous>` — what changed since the last
112
+ release you approved. With `--check` it is a **gate**: it reads the JSON verdict the skill
113
+ writes and refuses unless that verdict passed *and* names the sha256 of the bytes on disk,
114
+ so rebuilding invalidates an approval. `--approve --note '<what you checked>'` records a
115
+ verdict you reached by reading the archive yourself.
116
+
117
+ There is deliberately no environment variable that turns the gate off. It is a command you
118
+ choose to put in your pipeline — if you do not want it, do not put it there.
119
+
120
+ [`audit-bundle`](skills/audit-bundle/SKILL.md) is a [Claude Code](https://claude.com/claude-code)
121
+ skill that verifies the archive, extracts it, and security-reviews every file — load-time
122
+ hooks, encoded payloads, outbound calls, credential and CI-token reads, `eval` and dynamic
123
+ `require`, and members nothing references. Because a bundle is a **closed set** — nothing
124
+ resolves later, nothing is fetched at install — the review can actually be complete.
125
+
126
+ It is the same review whoever receives the bundle should run before trusting it. That is the
127
+ point: hold your own artifact to the standard you would hold someone else's. It can also
128
+ review only the **diff** against a previously approved archive, which is the realistic
129
+ repeat-use case.
130
+
131
+ ### 4. Sign
132
+
133
+ ```sh
134
+ bundle audit --check app.bundle && bundle sign --launcher --output app.run app.bundle
135
+ ```
136
+
137
+ Through **sigstore** by default: an ambient CI identity when there is one, otherwise a
138
+ browser sign-in. No long-lived key exists to steal — the certificate lasts about ten
139
+ minutes, and a transparency-log entry and timestamp are what let it verify afterwards.
140
+
141
+ Or against a certificate authority of your own:
142
+
143
+ ```sh
144
+ bundle sign --key leaf.key --chain chain.pem --output app.signed.bundle app.bundle
145
+ ```
146
+
147
+ Signing is separate from building, and that is what makes one build serve every target:
148
+
149
+ ```sh
150
+ bundle sign --launcher --output app.run app.bundle # a file you can run by name
151
+ bundle sea --output app.sea app.bundle # standalone executable
152
+ bundle sign --output app.signed.bundle app.bundle # plain, for a mount
153
+ ```
154
+
155
+ Each is correctly offset and signed over its own finished bytes. `--launcher` prepends the
156
+ shell prefix this package ships, so nobody has to know it lives inside `node_modules`;
157
+ `--prefix <file>` takes a launcher of your own, or a node binary.
158
+
159
+ ---
160
+
161
+ ## CLI
162
+
163
+ ```
164
+ bundle <command> [options]
165
+
166
+ create build an archive from a list of files
167
+ sign sign an archive into a new file, optionally behind a prefix
168
+ audit report what is about to be reviewed, and gate signing on the verdict
169
+ verify verify an archive and report its trust state
170
+ run mount a signed archive and run it
171
+ sea build a node runtime that verifies an archive before running it
172
+ trust refresh the sigstore trust root
173
+ skill install the bundle-auditing skill into a project
174
+ ```
175
+
176
+ `bundle <command> --help` — or [`src/cli.ts`](src/cli.ts) — has every option. The ones worth
177
+ knowing:
178
+
179
+ ### `verify`
180
+
181
+ ```sh
182
+ bundle verify --root ca.pem --json app.bundle
183
+ ```
184
+
185
+ Reports one of four states, and exits with the matching code:
186
+
187
+ | State | Exit | Meaning |
188
+ |---|---|---|
189
+ | `valid` | 0 | Hash, signature and every member digest are sound, and the chain is trusted. |
190
+ | `valid-untrusted` | 1 | All of that is sound; the certificate is not one you can place — or a sigstore trust root is missing, or a required identity did not match. |
191
+ | `unsigned` | 3 | No manifest, or a manifest with no signature. |
192
+ | `invalid` | 2 | The bytes changed since signing, a member's digest does not match its content, or the archive no longer parses as a ZIP at all. |
193
+
194
+ Note which side of the line "I could not check" falls on. Not being *able* to verify is
195
+ `valid-untrusted`, never `invalid` — conflating them is how people are trained to click
196
+ through warnings.
197
+
198
+ A certificate chain is trusted only for what it was issued for. The leaf must carry the
199
+ code-signing extended key usage, and everything above it must be a CA — otherwise the key
200
+ of any publicly trusted certificate, a web server's TLS certificate included, could sign an
201
+ archive that reads as `valid`. A root given with `--root` that *is* the leaf is trusted as
202
+ itself: that is pinning, and you chose it.
203
+
204
+ `--identity` and `--issuer` demand a particular sigstore signer, matched exactly — never as
205
+ a pattern. A mismatch is
206
+ `valid-untrusted`: the signature is genuine, it is simply not the one you asked for. An
207
+ archive signed against an ordinary CA carries no identity claim at all, so it also reads as
208
+ `valid-untrusted` under such a policy rather than passing.
209
+
210
+ ### `audit`
211
+
212
+ ```sh
213
+ bundle audit [--baseline <archive>] [--verdict <file>] [--check|--approve] <archive>
214
+ ```
215
+
216
+ The gate described above. Exits non-zero when `--check` finds no verdict, a verdict over
217
+ different bytes, a verdict reached against a different baseline, or one that failed.
218
+
219
+ ### `run`
220
+
221
+ ```sh
222
+ bundle run --root ca.pem app.signed.bundle -- --your --app --args
223
+ ```
224
+
225
+ Re-execs Node with the preload and the mount, so what runs is what the child's own bootstrap
226
+ verified. Everything after `--` is the application's argv.
227
+
228
+ ### `skill`
229
+
230
+ ```sh
231
+ bundle skill # -> .claude/skills/audit-bundle/SKILL.md
232
+ bundle skill --list # what this package carries
233
+ bundle skill --dir <dir> --force
234
+ ```
235
+
236
+ Never overwrites a file that is already there unless forced, so local edits survive.
237
+
238
+ ---
239
+
240
+ ## Using it from code
241
+
242
+ Everything the CLI does, as an API. The CLI is a `parseArgs` wrapper over exactly these
243
+ functions and holds no logic of its own.
244
+
245
+ ```ts
246
+ import {
247
+ createBundle, signBundle, verifyBundle, inspectBundle, runBundle, fileSigner,
248
+ } from '@pipobscure/bundle';
249
+
250
+ // Build unsigned — the single input to every shape you ship.
251
+ await createBundle({ base: 'app/', files, output: 'app.bundle' });
252
+
253
+ // Sign, once per shape.
254
+ const signer = fileSigner({ key: 'leaf.key', chain: 'chain.pem' });
255
+ await signBundle({ source: 'app.bundle', output: 'app.run', prefix: 'shell-base', signer });
256
+
257
+ // Ask what it claims, and then whether any of it is true.
258
+ const { members, signed, hash } = inspectBundle('app.run');
259
+ const { state, reason, identity } = await verifyBundle('app.run', { roots: ['ca.pem'] });
260
+
261
+ // Mount it through the verifying provider, in a child process, and run it.
262
+ const { status } = runBundle('app.signed.bundle', { roots: ['ca.pem'], args: ['--help'] });
263
+ ```
264
+
265
+ **Signers.** A signer is `{ chain, signAlg, sign(digest) }`. The chain goes into the archive
266
+ *before* hashing; `sign()` is called *after*, with the finished hash. That two-phase shape is
267
+ what lets sigstore work at all — the certificate has to be embedded before the bytes exist,
268
+ and the signature made after. `keySigner()` is the offline-CA implementation and
269
+ `@pipobscure/bundle/sigstore`'s `signer()` is the other one; a third (an HSM, a KMS, a
270
+ corporate signing service) is three properties away.
271
+
272
+ **Working out what to bundle.** `@pipobscure/bundle/recorder` observes a run;
273
+ `@pipobscure/bundle/files` computes a closure. Use both — the closure for completeness, the
274
+ observation as a cross-check:
275
+
276
+ ```ts
277
+ import { moduleFiles } from '@pipobscure/bundle/files';
278
+
279
+ const files = moduleFiles({
280
+ base: '.',
281
+ files: ['package.json'],
282
+ dirs: ['dist'],
283
+ dependencies: ['@sigstore/verify'], // and everything they depend on, transitively
284
+ filter: (name) => !name.endsWith('.map'),
285
+ });
286
+ ```
287
+
288
+ **Registering the verifying provider yourself**, when the environment variables are not
289
+ enough:
290
+
291
+ ```js
292
+ // my-preload.js — node --experimental-vfs -r ./my-preload.js --vfs-load=app.bundle
293
+ import { register } from '@pipobscure/bundle/provider';
294
+
295
+ register({
296
+ extensions: ['.bundle', '.app'], // claimed by name
297
+ claimSigned: true, // and anything carrying a signature marker, whatever it is called
298
+ roots: ['/etc/ssl/my-root.pem'], // PEM text or paths to PEM files
299
+ allowUntrusted: false,
300
+ identity: 'https://github.com/me/app/.github/workflows/release.yml@refs/heads/main',
301
+ issuer: 'https://token.actions.githubusercontent.com',
302
+ });
303
+ ```
304
+
305
+ A preload runs under the CommonJS loader, so it must contain no top-level `await`. ESM syntax
306
+ is otherwise fine, and `--import` works as well as `-r`.
307
+
308
+ ### Environment
309
+
310
+ A preload takes no arguments, so the mount is configured through the environment:
311
+
312
+ | | |
313
+ |---|---|
314
+ | `BUNDLE_MANIFEST` | where the recording provider writes the observed file list |
315
+ | `BUNDLE_ROOTS` | extra trusted roots, a path-delimiter-separated list of PEM files |
316
+ | `BUNDLE_ALLOW_UNTRUSTED` | mount an archive whose signature is good but unanchored |
317
+ | `BUNDLE_IDENTITY` / `BUNDLE_ISSUER` | require a particular sigstore signer at mount time |
318
+ | `BUNDLE_SIGSTORE_ROOT` | the sigstore trust root to check against, instead of the cache |
319
+ | `BUNDLE_NO_BROWSER` | never try to open a browser when signing; use the device flow |
320
+ | `BUNDLE_AUDIT_VERDICT` | where the audit skill writes its verdict, when CI asks for one |
321
+
322
+ ---
323
+
324
+ ## Exports
325
+
326
+ ```jsonc
327
+ {
328
+ ".": "create / sign / verify / inspect / run, from code",
329
+ "./register": "-r preload: mount only what is signed",
330
+ "./record": "-r preload: write down what a run reads",
331
+ "./sea": "build a verifying runtime, with or without an app inside",
332
+ "./launch": "verify a container, mount it, run it — and the runtime's CLI",
333
+ "./provider": "the verifying provider, and register(options)",
334
+ "./recorder": "the recording provider, and recording(Base, manifest)",
335
+ "./cli": "main(argv, io) -> exit code",
336
+ "./manifest": "the archive format on its own",
337
+ "./archive": "bundling and re-emitting",
338
+ "./files": "dependency closures",
339
+ "./skill": "the shipped skills, and installing them",
340
+ "./audit": "the audit gate: prepare, check, approve",
341
+ "./sigstore": "the sigstore signer and bundle verification",
342
+ "./oidc": "identity tokens: CI, browser, or device code"
343
+ }
344
+ ```
345
+
346
+ The package root deliberately does **not** re-export the two providers: importing either
347
+ needs `node:vfs`, and creating or verifying an archive does not, so `import
348
+ '@pipobscure/bundle'` must not drag that requirement in.
349
+
350
+ Written in TypeScript, published as ESM with declarations. The sources use erasable syntax
351
+ only, so `node src/main.ts` runs them directly under Node's type stripping.
352
+
353
+ ---
354
+
355
+ ## Executables that verify before they run
356
+
357
+ `bundle sea` builds a node runtime with this package inside it. What you do with that runtime
358
+ is the difference between the two shapes it can take.
359
+
360
+ **With an archive, it becomes that application** — one file that checks its own signature
361
+ before running anything:
362
+
363
+ ```
364
+ [ node runtime | SEA blob: stub + the verifier, as a mounted archive ] [ app.bundle ]
365
+ \_______________________ the prefix, and part of the _______________/
366
+ \______________________ archive's signed region ______/
367
+ ```
368
+
369
+ ```sh
370
+ bundle sea --output app.sea \
371
+ --root /etc/ssl/my-root.pem \
372
+ --identity 'https://github.com/me/app/.github/workflows/release.yml@refs/heads/main' \
373
+ --issuer 'https://token.actions.githubusercontent.com' \
374
+ app.bundle
375
+ ```
376
+
377
+ The whole-file hash covers the prefix too, so the runtime and the verifier inside it are
378
+ signed by the same signature that covers the application. There is nothing to check the
379
+ checker against, because the checker is inside what is checked.
380
+
381
+ **Without one, it becomes a verifying node** — a runtime that takes an archive on its command
382
+ line, checks it, and runs it:
383
+
384
+ ```sh
385
+ bundle sea --output node-verifying --root /etc/ssl/my-root.pem
386
+ ./node-verifying ./my-app.zip --args --for --the --app
387
+ ./node-verifying --verify ./my-app.zip # the trust state, without running it
388
+ ```
389
+
390
+ One runtime, any number of applications, none of them trusted until they verify. The
391
+ application sees the argv it would have had from `--vfs-load`: the archive where a script
392
+ path goes, its own arguments from index 2 on, and none of the runtime's flags — which is why
393
+ everything after the archive belongs to the program, `--help` included.
394
+
395
+ The two are the same binary. A verifying node with an archive appended to it — `bundle sign
396
+ --prefix node-verifying app.bundle` — *is* the self-validating executable, and at startup the
397
+ runtime decides which it is by looking at its own tail: a signed archive behind it runs that,
398
+ nothing behind it takes one from the command line, and an *unsigned* archive behind it is
399
+ refused rather than quietly treated as neither.
400
+
401
+ **Policy is baked in, or it is not.** The `--root`, `--identity` and `--issuer` given at build
402
+ time become the executable's own policy — the point being that a binary run by its own name
403
+ has no flags and no preload to configure it. A runtime built with a policy is **sealed**: it
404
+ takes no policy from its command line, because a binary that demands a signing identity is
405
+ not one whose user can ask it to stop. Build without one and the flags above work, falling
406
+ back to `BUNDLE_ROOTS` and friends, so one build can be decided about later.
407
+
408
+ From code, `createSeaBase()` and `buildSea()` split the expensive half (a ~155 MB copy of
409
+ Node) from the cheap one, `@pipobscure/bundle/launch` is the entry point all of this runs
410
+ through — `run()`, `runSelf()`, `verify()`, `main()` — and `verifySelf()` lets an application
411
+ report on its own provenance. The package rides inside the executable as an archive that node
412
+ mounts for itself: `"useVfs": true` ([nodejs/node#65675](https://github.com/nodejs/node/pull/65675),
413
+ released in v26.9.0) with `"vfsArchive"` ([nodejs/node#65810](https://github.com/nodejs/node/pull/65810),
414
+ still open), which is why the generated stub is a handful of lines and why there is no second
415
+ copy of the verifier anywhere.
416
+
417
+ ---
418
+
419
+ ## How it works
420
+
421
+ **The archive** is a ZIP. Its members each carry the hex digest of their own content in the
422
+ ZIP entry comment. A final `AUTHORITY.PEM` member declares the algorithms and carries the
423
+ signing certificate chain — a real, extractable filename, so `unzip` plus `openssl x509`
424
+ tells you who signed something without any of this code.
425
+
426
+ **The signature** covers the *entire file*: any prefix, every member, the whole central
427
+ directory, and the fixed part of the end-of-central-directory record — everything up to the
428
+ EOCD's trailing comment. That comment then records both:
429
+
430
+ ```
431
+ [ prefix | members | AUTHORITY.PEM | central directory | EOCD ] [ comment ]
432
+ \_____________ hashed region → H ______________________/ SIGNED:H:S[:FIELD=…]
433
+ ```
434
+
435
+ Staged deliberately. The hash alone is a cheap, certificate-free integrity gate you can run
436
+ before deciding to mount anything; only then is the signature over that hash checked against
437
+ the leaf certificate; only then is the chain anchored. Because the hash covers the central
438
+ directory, it fixes *which* members exist and what each one's digest is, so changing any byte
439
+ after signing yields `invalid`.
440
+
441
+ The comment sits outside the hash on purpose: it is the unsigned-attribute region every
442
+ code-signing scheme eventually grows. Anything obtained *after* the signature exists cannot
443
+ be inside what the signature covers — which is where the sigstore bundle rides, carrying the
444
+ transparency-log entry and timestamp that establish *when* a ten-minute certificate was
445
+ valid. RFC 3161 puts timestamp tokens in CMS `unsignedAttrs` for exactly this reason.
446
+
447
+ **The mount** is where it stops being advisory. `--vfs-load` asks registered providers who
448
+ wants its source; this package's provider claims `.bundle` files by name and any file carrying
449
+ a signature marker by content — so renaming a signed archive cannot quietly downgrade it to
450
+ the unchecked built-in ZIP provider. It verifies before returning a filesystem, and re-hashes
451
+ each member as it is first read, because a `ZipFile` reads lazily from an open descriptor and
452
+ a file rewritten underneath a running program would otherwise be served unchecked.
453
+
454
+ **Prefixes.** ZIP offsets are absolute, so an archive can sit *after* arbitrary bytes and
455
+ still be a valid ZIP — which is what lets one build become a `#!` launcher, a native
456
+ executable, or a plain mountable archive. The prefix has to be chosen before offsets are
457
+ fixed, and therefore before the hash exists, which is exactly why signing re-emits an archive
458
+ rather than appending to one.
459
+
460
+ ---
461
+
462
+ ## What it does and does not prove
463
+
464
+ **It proves provenance.** The code is the code that was signed, by someone holding that
465
+ certificate, and the runtime enforces it rather than the application checking itself.
466
+
467
+ **It does not prove safety.** Every significant npm compromise of recent years shipped a
468
+ correctly published, correctly signed package from a legitimately compromised account. A
469
+ signature would have confirmed it came from the real maintainer and been useless. That is
470
+ what step 3 is for, and why it is a separate step performed by a reviewer rather than a
471
+ property of the format.
472
+
473
+ Other limits, stated plainly:
474
+
475
+ - **VFS is not a sandbox.** It redirects `fs` calls; it does not confine untrusted code.
476
+ Verified code runs with the full authority of the process.
477
+ - **The gate is only as strong as how Node was launched.** Anyone who can change the command
478
+ line can drop the `-r`, and the mount falls back to the built-in provider, which checks
479
+ nothing. Registration is a userland opt-in, not a runtime policy. A SEA closes this for
480
+ itself by carrying its own bootstrap.
481
+ - **A shebang archive does not self-verify.** The kernel gives it no preload flag to carry a
482
+ provider. Mount it with the preload, or use a SEA.
483
+ - **A sigstore signature is public.** Signing puts your identity, the archive's hash and the
484
+ time in an append-only log. That is the mechanism working — it is what makes the
485
+ ten-minute certificate verifiable later — not something to discover afterwards.
486
+ - **Everything here is experimental**, including the Node it needs.
487
+
488
+ ---
489
+
490
+ ## Requirements
491
+
492
+ Everything here sits on Node's experimental `node:vfs` (by Matteo Collina) and runs under
493
+ `--experimental-vfs`. Where each piece stands, as of 2026-09-02:
494
+
495
+ | Piece | Where it is |
496
+ |---|---|
497
+ | **`node:vfs`**, and modules resolving and loading out of a mount | released, v26.4.0 |
498
+ | **ZIP support in `node:zlib`** — `ZipFile`, `ZipBuffer`, `ZipEntry` | released, v26.8.0 |
499
+ | **`ZipProvider`**, a VFS provider backed by such an archive | released, v26.9.0 — [nodejs/node#64915](https://github.com/nodejs/node/pull/64915) |
500
+ | **Native addons loaded from a mount** | released, v26.9.0 — [nodejs/node#65680](https://github.com/nodejs/node/pull/65680) |
501
+ | **`"useVfs"`**, a SEA's assets behind a VFS mount | released, v26.9.0 — [nodejs/node#65675](https://github.com/nodejs/node/pull/65675) |
502
+ | **`--vfs-load`**, and `vfs.registerProvider()` | released, v26.10.0 — [nodejs/node#65748](https://github.com/nodejs/node/pull/65748) |
503
+ | **`"vfsArchive"`**, a ZIP as a SEA's file system — `bundle sea` only | open — [nodejs/node#65810](https://github.com/nodejs/node/pull/65810) |
504
+
505
+ v26.9.0 already had everything a program needs to *be* an archive: it reads ZIP archives,
506
+ turns one into a file system, resolves modules out of it, and loads native addons from it.
507
+ v26.10.0 added the way to ask for that mount from *outside* the program, which is the whole
508
+ hinge: **`--vfs-load`** makes a mounted tree the thing a program resolves and runs from, and
509
+ the same pull request brings `vfs.registerProvider()` — the extension point that lets a
510
+ preload decide what backs a mount, and therefore the one that makes a *verifying* mount
511
+ possible from userland at all.
512
+
513
+ **`--vfs-load` is the only flag.** v26.10.0 also shipped `--vfs-mount`, which mounted a source
514
+ without running it, and the next patch release removes it
515
+ ([nodejs/node#66162](https://github.com/nodejs/node/pull/66162)): nothing needs more than one
516
+ mount from the command line, and a program that wants more mounts them through `node:vfs`,
517
+ where it also holds the instance. The same change reserves layer 0 for the `--vfs-load`
518
+ source, so it sits at the same mount point in every thread whatever else is mounted, and
519
+ mounts a program makes itself are numbered from 1. Nothing here uses `--vfs-mount`, and
520
+ mount points stay node's to assign: named mounts
521
+ ([nodejs/node#66119](https://github.com/nodejs/node/pull/66119)) were closed rather than
522
+ merged.
523
+
524
+ [nodejs/node#65810](https://github.com/nodejs/node/pull/65810) is needed only to build an
525
+ executable. It lets a SEA's file system *be* a ZIP archive rather than a list of assets, which
526
+ is how this package gets inside one: `bundle sea` embeds the verifier bundle whole and node
527
+ mounts it. It is still open; everything else here works without it.
528
+
529
+ Native addons out of a mount shipped in v26.9.0, as
530
+ [nodejs/node#65680](https://github.com/nodejs/node/pull/65680), which closed the last gap in
531
+ what a bundle can contain. A `dlopen()` needs a path with an inode behind it and a VFS path
532
+ has none, so it reads the addon's bytes out of the mount and loads them from a private,
533
+ self-cleaning image instead — an anonymous memfd on Linux, an unlinked temp file elsewhere.
534
+ Before it, a bundle whose dependency tree included a `.node` file mounted fine and then failed
535
+ at `require`.
536
+
537
+ [HISTORY.md](HISTORY.md) explains each in detail and why they are worth having.
538
+
539
+ `openssl` on `PATH` is needed only to generate the throwaway PKI the tests use.
540
+
541
+ ---
542
+
543
+ ## Development
544
+
545
+ ```sh
546
+ npm install
547
+ npm run build # TypeScript -> dist/, with declarations
548
+ npm test # 145 tests; generates a throwaway PKI into build/certs/ on first run
549
+ npm run typecheck
550
+ ```
551
+
552
+ The suite needs Node 26.10 or later. The sixteen tests that build an executable also need
553
+ [nodejs/node#65810](https://github.com/nodejs/node/pull/65810); on a Node without it they skip
554
+ themselves and say why, and they run on the first Node that has it. [CI](.github/workflows/ci.yml)
555
+ runs the suite on every push to `main` and every pull request, on 26.10.0 — the floor
556
+ `package.json` promises.
557
+
558
+ Tests import the sources rather than the build, so they run under Node's type stripping. The
559
+ test PKI is generated on demand by `tools/testpki.ts` and is **never committed** — a private
560
+ key in a repository is a private key people sign with, and it would produce signatures that
561
+ look like provenance and carry none.
562
+
563
+ Building the tool the way the tool says to build things — the same four steps:
564
+
565
+ ```sh
566
+ npm run release:cli # 1-3: observe, pack, fetch the baseline, stop at the gate
567
+ npm run sign:cli:local # 4: refuses — nothing has been audited yet
568
+ BUNDLE_AUDIT_VERDICT=build/cli.audit.json claude "/audit-bundle build/cli.bundle"
569
+ npm run sign:cli:local # 4: now allowed -> bundle.run
570
+ ```
571
+
572
+ | Script | |
573
+ |---|---|
574
+ | `manifest:cli` | observe a run, close over the dependencies, write the file list |
575
+ | `pack:cli` | `bundle create` over that list |
576
+ | `baseline:cli` | fetch and verify the published release, to review against |
577
+ | `audit:cli` | `bundle audit` — report the diff and print the skill invocation |
578
+ | `approve:cli` | `bundle audit --approve` |
579
+ | `sign:cli` | `bundle audit --check`, then `bundle sign --launcher` through sigstore |
580
+ | `release:cli` | steps 1–3, stopping at the gate |
581
+
582
+ Only `manifest:cli` and `baseline:cli` are scripts of their own; the rest are the CLI. The
583
+ first observes a run and computes a dependency closure, the second fetches this package's
584
+ own published release from npm — both specific to how *this* project is built.
585
+
586
+ **The gate is real, and it is a shipped command** — `bundle audit --check`, not repo
587
+ tooling. It runs before signing, reads the JSON verdict the skill writes, and refuses unless
588
+ that verdict passed *and* pins the sha256 of the bytes on disk. Everything this repository
589
+ does to release itself is something you can do to your own project.
590
+
591
+ [`.github/workflows/publish.yml`](.github/workflows/publish.yml) is the whole pipeline as a
592
+ workflow — test, pack, fetch the published release, audit the diff, gate, sign through
593
+ sigstore with the workflow's OIDC identity, publish through npm trusted publishing, every
594
+ action pinned to a commit SHA. It runs on every push to `main` and does nothing unless
595
+ `package.json` names a version npm does not have yet: bumping the version *is* the release.
596
+ There is no npm token anywhere; npm trusts that workflow file by name.
597
+
598
+ ---
599
+
600
+ ## Reading further
601
+
602
+ - **[HISTORY.md](HISTORY.md)** — why this exists, what changed in Node and why those changes
603
+ make sense, and the experiment that produced the tool. The long-form argument, with the
604
+ implementation notes at the end.
605
+ - **[HISTORY.md § Implementation notes](HISTORY.md#implementation-notes)** — design
606
+ decided before it was built, and what departed from the plan: signing-time attestation,
607
+ the audit skill, shipping the tool as a bundle of itself, the self-validating executable,
608
+ and the audit as a build step.
609
+ - **[examples/static-server/](examples/static-server/)** — an example application: a static web
610
+ server that serves the directories and archives it is handed, built and signed the way this
611
+ README says to build things. It ships with the repository, not with the package.
612
+ - **[skills/audit-bundle/SKILL.md](skills/audit-bundle/SKILL.md)** — the review procedure.
613
+ - **[slides/](slides/)** — *Ship the Tree*, a talk about the project, kept in step with it.
614
+ `slides/index.html` opens in any browser with no build step; press <kbd>S</kbd> for the
615
+ speaker notes, which carry most of the argument.
616
+ **[Read it here](https://claude.ai/artifact/FsAKxG5e5rKYaUXArqb6Us)** —
617
+ same deck, published.
618
+
619
+ ---
620
+
621
+ ## License
622
+
623
+ [EUPL-1.2](https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12)
package/bundle.run ADDED
Binary file