Skip to content
pipobscurePublic

About

Ship the Tree: Bundling NodeJS applications

Resources

Stars

9 stars

Watchers

0 watching

Forks

Repository files navigation

@pipobscure/bundle

Ship a Node.js application as one signed file that the runtime refuses to run if it has been tampered with.

bundle create --base ./app --files app.manifest --output app.run   # archive it
bundle sign --launcher --output app.nzip app.run                   # sign, via sigstore
./app.nzip                                                         # and it is a program

The archive is a real ZIP with a signature over the whole file. Mounting it through node:vfs is what enforces that signature: the provider verifies before it returns a filesystem, so an archive that does not check out never becomes one and its entry point never runs. Every member is re-hashed against its signed digest as it is read, for the life of the process.

Requires Node 26.10 or later, run with --experimental-vfs. Every piece this needs is in a released Node; the last, the --vfs-load loader, shipped in v26.10.0. Building a single executable additionally needs one open pull request. See Requirements. Everything here is experimental.


Contents


Installing bundle

npx @pipobscure/bundle install      # -> a signed `bundle` on your PATH
bundle --help
bundle update                       # later, when there is a new release

That is the whole install: npm fetches the package once, and what stays behind is the signed archive itself, on your PATH and keeping itself current. Or take it from the release page and skip npm altogether — Node 26.10 or later is all it needs:

curl -LO https://github.com/pipobscure/bundles/releases/latest/download/bundle.nzip
chmod +x bundle.nzip
./bundle.nzip install                # the same thing: fetch, verify, put on PATH

As a library instead:

npm install @pipobscure/bundle

The .nzip extension is what makes an archive runnable on Windows, where the association is by extension. On unix it means nothing, so bundle install drops it and leaves you a command called bundle.

Releases are signed by the publish workflow, so the identity to pin when you check one is https://github.com/pipobscure/bundles/.github/workflows/publish.yml@refs/heads/main, issuer https://token.actions.githubusercontent.com. The audit verdict each was signed under sits beside it as cli.audit.json.

The bundle command npm installs is the signed archive. bin points straight at bundle.nzip — the CLI, its skill and its whole dependency tree in one file, behind a two-line #!/bin/sh prefix that mounts it and runs it. There is no wrapper script in between, which is the point: nothing unsigned stands between you and the artifact, and

head -c 100 "$(npm root)/@pipobscure/bundle/bundle.nzip"   # what it will do
unzip -l    "$(npm root)/@pipobscure/bundle/bundle.nzip"   # everything it contains
bundle verify "$(npm root)/@pipobscure/bundle/bundle.nzip" # who signed it

answer every question about it without running anything. See the tool as a bundle of itself.

Running it by name does not verify it — the kernel gives a #! launcher no preload to carry a provider, and this package says so rather than pretending otherwise. Verification is a separate act, done with a copy of bundle you already trust: bundle verify bundle.nzip to check it, or bundle run bundle.nzip <args> to execute it through the verifying mount.


The four steps

Building a bundle is four steps, in this order:

1. observe   run the application, and write down every file it actually reads
2. create    archive exactly that list, unsigned          -> app.run
3. audit     review it — against the last release, if there is one
4. sign      only if step 3 came back clean               -> app.nzip

The two extensions say which is which. A .run is an archive that has not been signed — the thing step 3 reviews. A .nzip has been, and is what anything else should be handed. The verifying provider knows the difference: registered, it claims every archive it is offered and mounts only the ones that verify, so a .run does not run through it at all. Running an unsigned archive is something you do deliberately, with that provider out of the picture.

1. Observe

Static analysis is perennially wrong about dynamic require, data files and conditional imports. So the file list comes from running the thing:

BUNDLE_MANIFEST=app.manifest node --experimental-vfs \
    -r @pipobscure/bundle/record --vfs-load=./app -- <args>

Every file read through the mount is appended to app.manifest, one path per line, as it is read — so a killed process still leaves a usable list. Read-only open()s count too, which catches streamed files that a readFile hook would miss.

Observation has one blind spot worth knowing: code on a path the run never took. For a dependency tree, pair it with a computed closure — see moduleFiles.

2. Create

bundle create --base ./app --files app.manifest --output app.run

Unsigned, and deliberately so. This is the single input to every shape you ship.

3. Audit

A signature is a claim about bytes you stand behind, so the review belongs before it:

bundle audit app.run             # what is about to be reviewed, and how
bundle skill                        # install the audit skill into .claude/skills/
claude "/audit-bundle app.run"   # verify, extract, read every member
bundle audit --check app.run     # exits non-zero without a clean verdict

bundle audit does the two mechanical halves around the review. On its own it reports the archive's hash, its members and — with --baseline <previous> — what changed since the last release you approved. With --check it is a gate: it reads the JSON verdict the skill writes and refuses unless that verdict passed and names the sha256 of the bytes on disk, so rebuilding invalidates an approval. --approve --note '<what you checked>' records a verdict you reached by reading the archive yourself.

There is deliberately no environment variable that turns the gate off. It is a command you choose to put in your pipeline — if you do not want it, do not put it there.

audit-bundle is a Claude Code skill that verifies the archive, extracts it, and security-reviews every file — load-time hooks, encoded payloads, outbound calls, credential and CI-token reads, eval and dynamic require, and members nothing references. Because a bundle is a closed set — nothing resolves later, nothing is fetched at install — the review can actually be complete.

It is the same review whoever receives the bundle should run before trusting it. That is the point: hold your own artifact to the standard you would hold someone else's. It can also review only the diff against a previously approved archive, which is the realistic repeat-use case.

4. Sign

bundle audit --check app.run && bundle sign --launcher --output app.nzip app.run

Through sigstore by default: an ambient CI identity when there is one, otherwise a browser sign-in. No long-lived key exists to steal — the certificate lasts about ten minutes, and a transparency-log entry and timestamp are what let it verify afterwards.

Or against a certificate authority of your own:

bundle sign --key leaf.key --chain chain.pem --output app.signed.nzip app.run

Signing is separate from building, and that is what makes one build serve every target:

bundle sign --launcher --output app.nzip          app.run   # a file you can run by name
bundle sea             --output app.sea           app.run   # standalone executable
bundle sign            --output app.signed.nzip   app.run   # plain, for a mount

Each is correctly offset and signed over its own finished bytes. --launcher prepends the shell prefix this package ships, so nobody has to know it lives inside node_modules; --prefix <file> takes a launcher of your own, or a node binary.


CLI reference

Everything bundle does, command by command. bundle --help prints the same options in brief; this is the long form.

bundle <command> [options] [arguments]
bundle --help          bundle -v, --version
Building and signing
create build an unsigned archive from a list of files
audit report what is about to be reviewed, and gate signing on the verdict
sign sign an archive into a new file, optionally behind a launcher or a binary
sea build a node runtime that verifies an archive before running it
Checking and running
verify verify an archive and report its trust state
run verify an archive, mount it, and run what is inside
Vouching
attest vouch for archives from an atproto account, warn against them, or withdraw that
lexicon show, check or publish the atproto lexicon attestations are written in
Installing and keeping current
install fetch an archive from a URL or a domain, review it, and put it on your PATH
update refetch what was installed, and replace it if a new version is accepted
installed list what is installed, and re-check each against its record
validate re-check installs, and say what has been attested since
uninstall remove an install, and forget where it came from
Trust and policy
trust refresh the sigstore trust root and the cached attestations
policy show, start or check the rules this machine installs by
Setting up
shell print what to load at shell start: Tab completion, and a quiet re-check
skill install the bundle-auditing skill into a project

Conventions

  • Options come before arguments. --name value and --name=value both work. A short option takes its value as the next word: -o app.nzip.
  • Options that are on by default are turned off with --no-: --no-discover, --no-sigstore, --no-shell, --no-validate, --no-complete.
  • Repeatable options (--root, --attester, --block) may be given as many times as needed.
  • Durations (--every, --timeout, --max-age, the policy's maxAge) are a number with an optional unit: 90s, 30m, 12h, 7d. A bare number is seconds.
  • Attesters are written [kind@]<did or handle>: did:web:audit.example.com, audited@did:plc:ewvi7nxzyoun6zhxrhs64oiz, audited@alice.example.com. A handle is resolved to its DID when the command runs, and checked both ways: the DID's document must claim the handle back. Only the DID is ever stored or baked into anything, because a handle can change hands. With a kind, only attestations of that kind count.
  • Installs are named by the name they were installed under (tool), by the URL they came from, or by the domain whose nzip: record named them.
  • Errors go to stderr, results to stdout. --json, where offered, prints a machine-readable result on stdout and nothing else there.
  • Nothing is done without asking that is not yours to decide. A signer or attester nobody has accepted is a question; editing your shell's startup file is a question. With no terminal to ask on, those commands stop and say so (exit 4) rather than assume a yes.

Exit codes

Code Meaning
0 Done. For verify, run and friends: valid, meaning genuine and trusted as asked.
1 valid-untrusted: genuine, but not vouched for the way it was required to be: an unanchored certificate, a missing sigstore trust root, a signer or attesters other than the ones demanded. validate: a new warning. trust: a refresh failed. lexicon check: not published as this version.
2 invalid: the bytes changed since signing, a member does not match its digest, the archive no longer parses, something was appended after its end, or an attester you block on has marked it bad. For installed and validate: also an install that is missing, or was replaced by something other than update.
3 unsigned: no signature, and nothing else vouching for it.
4 Needs a decision: install or update found evidence nobody has accepted yet and had no terminal to ask on (pass --yes to accept what it shows), or you declined.
64 No command given.
70 Anything else that went wrong: a bad option, a file that is not there, a network error. The message says what.

"Could not check" is never 2. A missing trust root or library is 1, because conflating "I could not tell" with "this is forged" is how people learn to click through warnings.

Building and signing

create

bundle create [options] < file-list
bundle create --base ./app --files app.manifest --output app.run

Builds an archive from a newline-separated list of files, read from --files or from stdin, relative to --base. Each member records the digest of its own content, and an AUTHORITY.PEM member declares the algorithms. The result is unsigned, deliberately: it is the one input to every shape you then sign (sign, sea), and the thing audit reviews. By convention it is called .run.

Option
-b, --base <dir> the directory the file list is relative to (default: .)
-f, --files <file> read the file list from here (default: stdin)
-o, --output <file> write the archive here (default: stdout)
-p, --prefix <file> put this in front of the archive: a launcher or a node binary. Omit it for a plain archive, to be run from a mount.
-k, --key <file> sign as it is built, with this private key (PEM) — only with --chain
-c, --chain <file> the certificate chain for --key (PEM, leaf first)
--hash <alg> the digest for the whole-file hash and the member digests (default: sha256)
--sign <alg> the digest the signature uses (default: sha256)

Signing at build time is for a certificate authority of your own. To sign through sigstore, create unsigned and use sign.

audit

bundle audit [options] <archive>
bundle audit app.run                                  # what is about to be reviewed, and how
bundle audit --baseline last-release.nzip app.run     # ...as a diff against what was approved before
bundle audit --check app.run && bundle sign …         # the gate
bundle audit --approve --note 'read every member' app.run

The review itself needs judgement, so no command performs it. audit does the two mechanical halves around it.

  • On its own, it reports the archive's hash and members, and how to run the review: the audit-bundle skill (see skill), or reading it yourself. With --baseline, it says what was added and removed since an archive you approved before, so the review can be of the difference.
  • --check is the gate. It reads the JSON verdict (the skill writes one) and exits non-zero unless that verdict passed, names the sha256 of the bytes on disk, and was reached against the same baseline. Rebuilding invalidates an approval.
  • --approve records a passing verdict you reached by reading the archive yourself, pinned to its bytes, with --note saying what you checked.
Option
-b, --baseline <file> a previously approved archive to review against
-v, --verdict <file> where the verdict is (default: <archive>.audit.json)
--check exit non-zero unless a clean verdict pins these bytes
--approve record a clean verdict you reached yourself
-n, --note <text> what you checked, recorded with --approve

There is deliberately no switch that turns the gate off. It is a command you choose to put in your pipeline.

sign

bundle sign [options] <archive>
bundle sign --launcher --output app.nzip app.run                 # runs by name, signed through sigstore
bundle sign --output app.signed.nzip app.run                     # a plain archive, for a mount
bundle sign --key leaf.key --chain chain.pem --output app.nzip app.run   # your own CA

Re-emits an archive's members into a new file, behind whatever prefix is asked for, and signs the finished bytes. The input is never modified. The signature covers the whole file: the prefix, every member, and the central directory. So each shape (a launcher, an executable, a plain archive) is correctly offset and signed over itself.

Through sigstore by default. The signing certificate is issued for an identity you sign in as. In CI that is the workflow's own token; elsewhere it is a browser sign-in, or a device code. It is valid for about ten minutes, so there is no long-lived key to steal. A transparency-log entry and a timestamp, recorded with the signature, are what let it verify afterwards. Or sign against a certificate authority of your own with --key and --chain.

Option
-o, --output <file> write the signed archive here (default: stdout)
-l, --launcher prepend this package's two-line #!/bin/sh launcher, so the result runs by name. The usual way to make a self-executing archive.
-p, --prefix <file> prepend some other prefix: a launcher of your own, or a node binary
-x, --executable make the output executable (implied by --launcher and --prefix)
--hash <alg>, --sign <alg> as for create
-k, --key <file> sign with this private key (PEM) instead of sigstore — with --chain
-c, --chain <file> the certificate chain for --key (PEM, leaf first)
--flow <how> how to get a sigstore identity: auto (the default: CI if there is one, else a browser, else a device code), ci, browser, device
--token <jwt> use this OIDC token instead of signing in
--oidc-issuer <url> the OIDC issuer to sign in with (default: sigstore's)
--connector <name> the identity provider to go straight to: github (the default), google, microsoft
--fulcio <url> the certificate authority (default: fulcio.sigstore.dev)
--rekor <url> the transparency log; an empty string skips it
--tsa <url> the timestamp authority; an empty string skips it

A sigstore signature is public by design: your identity, the archive's hash and the time go into an append-only log.

sea

bundle sea --output <file> [options] [archive]
bundle sea --output app.sea --identity '<workflow>' --issuer '<issuer>' app.run   # the application
bundle sea --output node-verifying --root my-root.pem                             # a verifying node

Builds a node runtime with this package's verifier inside it.

  • With an archive, the result is that application: one executable that verifies its own signature before running anything. The signature covers the runtime and the verifier too.
  • Without one, the result is a verifying node: a runtime that takes an archive on its command line (./node-verifying app.zip --args), verifies it, and runs it. --verify reports the trust state without running anything.

The policy flags given here (--root, --identity, --issuer, --attester, --quorum, --max-age, --untrusted) are baked in, and a runtime with a policy is sealed: its command line cannot loosen that policy. --block only ever tightens, so the runtime accepts it at any time. Signing works as for sign: sigstore by default, or --key and --chain. Attestations are checked against the cache trust keeps, never the network.

Option
-o, --output <file> where to write the executable (required)
--node <file> the node binary to embed (default: the one running)
--base <file> reuse a runtime built before, instead of building one
--no-sigstore leave the sigstore libraries out of the verifier (it can then check only certificate-chain signatures)
-r, --root <file> a trusted root the executable checks against; repeatable
--identity <san>, --issuer <url> the sigstore signer the executable requires
--attester <[kind@]who> an attester the executable requires; repeatable. Handles are resolved now; the DID is baked.
--quorum <n> how many of the attesters (default: all)
--max-age <time> how stale a cached attestation may be (default: 7d)
--block <who> refuse what this DID or handle has marked bad; repeatable
--untrusted let it run an archive whose signature is good but unanchored
-k, -c, --hash, --sign, --flow, --token, --oidc-issuer, --connector, --fulcio, --rekor, --tsa signing, as for sign

Building an executable needs a Node with nodejs/node#65810; see Executables that verify before they run.

Checking and running

verify

bundle verify [options] <archive>
bundle verify --root ca.pem --json app.nzip
bundle verify --identity '<workflow>' --issuer https://token.actions.githubusercontent.com app.nzip
bundle verify --attester audited@did:web:audit.example.com app.nzip

Recomputes the whole-file hash, checks the signature over it, checks every member's digest, and decides whether whoever signed it means anything to you. It reports one of four states and exits with the matching code:

State Exit Meaning
valid 0 Hash, signature and every member digest are sound, and it is trusted as asked.
valid-untrusted 1 All of that is sound, but it is not vouched for as required: a certificate you cannot place, a missing sigstore trust root, a signer other than the one demanded, attestations missing.
invalid 2 The bytes changed since signing, a member's digest does not match its content, the archive no longer parses as a ZIP, something was appended after its end, or an attester you --block on marked it bad.
unsigned 3 No signature, and no attestations vouching for it.

A certificate chain is trusted only for what it was issued for. The leaf must carry the code-signing extended key usage, and everything above it must be a CA. Otherwise the key of any publicly trusted certificate, a web server's included, could sign an archive that reads as valid. A root given with --root that is the leaf is trusted as itself: that is pinning.

--identity and --issuer demand a particular sigstore signer, matched exactly, never as a pattern. An archive signed against an ordinary CA carries no identity at all, so under such a demand it is valid-untrusted, not passing.

With --attester, attestations are required instead of, or as well as, a signer (see attest). A signature is then optional: if present it must verify, but its certificate only has to be trusted when --identity/--issuer ask for a signer too. The attesters' proofs are fetched first; if that fails, the cache answers, for as long as --max-age allows.

Option
-a, --archive <file> the archive (or give it as the argument)
-r, --root <file> an extra trusted root certificate (PEM); repeatable
--identity <san> require this sigstore signing identity
--issuer <url> require this sigstore OIDC issuer
--sigstore-root <file> the sigstore trust root to check against (default: the one trust keeps)
--attester <[kind@]who> require an attestation from this DID or handle; repeatable
--quorum <n> how many of the attesters must have attested (default: all of them)
--max-age <time> how stale a cached attestation may be (default: 7d)
--block <who> refuse it if this DID or handle has marked it bad; repeatable
--json print the result as JSON

run

bundle run [options] <archive> [arguments for the program…]
bundle run --root ca.pem app.signed.nzip --its --own --flags

Verifies the archive exactly as verify does, mounts it through the verifying provider, and runs its entry point in this process, the way a verifying runtime does. Every member is re-hashed against its signed digest as it is read, for as long as the process lives. Nothing from the archive runs unless it is valid (or valid-untrusted with --untrusted).

run's own options come before the archive, and everything after it belongs to the program, flags included. A -- there is accepted too, out of habit, but not needed.

Option
-r, --root <file> an extra trusted root certificate (PEM); repeatable
--identity <san>, --issuer <url> require this sigstore signer
--attester <[kind@]who>, --quorum <n>, --max-age <time>, --block <who> as for verify
--untrusted run an archive whose signature is good but whose certificate is unanchored. It never waives what the other flags demand.

There is no child process and no preload: the provider is already registered in the process doing the mounting. The cost is isolation: the program shares the process with this package. For a process of its own, spawn node with the arguments mountArgv returns.

Vouching

attest

bundle attest [options] <archive>...
bundle attest --as audit.example.com --kind audited app.nzip app.sea app.run   # one sign-in for all three
bundle attest --as scanner.example --verdict bad --kind malware app.nzip
bundle attest --as audit.example.com --revoke app.nzip

An attestation is an atproto record, in the attester's own repository, saying I vouch for the archive with this whole-file hash: the same hash a signature covers. It can say what kind of claim it is (published, audited, reproduced), or it can say the opposite: --verdict bad warns everyone who installs the file, with --kind saying why (malware, vulnerable, …). Anyone with an atproto account (any did:plc or did:web) can attest, and only they can withdraw their attestation.

  • Every archive named is checked first. One whose bytes or signature do not hold together stops them all, before anyone signs in.
  • Then one sign-in covers them all. It uses OAuth in the browser, against the account's own PDS. It asks for write access to attestation records and nothing else (repo:com.pipobscure.bundle.attestation). It falls back to general write access only on a server that does not offer that, and says so.
  • Nothing is kept. The session is revoked when the command is done, so every attestation is approved by whoever it speaks for. In CI, where there is no browser, an app password from BUNDLE_ATPROTO_PASSWORD or --password-file is used instead.
  • What it writes is at://<your DID>/com.pipobscure.bundle.attestation/<hash>: one record per account per file, so attesting again replaces the old record.

How attestations are used, and what they do and do not prove, is in install, verify, the policy and proposals/atproto-attestations.md.

Option
--as <handle or did> the account to attest as (default: BUNDLE_ATPROTO_IDENTIFIER)
--kind <kind> what is being said: published, audited, reproduced, or with --verdict bad, malware, vulnerable, …
--verdict <good or bad> good (the default) vouches for the file; bad warns against it
--note <text> a short note kept with the attestation
--revoke withdraw your attestations of these archives instead
--password-file <file> use an app password from this file instead of signing in (BUNDLE_ATPROTO_PASSWORD works too). Never take a password as an argument.
-r, --root <file> an extra trusted root, for checking the archives first; repeatable

Different shapes of one release are different bytes. Attest each one you publish.

lexicon

bundle lexicon                                   # what needs publishing, and where
bundle lexicon check                             # is it published, and current?
bundle lexicon publish --as pipobscure.com --dry-run
bundle lexicon publish --as pipobscure.com

Attestations are written in the lexicon com.pipobscure.bundle.attestation. For the network to resolve and validate it, it is published in two parts. The first is a DNS TXT record _lexicon.bundle.pipobscure.com saying did=<DID>. The second is a com.atproto.lexicon.schema record in that DID's repository, holding the lexicon document.

  • With no subcommand, lexicon lists the lexicons this package carries and the record each needs.
  • check resolves the DNS record and fetches the published schema with its proof, verified. It says whether that schema is current, different, unpublished, or has no DNS record. It exits 1 unless all are current.
  • publish writes each schema record from --as. It signs in with access to com.atproto.lexicon.schema and nothing else, refuses an account the DNS record does not name (unless --force), and reads each record back to confirm it.

This is for whoever owns the lexicon's domain; using attestations needs none of it.

Option
--as <handle or did> the account to publish from (default: BUNDLE_ATPROTO_IDENTIFIER)
--dry-run say what publish would write, and stop before signing in
--force publish even though DNS does not name that account yet
--password-file <file> an app password instead of signing in, for CI (BUNDLE_ATPROTO_PASSWORD works too)

Installing and keeping current

install

bundle install [options] [url | domain]
bundle install https://example.com/tool.nzip     # fetch, review, put on PATH
bundle install tool.example.com                 # whatever its TXT record names, as `tool`
bundle install                                  # this package, from its own release

curl | sh with the dangerous parts removed. Nothing is executed to install it, and nothing lands on disk unless it verifies and somebody vouches for it whom you accept. The archive is fetched, reviewed, and renamed into place, so an archive that is refused never exists at its destination, not even briefly.

Everything that vouches for the archive is shown, and whom to believe is your decision. That is its signature, if it has one, and every attestation of its hash. Attestations come from attesters this machine knows, and from anyone the backlink index (Constellation, by default) says has attested it. Each one is fetched from the attester's own PDS and verified before it is shown:

* tool: sha256:3f1a… (signed)
   1 signed by   https://github.com/acme/tool/.github/workflows/release.yml@refs/heads/main via https://token.actions.githubusercontent.com — new
   2 attested by audit.acme.com (did:web:audit.acme.com) as audited, 2026-10-01T… — trusted by policy
     WARNING     scanner.example (did:plc:…) as malware — marked it bad
* tool: … accept which? numbers (1,2), 'all', or Enter to decline:
  • Accepted without asking: a signer or attester accepted for this install before, one the policy trusts, one a flag demands, or a certificate anchored in the trust store.
  • Otherwise you are asked. On a terminal you pick what to accept, and that is remembered for later updates. Elsewhere it stops with exit 4 unless --yes accepts everything shown. A bad verdict from someone you trust or accepted always produces a question.
  • A bad verdict from anyone else is a warning. Anyone can publish one, so on its own it decides nothing.
  • Refused outright: bytes or a signature that do not verify; a missing --identity, --issuer or --attester; a policy requirement that is not met; a bad verdict from someone --block or the policy blocks on.

Only flags and the policy make anything mandatory. A signer recorded at install time is accepted on later versions, not required. That is what lets a publisher move their releases elsewhere without breaking every install.

Where it goes. The name comes from the server's Content-Disposition, or the last segment of the URL, reduced to a bare file name: a suggestion from somebody else's server names a file, never a path. --name overrides it. The .nzip extension comes off everywhere but Windows, where it is what makes the file runnable. The file goes to ~/.local/bin (%LOCALAPPDATA%\bundle\bin on Windows; BUNDLE_INSTALL_DIR or --dir anywhere), is made executable, and you are told if that directory is not on your PATH.

On Windows it also registers .nzip for the current user, adds it to PATHEXT, and broadcasts the change so a new terminal sees it. It writes HKCU\Environment directly rather than through setx, which would freeze a copy of the machine's value. Both halves are checked before they are written, so installing twice changes nothing. An .nzip default set in Windows' app settings is reported rather than overridden. cmd runs an archive by name, or by an unquoted path, but refuses a quoted path. See examples/echo-argv/windows.

A domain works in place of a URL. bundle install tool.example.com looks for a TXT record on that domain of the form nzip:<url>. The URL is a full https: URL, or a reference resolved against https://tool.example.com/, and the install is named after the domain's first label (tool). DNS is not authenticated, so the record says only where to fetch from. The archive is reviewed exactly as a URL's would be, only https: is accepted, and two differing nzip: records are refused rather than guessed between.

With neither, it installs this package itself, from its own GitHub release, accepting the identity its publish workflow signs with. npx @pipobscure/bundle install is therefore the whole bootstrap. It then offers to set up the shell it is run from (see shell). Run it again once installed and it fetches nothing: it only offers the shell setup for the shell you are in now, so after switching shells, bundle install is all it takes.

Option
-y, --yes accept everything found, rather than asking
--identity <san> require this sigstore signing identity
--issuer <url> require this sigstore OIDC issuer
--attester <[kind@]who> require an attestation from this DID or handle; repeatable
--quorum <n> how many of the attesters must have attested (default: all)
--block <who> refuse it if this DID or handle has marked it bad; repeatable
--no-discover do not ask the backlink index who has attested it
-r, --root <file> an extra trusted root certificate (PEM); repeatable
-n, --name <name> install under this name
-d, --dir <dir> install here (default: ~/.local/bin, or %LOCALAPPDATA%\bundle\bin)
--no-shell installing itself, do not offer to set up the shell

update

bundle update [options] [name]
bundle update            # everything installed
bundle update tool       # one

Refetches each install from where it came, as a conditional request with the ETag recorded last time. A server with nothing new answers 304 and nothing is downloaded. Identical bytes are not an update either. Something new is reviewed exactly as an install is, against what has been accepted for it so far:

  • Evidence from someone accepted before proceeds.
  • A new signer, or attestations from people nobody accepted, is a question, not a failure. Publishers move their releases, and auditors do not review every version. To make a signer or attester mandatory, put it in the policy (for example "require": { "sameIssuer": true }) or pass the flags.
  • One install that is refused, declined or waiting on a decision does not stop the others. Each is reported, the installed copy stays as it was, and the exit code is the worst of them.
Option
-y, --yes accept everything found for a new version, rather than asking
--identity <san>, --issuer <url> require this sigstore signer of every new version
--attester <[kind@]who>, --quorum <n> require attestations of every new version
--block <who> refuse a new version this DID or handle has marked bad; repeatable
--no-discover do not ask the backlink index who has attested it
-r, --root <file> an extra trusted root certificate (PEM); repeatable

installed

bundle installed [--json]

Lists what is installed (where from, who signed and attested it, since when) and re-checks each install against its record:

  • the file is still there;
  • its bytes are still the bytes that were installed;
  • someone accepted for it still vouches for it;
  • the policy still holds;
  • nobody it blocks on has marked it bad.

Attestations are fetched fresh first, discovery included, so a warning published since the install shows up here.

The hash is the cheap check and the interesting one. update is the only thing that should ever replace an installed file, so a file whose hash moved without the record moving with it was changed by something else. A signature check alone would not notice that, because the replacement may be perfectly well signed. That reports as CHANGED. The exit code is the worst found, so a script can gate on it.

Option
-r, --root <file> an extra trusted root certificate (PEM); repeatable
--json print the results as JSON

validate

bundle validate [options] [name | url | domain]...
bundle validate                                    # every install: what has changed since the last look
bundle validate tool
bundle validate --quiet --every 1d --timeout 5s    # at shell start

installed says what is; validate says what is new. It re-checks each install exactly as installed does, with attestations fetched afresh and discovery asked again. It then compares the result with what the install saw last time:

  • an attestation that was not there before;
  • one that has been withdrawn;
  • above all, a new warning: someone marking the file bad since.

Then it remembers what it saw, so each change is reported once. It is made to run unattended, at login or on a timer; shell sets that up.

Option
-q, --quiet say nothing unless something needs attention
--every <time> leave alone any install validated more recently than this, without touching the network
--timeout <time> one deadline for all of its network requests, after which the cache answers
-r, --root <file> an extra trusted root certificate (PEM); repeatable
--json print the results as JSON
Exit
0 nothing needs attention (new good attestations are reported, but are not a problem)
1 a new warning, or nobody accepted for an install vouches for it any more
2 an install is changed, missing or invalid, or someone the policy blocks on has marked it bad
3 an install is unsigned and nothing vouches for it

Installs made before validate existed have no record of what they had seen, so the first run reports all their current attestations as new, once.

uninstall

bundle uninstall [name | url | domain]
bundle uninstall tool                              # by name
bundle uninstall https://example.com/tool.nzip     # by where it came from
bundle uninstall tool.example.com                  # by the domain it was installed by
bundle uninstall                                   # this package's own install

Deletes the file and forgets the record. With no argument it removes what bundle install left behind, found by the URL it came from whatever it ended up called. It also takes the shell setup out of every startup file it was added to. The .nzip association on Windows is left alone: other archives may need it.

Trust and policy

trust

bundle trust [options]
bundle trust                                       # everything
bundle trust --no-sigstore --attester audit.example.com

Verification never reaches for the network to decide whether to mount something. So the material it checks against is fetched ahead of time, here:

  • The sigstore trust root, over TUF: signed metadata with its own root of trust, not a plain download. Until it is first fetched, the copy the sigstore libraries ship is used.
  • The attestations of every attester this machine knows. That means attesters named here, in BUNDLE_ATTESTERS or BUNDLE_BLOCK, in either policy file, accepted for an install, or already in the cache. For each one it lists their attestations, fetches and verifies new ones, drops withdrawn ones, and re-confirms the rest.

This is what a sealed executable or the preload checks attestations against, and how stale that may be is the policy's maxAge (default seven days). Exit 1 if any refresh failed.

Option
--mirror <url> the TUF repository to refresh from (default: sigstore's)
--attester <who> also keep this DID's or handle's attestations; repeatable
--no-sigstore refresh only the attestations

policy

bundle policy [show | init | check <file>] [options]
bundle policy              # the rules in force, and the files they came from
bundle policy --app tool   # including the apps.tool section
bundle policy init         # write a starter file for you (--system: for the machine)
bundle policy show         # print the files themselves
bundle policy check f.json # check a file the way an install will read it

The rules this machine installs by, in JSON, from two files that both apply:

  • the machine's: /etc/bundle/policy.json, /Library/Application Support/bundle/policy.json on macOS, %ProgramData%\bundle\policy.json on Windows (BUNDLE_SYSTEM_POLICY);
  • the user's: ~/.config/bundle/policy.json, ~/Library/Application Support/bundle/ on macOS, %APPDATA%\bundle\ on Windows (BUNDLE_POLICY).
{
  "require": {                       // mandatory: not met means refused
    "signature": true,               // it must carry a signature that verifies
    "sameIssuer": true,              // updates signed through the same OIDC issuer as before
    "attesters": ["audited@did:web:audit.acme.com"],
    "quorum": 1
  },
  "issuers": ["https://token.actions.githubusercontent.com"],  // the only issuers that count
  "trust": {                         // accepted without asking
    "signers": [{ "identity": "…", "issuer": "…" }],
    "attesters": ["did:web:audit.acme.com"],
    "certificates": ["AB:CD:…"]      // certificate-chain root fingerprints
  },
  "block": ["did:plc:…"],            // their bad verdict refuses it
  "ignore": ["did:plc:…"],           // their verdicts are not shown at all
  "discovery": "https://constellation.microcosm.blue",  // or false
  "maxAge": "7d",
  "apps": { "tool": { "require": { "sameIssuer": true } } }
}

Requirements from every file and section apply together, and trust adds up. Unknown settings are an error, so a typo cannot quietly loosen anything. The policy governs install, update, installed and validate.

The format is described by a JSON Schema, schemas/policy.schema.json, which is also the full reference for every setting. Each release attaches the schema for that version, and the files bundle policy init writes and bundle policy show prints start with a $schema pointing at it. So an editor that understands JSON Schema completes settings, explains them, and flags mistakes as you type. A test holds the schema to the checker bundle itself uses.

VS Code needs to be told to trust it, once. It only downloads schemas from locations on its allow-list (json.schemaDownload.trustedDomains), and GitHub releases are not on it by default, so it reports Location … is untrusted (error 65538). Either use the quick fix on the $schema line (Trust URI, for that one version), or trust every release's schema at once in your user settings:

"json.schemaDownload.trustedDomains": {
    "https://github.com/pipobscure/bundles/releases/download/": true
}

That covers this project's release downloads and nothing else on GitHub. Setting it replaces VS Code's default list (SchemaStore and the rest), so copy those entries in too if you rely on them, or use the quick fix instead.

Option
-a, --app <name> the rules for this installed name, its apps section included
--json print the rules in force as JSON: merged, not a policy file
--system init and show the machine's file
--user show only the user's file (init writes it by default)
-f, --force init over a file that is already there

Setting up

shell

bundle shell [bash | zsh | fish | powershell] [options]
eval "$(bundle shell bash)"                                 # in ~/.bashrc
eval "$(bundle shell zsh)"                                  # in ~/.zshrc
bundle shell fish | source                                  # in ~/.config/fish/config.fish
bundle shell powershell | Out-String | Invoke-Expression    # in $PROFILE

Prints what to load when a shell starts. install offers to add it for you when it installs bundle itself.

  • Tab completion:

    • commands, and their options;
    • the values those options take (--verdict good|bad, --flow, the shells, …);
    • installed names for update, uninstall and validate;
    • file names wherever a file goes.

    Options come from the same table the commands parse with, so what Tab offers is what a command accepts. fish and PowerShell also show what each option does. fish ships completions for Ruby's Bundler, which is also called bundle; these replace them.

  • bundle validate --quiet --every 1d --timeout 5s in interactive shells only, so a warning about something you installed is the first thing a new terminal says. It asks the network at most once a day, gives up after five seconds and answers from the cache, and prints nothing when there is nothing to say. In PowerShell, a profile loaded for pwsh -Command or a script does not run it.

Which shell is the one you are in, not the platform:

  1. BUNDLE_SHELL, if set.
  2. Where the process tree can be read (Linux, macOS), the nearest shell in it. So pwsh started from zsh is pwsh, whatever $SHELL says.
  3. $SHELL. This is how Git Bash on Windows counts as bash.
  4. PowerShell on Windows sets no $SHELL, and is recognised by the module directory it adds to PSModulePath.

cmd.exe has no programmable completion, and gets nothing.

Under Windows, bundle is installed as bundle.nzip. Git Bash runs it by its #! line, so the bash setup uses that name. PowerShell would run a .nzip through its file association, whose output it cannot capture, so the PowerShell setup runs node with the installed archive mounted (which is what the association runs anyway). The PowerShell profile is the one PowerShell itself reports as $PROFILE, wherever Documents has been moved to.

What install adds is a marked block in the startup file, guarded so the shell still starts if bundle is gone:

# >>> bundle: Tab completion, and a quiet re-check of installs >>>
command -v bundle >/dev/null 2>&1 && eval "$(bundle shell bash)"
# <<< bundle <<<

It is added once, only with your yes, and uninstall takes it out again, leaving the rest of the file as it was.

Option
--every <time> how often a new shell re-validates installs (default: 1d)
--timeout <time> how long it may wait for the network (default: 5s)
--no-validate leave out the re-validation
--no-complete leave out Tab completion

skill

bundle skill [options] [name]
bundle skill                 # -> .claude/skills/audit-bundle/SKILL.md
bundle skill --list
bundle skill --dir <dir> --force

Installs the Claude Code skills this package carries, audit-bundle among them, into a project. It never overwrites a file that is already there unless forced, so local edits survive.

Option
-d, --dir <dir> where to install (default: .claude/skills)
-f, --force overwrite files that are already there
-l, --list list the skills this package carries, and stop

Files and directories

What Where
Installed programs ~/.local/bin; %LOCALAPPDATA%\bundle\bin on Windows BUNDLE_INSTALL_DIR, or --dir
Install records installed.json in the state directory
Attestation cache attestations/ in the state directory: each attester's DID document, and their verified proofs BUNDLE_ATTESTATIONS
The state directory $XDG_STATE_HOME/bundle (~/.local/state/bundle); ~/Library/Application Support/bundle on macOS; %LOCALAPPDATA%\bundle\Data on Windows
Policy see policy BUNDLE_POLICY, BUNDLE_SYSTEM_POLICY
Sigstore trust root $XDG_DATA_HOME/sigstore-js (~/.local/share/sigstore-js); ~/Library/Application Support/sigstore-js on macOS; %LOCALAPPDATA%\sigstore-js\Data on Windows BUNDLE_SIGSTORE_ROOT, --sigstore-root
Shell setup ~/.bashrc, ${ZDOTDIR:-~}/.zshrc, ~/.config/fish/config.fish, PowerShell's $PROFILE, only with your yes BUNDLE_SHELL, BUNDLE_POWERSHELL_PROFILE
.nzip association HKCU\Software\Classes and the user's PATHEXT, on Windows BUNDLE_NO_WINDOWS_SETUP

Nothing from signing in is ever stored: not the sigstore certificate's key, nor an OAuth session.

Environment

Variable
BUNDLE_INSTALL_DIR where install puts programs
BUNDLE_SELF_SOURCE where bundle install (with no argument) fetches this package from: a mirror. Its publish workflow's identity is still the one accepted.
BUNDLE_NO_WINDOWS_SETUP do not register .nzip or touch PATHEXT on Windows
BUNDLE_POLICY / BUNDLE_SYSTEM_POLICY the user's and the machine's policy file
BUNDLE_ATTESTATIONS the attestation cache directory
BUNDLE_SIGSTORE_ROOT the sigstore trust root to check against, instead of the cache
BUNDLE_SHELL the shell install sets up and shell prints for, instead of detecting it
BUNDLE_POWERSHELL_PROFILE the PowerShell profile install adds its setup to, instead of asking PowerShell
BUNDLE_ATPROTO_IDENTIFIER the account attest and lexicon publish act as, without --as
BUNDLE_ATPROTO_PASSWORD an app password: attest and lexicon publish use it instead of signing in, for CI
BUNDLE_OAUTH_CLIENT_ID a hosted OAuth client metadata URL, instead of the loopback development client
BUNDLE_PLC_DIRECTORY the PLC directory did:plc is resolved against (default: https://plc.directory)
BUNDLE_NO_BROWSER never open a browser to sign in with sigstore; use a device code
BUNDLE_AUDIT_VERDICT where the audit skill writes its verdict, when a pipeline asks for one

The verifying preload and an executable built without a baked policy take their policy from the environment, since they have no command line of their own:

Variable
BUNDLE_ROOTS extra trusted roots: a path-delimiter-separated list of PEM files
BUNDLE_ALLOW_UNTRUSTED mount an archive whose signature is good but unanchored
BUNDLE_IDENTITY / BUNDLE_ISSUER require a particular sigstore signer
BUNDLE_ATTESTERS require attestations: space- or comma-separated [kind@]did
BUNDLE_QUORUM / BUNDLE_ATTESTATION_MAX_AGE how many of them, and how stale a cached proof may be
BUNDLE_BLOCK refuse what any of these DIDs has marked bad
BUNDLE_MANIFEST where the recording preload writes the files a run read

Using it from code

Everything the CLI does, as an API. The CLI is a parseArgs wrapper over exactly these functions and holds no logic of its own.

import {
    createBundle, signBundle, verifyBundle, inspectBundle, runBundle, fileSigner,
} from '@pipobscure/bundle';

// Build unsigned — the single input to every shape you ship.
await createBundle({ base: 'app/', files, output: 'app.run' });

// Sign, once per shape.
const signer = fileSigner({ key: 'leaf.key', chain: 'chain.pem' });
await signBundle({ source: 'app.run', output: 'app.nzip', prefix: 'shell-base', signer });

// Ask what it claims, and then whether any of it is true.
const { members, signed, hash } = inspectBundle('app.nzip');
const { state, reason, identity } = await verifyBundle('app.nzip', { roots: ['ca.pem'] });

// Mount it through the verifying provider and run it, in this process.
const status = await runBundle('app.signed.nzip', { roots: ['ca.pem'], args: ['--help'] });

Signers. A signer is { chain, signAlg, sign(digest) }. The chain goes into the archive before hashing; sign() is called after, with the finished hash. That two-phase shape is what lets sigstore work at all — the certificate has to be embedded before the bytes exist, and the signature made after. keySigner() is the offline-CA implementation and @pipobscure/bundle/sigstore's signer() is the other one; a third (an HSM, a KMS, a corporate signing service) is three properties away.

Working out what to bundle. @pipobscure/bundle/recorder observes a run; @pipobscure/bundle/files computes a closure. Use both — the closure for completeness, the observation as a cross-check:

import { moduleFiles } from '@pipobscure/bundle/files';

const files = moduleFiles({
    base: '.',
    files: ['package.json'],
    dirs: ['dist'],
    dependencies: ['@sigstore/verify'],   // and everything they depend on, transitively
    filter: (name) => !name.endsWith('.map'),
});

Registering the verifying provider yourself, when the environment variables are not enough:

// my-preload.js — node --experimental-vfs -r ./my-preload.js --vfs-load=app.nzip
import { register } from '@pipobscure/bundle/provider';

register({
  extensions: ['.nzip', '.app'],   // claimed by name
  claimSigned: true,                 // and anything carrying a signature marker, whatever it is called
  roots: ['/etc/ssl/my-root.pem'],   // PEM text or paths to PEM files
  allowUntrusted: false,
  identity: 'https://github.com/me/app/.github/workflows/release.yml@refs/heads/main',
  issuer: 'https://token.actions.githubusercontent.com',
});

A preload runs under the CommonJS loader, so it must contain no top-level await. ESM syntax is otherwise fine, and --import works as well as -r.

A preload takes no arguments, so the verifying mount is configured through the environment: BUNDLE_ROOTS, BUNDLE_IDENTITY, BUNDLE_ATTESTERS and the rest, listed under Environment in the CLI reference.


Exports

{
  ".":          "create / sign / verify / inspect / run, from code",
  "./register": "-r preload: mount only what is signed",
  "./record":   "-r preload: write down what a run reads",
  "./sea":      "build a verifying runtime, with or without an app inside",
  "./launch":   "verify a container, mount it, run it — and the runtime's CLI",
  "./provider": "the verifying provider, and register(options)",
  "./recorder": "the recording provider, and recording(Base, manifest)",
  "./cli":      "main(argv, io) -> exit code",
  "./manifest": "the archive format on its own",
  "./archive":  "bundling and re-emitting",
  "./files":    "dependency closures",
  "./skill":    "the shipped skills, and installing them",
  "./audit":    "the audit gate: prepare, check, approve",
  "./sigstore": "the sigstore signer and bundle verification",
  "./attestation": "attestation policies, and the cache they are checked against",
  "./policy":   "the machine's install policy file",
  "./review":   "what install and update find, and what they decide",
  "./atproto":  "resolving DIDs, fetching and writing attestations",
  "./oauth":    "signing in to a PDS: atproto OAuth with PAR, PKCE and DPoP",
  "./lexicon":  "this package's lexicons, and publishing them",
  "./oidc":     "identity tokens: CI, browser, or device code"
}

The package root deliberately does not re-export the two providers: importing either needs node:vfs, and creating or verifying an archive does not, so import '@pipobscure/bundle' must not drag that requirement in.

Written in TypeScript, published as ESM with declarations. The sources use erasable syntax only, so node src/main.ts runs them directly under Node's type stripping.


Executables that verify before they run

bundle sea builds a node runtime with this package inside it. What you do with that runtime is the difference between the two shapes it can take.

With an archive, it becomes that application — one file that checks its own signature before running anything:

[ node runtime | SEA blob: stub + the verifier, as a mounted archive ] [ app.run ]
  \_______________________ the prefix, and part of the _______________/
   \______________________ archive's signed region ______/
bundle sea --output app.sea \
    --root /etc/ssl/my-root.pem \
    --identity 'https://github.com/me/app/.github/workflows/release.yml@refs/heads/main' \
    --issuer 'https://token.actions.githubusercontent.com' \
    app.run

The whole-file hash covers the prefix too, so the runtime and the verifier inside it are signed by the same signature that covers the application. There is nothing to check the checker against, because the checker is inside what is checked.

Without one, it becomes a verifying node — a runtime that takes an archive on its command line, checks it, and runs it:

bundle sea --output node-verifying --root /etc/ssl/my-root.pem
./node-verifying ./my-app.zip --args --for --the --app
./node-verifying --verify ./my-app.zip        # the trust state, without running it

One runtime, any number of applications, none of them trusted until they verify. The application sees the argv it would have had from --vfs-load: the archive where a script path goes, its own arguments from index 2 on, and none of the runtime's flags — which is why everything after the archive belongs to the program, --help included.

The two are the same binary. A verifying node with an archive appended to it — bundle sign --prefix node-verifying app.run — is the self-validating executable, and at startup the runtime decides which it is by looking at its own tail: a signed archive behind it runs that, nothing behind it takes one from the command line, and an unsigned archive behind it is refused rather than quietly treated as neither.

Policy is baked in, or it is not. The --root, --identity and --issuer given at build time become the executable's own policy — the point being that a binary run by its own name has no flags and no preload to configure it. A runtime built with a policy is sealed: it takes no policy from its command line, because a binary that demands a signing identity is not one whose user can ask it to stop. Build without one and the flags above work, falling back to BUNDLE_ROOTS and friends, so one build can be decided about later.

From code, createSeaBase() and buildSea() split the expensive half (a ~155 MB copy of Node) from the cheap one, @pipobscure/bundle/launch is the entry point all of this runs through — run(), runSelf(), verify(), main() — and verifySelf() lets an application report on its own provenance. The package rides inside the executable as an archive that node mounts for itself: "useVfs": true (nodejs/node#65675, released in v26.9.0) with "vfsArchive" (nodejs/node#65810, still open), which is why the generated stub is a handful of lines and why there is no second copy of the verifier anywhere.


How it works

The archive is a ZIP. Its members each carry the hex digest of their own content in the ZIP entry comment. A final AUTHORITY.PEM member declares the algorithms and carries the signing certificate chain — a real, extractable filename, so unzip plus openssl x509 tells you who signed something without any of this code.

The signature covers the entire file: any prefix, every member, the whole central directory, and the fixed part of the end-of-central-directory record — everything up to the EOCD's trailing comment. That comment then records both:

[ prefix | members | AUTHORITY.PEM | central directory | EOCD ] [ comment ]
  \_____________ hashed region → H ______________________/       SIGNED:H:S[:FIELD=…]

Staged deliberately. The hash alone is a cheap, certificate-free integrity gate you can run before deciding to mount anything; only then is the signature over that hash checked against the leaf certificate; only then is the chain anchored. Because the hash covers the central directory, it fixes which members exist and what each one's digest is, so changing any byte after signing yields invalid.

The comment sits outside the hash on purpose: it is the unsigned-attribute region every code-signing scheme eventually grows. Anything obtained after the signature exists cannot be inside what the signature covers — which is where the sigstore bundle rides, carrying the transparency-log entry and timestamp that establish when a ten-minute certificate was valid. RFC 3161 puts timestamp tokens in CMS unsignedAttrs for exactly this reason.

The mount is where it stops being advisory. --vfs-load asks registered providers who wants its source; this package's provider claims .nzip files by name and any file carrying a signature marker by content — so renaming a signed archive cannot quietly downgrade it to the unchecked built-in ZIP provider. It verifies before returning a filesystem, and re-hashes each member as it is first read, because a ZipFile reads lazily from an open descriptor and a file rewritten underneath a running program would otherwise be served unchecked.

Prefixes. ZIP offsets are absolute, so an archive can sit after arbitrary bytes and still be a valid ZIP — which is what lets one build become a #! launcher, a native executable, or a plain mountable archive. The prefix has to be chosen before offsets are fixed, and therefore before the hash exists, which is exactly why signing re-emits an archive rather than appending to one.


What it does and does not prove

It proves provenance. The code is the code that was signed, by someone holding that certificate, and the runtime enforces it rather than the application checking itself.

It does not prove safety. Every significant npm compromise of recent years shipped a correctly published, correctly signed package from a legitimately compromised account. A signature would have confirmed it came from the real maintainer and been useless. That is what step 3 is for, and why it is a separate step performed by a reviewer rather than a property of the format.

Other limits, stated plainly:

  • VFS is not a sandbox. It redirects fs calls; it does not confine untrusted code. Verified code runs with the full authority of the process.
  • The gate is only as strong as how Node was launched. Anyone who can change the command line can drop the -r, and the mount falls back to the built-in provider, which checks nothing. Registration is a userland opt-in, not a runtime policy. A SEA closes this for itself by carrying its own bootstrap.
  • A shebang archive does not self-verify. The kernel gives it no preload flag to carry a provider. Mount it with the preload, or use a SEA.
  • A sigstore signature is public. Signing puts your identity, the archive's hash and the time in an append-only log. That is the mechanism working — it is what makes the ten-minute certificate verifiable later — not something to discover afterwards.
  • Everything here is experimental, including the Node it needs.

Requirements

Everything here sits on Node's experimental node:vfs (by Matteo Collina) and runs under --experimental-vfs. Where each piece stands, as of 2026-09-02:

Piece Where it is
node:vfs, and modules resolving and loading out of a mount released, v26.4.0
ZIP support in node:zlib — ZipFile, ZipBuffer, ZipEntry released, v26.8.0
ZipProvider, a VFS provider backed by such an archive released, v26.9.0 — nodejs/node#64915
Native addons loaded from a mount released, v26.9.0 — nodejs/node#65680
"useVfs", a SEA's assets behind a VFS mount released, v26.9.0 — nodejs/node#65675
--vfs-load, and vfs.registerProvider() released, v26.10.0 — nodejs/node#65748
"vfsArchive", a ZIP as a SEA's file system — bundle sea only open — nodejs/node#65810

v26.9.0 already had everything a program needs to be an archive: it reads ZIP archives, turns one into a file system, resolves modules out of it, and loads native addons from it. v26.10.0 added the way to ask for that mount from outside the program, which is the whole hinge: --vfs-load makes a mounted tree the thing a program resolves and runs from, and the same pull request brings vfs.registerProvider() — the extension point that lets a preload decide what backs a mount, and therefore the one that makes a verifying mount possible from userland at all.

--vfs-load is the only flag. v26.10.0 also shipped --vfs-mount, which mounted a source without running it, and the next patch release removes it (nodejs/node#66162): nothing needs more than one mount from the command line, and a program that wants more mounts them through node:vfs, where it also holds the instance. The same change reserves layer 0 for the --vfs-load source, so it sits at the same mount point in every thread whatever else is mounted, and mounts a program makes itself are numbered from 1. Nothing here uses --vfs-mount, and mount points stay node's to assign: named mounts (nodejs/node#66119) were closed rather than merged.

nodejs/node#65810 is needed only to build an executable. It lets a SEA's file system be a ZIP archive rather than a list of assets, which is how this package gets inside one: bundle sea embeds the verifier bundle whole and node mounts it. It is still open; everything else here works without it.

Native addons out of a mount shipped in v26.9.0, as nodejs/node#65680, which closed the last gap in what a bundle can contain. A dlopen() needs a path with an inode behind it and a VFS path has none, so it reads the addon's bytes out of the mount and loads them from a private, self-cleaning image instead — an anonymous memfd on Linux, an unlinked temp file elsewhere. Before it, a bundle whose dependency tree included a .node file mounted fine and then failed at require.

HISTORY.md explains each in detail and why they are worth having.

openssl on PATH is needed only to generate the throwaway PKI the tests use.


Development

npm install
npm run build          # TypeScript -> dist/, with declarations
npm test               # the whole suite; generates a throwaway PKI into build/certs/ on first run
npm run typecheck

The suite needs Node 26.10 or later. The sixteen tests that build an executable also need nodejs/node#65810; on a Node without it they skip themselves and say why, and they run on the first Node that has it. CI runs the suite on every push to main and every pull request, on 26.10.0 — the floor package.json promises.

Tests import the sources rather than the build, so they run under Node's type stripping. The test PKI is generated on demand by tools/testpki.ts and is never committed — a private key in a repository is a private key people sign with, and it would produce signatures that look like provenance and carry none.

Building the tool the way the tool says to build things — the same four steps:

npm run release:cli         # 1-3: observe, pack, fetch the baseline, stop at the gate
npm run sign:cli:local      # 4: refuses — nothing has been audited yet
BUNDLE_AUDIT_VERDICT=build/cli.audit.json claude "/audit-bundle build/cli.run"
npm run sign:cli:local      # 4: now allowed -> bundle.nzip
Script
manifest:cli observe a run, close over the dependencies, write the file list
pack:cli bundle create over that list
baseline:cli fetch and verify the published release, to review against
audit:cli bundle audit — report the diff and print the skill invocation
approve:cli bundle audit --approve
sign:cli bundle audit --check, then bundle sign --launcher through sigstore
release:cli steps 1–3, stopping at the gate

Only manifest:cli and baseline:cli are scripts of their own; the rest are the CLI. The first observes a run and computes a dependency closure, the second fetches this package's own published release from npm — both specific to how this project is built.

The gate is real, and it is a shipped command — bundle audit --check, not repo tooling. It runs before signing, reads the JSON verdict the skill writes, and refuses unless that verdict passed and pins the sha256 of the bytes on disk. Everything this repository does to release itself is something you can do to your own project.

.github/workflows/publish.yml is the whole pipeline as a workflow — test, pack, fetch the published release, audit the diff, gate, sign through sigstore with the workflow's OIDC identity, publish through npm trusted publishing, every action pinned to a commit SHA. It runs whenever CI passes on main and does nothing unless package.json names a version npm does not have yet: bumping the version is the release. There is no npm token anywhere; npm trusts that workflow file by name.


Reading further

  • HISTORY.md — why this exists, what changed in Node and why those changes make sense, and the experiment that produced the tool. The long-form argument, with the implementation notes at the end.
  • HISTORY.md § Implementation notes — design decided before it was built, and what departed from the plan: signing-time attestation, the audit skill, shipping the tool as a bundle of itself, the self-validating executable, and the audit as a build step.
  • examples/static-server/ — an example application: a static web server that serves the directories and archives it is handed, built and signed the way this README says to build things. It ships with the repository, not with the package.
  • skills/audit-bundle/SKILL.md — the review procedure.
  • slides/ — Ship the Tree, a talk about the project, kept in step with it. slides/index.html opens in any browser with no build step; press S for the speaker notes, which carry most of the argument. Read it here — same deck, published.

License

EUPL-1.2

About

Ship the Tree: Bundling NodeJS applications

Resources

Stars

9 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages