Skip to content

feat(sha256): platform-neutral SHA-256, unblocking the XS daemon bundle - #903

Open
kriscendobot wants to merge 12 commits into
llmfrom
pr824-endo-sha256
Open

feat(sha256): platform-neutral SHA-256, unblocking the XS daemon bundle#903
kriscendobot wants to merge 12 commits into
llmfrom
pr824-endo-sha256

Conversation

@kriscendobot

@kriscendobot kriscendobot commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Implements the approved design designs/platform-neutral-hash.md (design PR #824).

What this unblocks

node packages/daemon/scripts/bundle-bus-daemon-rust-xs.mjs failed on llm with 16 unresolvable node: imports, so daemon_bootstrap.js was never produced, which blocks test:rust and full endor daemon integration (#600). It now produces the bundle.

@endo/sha256

A small buffer-oriented package: sha256(bytes) -> Uint8Array(32) and sha256Into(out, bytes, offset). Synchronous, raw digest bytes in and out, TypeError / RangeError rather than coercion. Conditional exports:

condition build backing
node src/sha256-node.js node:crypto createHash('sha256')
xs src/sha256-xs.js the Endor Rust host's hostSha256Init / hostSha256UpdateBytes / hostSha256Finish
browser, default src/sha256-browser.js pure-JS synchronous SHA-256

The XS build composes the host's existing binary-safe incremental triple, so this increment needs no new Rust. It never uses the host's one-shot hostSha256, which takes a UTF-8 string and so corrupts any input byte above 0x7f. It prefers a one-shot hostSha256Bytes if the host ever grows one (the design's deferred Rust optimization), and falls back to the pure-JS digest under an XS engine that is not the Endor host.

default maps to the pure-JS build so the package is safe under any bundler that sets none of the three conditions.

Open question, resolved as the design proposed. WebCrypto's crypto.subtle.digest is async and cannot back a synchronous sha256(bytes) -> bytes; the only in-graph consumer (makeBlobRefExo) content-addresses inside a synchronous exo factory. So the browser build is pure JS, and a separately named async sha256Async is left as a future addition rather than built speculatively. Say the word if you want WebCrypto in the browser build instead, which would force the whole BlobRef construction path async.

The rest of the bundle blockers

@endo/sha256 cleared one of the sixteen. Two more legs, both the same shape, cleared the other fifteen:

  • @endo/exo-git reached readOnly and wrapBackend through the @endo/platform/fs/extended index, which also re-exports makeNodeFilesystem / makeNodeFsBackend and so dragged in node:fs, node:fs/promises, node:path. It now imports those two modules directly.
  • @endo/git and @endo/host-spawner were statically imported by manager.js / host.js. Both shell out to a host process and could not run under XS regardless, so they are now injected as DaemonicPowers.hostTools, exactly as better-sqlite3 already reaches the daemon as an injected Database. Node supervisors supply them from host-tool-powers-node.js; the XS supervisor supplies none and gets stand-ins that refuse with a diagnosis, so a git or shell formula under XS reports what is unavailable rather than failing to link.

The bundler now passes the xs condition, which is what the repository's own bundler comments prescribe for steering retention. Without it @endo/sha256 would silently take its default arm (pure JS) instead of binding to the host's Rust-native digest.

Verification

All run locally on this branch.

  • XS daemon bundle generates. node packages/daemon/scripts/bundle-bus-daemon-rust-xs.mjs -> Wrote rust/endo/xsnap/src/daemon_bootstrap.js (2136164 bytes), down from a 16-failure TypeError. node --check on the output passes. The bundle's hostSha256Init occurrence count rises from 3 (all in bus-manager-rust-xs-powers.js) to 6, which is the evidence that the xs condition selected sha256-xs.js rather than the pure-JS default arm.

  • @endo/sha256: 72 tests pass. All three builds cross-checked against node:crypto over FIPS 180-4 vectors and lengths 0/1/55/56/57/63/64/65/119/120/128/1000; bytes above 0x7f; views into a larger buffer; sha256Into offset, BYOB-into-a-view, and rejection cases. fast-check properties cover the cross-build-agreement claim over generated input (seeded and shrinking, replacing unseeded randomBytes). The xs build is exercised against a stand-in host implementing the Rust contract: the one-shot preference, the no-host fallback, a malformed hex digest, one-shot returns that are a short buffer / long buffer / number / string, and a throwing update that must not leak a host handle.

  • @endo/platform: 309 tests pass (blob-ref, local-blob, snapshot-hash included). @endo/chat: 800 pass. @endo/exo-git: 3 pass.

  • @endo/daemon: the suite does not run clean in this sandbox, so it was run against a baseline of the same tree with these changes stashed. test/git.test.js: 6 failures before and after, identical (host git config and /tmp noexec). test/shell.test.js + test/mount-snapshot-and-entry.test.js + test/store-controller.test.js: 1 failure before and after, identical (spawn ... EACCES, /tmp noexec). No regression, and the passing git and shell tests exercise the new injected hostTools path end to end.

  • Lint and types: eslint reports 0 errors across packages/{sha256,daemon,platform,exo-git,chat}; tsc is clean in each of those five; prettier --check clean. yarn build:types:gen re-ran, so the composite tsconfigs are in sync.

  • TypeDoc: node node_modules/typedoc/bin/typedoc at the root exits 0 with no errors. That gate reddened once on this branch, because it type-checks JavaScript that packages/daemon's own tsconfig does not (checkJs: false), so the new daemon test passed the package's lint:types and still broke test. Fixed in 80521b46c; the local reproduction above is what closes that parity gap.

Not run here: yarn --cwd packages/daemon test:rust needs a built target/release/endor, and the full repository suite.

Follow-up for #600

Once this merges, #600 needs exactly one thing: rebase on llm and re-run yarn --cwd packages/daemon test:rust. The bundle generator it was blocked on now succeeds, and daemon_bootstrap.js is gitignored and generated, so no artifact needs to move between the branches. No source change is required in #600 for the crypto blocker.

Deliberately left for a separate change, as the design says: migrating native-git-backend.js's two createHash sites to @endo/sha256 (consistency only, it leaves the XS bundle by exclusion), and the Rust one-shot host_sha256_bytes (this build already prefers it if it appears).

Panel rounds

The scripted jury panel reviewed this twice and both rounds found real defects, fixed in 1ae5f99a1 and 75e2d35fb.

Round 1 (at 3a7f81bac): a wrong-sized or non-buffer one-shot XS host return became a silent wrong content address; the streaming triple leaked a host handle when an update threw; the pure-JS padded length used a signed 32-bit shift that diverges from the node build above 2 GiB. It also blocked on missing changesets and on rust/endo/README.md still describing this bundle as failing while the new code cited it as the durable record.

Round 2 (at f3499b3f3): three seats independently ran the same attack. assertBytes used instanceof, which a Proxy over a real Uint8Array satisfies, and the pure-JS build read bytes.length four separate times, so a proxy answering different lengths returned the empty-input digest for eight bytes of content while the node build threw on the same value. It now brand-checks through %TypedArray%.prototype's length accessor and reads the length once. A detached buffer likewise diverged (node: empty digest, pure JS: V8's own throw) and now digests as empty everywhere, matching node. The hostSha256Bytes digest was aliased rather than copied, and the no-host fallback memoized so an early digest could pin pure JS for the process; both fixed.

The prover seat broke three things that reddened nothing and each now has a test: makeSha256Into's digest guard, every conditional-export arm (it pointed xs at a typo and the whole suite still passed), and provideHostToolPowers' refusal diagnostics. CI now also generates the daemon bundle, which was ungated because the artifact is gitignored.

Remaining juror suggestions are preferences and follow-ups rather than defects, and are listed in the panel record rather than acted on here.

Draft for review.

kriscendobot and others added 10 commits August 1, 2026 14:09
A module that statically imports `node:crypto` cannot pass through the
SES/XS bundler, which has no `node:crypto` to resolve. `@endo/sha256`
gives such a module one import specifier that resolves to a working
digest on every target, through conditional exports:

- `node` -> `node:crypto`'s `createHash('sha256')`, copied off the
  `Buffer` so the result is a plain `Uint8Array`.
- `xs` -> the Endor Rust host's binary-safe incremental triple
  (`hostSha256Init` / `hostSha256UpdateBytes` / `hostSha256Finish`), so
  the first increment needs no new Rust. It prefers a one-shot
  `hostSha256Bytes` if the host ever grows one, and falls back to the
  pure-JS digest under an XS engine that is not the Endor host. It
  never uses the host's one-shot `hostSha256`, which takes a UTF-8
  string and so corrupts any input byte above 0x7f.
- `browser` and `default` -> a pure-JS synchronous SHA-256, lifted from
  the browser stand-in in `packages/chat/node-crypto-shim.js`.

`default` maps to the pure-JS build so the package is safe under any
bundler that sets none of the three conditions.

The API is synchronous because the sole in-graph consumer,
`makeBlobRefExo`, content-addresses inside a synchronous exo factory;
WebCrypto's async `crypto.subtle.digest` cannot back it. Streaming stays
out of scope: it is already served by the injected
`CryptoPowers.makeSha256`.

Tests cross-check all three builds against `node:crypto` over FIPS 180-4
vectors and lengths straddling the block and padding boundaries, and
exercise the xs build against a stand-in host implementing the Rust
contract.

Design: designs/platform-neutral-hash.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`blob-ref.js` is on the XS daemon bundle's compartment graph, and its
static `node:crypto` import was the named blocker for generating
`daemon_bootstrap.js`. Swapping it for `@endo/sha256` is
behavior-preserving: `encodeBase64` already operated over the digest as
a `Uint8Array` rather than through `Buffer.prototype.toString`, so the
recorded hash is byte-for-byte what it was.

Design: designs/platform-neutral-hash.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The shim carried its own 100-line pure-JS SHA-256, which is now
`@endo/sha256`'s browser build. The shim keeps only the `createHash`
streaming shape that `node:crypto` callers expect and takes the digest
from the package, so the two cannot drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`bundle-bus-daemon-rust-xs.mjs` failed with sixteen unresolvable
`node:` imports. `@endo/sha256` cleared one of them; this clears the
other fifteen, so `daemon_bootstrap.js` is produced once more and
`test:rust` and the endor daemon integration are unblocked.

Two legs, both "a module on the graph statically imports something XS
cannot resolve":

- `@endo/exo-git` reached `readOnly` and `wrapBackend` through the
  `@endo/platform/fs/extended` index, which also re-exports
  `makeNodeFilesystem` and `makeNodeFsBackend` and so pulled in
  `node:fs`, `node:fs/promises`, and `node:path`. It now imports the
  two modules it uses directly.

- `manager.js` and `host.js` statically imported `@endo/git` and
  `@endo/host-spawner`, which between them reach nine `node:` builtins
  and could not run under XS regardless: both shell out to a host
  process. They are now injected as `DaemonicPowers.hostTools`, the way
  `better-sqlite3` already reaches the daemon as an injected `Database`.
  Every Node supervisor supplies them from
  `host-tool-powers-node.js`; the XS supervisor supplies none and gets
  stand-ins that refuse with a diagnosis, so a `git` or `shell` formula
  under XS reports what is unavailable instead of failing to link.

The bundler now also passes the `xs` condition, which is how the
repository's own bundler comments say to steer retention. Without it
`@endo/sha256` would silently resolve its `default` arm, a pure-JS
digest, rather than binding to the host's Rust-native SHA-256.

Design: designs/platform-neutral-hash.md
Refs: rust/endo/README.md, "The XS daemon bundle pulls in Node-only packages"

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Marks the design In Progress, resolves its three open questions as the
implementation resolved them, and records the two blockers the original
survey did not anticipate: the `@endo/platform/fs/extended` index leg
reached through `@endo/exo-git`, and the `@endo/git` /
`@endo/host-spawner` exclusion, which turned out to be a prerequisite
for generating the bundle rather than a parallel workstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Correctness, from the corner-prober seat:

- The preferred one-shot XS host path returned whatever the host handed
  back. `new Uint8Array` coerces rather than refuses, so a host
  returning the number 32 produced thirty-two zero bytes: one content
  address shared by every blob, silently. Every digest arriving from a
  host function is now type- and length-checked, and `sha256Into`
  checks before writing so a short digest cannot leave stale
  destination bytes behind a return value of 32.
- The streaming triple leaked a host handle when `hostSha256Update
  Bytes` threw, since `hostSha256Finish` is what consumes it.
- The pure-JS padded length used `>> 6`, which coerces to signed
  32-bit and goes negative at 2**31 - 8 bytes, throwing where the node
  build hashes happily. Computed by division now: the builds must not
  diverge on any input.

Tests: fast-check properties over generated input for the
cross-build-agreement claim the header states universally and the
suite checked at eight lengths, plus host stand-ins returning a short
buffer, a long buffer, a number, and a string, plus a throwing update
that must not leak a handle. The generated cases replace unseeded
`randomBytes`, so a CI failure now reports a seed and a shrunk input.

Changesets for the three published surfaces (the new package, the
`@endo/platform` shim requirement dropped, the `@endo/daemon` powers
field), which the changeset-auditor seat blocked on.

Documentation:

- `rust/endo/README.md` still described this bundle as failing and
  prescribed the fix as future work, with specifics the build
  falsified, and `host-tool-powers.js` cited it as the durable record.
- The design's "Open questions" held three answered questions with
  their deliberation intact; they are resolved questions now, and the
  deferred host function's trigger names an owner.
- Dropped the `sha256Into` allocation claim, which was backwards: it
  saves a 32-byte array and still allocates its padded input.
- Trimmed the WebCrypto rationale from five statements to one, and the
  README API block from pasted JSDoc to prose signatures.
- Said why the sibling XS bundle generators do not pass the `xs`
  condition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three seats independently ran the same class of attack and it worked:
a value that answers a different length to each read is not caught by
`instanceof`, and the pure-JS build read `bytes.length` four separate
times. A `Proxy` over a real `Uint8Array` returned the empty-input
digest for eight bytes of content while the node build threw on the
same value, so the package's central claim (the builds never diverge)
was false, and in a package whose only in-graph consumer is content
addressing a wrong digest is a wrong address, not an error.

- `assertBytes` now brand-checks through `%TypedArray%.prototype`'s
  `length` accessor, captured at module load, which refuses a proxy,
  ignores a subclass's own `length`, and still accepts a `Buffer`.
  `jsSha256Into` reads that length once and uses the snapshot.
- A detached buffer digested as empty on node and threw V8's own error
  on the pure-JS build. It now digests as empty everywhere, which is
  what `node:crypto` does, pinned as deliberate.
- The `hostSha256Bytes` digest was aliased, not copied, so a host
  reusing a scratch buffer could mutate an already-published content
  address. `.slice()`, as `sha256-node.js` already did off its
  `Buffer`.
- The no-host fallback no longer memoizes, so a digest taken before
  host-power registration cannot pin pure JS for the process.
- The 64-bit bit length is written with `setBigUint64` rather than
  decomposed into two 32-bit halves.

Coverage the prover seat broke and found nothing reddening:
`makeSha256Into`'s digest guard, every conditional-export arm (it
redirected `xs` at a typo and all 63 tests still passed), and
`provideHostToolPowers`' refusal diagnostics, which no daemon test
could reach because every Node supervisor supplies all three tools.
Each now has a test. CI also generates the daemon bundle now: it is
gitignored and was ungated, so a re-introduced `node:` import in the
daemon graph reddened nothing.

`HostToolPowers` is typed from the implementations rather than
`(...args: any[]) => any`, which the locksmith seat flagged as erasing
the contract at exactly the seam this refactor introduces. Type-only
imports, so no `node:` builtin reaches the XS graph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The TypeDoc CI gate type-checks JavaScript that `packages/daemon`'s own
tsconfig does not (`checkJs: false`), so this file passed the package's
`lint:types` and reddened `test`. Now that `HostToolPowers` is typed
from the real implementations, calling a tool with no arguments is a
type error; the test calls them through a loosely typed view, since
what it pins is that a call is refused before any argument is read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kriscendobot
kriscendobot marked this pull request as ready for review August 1, 2026 16:13
@kriscendobot

Copy link
Copy Markdown
Collaborator Author

Ready for review at 80521b4, all 25 CI checks green.

What it does. Implements the approved designs/platform-neutral-hash.md: @endo/sha256 with node / xs / browser+default conditional builds, BlobRef content-addressing through it, and the chat shim folded into it. The XS daemon bundle generates again, which is what #600 was blocked on.

What the survey did not anticipate. @endo/sha256 cleared one of sixteen unresolvable node: imports. @endo/exo-git reached readOnly and wrapBackend through the @endo/platform/fs/extended index, dragging node:fs onto the graph; and the @endo/git exclusion the design deferred turned out to be a prerequisite, together with @endo/host-spawner. Both now reach the daemon core as an injected DaemonicPowers.hostTools, the way better-sqlite3 already did.

One open question for you. The design proposed pure-JS (sync) as the browser build with WebCrypto as a separately named future async export, rather than forcing BlobRef construction async. That is what shipped. Say the word if you want WebCrypto in the browser build instead.

The panel found three real defects, all fixed and each now covered by a test: a wrong-sized or non-buffer one-shot XS host return became a silent wrong content address; assertBytes used instanceof, so a Proxy answering different lengths returned the empty-input digest for eight bytes of content while the node build threw; and a detached buffer diverged the same way. Details are in the PR body and the commit messages.

Deferred, as the design says: migrating native-git-backend.js's two createHash sites (consistency only now that @endo/git leaves the bundle by exclusion), and the Rust one-shot host_sha256_bytes (the XS build already prefers it when present, with the branch tested).

harness claude · garden be985a3c

@kriskowal kriskowal left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address the one caveat, rebase, retcon, weave, and merge.

// moment a worker-graph package grows a condition-dependent
// implementation, since the failure mode is a silently wrong arm
// rather than an error.
conditions: new Set(['xs']),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should rename this condition endor.

kriscendobot added a commit to kriscendobot/garden that referenced this pull request Aug 6, 2026
@kriscendobot

Copy link
Copy Markdown
Collaborator Author

Follow-up work is complete at head f6cd2b4fab0b1bbde601ea7a05576cb8e7af76bd.

  • 67b6b015d8: migrated the native git backend’s two SHA-256 sites to @endo/sha256, and added the binary one-shot XS host function with its hostSha256Bytes alias, ambient declaration, append-only callback registration, and raw-byte tests.
  • f6cd2b4fab: updated the lockfile for the @endo/git workspace dependencies.
  • Declined items: none.

Verification: @endo/git typecheck passed; its AVA suite passed (3 tests); ESLint completed with 0 errors (9 existing warnings); @endo/daemon typecheck passed; all three Rust/XS bundles regenerated successfully; cargo test --manifest-path rust/endo/xsnap/Cargo.toml crypto_sha256 passed (1 test, 119 filtered); and yarn install --immutable passed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants