@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/HISTORY.md ADDED
@@ -0,0 +1,1924 @@
1
+ # bundles — the argument
2
+
3
+ > **This is the long-form document: why this project exists, what it is arguing about
4
+ > Node.js packaging and software supply chains, and how the design got to where it is.**
5
+ > It was the README for most of the project's life and is kept as the reasoning, which is
6
+ > worth more than the diff.
7
+ >
8
+ > For **using** the tool — install, commands, API, the four steps of building a bundle —
9
+ > see [README.md](README.md). Design decisions written down before they were built, and
10
+ > what departed from the plan, are the [implementation notes](#implementation-notes) at
11
+ > the end of this document.
12
+ >
13
+ > Paths and script names here are current as of writing; where the repository has since
14
+ > moved things, the commands have been updated but the narrative has not been rewritten.
15
+
16
+ ---
17
+
18
+ A tool — **`bundle`** — for **bundling and distributing Node.js applications as single,
19
+ signed files**, and the experiment that produced it. It is one TypeScript package, published
20
+ as ESM, with four things in it:
21
+
22
+ - **`@pipobscure/bundle/record`** — a `node:vfs` provider you preload with `-r` that writes
23
+ down every file a run actually reads, so the list of what to archive comes from
24
+ observation rather than guesswork. It is the userland replacement for the
25
+ `--vfs-manifest` flag.
26
+ - **`@pipobscure/bundle/register`** — a **verifying `node:vfs` provider** you preload with
27
+ `-r` (or `--import`), so `node --vfs-load=app.bundle` mounts and runs an
28
+ application *only* if it is properly signed, and checks each member against its recorded
29
+ digest as that member is read.
30
+ - **`@pipobscure/bundle`** — the same operations as an API: `createBundle`, `signBundle`,
31
+ `verifyBundle`, `inspectBundle`, `runBundle`. The CLI is a `parseArgs` wrapper over these
32
+ and nothing else, so an embedder can do everything the command line can.
33
+ - **`bundle`**, the CLI — `create`, `sign`, `verify`, `run`, `sea`, `trust`, `skill`.
34
+
35
+ Plus **`@pipobscure/bundle/sea`**, which puts the verifying mount inside a single executable:
36
+ a Node runtime, this package as a mounted archive in its own SEA blob, and the application
37
+ appended as a signed archive — so the finished binary checks its own signature before
38
+ running anything, with the checker inside what is checked.
39
+
40
+ Signing is a step of its own rather than part of building, and that is what makes one build
41
+ serve every target. `create` produces an unsigned archive; `sign` re-emits it behind
42
+ whatever prefix you name and signs the finished bytes. So a single `app.bundle` becomes a
43
+ `#!` launcher, a self-contained executable and a plain mountable archive — each correctly
44
+ offset, each signed over itself.
45
+
46
+ That split is also what makes room for the step in the middle. Building a bundle goes:
47
+
48
+ ```
49
+ 1. observe run the application, and write down every file it actually reads
50
+ 2. create archive exactly that list, unsigned
51
+ 3. audit review it — against the last release, if there is one
52
+ 4. sign only if step 3 came back clean
53
+ ```
54
+
55
+ A signature is a claim about bytes you stand behind, so the review belongs *before* it,
56
+ not after. Step 3 is the [`audit-bundle` skill](skills/audit-bundle/SKILL.md), and it is
57
+ the same review whoever receives the bundle should run before trusting it — which is the
58
+ point: hold your own artifact to the standard you would hold someone else's.
59
+
60
+ A signature says who produced the bytes; it says nothing about whether they are safe.
61
+ Because a bundle is a closed set — nothing resolves later, nothing is fetched at install,
62
+ no lifecycle script pulls in more code — a review over one can actually be complete, which
63
+ is what makes step 3 worth doing at all.
64
+
65
+ The result is an application in one file that the runtime itself refuses to run when it
66
+ has been tampered with — either as a plain `.bundle` archive, as a small self-executing ZIP
67
+ that runs on any installed Node, or as a fully self-contained native executable that needs
68
+ no Node at all.
69
+
70
+ It is driven by Node.js itself, in three additions on top of Node's existing experimental
71
+ **virtual file system** (`node:vfs`, by Matteo Collina). All three have shipped since this
72
+ was written, the last of them in v26.10.0:
73
+
74
+ 1. **ZIP archive support in `node:zlib`** —
75
+ [nodejs/node#64339](https://github.com/nodejs/node/pull/64339), released in **v26.8.0** —
76
+ plus a **`ZipProvider`** that mounts such an archive through VFS as a file tree,
77
+ [nodejs/node#64915](https://github.com/nodejs/node/pull/64915), released in **v26.9.0**.
78
+ 2. A **`--vfs-load` module loader** that mounts a directory or an archive and resolves a
79
+ program's entry point and all its `require()`/`import` against it —
80
+ [nodejs/node#65748](https://github.com/nodejs/node/pull/65748), released in **v26.10.0**.
81
+ 3. **`vfs.registerProvider()`**, the extension point that lets a preloaded module
82
+ decide which provider backs a mount — which is what makes a *verifying* mount, or a
83
+ *recording* one, possible from userland at all. It came in the same pull request as the
84
+ flags, which is why that one was the piece nothing here could run without.
85
+
86
+ Together they let the root a program runs from be a plain `.zip` embedded inside the
87
+ program's own file. Combined with Node's newer **Single Executable Application (SEA)**
88
+ tooling, this turns "an application plus its files" into "one file you can `chmod +x` and
89
+ run."
90
+
91
+ ---
92
+
93
+ ## Why this exists
94
+
95
+ Shipping a Node application to someone else is still awkward. The options today are all
96
+ compromises:
97
+
98
+ - **A directory of files + `npm install`.** The user needs the right Node, a working
99
+ toolchain, and network access; `node_modules` is enormous and platform-specific for
100
+ anything with native addons.
101
+ - **A bundler (esbuild/webpack/ncc).** Collapses JS into one file, but assets, addons,
102
+ and anything that does `fs.readFile(__dirname + ...)` still leak out. You are shipping
103
+ a JS blob, not an application.
104
+ - **`pkg` / SEA.** Produce a real executable, but historically SEA only took a **single
105
+ CommonJS script**, code caching and asset handling were fiddly, and building one meant
106
+ bolting a WASM copy of `postject` onto the side of your build to inject a blob into the
107
+ binary.
108
+
109
+ The thing all of these dance around is that a real application is a *file tree*: an entry
110
+ point, sibling modules, a `package.json`, templates, static assets, maybe a native addon.
111
+ Node's module resolution and every `fs` call assume that tree lives on the real disk. If
112
+ you want to ship the tree *inside* a single file, you need Node to be able to treat
113
+ something-that-isn't-a-directory as the directory it resolves against.
114
+
115
+ That is exactly what these additions to Node provide.
116
+
117
+ ---
118
+
119
+ ## What changed in node
120
+
121
+ Three layers matter here, and it's worth being precise about who wrote what and where each
122
+ one lives:
123
+
124
+ - **The `node:vfs` subsystem is pre-existing.** It was written and merged (as an
125
+ experimental builtin) by **Matteo Collina** — not part of this work. It's summarized
126
+ below only because it's the foundation everything else stands on.
127
+ - **The novel work is two additions to Node:**
128
+ - **ZIP archive support in `node:zlib`** ([nodejs/node#64339](https://github.com/nodejs/node/pull/64339),
129
+ released in v26.8.0) and the **`ZipProvider`** that mounts an archive through VFS
130
+ ([nodejs/node#64915](https://github.com/nodejs/node/pull/64915), released in v26.9.0).
131
+ - The **`--vfs-load` module loader** that makes a mounted tree the thing a program
132
+ actually resolves and runs from, and the provider registry that decides what backs a
133
+ mount — [nodejs/node#65748](https://github.com/nodejs/node/pull/65748), released in
134
+ v26.10.0. Loading a **native addon** out of a mount
135
+ was a separate pull request, [nodejs/node#65680](https://github.com/nodejs/node/pull/65680),
136
+ released in v26.9.0.
137
+ - **The SEA group** is recent upstream Node functionality the experiment leans on, carried
138
+ along so the whole pipeline works from one binary.
139
+
140
+ ### 0. Foundation (pre-existing): `node:vfs` — a virtual file system with pluggable providers
141
+
142
+ *By Matteo Collina; here for context, not part of this work's contribution.* An experimental
143
+ builtin (`--experimental-vfs` to enable) exposing a `node:fs`-shaped API backed by a
144
+ swappable **provider**:
145
+
146
+ - **`MemoryProvider`** — an in-memory tree (the default); supports symlinks and watching,
147
+ and can be frozen read-only.
148
+ - **`RealFSProvider`** — wraps a real directory and maps every VFS path under it,
149
+ rejecting paths (and symlinks) that resolve outside the root. It gives a subtree *path
150
+ containment* it wouldn't otherwise have.
151
+
152
+ The full synchronous / callback / promise surfaces of `fs` are mirrored, and `Stats`
153
+ objects are real `fs.Stats`. Crucially, the docs are explicit that **VFS is not a sandbox** —
154
+ it redirects supported `fs` calls whose resolved path falls under a mount; it is not a
155
+ security boundary. That honesty matters for how it's positioned below.
156
+
157
+ ### 1. ZIP support in `node:zlib` *([nodejs/node#64339](https://github.com/nodejs/node/pull/64339) — merged, released in v26.8.0)*
158
+
159
+ `node:zlib` gains a small archive toolkit:
160
+
161
+ - **`ZipEntry`** — one immutable archive member (name, metadata, content), created from a
162
+ buffer or stream, or read back from raw bytes.
163
+ - **`ZipFile`** — a ZIP on disk, opened read-only by default (`{ writable: true }` to
164
+ mutate), with get/add/delete/stream-by-name and `compact()` to reclaim deleted space.
165
+ - **`ZipBuffer`** — the fully in-memory equivalent, serializable back to a `Buffer`.
166
+ - **`createZipArchive()` / `...Sync()`** — build a fresh archive from a list of entries,
167
+ returned as a `Readable` you can pipe straight to a file or socket.
168
+
169
+ Two details make the whole single-file trick possible:
170
+
171
+ - **`baseOffset`** — an archive records internal offsets; seeding them with a base offset
172
+ lets the archive stay valid even when it is **not at byte 0 of its file** — e.g. when
173
+ it's appended *after* a shebang line or after an entire Node binary.
174
+ - Read paths enforce content-size limits and reject malformed records (zip-bomb / corrupt
175
+ input guards), with dedicated `ERR_ZIP_*` codes.
176
+
177
+ ### 2. `ZipProvider` — a VFS provider backed by a ZIP archive *([nodejs/node#64915](https://github.com/nodejs/node/pull/64915) — released in v26.9.0)*
178
+
179
+ The bridge between the two: a provider for Matteo's `node:vfs` that exposes the entries of
180
+ a `ZipFile` (on disk) or `ZipBuffer` (in memory) as a browsable, read/write file tree.
181
+ Directories are recognized both explicitly and implicitly; a file opened for write commits
182
+ as a new archive entry when its handle is closed. This is what lets a `.zip` be *mounted*
183
+ and treated like a directory.
184
+
185
+ ### 3. `--vfs-load` — the keystone *([nodejs/node#65748](https://github.com/nodejs/node/pull/65748) — released in v26.10.0)*
186
+
187
+ This is what wires VFS into Node's *startup and module resolution* so a mounted tree
188
+ becomes the thing the program actually runs from. One flag does both halves: it mounts a
189
+ source and runs the program out of it.
190
+
191
+ - **`--vfs-load=<source>`** mounts `<source>` at a reserved mount point Node assigns, and
192
+ runs the entry point out of that mount, resolving it *and all subsequent `require()` /
193
+ `import`* against it instead of the real filesystem. It may be given at most once. The
194
+ mount's own `package.json` `"main"` decides what runs; a positional argument is the
195
+ program's own argument (from `argv[2]` on), never an entry-point override. `argv[1]`
196
+ reports the *source* rather than the mount point — which is what lets a launcher archive
197
+ read its own bytes and verify itself.
198
+ - The provider is chosen from the **source itself, not its name**: a **directory** is
199
+ mounted with `RealFSProvider`, and a **file whose bytes are a ZIP archive** with
200
+ `ZipProvider` — so an archive can be called anything at all.
201
+ - The mount point is deliberately not yours to choose, so a mount never shadows a real
202
+ path and no invocation can redirect one tree onto another. It is not yours to *name*
203
+ either: named mounts ([nodejs/node#66119](https://github.com/nodejs/node/pull/66119))
204
+ were proposed and closed.
205
+ - **It shipped with a second flag, and is losing it.** v26.10.0 also carries
206
+ `--vfs-mount`, which mounted a source without running it; the two appended to one list,
207
+ so `--vfs-load`'s entry point had to be recovered from its position among the mounts. The
208
+ next patch release removes it ([nodejs/node#66162](https://github.com/nodejs/node/pull/66162)),
209
+ on the grounds that nothing needs more than one mount from the command line — a program
210
+ that wants more mounts them through `node:vfs`, where it also holds the instance — and
211
+ reserves **layer 0** for the `--vfs-load` source, so it sits at the same mount point in
212
+ every thread whatever else has been mounted, with a program's own mounts numbered from 1.
213
+ - Before that, `--vfs-load` selected a mount by 0-based *index*, until early September
214
+ 2026 — which meant counting `--vfs-mount`s out by hand and left the flag's value
215
+ optional, and an optional value needs an alias onto a hidden index flag, the trick
216
+ `--inspect=<port>` uses. Naming the source removed the flag, the range check and the
217
+ counting; dropping `--vfs-mount` removes the list the index pointed into.
218
+ - **`--vfs-load` is refused in `NODE_OPTIONS`**: which entry point runs is the command
219
+ line's decision, and the environment must not be able to redirect any invocation on the
220
+ machine.
221
+ - The entry-point rule is precisely what makes a **self-mounting shebang** work:
222
+ `#!/usr/bin/env -S node --vfs-load`. The kernel appends the script's own path as the value
223
+ of the trailing `--vfs-load`, so the script mounts *itself* and runs its embedded
224
+ `package.json` main — one flag now that the flag names its source.
225
+ - To make this real, four module-resolution primitives (package.json reading,
226
+ nearest-scope lookup, legacy main resolution, extensionless format sniffing) were
227
+ changed to stop calling native bindings directly and instead go through a VFS-aware path
228
+ — deferring unchanged to the real bindings whenever no mount is active, so non-mounted
229
+ behavior is identical.
230
+ - **Worker threads** inherit the active mounts — including when constructed with an
231
+ explicit `execArgv`, which would otherwise get a fresh options parse and escape the
232
+ mount — so code cannot spawn an "escaped" worker. In a worker `--vfs-load` mounts but does
233
+ not load: the same sources are mounted in the same order, so the reserved paths line up,
234
+ and the worker runs its own entry point.
235
+ - **Native addons** were a separate pull request,
236
+ [nodejs/node#65680](https://github.com/nodejs/node/pull/65680), released in v26.9.0. The
237
+ reason they needed one is that
238
+ `dlopen()`/`LoadLibrary()` open a shared object *by path* and a VFS path has no inode to
239
+ open. It reads the addon's bytes out of the mount and loads them from a private,
240
+ self-cleaning image using the smallest on-disk footprint each platform allows: an
241
+ anonymous `memfd` through `/proc/self/fd` on Linux, so the bytes never touch the file
242
+ system at all; a file in a `0700` `mkdtemp()` directory, unlinked immediately after
243
+ loading, elsewhere on POSIX; a `FILE_FLAG_DELETE_ON_CLOSE` temp file on Windows. Addons on
244
+ the real file system are untouched and load directly. Before it, a bundle whose dependency
245
+ tree contained a `.node` file mounted and then failed at `require` — the last thing a bundle
246
+ could not carry.
247
+
248
+ Recording the path of *every file actually read through a mount* — by module resolution or
249
+ by the program's own `fs` calls — used to be a third flag, `--vfs-manifest`, implemented as
250
+ an observer hook inside `node:vfs`. It is now a **provider** instead, in this repo: see
251
+ [Recording the manifest](#recording-the-manifest). Either way it gives you a **dependency
252
+ manifest by observation**: run the app once, and you get the exact minimal set of files it
253
+ touches — the correct contents for the archive you're about to build.
254
+
255
+ ### 4. `vfs.registerProvider()` — choosing what backs a mount *(part of [nodejs/node#65748](https://github.com/nodejs/node/pull/65748))*
256
+
257
+ A mount source is not hard-wired to the built-in provider for its kind. `node:vfs` exposes
258
+ a small registry:
259
+
260
+ ```js
261
+ vfs.registerProvider({ name, canHandle(resolvedPath, stats), create(resolvedPath, stats) });
262
+ ```
263
+
264
+ Registered providers are consulted **before** the built-ins — the `ZipProvider` for an
265
+ archive and the `RealFSProvider` for a directory are themselves just the last two entries —
266
+ newest first, and selection happens *after* preload modules have run. That is the whole
267
+ point: a preloaded module can install a provider for the source about to be mounted. A
268
+ registered provider is offered **directories as well as files**, so it can back, wrap or vet
269
+ any source rather than only adding a format.
270
+
271
+ ```sh
272
+ node --experimental-vfs -r @pipobscure/bundle/register --vfs-load=app.bundle
273
+ ```
274
+
275
+ `canHandle` receives the `statSync()` of the source, so a provider can claim archives, or
276
+ directories, or both — and because the built-ins are last, it can *wrap* either one rather
277
+ than only adding new formats.
278
+
279
+ Because a registered provider outranks the built-in one even for a file the built-in would
280
+ happily handle, it can also **vet** a file rather than merely add a format. That is exactly
281
+ what this repo does with `.bundle`: the provider claims the file, verifies it, and either
282
+ returns a filesystem or throws — and a throw during provider selection means the process
283
+ never reaches the entry point.
284
+
285
+ Two consequences worth stating plainly:
286
+
287
+ - **Either preload flag works, because mounting waits for both.** `-r` modules run during
288
+ bootstrap; `--import` modules are only evaluated later, on the way into the entry point.
289
+ Mounting is therefore deferred past *both* — when `--import` is present the mounts are
290
+ made from `asyncRunEntryPointWithESMLoader`, and idempotently, so a registration from
291
+ either flag is in place before any provider is chosen.
292
+ - **Claim by content, not just by name.** The built-in provider recognizes a ZIP by
293
+ sniffing its leading bytes, so an archive can be called anything. A verifying provider
294
+ that only claimed `*.bundle` could be bypassed by renaming the file, which is why the one
295
+ here also claims anything carrying a `SIGNED:` marker.
296
+
297
+ ### SEA support carried along
298
+
299
+ The fork also carries Node's newer SEA work so a single binary can build a SEA end-to-end:
300
+ `--build-sea <config.json>` generates a SEA directly from core (using LIEF instead of a
301
+ bolted-on WASM `postject`), plus **ESM entry-point support** in SEA (`"mainFormat"`) and
302
+ code-cache support for it. That is why `bundle sea` is a single `node --build-sea` call and
303
+ not a build pipeline of its own.
304
+
305
+ ---
306
+
307
+ ## The experiment in this repo
308
+
309
+ The tool bundles itself. That is the demonstration: the same pipeline you would run over
310
+ your application is the one that produces the `bundle` command npm installs, and every
311
+ property this README claims is exercised by the way the tool arrives on your machine.
312
+
313
+ ### The pieces
314
+
315
+ Everything is TypeScript under `src/`, compiled to ESM in `dist/`. The source is written in
316
+ erasable syntax only, so `node src/main.ts` runs it directly through node's type stripping —
317
+ the tests import the sources rather than the build for exactly that reason.
318
+
319
+ | File | Role |
320
+ |------|------|
321
+ | `src/manifest.ts` | The format: `buildManifest()` / `parseManifest()`, `parseSignature()` / `formatSignature()`, `signatureOf()`, and `verifySync()` — the staged check that decides one of four states. |
322
+ | `src/archive.ts` | The bundler: optionally writes a **prefix** (shebang stub or Node binary), then appends a ZIP of the listed files — each stamped with its content digest — with a `baseOffset` equal to the prefix size and an `AUTHORITY.PEM` manifest, then signs the whole file into the EOCD comment. `bundle()` builds from a directory; `rebundle()` re-emits an existing archive behind a new prefix, which is what `sign` runs. |
323
+ | `src/api.ts` | The programmatic drive — `createBundle`, `signBundle`, `verifyBundle`, `inspectBundle`, `runBundle` — with the file plumbing the CLI would otherwise be the only user of. |
324
+ | `src/cli.ts` | Argument parsing and reporting, and nothing else. `main(argv, io)` returns an exit code instead of exiting, so it is callable in-process; most of the CLI test suite does exactly that. |
325
+ | `src/main.ts` | The executable entry: `process.exitCode = await main(process.argv.slice(2))`. Also the package's `main`, which is what `--vfs-load` runs out of a mounted bundle. |
326
+ | `src/provider.ts` | The verifying VFS provider: verifies an archive before it becomes a filesystem, then hashes each member as it is fetched. `register()` installs it with `node:vfs`. |
327
+ | `src/register.ts` | The `-r` preload entry point — one call to `register()`, configured through the environment. |
328
+ | `src/recorder.ts` | The recording provider: wraps a provider class so every read through it is appended to a manifest. Replaces the `--vfs-manifest` flag. |
329
+ | `src/record.ts` | The `-r` preload for recording — set `BUNDLE_MANIFEST` and mount a directory. |
330
+ | `src/launch.ts` | The verification entry point: verify a container, mount it, run what is inside — as an executable checking itself, as a runtime handed an archive, or as a library call. Carries the verifying node's own command line. |
331
+ | `src/sea.ts` | Building the executables: `createSeaBase()` / `buildSea()`, the generated ES-module stub, and the self-test that runs the result once before handing it back. |
332
+ | `src/sigstore.ts` | Sigstore as one of the signers the format can carry: a two-phase signer (get the Fulcio certificate, *then* sign the finished hash), synchronous bundle verification, and the trust root. |
333
+ | `src/oidc.ts` | Getting an OIDC identity token — an ambient CI token, a browser sign-in through sigstore's Dex, or a device code. No dependencies of its own. |
334
+ | `src/files.ts` | Working out a member list the way observation cannot: a dependency closure resolved through `node_modules`, for code that is only required on a path a test run never takes. |
335
+ | `src/skill.ts` | The skills this package ships, and installing them into a project — what `bundle skill` runs. |
336
+ | `src/types/*.d.ts` | The `node:zlib` ZIP API and the `node:vfs` provider registry, neither of which `@types/node` carries yet. |
337
+ | `tools/observe.ts` | Drives the CLI through a recording mount of the package root, for the build's cross-check. |
338
+ | `tools/pack.ts` | Builds `build/cli.bundle`: computes the member list, checks it against an observation run, and writes the archive. |
339
+ | `tools/prepublish.ts` | The gate on `npm publish` — the signed CLI must exist, verify, and match a build of the current tree. |
340
+ | `test/*.test.ts` | 145 tests: the format, the archive, the two providers, the API, the CLI, the SEA, the skills, and the published package's own shape. |
341
+ | `shell-base` | The launcher prefix: two lines of `sh` that `exec node --no-warnings --experimental-vfs --vfs-load="$0" -- "$@"`. |
342
+ | `certs/` | A self-signed test PKI (root CA + leaf, `gen.sh`) used to sign and trust the demo archives offline. |
343
+ | `skills/audit-bundle/` | The audit skill: verify → extract → security-review every file. |
344
+
345
+ ### The exports
346
+
347
+ ```jsonc
348
+ {
349
+ ".": "./dist/index.js", // create / sign / verify / inspect / run, from code
350
+ "./register": "./dist/register.js", // -r preload: mount only what is signed
351
+ "./record": "./dist/record.js", // -r preload: write down what a run reads
352
+ "./sea": "./dist/sea.js", // build a verifying runtime, with or without an app
353
+ "./launch": "./dist/launch.js", // verify a container, mount it, run it
354
+ "./provider": "./dist/provider.js", // the verifying provider, and register(options)
355
+ "./recorder": "./dist/recorder.js", // the recording provider, and recording(Base, manifest)
356
+ "./cli": "./dist/cli.js", // main(argv, io) -> exit code
357
+ "./manifest": "./dist/manifest.js", // the format on its own
358
+ "./archive": "./dist/archive.js",
359
+ "./files": "./dist/files.js",
360
+ "./skill": "./dist/skill.js",
361
+ "./sigstore": "./dist/sigstore.js",
362
+ "./oidc": "./dist/oidc.js"
363
+ }
364
+ ```
365
+
366
+ The package root deliberately does **not** re-export the two providers. Importing either
367
+ needs `node:vfs`, which exists only under `--experimental-vfs`, and creating or verifying an
368
+ archive does not — so `import '@pipobscure/bundle'` must not drag that requirement in. That
369
+ is what the separate entry points are for, and there is a test that holds the line.
370
+
371
+ ### The scripts (`package.json`)
372
+
373
+ The four steps are four scripts, each runnable on its own, so the order is something you
374
+ can see rather than something the README asserts.
375
+
376
+ ```jsonc
377
+ "build": "tsc && chmod +x dist/main.js",
378
+ // TypeScript to ESM in dist/, with .d.ts and declaration maps beside it.
379
+
380
+ // --- 1. observe -------------------------------------------------------------
381
+ "manifest:cli": "node --experimental-vfs tools/manifest.ts",
382
+ // Writes build/cli.manifest: the file list, from a computed dependency closure
383
+ // cross-checked against a recording run of the CLI. Anything the run reads that the
384
+ // closure missed stops the build.
385
+
386
+ // --- 2. create --------------------------------------------------------------
387
+ "pack:cli": "node dist/main.js create --base . --files build/cli.manifest --output build/cli.bundle",
388
+ // Plainly the CLI, over the list step 1 produced. Unsigned.
389
+
390
+ // --- 3. audit ---------------------------------------------------------------
391
+ "baseline:cli": "node --experimental-vfs tools/baseline.ts --allow-missing",
392
+ // Fetches the currently published bundle and verifies it, to review the new one
393
+ // *against*. The first release has none, and then the audit reviews everything.
394
+ "audit:cli": "node dist/main.js audit --baseline build/baseline.bundle …",
395
+ // Reports the archive's sha256, its member count and what changed against the baseline,
396
+ // then prints the exact skill invocation. The review needs judgement, so no script
397
+ // performs it.
398
+ "approve:cli": "node dist/main.js audit --approve …",
399
+ // Record a clean verdict reached by a person instead of by the skill.
400
+
401
+ // --- 4. sign ----------------------------------------------------------------
402
+ "sign:cli": "node dist/main.js audit --check … && node dist/main.js sign --launcher --output bundle.run build/cli.bundle",
403
+ // The gate runs first and exits non-zero without a clean verdict pinned to these bytes.
404
+ // Then sigstore — CI identity if there is one, otherwise a GitHub sign-in.
405
+ "sign:cli:local": "… --check && node dist/main.js sign --key build/certs/leaf.key --chain build/certs/chain.pem …",
406
+ // The same against the test PKI, for working offline.
407
+
408
+ "release:cli": "npm run build && npm run manifest:cli && npm run pack:cli && npm run baseline:cli && npm run audit:cli",
409
+ // Steps 1-3, stopping at the gate. Step 4 is deliberately not chained on: it needs a
410
+ // verdict, and a verdict is not a script's to give.
411
+
412
+ "verify:cli": "node dist/main.js verify bundle.run",
413
+ "trust": "node dist/main.js trust",
414
+ // Refresh the sigstore trust root (over TUF) into the local cache. Verification never
415
+ // reaches for the network, so this is the explicit step that feeds it.
416
+
417
+ "test": "npm run build && npm run typecheck && node --experimental-vfs --test test/*.test.ts",
418
+ "prepublishOnly": "node --experimental-vfs tools/prepublish.ts"
419
+ // Refuses to publish a package whose signed CLI is missing, unsigned, or stale.
420
+ ```
421
+
422
+ **The gate.** `bundle audit --check` is what makes step 3 a step rather than a
423
+ suggestion. It reads a JSON verdict the skill writes — `verdict: "pass" | "fail"`, the
424
+ findings, the **sha256 of the archive**, and the baseline's sha256 when the review was a
425
+ diff — and refuses unless the verdict passed *and* pins the bytes on disk. That pin is the
426
+ whole mechanism: an approval that could be carried to a later build is not an approval of
427
+ anything, so rebuilding invalidates it. A verdict naming a *different* baseline is refused
428
+ the same way; one naming none is accepted as a full review, with a note saying so.
429
+
430
+ ```
431
+ $ npm run sign:cli
432
+ error: build/cli.bundle has not been audited — there is no verdict at build/cli.audit.json.
433
+ run 'npm run audit:cli' to see how
434
+ ```
435
+
436
+ There is deliberately no environment variable that turns it off: a switch like that gets set
437
+ in CI once and never unset. It is a command you choose to put in your chain, and if you do
438
+ not want it, you do not put it there. That is the publisher's own gate, not a runtime policy
439
+ — the same reason `--identity` is something the verifier chooses rather than something the
440
+ format imposes.
441
+
442
+ Note the shape of the pipeline: **`create` once, `sign` many times.** `create` never needs a
443
+ key, and the archive it writes is the single input to every signed artifact. Drop `--root`
444
+ from `verify` to see how the same archive reads when its certificate isn't trusted; drop
445
+ `--prefix` from `sign` to produce a plain archive instead of a self-executing container.
446
+
447
+ `create` still accepts `--key`/`--chain` to sign at build time, which is convenient when you
448
+ only ever want one shape. It cannot be used with sigstore: a Fulcio certificate is bound to
449
+ an identity you have to authenticate for, so that path goes through `sign`.
450
+
451
+ ### The three artifacts, and how each runs itself
452
+
453
+ All three come from `sign`, from one unsigned archive, differing only in the `--prefix` they
454
+ are re-emitted behind.
455
+
456
+ **The plain signed archive (needs Node and the preload).**
457
+ No prefix at all: just the ZIP, with its per-member digests, its `AUTHORITY.PEM` manifest
458
+ and the whole-file signature in the EOCD comment. It is run by mounting it:
459
+
460
+ ```sh
461
+ node --experimental-vfs -r @pipobscure/bundle/register --vfs-load=app.bundle -- <args>
462
+ ```
463
+
464
+ The preload registers the provider; `--vfs-load` hands it the archive; the provider verifies
465
+ the signature and the chain **before** returning a filesystem, so an archive that fails is
466
+ never mounted and the entry point never runs. This is the mode where the *runtime* enforces
467
+ the signature rather than the application checking itself — the application needs no boot
468
+ code of its own at all. (The `--` matters: without it node claims any argument that looks
469
+ like one of its own flags, and the application never sees it.)
470
+
471
+ **`app.run` — the shebang archive (the same ZIP plus a one-line header; needs Node installed).**
472
+ It is the `shell-base` prefix — `#!/bin/sh` and one `exec node … --vfs-load="$0" -- "$@"`
473
+ line — followed by the ZIP. `exec` replaces the shell before it reads past that line, so the
474
+ archive bytes are never parsed as script; `"$0"` is the file's own path, so it mounts
475
+ **itself** and its `package.json` main becomes the entry point. The archive's `baseOffset`
476
+ was seeded past the prefix, so it stays a valid ZIP even though it doesn't start at byte 0.
477
+ A whole application in a file you can email — provided the recipient has a compatible Node.
478
+
479
+ > The obvious prefix is `#!/usr/bin/env -S node … --vfs-load`, letting the kernel's appended
480
+ > path become the trailing flag's value. That is prettier and it works, but the user's
481
+ > arguments land after that path with nowhere to put a `--`, so every dash-leading argument
482
+ > goes to node rather than to the program — `app.run --help` prints node's help. The shell
483
+ > line exists to place that `--`.
484
+
485
+ > **Note:** this needs a Node whose provider selection recognizes a ZIP by locating its
486
+ > end-of-central-directory record rather than by sniffing `PK\x03\x04` at byte 0 — a
487
+ > prefixed container by construction has no `PK` at byte 0, and the leading-bytes test
488
+ > rejected it with `ERR_VFS_INVALID_TARGET` before anything else happened. The bundle
489
+ > provider never had that blind spot (it always scanned from the tail), so
490
+ > `node -r @pipobscure/bundle/register --vfs-load=app.run` mounts and verifies
491
+ > the same file either way.
492
+ >
493
+ > The shebang launcher runs the archive *without* the verifying provider — the kernel gives
494
+ > it no preload flag to carry one. It is the convenient shape, not the enforcing one. For a
495
+ > container that gates itself, use the SEA below.
496
+
497
+ **The native executable (~155 MB; needs nothing).**
498
+ `bundle sea` produces it: a Node runtime whose SEA blob carries this package as a mounted
499
+ asset, with the application appended as a signed archive. Running it verifies the whole
500
+ file — runtime, verifier and application alike, since the signature covers all of it — and
501
+ only then mounts the archive and runs what is inside. No Node on the target, no
502
+ `node_modules`, no extraction to disk. See [Self-verifying the SEA](#self-verifying-the-sea).
503
+
504
+ Same application, same archive format, three shapes — one where the **runtime** enforces
505
+ the signature (`.bundle`), one optimizing for **size** (reuse the user's Node), one for
506
+ **self-containment** (bring your own Node). And, because the prefix is chosen at *signing*
507
+ time rather than at build time, all three come from one `create` and differ by one flag.
508
+
509
+ ### Try it
510
+
511
+ ```sh
512
+ npm install
513
+ npm run build
514
+
515
+ # 1. observe what a run reads.
516
+ BUNDLE_MANIFEST=app.manifest node --experimental-vfs \
517
+ -r ./dist/record.js --vfs-load=./some/app > /dev/null
518
+
519
+ # 2. archive exactly that, unsigned.
520
+ node dist/main.js create --base ./some/app --files app.manifest --output app.bundle
521
+ node dist/main.js verify app.bundle # -> UNSIGNED
522
+
523
+ # 3. audit it, before putting your name on it.
524
+ node dist/main.js skill # install the skill, once per project
525
+ claude "/audit-bundle app.bundle" # or however you drive Claude Code
526
+
527
+ # 4. sign it — offline here, against the repository's test PKI.
528
+ node dist/main.js sign --key build/certs/leaf.key --chain build/certs/chain.pem \
529
+ --output app.signed.bundle app.bundle
530
+ node dist/main.js verify --root build/certs/root.pem app.signed.bundle # -> VALID
531
+ node dist/main.js verify app.signed.bundle # -> VALID (UNTRUSTED)
532
+
533
+ # Run it through the verifying mount.
534
+ node dist/main.js run --root build/certs/root.pem app.signed.bundle -- <args>
535
+ node --experimental-vfs -r ./dist/register.js --vfs-load=app.signed.bundle
536
+ # refuses: the test root is trusted by nothing
537
+
538
+ # The same archive behind a shebang, and inside a self-validating executable.
539
+ node dist/main.js sign --key build/certs/leaf.key --chain build/certs/chain.pem \
540
+ --prefix shell-base --output app.run app.bundle
541
+ node dist/main.js sea --key build/certs/leaf.key --chain build/certs/chain.pem \
542
+ --root build/certs/root.pem --output app.sea app.bundle
543
+ ./app.sea <args> # verifies itself, then runs
544
+
545
+ npm test # 145 tests: sign, verify, mount, run, SEA, the gate, and every refusal
546
+ ```
547
+
548
+ Building the tool the way the tool says to build things — the same four steps:
549
+
550
+ ```sh
551
+ npm run release:cli # 1-3: observe, pack, fetch the baseline, stop at the gate
552
+ # -> build/cli.manifest, build/cli.bundle (904 members)
553
+
554
+ npm run sign:cli:local # 4: refuses, because nothing has been audited yet
555
+ BUNDLE_AUDIT_VERDICT=build/cli.audit.json claude "/audit-bundle build/cli.bundle"
556
+ npm run sign:cli:local # 4: now allowed -> bundle.run
557
+
558
+ ./bundle.run --help # the artifact runs itself; this is what npm links as `bundle`
559
+ ```
560
+
561
+ To sign through sigstore instead — this opens a browser, or uses the CI identity when there
562
+ is one:
563
+
564
+ ```sh
565
+ npm run trust # fetch the sigstore trust root, once
566
+ npm run sign:cli # -> bundle.run, signed by whoever you signed in as
567
+ npm run verify:cli # -> VALID, with the identity that signed it
568
+ ```
569
+
570
+ A launcher archive can verify **itself**: `--vfs-load` leaves the container's own path
571
+ readable, so from inside, `process.argv[1]` is the real file and reading it yields the raw
572
+ bytes rather than the mounted tree. `./app.run verify app.run` works.
573
+
574
+ ---
575
+
576
+ ## Signing and verification
577
+
578
+ A single-file application is only as trustworthy as the bytes inside it. Building on the ZIP
579
+ toolkit, every archive this repo produces is protected by a **staged** scheme: one hash covers
580
+ the **whole file** (prefix included), the leaf certificate signs *that hash*, and both are
581
+ written into the archive's end-of-central-directory comment. Each **member** additionally
582
+ carries its own content digest. The staging is the point — a verifier can prove the bytes are
583
+ intact *before* it commits to anything (a cheap, cert-free gate), and only then spend a
584
+ certificate check. This is an application-level feature — it uses `node:zlib`'s
585
+ `ZipEntry`/`X509Certificate` primitives; it is not a change to Node.
586
+
587
+ ### The `AUTHORITY.PEM` manifest
588
+
589
+ **The `AUTHORITY.PEM` manifest** is a normal archive entry that declares the algorithms and
590
+ carries the certificate chain — the signing authority. Its name is a real, extractable
591
+ filename, so a plain zip utility can pull it out for auditing:
592
+
593
+ ```
594
+ !manifest 2 magic + format version
595
+ !hash sha256 digest used for the whole-file hash and member digests
596
+ !sign sha256 digest the signature (over that hash) uses
597
+ (blank line — present only when signed)
598
+ -----BEGIN CERTIFICATE----- full PEM chain, leaf first, embedded so a
599
+ … verifier is self-contained
600
+ -----END CERTIFICATE-----
601
+ ```
602
+
603
+ **Every member** (every entry except the manifest) also records the hex digest of its own
604
+ content in its ZIP **entry comment**, computed with `!hash`.
605
+
606
+ ### The whole-file hash, and a signature over it
607
+
608
+ One hash covers the *entire file* — the prepended launcher or Node/SEA binary, every member,
609
+ the complete central directory (member digests included) and the fixed part of the EOCD
610
+ record — up to but **excluding the EOCD's 2-byte comment-length field**. The EOCD must be the
611
+ last structure in the file, so the hashed region is simply everything before its trailing
612
+ comment. The leaf certificate then signs **that hash** (not the file), and the EOCD comment —
613
+ which the hash deliberately stops short of — records both:
614
+
615
+ ```
616
+ SIGNED:<hash-of-region-hex>:<signature-hex>[:<NAME>=<value>]*
617
+ ```
618
+
619
+ Signing the hash rather than the file is what makes the stages cheap: a verifier hashes the
620
+ file once, matches it against `<hash>`, and can then check `<signature>` over that same hash
621
+ without touching the file again. Because the hash spans the whole file, nothing — a byte of
622
+ the runtime prefix, a member's bytes, a recorded digest in the central directory, the
623
+ algorithm lines, or the embedded chain — can be altered without changing it. The per-member
624
+ digests are that guarantee applied one file at a time, so an individual extracted member can
625
+ be checked on its own (and a member fetch can re-verify it).
626
+
627
+ The trailing `NAME=value` fields are the **unsigned-attribute region** every code-signing
628
+ scheme eventually grows. Anything obtained *after* the signature exists cannot be inside
629
+ what the signature covers, so it goes beside it instead — the same placement RFC 3161 gives
630
+ timestamp tokens in CMS `unsignedAttrs`, and Authenticode gives counter-signatures. Today
631
+ the only field is `SIGSTORE=`, carrying the transparency-log entry and timestamp that
632
+ establish *when* the archive was signed. Fields are optional and unknown ones are ignored,
633
+ so a two-field marker written before the grammar existed still parses.
634
+
635
+ ### Signing with sigstore
636
+
637
+ `bundle sign` defaults to sigstore, which replaces "a signing key on someone's disk" with
638
+ "an identity you authenticate as". [Fulcio](https://github.com/sigstore/fulcio) issues a
639
+ certificate binding an OIDC identity to a keypair that exists only for the duration of the
640
+ command; there is no long-lived key to steal, because the certificate expires in about ten
641
+ minutes.
642
+
643
+ Where the identity comes from is a property of where the command runs, so it is not a flag
644
+ you have to remember:
645
+
646
+ | Where | What happens | The identity |
647
+ |---|---|---|
648
+ | GitHub Actions | `ACTIONS_ID_TOKEN_REQUEST_URL` is read directly (needs `permissions: id-token: write`) | the repository and workflow — `https://github.com/OWNER/REPO/.github/workflows/release.yml@refs/heads/main` |
649
+ | A workstation | a browser opens on sigstore's Dex, which redirects straight to GitHub | your email |
650
+ | A headless box | a device code to enter elsewhere | your email |
651
+
652
+ Override with `--flow ci\|browser\|device`, `--token <jwt>` to supply one yourself, or
653
+ `--connector google\|microsoft\|none` to sign in as something other than GitHub.
654
+
655
+ **The ordering problem.** `AUTHORITY.PEM` is a member, so it is inside the hashed region:
656
+ the certificate chain must be known *before* the hash exists. The signature must be made
657
+ *after*. Sigstore's own `BundleBuilder` does both in one call, which cannot work here — but
658
+ it does not have to, because a Fulcio certificate binds an *identity to a public key* and
659
+ says nothing about any message. So the two halves separate cleanly, and `src/sigstore.ts`
660
+ exposes them as a two-phase signer: sign in and get the certificate, then, once the archive
661
+ has been built around it and hashed, sign the hash and have that signature witnessed by
662
+ Rekor and the timestamp authority.
663
+
664
+ **Why the witnesses matter more here than usual.** A ten-minute certificate makes "is this
665
+ chain in date *now*?" the wrong question — it fails every archive older than lunchtime. The
666
+ right question is whether the certificate was valid *when the signature was made*, and
667
+ answering it needs a trustworthy assertion of when that was. That is exactly what the
668
+ transparency-log entry and the RFC 3161 token in the `SIGSTORE=` field provide, and it is
669
+ why a sigstore signature takes a different trust path from an ordinary one rather than an
670
+ additional check on the same path. `bundle sign` refuses to finish if neither witness could
671
+ be reached, because the result could never be verified again.
672
+
673
+ Two things are checked that the sigstore libraries do not check for you. The bundle must be
674
+ over *this* archive's hash — otherwise a valid bundle could be transplanted from another
675
+ archive — and the certificate it was verified against must be the same one `AUTHORITY.PEM`
676
+ names. Without the second, an attacker could pin a genuine bundle for their own identity to
677
+ an archive whose extractable manifest claims a different signer: the signature would check
678
+ out and the inspectable file would be a lie.
679
+
680
+ Verification never reaches for the network. The sigstore trust root is fetched by an
681
+ explicit `bundle trust` (over TUF — signed metadata with its own root of trust, not a plain
682
+ download) and cached; `verifySync()` then loads it synchronously, which it has to, because
683
+ the mount path decides whether to serve an archive before any of the program in it runs.
684
+
685
+ ### The four verification states
686
+
687
+ `verify()` runs the stages in order and reports exactly one state:
688
+
689
+ | State | Meaning |
690
+ |-------|---------|
691
+ | **unsigned** | no `AUTHORITY.PEM` manifest, or no `SIGNED:…` marker in the EOCD comment |
692
+ | **invalid** | the recomputed hash doesn't match the recorded one, the signature doesn't verify over it, **or** a member's recorded digest doesn't match its content |
693
+ | **valid-untrusted** | hash + signature + digests are sound, but the certificate chain isn't anchored in the trust store — or, for a sigstore signature, the trust root is missing or the identity didn't match a required policy |
694
+ | **valid** | all of the above sound **and** the chain is trusted |
695
+
696
+ Note which side of the line the sigstore cases fall on. Not being *able* to check — no trust
697
+ root cached, the libraries absent — is `valid-untrusted`, not `invalid`: "I could not check"
698
+ and "this is forged" are different answers and conflating them is how people are trained to
699
+ click through warnings. A bundle that is present and fails to verify is `invalid`.
700
+
701
+ `verifySync()` is the implementation and `verify()` an `async` wrapper around it; both take
702
+ `{ extraRoots, now, deep, trustedRoot, identity, issuer }`. `identity` and `issuer` impose a
703
+ policy on a sigstore signature — "it must be *this* signer" — and a mismatch reports
704
+ `valid-untrusted`, since the signature is genuine and simply not the one demanded. `now` is
705
+ ignored on the sigstore path, which derives the signing time from the archive's own log
706
+ entry instead. With `deep: false` the member digests are checked for
707
+ *presence* but not recomputed — the right trade for a mount, which re-checks each member as
708
+ it is actually read (see below). The result also carries `hashAlg` and the `digests` map
709
+ read from the archive that was just hashed, so a caller that goes on to serve those members
710
+ checks them against what the signature covered rather than re-reading the comments later.
711
+
712
+ For a sigstore signature the result also carries `identity`, `issuer` and `signedAt`. Those
713
+ are the answer to "who signed this"; the certificate subject is an ephemeral Fulcio artifact
714
+ and says nothing useful, so `bundle verify` prints the identity instead when there is one.
715
+
716
+ The whole scheme is gated on the `SIGNED:` marker: only a signed archive is checked at all.
717
+ Because the hash covers the central directory, it fixes *which* members exist and every
718
+ member's digest, so editing *any* byte after signing yields **invalid**. Trust is evaluated
719
+ against the system CA store **plus** `NODE_EXTRA_CA_CERTS` (and any extra roots passed to
720
+ `verify`), so the trusted path is testable without touching the OS store.
721
+
722
+ ### The library
723
+
724
+ Every operation the CLI has is an export, and the CLI is a `parseArgs` wrapper over them:
725
+
726
+ ```ts
727
+ import {
728
+ createBundle, signBundle, verifyBundle, inspectBundle, runBundle, fileSigner,
729
+ } from '@pipobscure/bundle';
730
+
731
+ // Build unsigned. The archive this writes is the single input to every shape you ship.
732
+ await createBundle({ base: 'app/', files, output: 'app.bundle' });
733
+
734
+ // Sign it — once per shape, each correctly offset and each signed over its own bytes.
735
+ await signBundle({ source: 'app.bundle', output: 'app.signed.bundle', signer });
736
+ await signBundle({ source: 'app.bundle', output: 'app.run', prefix: 'shell-base', signer });
737
+
738
+ // Ask what it claims, and then whether any of it is true.
739
+ const { members, signed, hash } = inspectBundle('app.run');
740
+ const { state, reason, identity } = await verifyBundle('app.run', { roots: ['root.pem'] });
741
+
742
+ // Mount it through the verifying provider, in a child process, and run it.
743
+ const { status } = runBundle('app.signed.bundle', { roots: ['root.pem'], args: ['--help'] });
744
+ ```
745
+
746
+ The layer underneath is exported too, for callers assembling members themselves rather than
747
+ from a directory:
748
+
749
+ ```ts
750
+ import { buildManifest, verifySync } from '@pipobscure/bundle/manifest';
751
+ import { bundle, rebundle, createArchive, keySigner } from '@pipobscure/bundle/archive';
752
+ ```
753
+
754
+ A **signer** is just `{ chain, signAlg, sign(digest) }`: the chain goes into `AUTHORITY.PEM`
755
+ before hashing, and `sign()` is called after, with the finished hash, returning a signature
756
+ and any unsigned-attribute fields. `keySigner()` is the offline-CA implementation and
757
+ `@pipobscure/bundle/sigstore`'s `signer()` is the other one — `rebundle()` knows about
758
+ neither, which is the point. Writing a third (an HSM, a KMS, a corporate signing service) is
759
+ a matter of implementing those three properties.
760
+
761
+ Working out *what* to bundle has two answers, and the build here uses both. `@pipobscure/bundle/recorder`
762
+ observes a run; `@pipobscure/bundle/files` computes a closure:
763
+
764
+ ```ts
765
+ import { moduleFiles } from '@pipobscure/bundle/files';
766
+
767
+ const files = moduleFiles({
768
+ base: '.',
769
+ files: ['package.json'],
770
+ dirs: ['dist'],
771
+ dependencies: ['@sigstore/verify'], // and everything they depend on, transitively
772
+ filter: (name) => !name.endsWith('.map'),
773
+ });
774
+ ```
775
+
776
+ Observation is the honest answer — it produces exactly the set that was used — but it has one
777
+ blind spot that matters for a verifier: code loaded lazily on a path the run never took.
778
+ `sigstore.ts` requires `@sigstore/verify` only when it meets an archive with a `SIGSTORE=`
779
+ field, so a build that signs with a local key never touches it, and the resulting bundle
780
+ would be unable to check a sigstore signature it later met. So `tools/pack.ts` computes the
781
+ closure for completeness and uses the observation run as the *check* on it: anything read
782
+ that the closure missed is a hole, and the build stops.
783
+
784
+ `./provider` and `./recorder` are deliberately *not* re-exported from the package root:
785
+ importing them needs `node:vfs`, which only exists under `--experimental-vfs`, while
786
+ creating and verifying archives does not. `./sigstore` is namespaced rather than flattened
787
+ for a different reason: it is a signer implementation, not part of the archive format, and
788
+ an archive signed against an ordinary CA verifies with nothing but `node:crypto`. The
789
+ sigstore libraries' absence degrades a sigstore verification to `valid-untrusted` rather
790
+ than breaking it.
791
+
792
+ ### Running only what is signed — the `.bundle` provider
793
+
794
+ `src/provider.ts` is where verification stops being something an application does to itself
795
+ and becomes a property of the mount. It is a `ZipProvider` subclass, registered ahead of the
796
+ built-in one, and it gates in two places:
797
+
798
+ **At mount.** `open()` recomputes the whole-file hash, checks the signature over it against
799
+ the leaf certificate in `AUTHORITY.PEM`, and anchors the chain in the trust store. Anything
800
+ short of `valid` throws `ERR_BUNDLE_UNTRUSTED` out of provider selection, which is *before*
801
+ the entry point is resolved — an archive that fails does not become a filesystem, so there
802
+ is no window in which its code could run.
803
+
804
+ **At fetch.** Every member is hashed as it is read and compared with the digest recorded for
805
+ it, and only then handed over; a mismatch throws `ERR_BUNDLE_INTEGRITY` instead of returning
806
+ content. The mount-time hash already covers every member's bytes — but it covers them *as
807
+ they were when the file was hashed*, and a `ZipFile` reads members lazily from an open file
808
+ descriptor. Rewriting the archive underneath a running program would otherwise serve the new
809
+ bytes unchecked. Verified content is kept in memory (members are an application's own files,
810
+ not the runtime), so each member is read and hashed at most once, and what later reads see is
811
+ the copy that was verified rather than a fresh read of the file.
812
+
813
+ A mounted archive is read-only regardless of how the underlying ZIP was opened: any write
814
+ would invalidate the signature the mount was granted on, so writes fail with `EROFS`.
815
+
816
+ Configure the preload through the environment, since `-r` takes no arguments:
817
+
818
+ | Variable | Effect |
819
+ |----------|--------|
820
+ | `BUNDLE_ROOTS` | Extra trusted roots, a path-delimiter-separated list of PEM files. |
821
+ | `BUNDLE_ALLOW_UNTRUSTED` | Accept a good signature whose chain is not anchored in the trust store. |
822
+ | `BUNDLE_IDENTITY` | Require this sigstore signing identity — a machine configured to run only releases from one workflow. |
823
+ | `BUNDLE_ISSUER` | Require this sigstore OIDC issuer. |
824
+ | `BUNDLE_SIGSTORE_ROOT` | The sigstore trust root to check against, instead of the cache `bundle trust` fills. |
825
+
826
+ Demanding an identity is a demand about *who signed this*, and only a sigstore signature
827
+ carries an answer. An archive signed against an ordinary CA makes no such claim, so it reads
828
+ as `valid-untrusted` under such a policy rather than passing — otherwise `BUNDLE_IDENTITY`
829
+ would be a no-op in exactly the deployment that relies on it.
830
+
831
+ For anything more, call `register()` yourself from a preload of your own:
832
+
833
+ ```js
834
+ // my-preload.js — node --experimental-vfs -r ./my-preload.js --vfs-load=app.bundle
835
+ import { register } from '@pipobscure/bundle/provider';
836
+
837
+ register({
838
+ extensions: ['.bundle', '.app'], // claimed by name
839
+ claimSigned: true, // and anything carrying a SIGNED: marker, whatever it is called
840
+ roots: ['/etc/ssl/my-root.pem'], // PEM text or paths to PEM files
841
+ allowUntrusted: false,
842
+ deep: false, // recompute every digest at mount, not on fetch
843
+ identity: 'https://github.com/me/app/.github/workflows/release.yml@refs/heads/main',
844
+ issuer: 'https://token.actions.githubusercontent.com',
845
+ });
846
+ ```
847
+
848
+ A preload runs under the CommonJS loader, so it must not contain a top-level `await` — but
849
+ ESM syntax is otherwise fine, and `--import` works as well as `-r`.
850
+
851
+ `bundle run <archive> [-- <args>]` is the same thing with the flags filled in: it re-execs
852
+ `node` with the preload and `--vfs-load`, so what runs is what the child's own bootstrap
853
+ verified.
854
+
855
+ ### The verifying runtime, in two shapes
856
+
857
+ `bundle sea` builds a node runtime with this package inside it. Whether the result is an
858
+ application or a tool depends on nothing but what is behind it.
859
+
860
+ **With an archive appended, it is that application**, checking its own signature before it
861
+ runs anything:
862
+
863
+ ```
864
+ [ node runtime | SEA blob: stub + the verifier, as a mounted archive ] [ app.bundle ]
865
+ \_______________________ the prefix, and part of the _______________/
866
+ \______________________ archive's signed region ______/
867
+ ```
868
+
869
+ The application is an ordinary signed `.bundle` appended to a node binary — the same
870
+ `sign --prefix` that produces a shebang launcher. What makes the result self-validating is
871
+ that the whole-file hash covers the prefix too, so the runtime and the verifier inside it
872
+ are signed by the same signature that covers the application. There is nothing to check the
873
+ checker against, because the checker is inside what is checked.
874
+
875
+ **With nothing appended, it is a verifying node**: a runtime that takes an archive on its
876
+ command line, checks it, and runs it.
877
+
878
+ ```sh
879
+ bundle sea --output node-verifying --root /etc/ssl/my-root.pem
880
+ ./node-verifying ./my-app.zip --args --for --the --app
881
+ ```
882
+
883
+ One runtime, any number of applications, none of them trusted until they verify — the shape
884
+ you want when the thing being distributed is *many* signed archives rather than one program.
885
+ The application sees the argv `--vfs-load` would have given it: the archive where a script
886
+ path goes, its own arguments from index 2 on, and none of the runtime's flags, which is why
887
+ everything after the archive belongs to the program.
888
+
889
+ The two are one binary, and it decides which it is by looking at its own tail: a signed
890
+ archive behind it runs that, nothing behind it takes one from the command line, and an
891
+ *unsigned* archive behind it is refused rather than quietly treated as neither. So a
892
+ verifying node built today becomes a self-validating executable tomorrow with nothing but
893
+ `bundle sign --prefix`, which is the same operation that puts a shebang in front of an
894
+ archive.
895
+
896
+ **A policy is baked in, or it is not.** `--root`, `--identity` and `--issuer` at build time
897
+ become the binary's own policy, and a binary built with one is **sealed**: it accepts no
898
+ policy from its command line, because a runtime that demands a signing identity is not one
899
+ whose user can ask it to stop. Built without, the same flags work and fall back to the
900
+ environment — one build, decided about later.
901
+
902
+ **The package rides inside as an archive node mounts for itself.** The stub runs before
903
+ anything is mounted, so it cannot import this package the ordinary way. It no longer has to:
904
+ `"useVfs": true` ([nodejs/node#65675](https://github.com/nodejs/node/pull/65675), released in
905
+ v26.9.0) with `"vfsArchive"` ([nodejs/node#65810](https://github.com/nodejs/node/pull/65810),
906
+ still open) embeds a ZIP in the executable and mounts it as the file system the main script
907
+ runs from. The stub is injected at the root of that mount, so
908
+ requiring the launcher is a relative path and nothing else.
909
+
910
+ That replaced the userland version of the same idea: the verifier bundle as a raw SEA asset,
911
+ copied into a `ZipBuffer` and mounted by hand in a stub that had to know how. Two mounts
912
+ became one, `getRawAsset` and the copy went with it, and the piece of this system that no
913
+ test can exercise from source — the generated stub — went from twenty lines to three.
914
+
915
+ The startup, in order:
916
+
917
+ 1. **verify** — the verifying provider from `./provider` recomputes the whole-file hash over
918
+ the container (for a self-validating executable that is `process.execPath`, runtime and
919
+ all; for a verifying node it is the archive named on the command line), checks the
920
+ signature over it against the chain in `AUTHORITY.PEM`, and anchors that chain. Anything
921
+ short of acceptable exits non-zero here, before the archive is a filesystem.
922
+ 2. **mount** — only then does the archive become the application's file tree, and `__filename`,
923
+ `import.meta.dirname`, relative imports and `node_modules` all resolve inside it.
924
+ 3. **run** — the archive's `package.json` `main`, `require()`d or `import()`ed as its `type`
925
+ and extension say.
926
+
927
+ Because the mount is the *verifying* provider rather than a plain `ZipProvider`, this is not
928
+ merely a signature check at startup: every member is re-hashed against its signed digest as
929
+ it is first read, for the whole life of the process.
930
+
931
+ **What runs before the check.** The stub and the verifier execute before the signature has
932
+ been verified. That is not a hole so much as the place where trust has to start: both live
933
+ inside the prefix, which is inside the hashed region, so tampering with either invalidates
934
+ the signature over the application — and an attacker who could rewrite the executable's own
935
+ runtime could equally rewrite a verifier that ran first. The application never runs until
936
+ the check passes.
937
+
938
+ Building one, and configuring what the finished binary will accept:
939
+
940
+ ```sh
941
+ bundle sea --output app.sea \
942
+ --root /etc/ssl/my-root.pem \
943
+ --identity 'https://github.com/me/app/.github/workflows/release.yml@refs/heads/main' \
944
+ --issuer 'https://token.actions.githubusercontent.com' \
945
+ app.bundle
946
+ ```
947
+
948
+ Those become the executable's own policy, baked into the stub — which is the point, since a
949
+ binary run by its own name has no flags and no preload to configure it. Leave them off and
950
+ the policy comes from `BUNDLE_ROOTS` / `BUNDLE_IDENTITY` / `BUNDLE_ALLOW_UNTRUSTED` in the
951
+ environment instead, so one build can be decided about later. From code:
952
+
953
+ ```ts
954
+ import { buildSea, createSeaBase } from '@pipobscure/bundle/sea';
955
+
956
+ // The base is the expensive half (a ~155 MB copy of node); build it once and reuse it.
957
+ await createSeaBase({ output: 'sea-base', bootstrap: { roots: ['root.pem'] } });
958
+ await buildSea({ app: 'app.bundle', output: 'app.sea', base: 'sea-base', signer });
959
+ ```
960
+
961
+ An application can also ask about its own provenance without mounting anything:
962
+
963
+ ```ts
964
+ import { verifySelf } from '@pipobscure/bundle/sea';
965
+
966
+ const { state, identity, signedAt } = verifySelf();
967
+ ```
968
+
969
+ All of it runs through one entry point, `@pipobscure/bundle/launch`, which is where the
970
+ verify-mount-run path lives now that three callers share it: `runSelf()` for an executable
971
+ with an archive behind it, `main(argv)` for a runtime that is handed one, and `run(container)`
972
+ for a process that already exists. They differ in which container they are given and in what
973
+ `process.argv` should look like afterwards; everything else — the refusal, the mount, the
974
+ choice between `require` and `import` — is the same code.
975
+
976
+ By contrast, the shebang launcher has no pre-mount stage of its own, so `app.run` executed
977
+ directly does not self-verify — the kernel gives it no preload flag to carry a provider, and
978
+ it hands straight off to the app. Mounting it with the provider preloaded
979
+ (`node -r @pipobscure/bundle/register --vfs-load=app.run`) is what closes that
980
+ gap, and is the one route by which `app.run` runs verified at all.
981
+
982
+ ---
983
+
984
+ ## Recording the manifest
985
+
986
+ Building an archive needs a file list, and the honest way to get one is to run the
987
+ application and write down what it read. That used to be `--vfs-manifest=<file>`, a flag
988
+ that poked an observer slot inside `node:vfs`. With mounting reduced to `--vfs-load` and
989
+ provider selection the one place a mount can be influenced, the same job is better done by
990
+ a **provider** — which is what `src/recorder.ts` is:
991
+
992
+ ```sh
993
+ BUNDLE_MANIFEST=app.manifest node --experimental-vfs \
994
+ -r @pipobscure/bundle/record --vfs-load=./app
995
+ ```
996
+
997
+ `recording(Base, manifest)` wraps a provider *class*, leaving its constructor signature
998
+ alone, and records every read that passes through it; `register()` installs
999
+ `recording(RealFSProvider)` for directory mounts. Paths are appended, VFS-relative and one
1000
+ per line, **as they are read** rather than buffered until exit, so a killed process still
1001
+ leaves a usable list — and worker threads, which inherit `execArgv` and so run this preload
1002
+ too, append to the same file instead of truncating it (only the main thread starts a fresh
1003
+ one; `O_APPEND` writes don't interleave).
1004
+
1005
+ It is a superset of what the flag recorded, in one respect. The flag hooked
1006
+ `readFile()`/`readFileSync()`, where the module loader and ordinary `fs` reads converge —
1007
+ but a `createReadStream()` goes through `open()` and a handle, and was never recorded. Here
1008
+ read-only `open()`s count too: a file that was opened but never read is harmless in a
1009
+ bundle, a streamed file missing from one is not.
1010
+
1011
+ Because it wraps a *class*, it composes — this records what a signed archive's own code
1012
+ touches at run time:
1013
+
1014
+ ```ts
1015
+ // my-preload.js
1016
+ import { BundleProvider } from '@pipobscure/bundle/provider';
1017
+ import { recording, Manifest } from '@pipobscure/bundle/recorder';
1018
+
1019
+ const Recording = recording(BundleProvider, new Manifest('reads.txt'));
1020
+ ```
1021
+
1022
+ One thing it does not do: it records per *mount*, not per process, so several mounted
1023
+ directories merge into one list — the `--vfs-manifest` flag only ever supported a single
1024
+ directory target, so this is new ground rather than a regression.
1025
+
1026
+ An earlier constraint here has since gone away, and it is worth recording because it shaped
1027
+ the code. Provider selection once applied only when `--vfs-mount` was handed a *file*, with
1028
+ a directory mounted by node's own `RealFSProvider` without asking — so a recording mount
1029
+ could not be installed from a preload at all, and `tools/observe.ts` makes the mount itself
1030
+ and resolves the entry point against the path it is given back.
1031
+ [nodejs/node#65748](https://github.com/nodejs/node/pull/65748) offers registered providers
1032
+ directories as well as files, which is what the `-r @pipobscure/bundle/record` line above now
1033
+ relies on. The runner is still the shape to copy when you want the mount point in hand — but
1034
+ it is a choice now, not the only route.
1035
+
1036
+ ---
1037
+
1038
+ ## Auditing a bundle
1039
+
1040
+ A signature answers *who produced these bytes*. It does not answer *are these bytes safe*.
1041
+ Those are different questions, and the second one is the one the recent supply-chain
1042
+ attacks actually exploited: every significant npm compromise of recent years shipped a
1043
+ correctly published, correctly signed package from a legitimately compromised account.
1044
+ Provenance would have confirmed it came from the real maintainer, and been useless.
1045
+
1046
+ What makes the second question tractable here is that a bundle is a **closed set**. Nothing
1047
+ resolves later, nothing is fetched at install, no lifecycle script pulls in more code.
1048
+ Unlike a review of a dependency tree, a review over a bundle can be complete.
1049
+
1050
+ `skills/audit-bundle/` is a [Claude Code](https://claude.com/claude-code) skill that
1051
+ does it in three phases:
1052
+
1053
+ 1. **Verify** — run `bundle verify --json` and report the state and signing identity. This
1054
+ phase gates the rest and is not skippable: it stops on `invalid`, and continues with a
1055
+ prominent warning on `unsigned` or `valid-untrusted`, since reviewing an archive you
1056
+ cannot place is precisely the useful case. An audit that silently reviewed a tampered
1057
+ archive would be worse than no audit.
1058
+ 2. **Extract** — `unzip` to a scratch directory. It is a real ZIP, so this needs no special
1059
+ tooling, and the extracted file list is reconciled against the signed member list.
1060
+ 3. **Review** — read every file, hunting what those attacks did: install and load-time
1061
+ hooks, obfuscated or encoded payloads, outbound network calls from modules with no
1062
+ reason to make them, environment and credential reads, CI token and cloud metadata
1063
+ endpoints, `child_process`/`eval`/dynamic `require`, and members nothing references.
1064
+
1065
+ ```
1066
+ /audit-bundle app.run
1067
+ ```
1068
+
1069
+ Claude Code discovers skills under `.claude/skills/`, and the skill belongs next to whoever
1070
+ is about to run an archive rather than in this repository — so the CLI writes it out:
1071
+
1072
+ ```sh
1073
+ bundle skill # -> .claude/skills/audit-bundle/SKILL.md
1074
+ bundle skill --list # what this package carries
1075
+ bundle skill --dir ~/.claude/skills # somewhere else
1076
+ ```
1077
+
1078
+ It never overwrites a file that is already there (`--force` if you mean to), so local edits
1079
+ survive an upgrade. The same thing from code, if you are wiring it into a setup script:
1080
+
1081
+ ```ts
1082
+ import { installSkill, skills } from '@pipobscure/bundle';
1083
+
1084
+ const { written, skipped } = installSkill('audit-bundle', { dir: '.claude/skills' });
1085
+ ```
1086
+
1087
+ It also has a diff mode: verify and extract two archives, review only what changed against a
1088
+ previously approved one, and call out any member added or removed. That is the realistic
1089
+ repeat-use case, and considerably more valuable than a fresh full review each time.
1090
+
1091
+ Steps 1 and 2 are the existing CLI; only step 3 is new. That split is deliberate — the
1092
+ policy layer is a library and a skill you can replace, not something the runtime does.
1093
+
1094
+ ### Gating a release on the audit, in CI
1095
+
1096
+ The review needs judgement, so nothing here pretends a script can perform it. What a script
1097
+ can do is refuse to proceed without one — and that needs the audit to produce something a
1098
+ build step can read. So the skill writes a JSON verdict beside its prose report:
1099
+
1100
+ ```json
1101
+ { "sha256": "f59a1a0a…", "baselineSha256": "e18542c9…", "mode": "sign",
1102
+ "verdict": "pass", "members": 904, "reviewed": 12, "findings": [],
1103
+ "summary": "12 members changed since 0.1.3; the sigstore tree is upstream and unmodified" }
1104
+ ```
1105
+
1106
+ `bundle audit --check` reads it, re-hashes the archive, and exits non-zero unless the
1107
+ verdict passed *and* names those exact bytes. That is the whole gate, and it works the same
1108
+ locally and in CI.
1109
+
1110
+ [`.github/workflows/publish.yml`](.github/workflows/publish.yml) is that pipeline as a
1111
+ workflow. On a push to `main` whose `package.json` version npm does not have yet, it runs:
1112
+
1113
+ ```
1114
+ test → pack → fetch the published release → audit the diff → gate → sign → publish
1115
+ ```
1116
+
1117
+ Step 3 is [`anthropics/claude-code-action`](https://github.com/anthropics/claude-code-action)
1118
+ in automation mode — a `prompt` input rather than an `@claude` mention — invoking this
1119
+ repository's own skill:
1120
+
1121
+ ```yaml
1122
+ - name: Install the audit skill
1123
+ run: node dist/main.js skill # into .claude/skills/, where the action looks
1124
+
1125
+ - name: Audit — review the difference before signing
1126
+ uses: anthropics/claude-code-action@833fb0f8c9f6686b33d963a8bae0a94f4936ab2a # v1
1127
+ env:
1128
+ BUNDLE_AUDIT_VERDICT: build/cli.audit.json
1129
+ with:
1130
+ anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
1131
+ anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }}
1132
+ prompt: "/audit-bundle build/cli.bundle against build/baseline.bundle …"
1133
+ claude_args: |
1134
+ --max-turns 120
1135
+ --allowedTools "Bash,Read,Glob,Grep,Write"
1136
+
1137
+ - name: Gate on the audit verdict
1138
+ run: node dist/main.js audit --check --baseline build/baseline.bundle build/cli.bundle
1139
+ ```
1140
+
1141
+ **Why a diff.** Re-reading 904 unchanged members every release is the kind of review that
1142
+ decays into a rubber stamp; a small diff gets read properly. `tools/baseline.ts` fetches the
1143
+ currently published package with `npm pack` — which downloads without installing or running
1144
+ anything — pulls its `bundle.run` out, and **verifies it before using it**, optionally
1145
+ requiring the release workflow's own identity. A baseline that cannot be placed would make
1146
+ the diff lie by omission: everything it already contained would read as "unchanged" and
1147
+ therefore go unread. With no baseline at all — the first release — it says so and the audit
1148
+ reviews everything.
1149
+
1150
+ **Why the actions are pinned to commit SHAs.** A tag is a mutable pointer. Whoever can move
1151
+ `v1` can change what runs inside a job holding an OIDC token that can sign releases and
1152
+ publish to npm — which is precisely the attack this project exists to talk about, so the
1153
+ workflow that publishes it should not be open to it. Update the SHAs deliberately and read
1154
+ the diff when you do.
1155
+
1156
+ **Why signing and publishing use the same identity.** `permissions: id-token: write` gets
1157
+ one OIDC token; Fulcio certifies it for the signature, and `npm publish --provenance` files
1158
+ its attestation from it. Two attestations over one artifact, from one identity: npm's over
1159
+ the registry copy, and this project's over the bytes inside it.
1160
+
1161
+ > **It is disabled, not blocked.** Every step depends on `--vfs-load`, which shipped in
1162
+ > v26.10.0, so there is now a `node-version` it can run on; what it still needs is its
1163
+ > secrets, a committed lockfile for its `npm ci`, and a decision to publish. The
1164
+ > file is disabled two ways over — it does not end in `.yml`, so GitHub never parses it, and
1165
+ > its body is commented out — and it carries the one-line command that turns it back into a
1166
+ > live workflow. It is there to be read.
1167
+
1168
+ Two honest limits. An LLM review is a good reviewer, not a proof — it raises the cost of
1169
+ slipping something past and does not reduce it to zero, which is what the `severity` and
1170
+ `summary` fields are for: they leave a record of what was judged acceptable and why, so a
1171
+ later reader can disagree with it. And the gate protects the *publisher's* pipeline; a
1172
+ consumer who wants the same assurance runs the same skill over what they received, which is
1173
+ the point of it being one review at two points rather than a build-only step.
1174
+
1175
+ ---
1176
+
1177
+ ## Why these changes to Node make sense
1178
+
1179
+ The through-line is: **let a single file be the file tree a program runs from.**
1180
+
1181
+ - **Distribution wants one artifact, but applications are trees.** ZIP is the obvious
1182
+ container for a tree, and `baseOffset` is the one small primitive that lets a ZIP live
1183
+ *inside* another file — after a shebang, after a binary — without ceasing to be a valid
1184
+ ZIP. That is what makes "append and go" possible instead of "carve out a section and
1185
+ inject with a separate tool."
1186
+ - **Resolution has to believe the tree.** Bundlers fail at the edges because they rewrite
1187
+ *some* module loading but the rest of Node — legacy main resolution, `package.json`
1188
+ lookup, extension sniffing, and every user `fs.readFile` — still points at the real disk.
1189
+ Routing those primitives through a VFS-aware layer (that is a no-op when nothing is
1190
+ mounted) means the *whole* runtime, not just the bundler's slice, agrees on where files
1191
+ are. Assets and addons come along for free.
1192
+ - **The self-mounting shebang is the elegant payoff.** Because the kernel appends the
1193
+ invoked script's own path to a trailing `--vfs-load`, so the script mounts *itself*,
1194
+ a plain executable ZIP with a one-line header behaves
1195
+ like an installed program — no launcher, no wrapper, no unpacking. It's the Python
1196
+ zipapp / self-extracting-jar idea, but resolved natively by the runtime rather than
1197
+ bootstrapped by user code.
1198
+ - **The manifest closes the "what do I even ship?" problem.** Static dependency analysis is
1199
+ perennially wrong for dynamic `require`, data files, and conditional imports.
1200
+ A recording provider answers it empirically: *these are the files this run touched.*
1201
+ Combined with the mount, build-time discovery and run-time resolution use the same
1202
+ mechanism — and once selection is an extension point, discovery doesn't need to be a
1203
+ flag in the runtime at all.
1204
+ - **SEA is another delivery mode, not a different world.** By carrying `--build-sea`,
1205
+ ESM SEA entry points, and their code cache, the *same* archive that powers `app.run` also
1206
+ powers `app.sea`. You choose "small, needs Node" vs. "large, needs nothing" per target
1207
+ without changing how you package.
1208
+ - **A pluggable provider registry turns "can mount an archive" into "can refuse to."**
1209
+ Selection happening after `-r` preloads, and registered providers outranking the built-in
1210
+ one, are what let a policy — verify this signature, or don't mount at all — sit *below*
1211
+ the entry point instead of in it. An application cannot forget to call its own checker if
1212
+ the checker is what produced its filesystem.
1213
+
1214
+ ### Honest limitations
1215
+
1216
+ - **VFS is not a sandbox.** It redirects `fs`; it does not confine untrusted code. Real
1217
+ isolation still needs OS-level mechanisms. Node's own docs say so.
1218
+ - **A verified mount is an integrity gate, not a confinement.** It proves the code is the
1219
+ code that was signed, by someone whose chain you trust. Once that code runs it has the
1220
+ full authority of the process — the guarantee is about *provenance*, not privilege.
1221
+ - **The gate is only as strong as how the runtime was launched.** Anyone who can change the
1222
+ command line can drop the `-r`, and the archive is mounted with the built-in provider,
1223
+ which verifies nothing. Registration is a userland opt-in, not a runtime policy.
1224
+ - **Native SEAs are large** because they include a full Node. That's inherent to
1225
+ zero-dependency native distribution, not a flaw in the approach.
1226
+ - **Signing proves provenance, and provenance is not safety.** Every significant npm
1227
+ compromise of recent years shipped a correctly signed package from a legitimately
1228
+ compromised account. A signature would have confirmed it came from the real maintainer
1229
+ and been useless. That is what the audit skill is for, and why it is a separate step.
1230
+ - **A sigstore signature is only as private as the transparency log.** Signing puts your
1231
+ identity, the archive's hash and the time in a public append-only log. That is the
1232
+ mechanism working — it is what makes the ten-minute certificate verifiable later — but it
1233
+ is not something to discover after the fact.
1234
+ - **Everything here is experimental** — a personal fork, `--experimental-vfs`, `REPLACEME`
1235
+ version markers. It's a proof of concept for a distribution model, not a supported
1236
+ product.
1237
+
1238
+ ---
1239
+
1240
+ ## Layout
1241
+
1242
+ ```
1243
+ bundles/
1244
+ src/ the tool, in TypeScript; compiled to ESM in dist/
1245
+ index.ts the package root: create / sign / verify / inspect / run, from code
1246
+ api.ts the programmatic drive the CLI is a wrapper over
1247
+ cli.ts parseArgs and reporting; main(argv, io) -> exit code
1248
+ main.ts the executable entry, and the package `main` --vfs-load= runs
1249
+ archive.ts bundle() builds unsigned from a directory; rebundle() re-emits behind a
1250
+ new prefix and signs; keySigner() is the offline-CA signer
1251
+ manifest.ts buildManifest() / parseManifest() / verifySync(): the format and its check
1252
+ provider.ts BundleProvider + register(): verify at mount, hash every member at fetch
1253
+ register.ts the `-r` preload; configured via BUNDLE_ROOTS / BUNDLE_ALLOW_UNTRUSTED / …
1254
+ recorder.ts recording() + Manifest: the userland replacement for --vfs-manifest
1255
+ record.ts the `-r` preload for recording; configured via BUNDLE_MANIFEST
1256
+ launch.ts verify a container, mount it, run it — and the verifying node's CLI
1257
+ sea.ts createSeaBase() / buildSea(): building the executables
1258
+ sigstore.ts the sigstore signer (two-phase) and synchronous bundle verification
1259
+ oidc.ts identity tokens: ambient CI, browser sign-in, or device code
1260
+ files.ts dependency closures, for what an observation run cannot see
1261
+ skill.ts the skills this package ships, and installing them into a project
1262
+ preload.ts the shared plumbing the two `-r` preloads need
1263
+ types/ node:zlib's ZIP API and node:vfs's provider registry, until @types/node has them
1264
+ tools/ the build steps that are not just a CLI call:
1265
+ manifest.ts observe a run, close over the dependencies, write the list
1266
+ observe.ts the recording mount that run goes through
1267
+ baseline.ts fetch and verify the published release, to diff against
1268
+ audit.ts prepare the audit, and gate signing on its verdict
1269
+ prepublish.ts refuse to publish a stale or unsigned CLI
1270
+ .github/workflows/ci.yml build, typecheck and test on node 26.10.0
1271
+ .github/workflows/publish.yml the release pipeline: publishes any version npm lacks
1272
+ test/ 150 tests over the format, both providers, the API, the CLI, the SEA and the package
1273
+ skills/audit-bundle/
1274
+ the audit skill: verify -> extract -> security-review every file.
1275
+ `bundle skill` writes it into a project's .claude/skills/
1276
+ tools/testpki.ts generates a throwaway PKI into build/certs/ on demand
1277
+ shell-base shebang prefix for the portable archive
1278
+ tsconfig.json strict, ESM, erasable syntax only — so `node src/main.ts` runs the sources
1279
+ package.json the build / pack:cli / sign:cli / verify:cli / trust / test scripts
1280
+ ```
1281
+
1282
+ Build outputs — `dist/`, `build/cli.bundle` and the signed `bundle.run` — are generated
1283
+ by those scripts and are not in the repository.
1284
+
1285
+ **Environment variables**
1286
+
1287
+ | | |
1288
+ |---|---|
1289
+ | `BUNDLE_MANIFEST` | where the recording provider writes the observed file list |
1290
+ | `BUNDLE_ROOTS` | extra trusted roots for verification, path-delimiter separated |
1291
+ | `BUNDLE_ALLOW_UNTRUSTED` | mount an archive whose signature is good but untrusted |
1292
+ | `BUNDLE_IDENTITY` / `BUNDLE_ISSUER` | require a particular sigstore signer at mount time |
1293
+ | `BUNDLE_SIGSTORE_ROOT` | path to the sigstore trust root, instead of the cache |
1294
+ | `BUNDLE_NO_BROWSER` | never try to open a browser; use the device flow |
1295
+
1296
+
1297
+ ---
1298
+
1299
+ # Implementation notes
1300
+
1301
+ Design that was decided-enough to write down before it was built. These were kept in a
1302
+ separate file for most of the project's life, on the theory that the reasoning is worth
1303
+ more than the diff; they live here now because the reasoning and the argument are the same
1304
+ document read at two zoom levels. Where an implementation departed from its plan, that is
1305
+ recorded inline rather than edited away — a design note that only ever describes what
1306
+ happened is a changelog, and the interesting part is usually the gap.
1307
+
1308
+ | | |
1309
+ |---|---|
1310
+ | **§1 signing-time attestation** | Built. The marker grammar is extensible, and the evidence rides in a `SIGSTORE=` field carrying a full sigstore bundle — transparency-log entry *and* RFC 3161 token — rather than a bare TSA token. |
1311
+ | **§2 the audit skill** | Built, as `skills/audit-bundle/`. |
1312
+ | **§3 the tool as a bundle of itself** | Built. The published package carries its own CLI as one signed archive and the `bundle` command is a launcher for it; the sigstore dependencies became members, as this section said they would have to. |
1313
+ | **§4 the self-validating executable** | Built, as `src/sea.ts` and `bundle sea`. The VFS mount that drives a SEA, applied to the archive appended to it. |
1314
+ | **§5 the audit as a build step** | Built, as `bundle audit`, `tools/baseline.ts` and `.github/workflows/publish.yml`. The review moves from something you do to an archive you received to something that happens between `create` and `sign`. |
1315
+
1316
+ ---
1317
+
1318
+ ### 1. Signing-time attestation, so short-lived certificates verify later
1319
+
1320
+ > **Built.** What follows is the reasoning; see the note at the end of this section for
1321
+ > where the implementation differs.
1322
+
1323
+ #### The problem
1324
+
1325
+ `verifySync()` anchors the chain with `anchored(chain, roots, now)`, and `within()`
1326
+ requires **every certificate in the chain to be inside its validity window at `now`**,
1327
+ which defaults to `Date.now()` (`lib/manifest.js`). That is correct for a long-lived
1328
+ code-signing certificate and wrong for a short-lived one.
1329
+
1330
+ Sigstore's Fulcio issues certificates valid for about **ten minutes** — deliberately, so
1331
+ there is no long-lived key to steal. An archive signed that way verifies as `valid` for
1332
+ ten minutes and `valid-untrusted` forever after. The signature is still good; the
1333
+ verifier is simply asking the wrong question.
1334
+
1335
+ The right question is not *"is this certificate valid now?"* but *"was it valid when the
1336
+ signature was made?"* — which requires the archive to carry a trustworthy assertion of
1337
+ when that was.
1338
+
1339
+ #### The circular approach, and why it fails
1340
+
1341
+ The obvious move — put the Rekor transparency-log entry in the archive as a member — does
1342
+ not work:
1343
+
1344
+ 1. The archive is hashed and signed, producing `H` and `S`.
1345
+ 2. Submitting `(H, S, cert)` to Rekor yields a signed entry timestamp.
1346
+ 3. Adding that as a member changes the member list, so the central directory changes, so
1347
+ `H` changes, so `S` is invalid.
1348
+ 4. Re-signing produces a new `H`, so a new log entry, so back to 3.
1349
+
1350
+ Anything placed **inside the hashed region** has this problem. The timestamp is obtained
1351
+ *after* the signature exists, so it can never be part of what the signature covers.
1352
+
1353
+ #### The resolution: the EOCD comment is already an unsigned-attribute region
1354
+
1355
+ This is a solved problem in code signing generally. [RFC 3161][rfc3161] puts the timestamp
1356
+ token in the CMS `SignerInfo` **`unsignedAttrs`**, for exactly this reason: the token is
1357
+ produced after the signature, so it cannot live inside the signed data. Authenticode does
1358
+ the same with a counter-signature unsigned attribute.
1359
+
1360
+ This format already has such a region. The whole-file hash deliberately stops short of the
1361
+ EOCD comment, and `bundle()` writes that comment *after* hashing and signing:
1362
+
1363
+ ```
1364
+ [ prefix | members | AUTHORITY.PEM | central directory | EOCD fixed part ] [ comment ]
1365
+ \___________________ hashed region → H _______________________________/ \__ not hashed
1366
+ SIGNED:H:S
1367
+ ```
1368
+
1369
+ So the timestamp goes in the comment, beside the signature — not in a member.
1370
+
1371
+ #### Proposed change
1372
+
1373
+ **Build** (`lib/archive.js`, step 3 of `bundle()` — already runs after hashing):
1374
+
1375
+ 1. Compute `H`, sign it → `S`. Unchanged.
1376
+ 2. Send **`S`** to a timestamp authority; receive a token.
1377
+ 3. Write `SIGNED:<H>:<S>:TS=<base64 token>` as the EOCD comment.
1378
+
1379
+ No re-hash, no re-sign, no loop.
1380
+
1381
+ **Verify** (`lib/manifest.js`):
1382
+
1383
+ 1. Recompute the hash of the region and match `H`. Unchanged — still the cheap,
1384
+ certificate-free first gate.
1385
+ 2. Parse the token from the comment; verify it against a **pinned** TSA key/root. This
1386
+ yields a trusted time `T`, bound to `S`.
1387
+ 3. Verify `S` over `H` against the leaf. Unchanged.
1388
+ 4. Anchor the chain with `now = T` instead of `Date.now()`.
1389
+
1390
+ `verifySync()` already accepts a `now` option, so step 4 needs no new plumbing.
1391
+
1392
+ **Code touch points**
1393
+
1394
+ | Where | Change |
1395
+ |---|---|
1396
+ | `parseSignature()` | The regex is anchored at exactly two fields: `/^SIGNED:([0-9a-f]+):([0-9a-f]+)$/i`. Needs an extensible trailing-field grammar, and must stay backward compatible with two-field markers. |
1397
+ | `bundle()` | Request and append the token after signing. |
1398
+ | `inspect()` | Verify the token, derive `T`, pass it as `now`. |
1399
+ | trust config | A pinned TSA root, alongside the existing `extraRoots` / `BUNDLE_ROOTS`. |
1400
+
1401
+ #### Timestamp `S`, not `H`
1402
+
1403
+ A token over `H` proves *the archive* existed at `T`. A token over `S` proves *the
1404
+ signature* existed at `T`, which is the question actually being asked about certificate
1405
+ validity. CMS convention is to timestamp the signature value.
1406
+
1407
+ #### Why an unhashed comment is acceptable here
1408
+
1409
+ The comment is not covered by `H`, so it is malleable. That is not exploitable:
1410
+
1411
+ - **Strip the token** → verification falls back to `Date.now()` → the certificate reads as
1412
+ expired → result degrades to `valid-untrusted`. A denial of service, not a forgery.
1413
+ - **Forge a token** → requires the TSA's key.
1414
+ - **Transplant a token from another archive** → it binds to that archive's `S`.
1415
+ - **Obtain a fresh token** → a later `T` only makes the certificate look more expired.
1416
+
1417
+ This is also no weaker than the status quo: `S` itself already lives in the unhashed
1418
+ comment and self-authenticates. Nothing about the trust model changes.
1419
+
1420
+ **Size budget.** The EOCD comment length field is 2 bytes → 65535 bytes. An RFC 3161 token
1421
+ carrying its TSA chain is roughly 2–4 KB. Not a constraint.
1422
+
1423
+ #### Cheaper alternatives, if this is too much machinery
1424
+
1425
+ - **Self-asserted signing time.** A `!signed <RFC3339>` directive in `AUTHORITY.PEM` sits
1426
+ *inside* the hashed region, so it is covered by `S` and is tamper-evident. But it is only
1427
+ as good as the signer's clock, and it gives back the bounded-damage property that
1428
+ short-lived certificates exist to provide. A convenience fallback, not an equivalent —
1429
+ and it should produce a distinguishable result state, not silently pass as `valid`.
1430
+ - **Verify once at ingest, then pin the hash.** Sidesteps long-lived re-verification
1431
+ entirely. For many deployment models this is the right answer and needs no format change
1432
+ at all.
1433
+
1434
+ #### Open questions
1435
+
1436
+ - Which TSA to pin by default. Sigstore operates one; confirm the current endpoint and
1437
+ root distribution rather than hard-coding a hostname.
1438
+ - Whether an archive with a good signature but *no* timestamp and an expired certificate
1439
+ deserves its own state/reason rather than collapsing into `valid-untrusted`.
1440
+ - Whether to support Rekor inclusion proofs as an alternative to a plain TSA token. Same
1441
+ placement, strictly more verification work; only worth it if log transparency is wanted
1442
+ for its own sake.
1443
+
1444
+ #### What was actually built
1445
+
1446
+ The placement argument survived intact — the evidence lives in the EOCD comment, beside
1447
+ the signature, because it postdates it. Three things landed differently:
1448
+
1449
+ - **A sigstore bundle, not a bare TSA token.** The `SIGSTORE=` field carries the whole
1450
+ sigstore bundle: the Fulcio certificate, the signature, the Rekor entry *and* any RFC
1451
+ 3161 timestamps. That answers the "Rekor inclusion proofs as an alternative?" open
1452
+ question with *both*, because the bundle format carries both and `@sigstore/verify`
1453
+ checks whichever are present.
1454
+ - **The signing time is not plumbed through `now`.** The plan was to derive `T` and pass
1455
+ it to the existing `anchored()`. In practice the sigstore path *replaces* X.509
1456
+ anchoring rather than parameterising it: `Verifier` does certificate-to-Fulcio-root,
1457
+ SCT, log inclusion, timestamps and the signature as one coherent check, and splitting
1458
+ that across two trust models would have been strictly worse. `now` is simply ignored
1459
+ when a `SIGSTORE=` field is present.
1460
+ - **No TSA to pin.** The open question about which TSA to pin by default dissolved: the
1461
+ trust material comes from the sigstore trust root over TUF, refreshed by `bundle
1462
+ trust`, so the TSA and log keys are distributed the same way as the Fulcio roots and
1463
+ need no hard-coded hostname.
1464
+
1465
+ Two checks the design did not anticipate turned out to be necessary, both because the
1466
+ field sits in the malleable region. The bundle must be over *this* archive's hash, and
1467
+ the certificate it verifies against must be the one `AUTHORITY.PEM` names — otherwise a
1468
+ genuine bundle for another identity could be pinned to an archive whose extractable
1469
+ manifest claims a different signer. The signature would check out and the inspectable
1470
+ file would be a lie.
1471
+
1472
+ The remaining open question stands: an archive with a good signature, no timestamp and an
1473
+ expired certificate still collapses into `valid-untrusted` rather than getting a state of
1474
+ its own.
1475
+
1476
+ [rfc3161]: https://www.rfc-editor.org/rfc/rfc3161.html
1477
+
1478
+ ---
1479
+
1480
+ ### 2. An audit skill: verify → extract → review
1481
+
1482
+ > **Built**, as `skills/audit-bundle/SKILL.md`, following this shape closely —
1483
+ > including the diff mode and the non-skippable verification step.
1484
+
1485
+ #### Why a bundle is the right shape for this
1486
+
1487
+ A signed archive answers *who produced these bytes*. It does not answer *are these bytes
1488
+ safe*. Those are different questions, and every npm compromise of recent years shipped a
1489
+ correctly published, correctly signed package from a legitimately compromised account —
1490
+ provenance alone would have confirmed it came from the real maintainer and been useless.
1491
+
1492
+ What makes the second question tractable here is that a bundle is a **closed set**: nothing
1493
+ resolves later, nothing is fetched at install, no lifecycle script pulls in more code. A
1494
+ review over it can be complete in a way that a review of a dependency tree cannot.
1495
+
1496
+ #### Shape
1497
+
1498
+ A Claude Code skill that takes an archive path and:
1499
+
1500
+ 1. **Verify** — run the existing verification. Report the state and the signing identity.
1501
+ Refuse to continue on `invalid`; continue with a clear warning on `unsigned` or
1502
+ `valid-untrusted`, since reviewing an untrusted archive is precisely the useful case.
1503
+ 2. **Extract** — unzip to a scratch directory. It is a real zip, so this needs no special
1504
+ tooling. Record the identity from `AUTHORITY.PEM` alongside the extraction.
1505
+ 3. **Review** — read every extracted file and report on:
1506
+ - install/lifecycle hooks and anything that runs at load time
1507
+ - obfuscated, minified, or encoded payloads in a source bundle
1508
+ - outbound network calls, especially in modules with no reason to make them
1509
+ - environment and credential reads; CI token and cloud metadata endpoints
1510
+ - `child_process`, `eval`, dynamic `require`, and other indirection
1511
+ - files present in the archive that nothing references
1512
+
1513
+ Output is a per-file report over a fixed, finite set of bytes.
1514
+
1515
+ #### Notes
1516
+
1517
+ - Steps 1 and 2 are the existing CLI; only step 3 is new work.
1518
+ - The verification step must not be skippable — an audit that silently reviewed a
1519
+ tampered archive would be worse than no audit.
1520
+ - Worth pairing with a diff mode: review only what changed against a previously approved
1521
+ archive, which is the realistic repeat-use case.
1522
+
1523
+ ---
1524
+
1525
+ ### 3. Shipping the tool as a bundle of itself
1526
+
1527
+ > **Built.** What follows is the reasoning; see the note at the end of this section for
1528
+ > where the implementation differs.
1529
+
1530
+ #### The claim
1531
+
1532
+ `bundle` should not be installed from npm. It should be distributed the way it tells
1533
+ everyone else to distribute: **one signed file**, verifiable by a copy of `bundle` you
1534
+ already have, and auditable as a closed set before you run it.
1535
+
1536
+ That makes the tool its own best demonstration. Every property this project claims — a file
1537
+ list discovered by observation, a whole-file signature, a mount that refuses what does not
1538
+ verify, a closed set an audit can be complete over — is exercised by the way the tool
1539
+ itself arrives on your machine. If the model does not hold up for `bundle`, it does not
1540
+ hold up for anything.
1541
+
1542
+ #### Why this is the answer to "where is the lock file?"
1543
+
1544
+ A lock file pins the bytes of a dependency tree that is still **fetched and executed at
1545
+ install time**. It is a real improvement over not having one, but note what it is
1546
+ improving: an install step that resolves a graph, runs lifecycle scripts, and ends with
1547
+ code on your disk that nobody looked at. Reproducing that exactly is worth something, and
1548
+ it is not the same as not needing it.
1549
+
1550
+ A signed bundle removes the step rather than pinning it:
1551
+
1552
+ | | lock file | signed bundle |
1553
+ |---|---|---|
1554
+ | what you fetch | a graph, resolved at install | one file |
1555
+ | what runs at install | lifecycle scripts, transitively | nothing; there is no install |
1556
+ | what you can review | in principle the tree, in practice not | every byte, and the set is finite |
1557
+ | what proves origin | the registry's word | a signature over the whole artifact |
1558
+ | what "the same bytes" means | same versions, re-resolved | literally the same bytes |
1559
+
1560
+ So the lock file is eye-candy *for this repo specifically* — the shipped artifact is not
1561
+ produced by `npm install` on the user's machine. It stays useful for contributors
1562
+ reproducing a build, which is a different question and an orthogonal one.
1563
+
1564
+ #### The chain of custody
1565
+
1566
+ Version *N* is verified by version *N−1*. That is an ordinary trust chain over time, and
1567
+ it is how package managers already handle their own updates:
1568
+
1569
+ ```
1570
+ bundle@0.1 (you have it) --verify--> bundle@0.2.bundle --verify--> bundle@0.3.bundle
1571
+ ```
1572
+
1573
+ Each release is signed through sigstore by the publish workflow, so the identity to pin is
1574
+ a workflow ref rather than a person — except 0.0.1, published by hand before the workflow
1575
+ existed and signed as its maintainer:
1576
+
1577
+ ```sh
1578
+ bundle verify --identity 'https://github.com/pipobscure/bundles/.github/workflows/publish.yml@refs/heads/main' \
1579
+ --issuer 'https://token.actions.githubusercontent.com' \
1580
+ bundle.run
1581
+ ```
1582
+
1583
+ `--identity` and `--issuer` already exist and already report a mismatch as
1584
+ `valid-untrusted` rather than `invalid` — genuinely signed, just not by who you demanded.
1585
+ `BUNDLE_IDENTITY`/`BUNDLE_ISSUER` impose the same policy at mount time, so a machine can
1586
+ be configured to run *only* releases from that workflow.
1587
+
1588
+ **Bootstrapping.** The first copy has to be trusted some other way; there is no way around
1589
+ that and pretending otherwise would be the dishonest part. The options are the usual ones —
1590
+ verify the sigstore identity by hand against the transparency log, or take it from a
1591
+ release page over TLS and audit it before first run. Trust-on-first-use, with the
1592
+ mitigation that TOFU here is over an artifact you can read completely.
1593
+
1594
+ #### The wrinkle: the tool has dependencies now
1595
+
1596
+ This is the part that needs work rather than just a decision. `lib/sigstore.js` pulls in
1597
+ `@sigstore/*`, and a bundle has no `node_modules` at runtime — so those libraries have to
1598
+ become **members of the bundle**.
1599
+
1600
+ That is not a workaround, it is the point: the dependency tree stops being a thing you
1601
+ fetch and becomes a fixed set of files inside one signed artifact that `/audit-bundle` can
1602
+ review in full. But it means:
1603
+
1604
+ - The manifest run has to resolve `node_modules`, not just `lib/`. Today `npm run manifest`
1605
+ mounts `lib/` alone, so nothing under `node_modules` is observed. The recording provider
1606
+ will capture them once the mount covers them — the mechanism is right, the mount point is
1607
+ not.
1608
+ - The result is a much larger member list, and it is the honest one. Roughly 96 packages
1609
+ came in with the sigstore libraries; every one of them would be visible in the manifest,
1610
+ which is uncomfortable and correct.
1611
+ - Verification-only builds could drop the signing half. `@sigstore/sign` and the OIDC flow
1612
+ are needed to *produce* a signature, not to check one, so a verify-only distribution is
1613
+ meaningfully smaller. Worth doing only if the size difference turns out to matter.
1614
+
1615
+ Until then the shipped `app.run` carries no sigstore libraries, which is why it degrades a
1616
+ sigstore-signed archive to `valid-untrusted` — correct behaviour, and a limitation this
1617
+ section is what fixes.
1618
+
1619
+ #### Open questions
1620
+
1621
+ - **Downgrade.** A correctly signed old release stays valid forever, which is the point of
1622
+ the timestamp and also means nothing stops someone handing you version 0.1 tomorrow.
1623
+ Whether the tool should carry a version floor, or leave that to whoever is deploying it,
1624
+ is undecided. Probably the latter — it is a policy, and policy is the thing this project
1625
+ keeps insisting does not belong in the mechanism.
1626
+ - **What the release artifact actually is.** A plain `.bundle` needs Node plus the preload
1627
+ flags; an `app.run` shebang runs anywhere with a compatible Node; a SEA needs nothing and
1628
+ costs 150 MB. Probably all three, but only the shebang one is a pleasant default.
1629
+ - **Whether `npm` publication continues in parallel.** Useful for people embedding the
1630
+ library, and it should be explicit that the npm package is the *library* and the signed
1631
+ bundle is the *tool*, rather than pretending the registry copy does not exist.
1632
+
1633
+ #### What was actually built
1634
+
1635
+ The chain-of-custody argument survived intact. Three things landed differently, all of them
1636
+ about the *npm* copy rather than about the model.
1637
+
1638
+ - **npm publication continues, and carries the signed bundle inside it.** The open question
1639
+ resolved into "both, in one package": the registry copy is the library (an `exports` map
1640
+ of ESM entry points, typed), and beside it sits `bundle.run` — the CLI as one signed
1641
+ archive, 904 members including the whole sigstore dependency tree. `bin` points *straight
1642
+ at it*: it carries a `#!/bin/sh` prefix that mounts and runs itself, so
1643
+ `npx @pipobscure/bundle` executes the signed artifact with nothing in between. That keeps
1644
+ the model honest without pretending the registry does not exist.
1645
+
1646
+ A first attempt put a small JavaScript launcher in front, which verified the archive
1647
+ before mounting it. Removing it was the right call and worth recording why: the launcher
1648
+ was unsigned code installed beside the thing it vouched for, so anybody able to swap the
1649
+ archive could equally swap the launcher — it detected corruption, not an adversary — and
1650
+ it put an opaque step in front of the one file a reader should be looking at. Inspectability
1651
+ was the thing being traded away, and inspectability is the argument.
1652
+ - **The member list is computed, not observed.** The section assumed the manifest run would
1653
+ simply cover `node_modules` once the mount did. It does not, and the reason is the thing
1654
+ worth recording: `sigstore.ts` requires `@sigstore/verify` only when it meets an archive
1655
+ carrying a `SIGSTORE=` field, so an observation run that signs with a local key never
1656
+ loads it — and the resulting bundle could not check a sigstore signature it later met.
1657
+ Observation cannot see a path it did not take. So `src/files.ts` computes the dependency
1658
+ closure through `node_modules`, and the observation run is kept as the *check* on it:
1659
+ anything read that the closure missed stops the build. Both mechanisms, each doing what it
1660
+ is good at.
1661
+ - **Unanchored is not a refusal, for the launcher specifically.** A sigstore-signed release
1662
+ is `valid-untrusted` on a machine that has never run `bundle trust`, and refusing there
1663
+ would make `npx` fail out of the box for a correct artifact. The launcher warns and
1664
+ continues on `valid-untrusted`, refuses outright on `invalid` or `unsigned`, and takes
1665
+ `BUNDLE_STRICT=1` to make the middle case fatal. (`trustedRootSync()` also falls back to
1666
+ the trust root `@sigstore/tuf` ships as a seed, so the common case needs no network.)
1667
+
1668
+ One thing the section did not anticipate: the identity policy had a hole. `--identity` and
1669
+ `BUNDLE_IDENTITY` were only consulted on the sigstore path, so an archive signed against an
1670
+ ordinary CA — which carries no identity claim at all — passed a policy demanding one. A
1671
+ machine configured to run *only* releases from a workflow would have mounted anything
1672
+ key-signed. It now reports `valid-untrusted`: the signature is genuine and simply is not the
1673
+ one demanded.
1674
+
1675
+ The downgrade question is still open and still, deliberately, unanswered: a correctly signed
1676
+ old release stays valid forever, and whether to carry a version floor is a policy, which is
1677
+ the thing this project keeps insisting does not belong in the mechanism.
1678
+
1679
+ ---
1680
+
1681
+ ### 4. The self-validating single executable
1682
+
1683
+ > **Built**, as `src/sea.ts` and `bundle sea`.
1684
+
1685
+ #### The claim
1686
+
1687
+ Every shape this tool produces gates on something outside itself. A `.bundle` needs the
1688
+ verifying provider preloaded; a shebang launcher has no preload at all and hands straight
1689
+ off to the application. The SEA is the shape that can carry its own gate, because the
1690
+ container *is* the runtime — and the interesting property is that the gate can be inside
1691
+ what it gates.
1692
+
1693
+ #### The shape
1694
+
1695
+ ```
1696
+ [ node runtime | SEA blob: stub + verifier.bundle ] [ app.bundle ]
1697
+ \____________________ the prefix, and part of ___/
1698
+ \___________ the archive's signed region ______/
1699
+ ```
1700
+
1701
+ The application is an ordinary signed archive appended to a node binary — the same
1702
+ `sign --prefix` that produces a shebang launcher, pointed at a different prefix. The
1703
+ whole-file hash covers the prefix, so the runtime and the verifier inside it are signed by
1704
+ the same signature that covers the application. There is nothing to check the checker
1705
+ against because the checker is inside what is checked.
1706
+
1707
+ #### Why the verifier is a mount rather than an inlined copy
1708
+
1709
+ The bootstrap runs before anything is mounted, so it cannot import the library the ordinary
1710
+ way. The previous `sea.js` solved that by copying `manifest.js` into itself — about 180 lines
1711
+ of duplicated verification logic, which promptly drifted: its marker regex was still the
1712
+ two-field form, so it read every sigstore-signed container as *unsigned*.
1713
+
1714
+ The fix was to make the verifier reachable before the application is: this package's own
1715
+ files rode in the SEA blob as one `.bundle` asset, and a fifteen-line CommonJS stub mounted
1716
+ *that* with `node:vfs` and required the real library out of it. Two mounts, in order: the
1717
+ verifier's from the blob, then the application's from the archive at the end of the file.
1718
+ Nothing was duplicated, and the verifier the container ran was the one the test suite tests.
1719
+
1720
+ That was the userland form of [nodejs/node#65675](https://github.com/nodejs/node/pull/65675)
1721
+ (`"useVfs": true`), which puts a SEA's own assets behind a VFS mount and runs the main script
1722
+ from its root — and the note closed by predicting that when `useVfs` landed, the generated
1723
+ stub would be the only piece that changed.
1724
+
1725
+ **Which is what happened.** `useVfs`, with a `vfsArchive` naming the bundle, embeds the ZIP
1726
+ in the executable and mounts it as the file system the main script runs from. So the first
1727
+ of the two mounts is node's now, the stub is injected at the root of it, and reaching this
1728
+ package is one relative `import`. What is left of the stub is a handful of lines that pick
1729
+ between verifying the archive appended to the executable and verifying the one named on its
1730
+ command line — and it is an ES module, because it is injected into a bundle whose
1731
+ `package.json` says `"type": "module"` and format detection inside a mount follows the same
1732
+ rules it follows on disk.
1733
+
1734
+ #### What runs before the check
1735
+
1736
+ The stub and the verifier execute before the signature has been verified, and that is worth
1737
+ being explicit about rather than glossing. It is where the trust has to start: both live
1738
+ inside the prefix, which is inside the hashed region, so tampering with either invalidates
1739
+ the signature over the application — and an attacker who could rewrite the executable's own
1740
+ runtime could equally rewrite a verifier that ran first. The application never runs until
1741
+ the check passes.
1742
+
1743
+ The gain over the old arrangement is real, though: because the mount is the *verifying*
1744
+ provider rather than a plain `ZipProvider`, every member is re-hashed against its signed
1745
+ digest as it is first read, for the life of the process — the per-member guarantee the old
1746
+ `sea.js` explicitly could not offer.
1747
+
1748
+ #### Configuration, and where policy lives
1749
+
1750
+ An executable run by its own name has no flags and no preload, so `createSeaBase()` bakes
1751
+ the bootstrap options into the generated stub — trusted roots, a required sigstore identity
1752
+ and issuer, whether an unanchored chain is acceptable. Leave them off and the same
1753
+ `BUNDLE_ROOTS` / `BUNDLE_IDENTITY` / `BUNDLE_ALLOW_UNTRUSTED` variables the mount honours
1754
+ apply, so one build can be decided about later. Both are legitimate; which one you want is
1755
+ whether the policy belongs to the publisher or to the deployment.
1756
+
1757
+ #### Open questions
1758
+
1759
+ - **Cross-compilation.** `createSeaBase({ node })` takes the binary to embed, so building a
1760
+ container for another platform is a matter of having that platform's node to hand. Whether
1761
+ the tool should fetch one is a packaging decision it has so far declined to make.
1762
+ - **Size, again.** The verifier asset is about a megabyte inside a 155 MB runtime, so the
1763
+ sigstore libraries are not what makes a SEA large. A verify-only verifier would save
1764
+ little and cost a build variant; `sigstore: false` exists for anyone who disagrees.
1765
+
1766
+
1767
+
1768
+ ---
1769
+
1770
+ ### 5. The audit as a build step, and a gate that can act on it
1771
+
1772
+ > **Built**, as `tools/audit.ts`, the verdict contract in `skills/audit-bundle/SKILL.md`,
1773
+ > and `.github/workflows/publish.yml`.
1774
+
1775
+ #### The claim
1776
+
1777
+ §2 built the audit as something you do to an archive somebody sent you. That is half of it.
1778
+ The other half is that a signature is a claim about bytes you stand behind, so the review
1779
+ belongs *before* the signature, not only after it:
1780
+
1781
+ ```
1782
+ observe → create → AUDIT → sign → … ship … → AUDIT → run
1783
+ ```
1784
+
1785
+ Both ends are the same review. Treating them as different tasks would be the mistake: if
1786
+ you would not run someone else's bundle without reading it, you should not sign your own
1787
+ without reading it either, and the standard is the one that survives being applied to
1788
+ yourself.
1789
+
1790
+ #### Why the ordering has to be create-then-audit, not audit-then-create
1791
+
1792
+ The tempting alternative is to review the source tree and then bundle it. That reviews the
1793
+ wrong thing. What ships is the archive, and the archive's member list came from an
1794
+ observation run — so the interesting question is not "is this source good" but "is this
1795
+ *set* the right set, and does everything in it belong". A dependency that arrived through
1796
+ the closure, a file the observation pulled in that nobody expected, a member nothing
1797
+ references: none of those are visible until the bundle exists. So the archive is built
1798
+ first, unsigned, and the review is over the thing that will actually be signed.
1799
+
1800
+ This also makes the failure cheap. An unsigned archive costs nothing to throw away, which
1801
+ is what makes this the right place to catch things — a finding here is "rebuild it", not
1802
+ "decide whether to accept a risk in something already published".
1803
+
1804
+ #### The gate
1805
+
1806
+ An audit needs judgement, so no script performs it. What a script can do is refuse to let
1807
+ step 4 happen without one, and that requires the audit to leave behind something a build
1808
+ step can read. Hence the verdict file: `verdict: "pass" | "fail"`, the findings with
1809
+ severities, and — the load-bearing field — the **sha256 of the archive**.
1810
+
1811
+ `bundle audit --check` re-hashes the file and refuses a verdict that names different
1812
+ bytes. Without that pin the gate is theatre: it would approve any later build on the
1813
+ strength of one earlier approval, which is precisely the failure mode of every
1814
+ "security review completed" checkbox. Rebuilding invalidates the approval, and it should.
1815
+
1816
+ `--approve` exists so a person who read the archive themselves is a first-class auditor; it
1817
+ writes the same file with `by: "human"`, so the gate treats both identically while the
1818
+ record still says which happened.
1819
+
1820
+ It is a *shipped command* rather than build tooling, and that was a correction. The gate
1821
+ lived in `tools/` at first, which meant the four-step flow this project preaches was
1822
+ available only to this project — the deck had a slide showing `npm run sign:cli` refusing,
1823
+ which no reader could reproduce. A pipeline you recommend and cannot hand over is an
1824
+ argument, not a tool.
1825
+
1826
+ #### Reviewing the diff, not the archive
1827
+
1828
+ Reviewing 904 members from scratch every release is expensive and, worse, is the same
1829
+ reading over the same unchanged dependency tree — the kind of review that decays into a
1830
+ rubber stamp precisely because nothing ever changes in most of it. What deserves attention
1831
+ is the difference: which members appeared, which vanished, and what changed inside the ones
1832
+ that stayed.
1833
+
1834
+ So the baseline is the **currently published release**, fetched with `npm pack` (which
1835
+ downloads without installing or running anything, which is the property the whole project
1836
+ is about) and verified before it is used. That verification is not ceremony. A diff makes
1837
+ everything the baseline already contained read as "unchanged", and therefore go unread — so
1838
+ a baseline that cannot be placed makes the review lie by omission rather than merely being
1839
+ less useful. `tools/baseline.ts` refuses an `unsigned` or `invalid` baseline outright, and
1840
+ can be told to require the release workflow's own signing identity.
1841
+
1842
+ This is the §3 chain of custody used for a second purpose. There, version *N* verifies
1843
+ version *N+1* to establish trust; here *N* is what *N+1* is read *against*. Same edge,
1844
+ different question.
1845
+
1846
+ Two states the design has to admit rather than paper over. The first release has no
1847
+ baseline: `--allow-missing` says so and the audit reviews everything. And a diff inherits
1848
+ every earlier verdict — an unchanged member is exactly as trustworthy as the review that
1849
+ cleared it last time, which is worth stating in the report rather than leaving implied.
1850
+
1851
+ #### Running the review in CI
1852
+
1853
+ `anthropics/claude-code-action@v1` runs Claude Code in *automation mode* when the workflow
1854
+ supplies a `prompt` rather than waiting for an `@claude` mention, and a prompt can be a
1855
+ skill invocation. So the release workflow installs the skill with the tool's own
1856
+ `bundle skill` command — into `.claude/skills/`, where the action looks — and invokes
1857
+ `/audit-bundle` over the freshly packed archive.
1858
+
1859
+ The action reports into the workflow log; it does not hand the job a structured result. That
1860
+ is why the gate is a separate step reading a file rather than a condition on the action's
1861
+ output, and why the verdict contract lives in the skill rather than in the workflow. The
1862
+ verdict is uploaded as a run artifact `if: always()`, so a refused release leaves its
1863
+ reasoning behind rather than only a red X.
1864
+
1865
+ Step 4 then signs through sigstore with the workflow's ambient OIDC identity, so the
1866
+ release is signed *as the workflow* — which is the identity §3 says a consumer should pin,
1867
+ and it closes the loop: the thing that was audited, the thing that was signed, and the
1868
+ thing whose identity you can check are all the same bytes. `npm publish --provenance` files
1869
+ its attestation from the same token, so the registry copy and the signed artifact inside it
1870
+ trace to one identity rather than two.
1871
+
1872
+ #### Pinning the actions
1873
+
1874
+ Every action is pinned to a full commit SHA rather than a tag. A tag is a mutable pointer,
1875
+ and whoever can move `v1` can change what runs inside a job that holds an OIDC token able
1876
+ to sign releases and publish to npm. That is not a hypothetical class of attack here; it is
1877
+ the same class HISTORY.md's opening argument is about, and a workflow that publishes *this*
1878
+ project while being open to it would be self-refuting.
1879
+
1880
+ The cost is real and worth naming: pinned SHAs do not pick up security fixes on their own,
1881
+ so they have to be updated deliberately, with the diff read. That is the trade, and it is
1882
+ the right one for a release pipeline specifically — less obviously so for ordinary CI.
1883
+
1884
+ #### How it is switched on
1885
+
1886
+ For a long time it could not be: until v26.10.0 there was no `node-version` GitHub Actions
1887
+ could install that carried `--vfs-load`, so the workflow sat in the repository as
1888
+ `release.yml.disabled`, commented out, to be read rather than run. Once 26.10 shipped it
1889
+ became `publish.yml`, with two changes that make it cheap to leave on:
1890
+
1891
+ - **The trigger is the version, not a tag.** Every push to `main` asks the registry whether
1892
+ `package.json`'s version exists; only when it does not does the release job run.
1893
+ - **There is no npm token.** npm's trusted publishing takes the job's OIDC token — the same
1894
+ one Fulcio certifies. The audit reaches Anthropic by workload identity federation with
1895
+ that token too, so the release job holds no long-lived secret at all.
1896
+
1897
+ The first release, 0.0.1, was published by hand and signed through sigstore as its
1898
+ maintainer; the workflow's baseline step accepts that identity alongside its own, so the
1899
+ chain of custody runs through it.
1900
+
1901
+ #### What this does not claim
1902
+
1903
+ An LLM review is a reviewer, not a proof. It raises the cost of slipping something past a
1904
+ release and does not reduce it to zero, and a gate that implied otherwise would be worse
1905
+ than no gate — which is what the `severity` and `summary` fields are for: they leave a
1906
+ record of what was judged acceptable and by what reasoning, so a later reader can disagree
1907
+ with it.
1908
+
1909
+ The gate also protects the publisher's pipeline only. A consumer who wants the same
1910
+ assurance runs the same skill over what they received. That is not a gap being papered
1911
+ over; it is the reason the skill is one review at two points rather than a build-only step.
1912
+
1913
+ #### Open questions
1914
+
1915
+ - **Whether a `fail` should open an issue** rather than only failing the run. Probably yes,
1916
+ and it is a workflow concern rather than a tool one.
1917
+ - **How far back a diff should reach.** The baseline is `@latest`, which is right for a
1918
+ normal release and wrong after a release that was itself under-reviewed — the diff would
1919
+ inherit that. A periodic full review, or diffing against the last *fully* reviewed
1920
+ release rather than the last one, would close it. Both need a record of which releases
1921
+ got which treatment, which the verdict file could carry but does not yet.
1922
+ - **Whether the gate belongs in `prepublishOnly` too.** Today it gates signing, which is
1923
+ the step that makes the claim. Publishing an already-signed artifact is arguably a
1924
+ separate decision, and arguably not.