From 03ffcfbe8faafe7e1a3a3d1565ee042060e101e0 Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Sun, 8 Nov 2026 04:52:47 -0800 Subject: [PATCH 1/4] fix(receipts): seal and verify on a clean install without ethers trustshell demo exited 3 on a fresh install from the packed tarball, because the receipt hash needed the optional ethers peer. The hash now comes from @noble/hashes 1.3.2, the version ethers already pins. Output is unchanged: a test compares it with ethers byte for byte. Args hashing is not touched. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 2 +- package.json | 1 + src/lib/kernel-client.ts | 10 ++++-- src/lib/owner-receipt.ts | 4 +-- tests/kernel-receipt-hash-no-ethers.test.ts | 40 +++++++++++++++++++++ 5 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 tests/kernel-receipt-hash-no-ethers.test.ts diff --git a/package-lock.json b/package-lock.json index d901dfe4..d48c686f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@hyperdag/proof-verifier": "^0.2.0", + "@noble/hashes": "1.3.2", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^3.25.76" }, @@ -2132,7 +2133,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 16" diff --git a/package.json b/package.json index 8f2138a1..e9338ab7 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ "dependencies": { "@hyperdag/proof-verifier": "^0.2.0", "@modelcontextprotocol/sdk": "^1.29.0", + "@noble/hashes": "1.3.2", "zod": "^3.25.76" }, "peerDependencies": { diff --git a/src/lib/kernel-client.ts b/src/lib/kernel-client.ts index 817420ec..3834f461 100644 --- a/src/lib/kernel-client.ts +++ b/src/lib/kernel-client.ts @@ -45,6 +45,8 @@ * an unsigned receipt is then a downgrade, so pin only after the engine is signing. */ import { createHash, createPublicKey, verify } from 'node:crypto'; +import { keccak_256 } from '@noble/hashes/sha3'; +import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils'; import { loadEthers, MissingDependencyError } from './optional-ethers'; import { TrustShellError } from './trustshell'; import { ApiUrlError, isHttps, resolveApiBase } from './api-url'; @@ -196,11 +198,13 @@ export async function hashKernelArgs(args: unknown): Promise { return keccak256(toUtf8Bytes(kernelCanonicalJson(args))); } -/** keccak256 of the canonical JSON of a receipt without `header.receipt_hash`. */ +/** + * keccak256 of the canonical JSON of a receipt without `header.receipt_hash`. Uses @noble/hashes, a + * direct dependency, so sealing and verifying a receipt work on a cold install with no `ethers`. + */ export async function kernelReceiptHash(receipt: KernelReceipt): Promise { - const { keccak256, toUtf8Bytes } = await loadEthers('Hashing a kernel receipt'); const { receipt_hash: _omit, ...header } = receipt.header; - return keccak256(toUtf8Bytes(kernelCanonicalJson({ ...receipt, header }))); + return `0x${bytesToHex(keccak_256(utf8ToBytes(kernelCanonicalJson({ ...receipt, header }))))}`; } // ── authenticity (kernel-receipt/2) ───────────────────────────────────────────────────────────── diff --git a/src/lib/owner-receipt.ts b/src/lib/owner-receipt.ts index 1aa98005..a8b57792 100644 --- a/src/lib/owner-receipt.ts +++ b/src/lib/owner-receipt.ts @@ -77,8 +77,8 @@ export interface SealableReceipt { * 2. signature = Ed25519 over `DOMAIN + canonical(receipt-without-receipt_hash-and-signature)`, 0x+128 hex; * 3. receipt_hash = keccak256 over the canonical receipt WITH the signature but WITHOUT receipt_hash. * The caller supplies the body; any `schema`/`version`/`signer`/`signature`/`receipt_hash` already on the - * header are overwritten (you cannot pre-stamp the sealing fields). Needs keccak (ethers peer or the - * @noble path); never makes a network call. + * header are overwritten (you cannot pre-stamp the sealing fields). Keccak comes from @noble/hashes, so + * this works without the optional ethers peer. Never makes a network call. */ export async function signOwnerReceipt(body: SealableReceipt, key: KeyObject): Promise { if (!body || typeof body !== 'object' || typeof body.header !== 'object' || body.header === null) { diff --git a/tests/kernel-receipt-hash-no-ethers.test.ts b/tests/kernel-receipt-hash-no-ethers.test.ts new file mode 100644 index 00000000..60f32d4b --- /dev/null +++ b/tests/kernel-receipt-hash-no-ethers.test.ts @@ -0,0 +1,40 @@ +/** + * Sealing and verifying a receipt must work on a cold install, where the optional `ethers` is absent. + * The hash is compared with real ethers, because a receipt sealed by one must verify under the other. + */ +const receipt = { + header: { schema: 'kernel-receipt/2', receipt_id: 'r-1', receipt_hash: '0xignored', note: 'café ✓ 雪' }, + decision: { verdict: 'ALLOW', checks: [{ name: 'b' }, { name: 'a' }] }, +}; + +afterEach(() => { + jest.resetModules(); + jest.dontMock('ethers'); +}); + +describe('kernelReceiptHash', () => { + it('matches ethers byte for byte, including non-ASCII text', async () => { + const { keccak256, toUtf8Bytes } = jest.requireActual('ethers'); + const { kernelCanonicalJson, kernelReceiptHash } = await import('../src/lib/kernel-client'); + const { receipt_hash: _omit, ...header } = receipt.header; + const expected = keccak256(toUtf8Bytes(kernelCanonicalJson({ ...receipt, header }))); + expect(await kernelReceiptHash(receipt as never)).toBe(expected); + }); + + it('runs with no ethers at all', async () => { + jest.doMock('ethers', () => { + const e = new Error("Cannot find module 'ethers'") as Error & { code?: string }; + e.code = 'MODULE_NOT_FOUND'; + throw e; + }); + await expect(import('ethers')).rejects.toThrow(/Cannot find module/); + const { kernelReceiptHash } = await import('../src/lib/kernel-client'); + expect(await kernelReceiptHash(receipt as never)).toMatch(/^0x[0-9a-f]{64}$/); + }); + + it('changes when the receipt changes', async () => { + const { kernelReceiptHash } = await import('../src/lib/kernel-client'); + const other = { ...receipt, decision: { ...receipt.decision, verdict: 'DENY' } }; + expect(await kernelReceiptHash(other as never)).not.toBe(await kernelReceiptHash(receipt as never)); + }); +}); From ef41b174a210f70ea6bcf6ee8cda4c8f421e204f Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Sun, 8 Nov 2026 04:54:11 -0800 Subject: [PATCH 2/4] docs(readme): lead with a copy-paste command that needs no URL of your own Measured on 1.8.1 from npm in bash and Windows PowerShell on 2026-11-08. Co-Authored-By: Claude Opus 5.5 --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index b97c2917..fe444c51 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,24 @@ TrustShell reads a **public GitHub Actions run** from GitHub and sets what your **Try Inspect:** [the five-minute challenge](https://trustshell.dev/inspect). One true claim and one false one about a pinned run, the evidence that decides each, and an invitation to break it. +## See it catch a false claim + +No setup, and no URL of your own. This command works as written in bash, zsh and Windows PowerShell. The run is TrustShell's own CI, pinned, and it passed. The sentence cites that green run as proof for a different commit. + +```bash +npx @hyperdag/trustshell@latest check https://github.com/DealAppSeo/trustshell/actions/runs/39619591025 --claims "The check workflow succeeded on commit 5d1c5e3f57e1, run 39619591025." +``` + +You should see this, abridged, and exit code `1`: + +``` +### Claims: 1 supported · 1 contradicted · 1 not checked +- **CONTRADICTED** `commit 5d1c5e3f57e1` + - **Why** The claim cites commit 5d1c5e3f57e1; the inspected run is on 392797a69b6a. +``` + +Check the answer without TrustShell: [the run on GitHub](https://github.com/DealAppSeo/trustshell/actions/runs/39619591025) shows commit `392797a`. + ## Check your own CI run Open any public repository's **Actions** tab, open a run, and paste its URL below. Tested on Node 22. `--claims` and `--claims-file` are new in 1.8.0 (see Status below); 1.7.0 and older do not have them. `@latest` makes npx fetch the current release; without it, npx runs any older copy installed globally. From f569e43ec8072f7530c2da8d25bacf28c30c510f Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Sun, 8 Nov 2026 05:01:55 -0800 Subject: [PATCH 3/4] fix(readme): keep the first recipe first and offer the pinned command inline tests/readme-first-screen pins the first fenced recipe as the SUPPORTED path a stranger copies, and the previous commit displaced it. Also rebuilds dist for the receipt hash change. Co-Authored-By: Claude Opus 5.5 --- README.md | 20 +------------------- dist/lib/kernel-client.d.ts | 5 ++++- dist/lib/kernel-client.js | 10 +++++++--- dist/lib/owner-receipt.d.ts | 4 ++-- dist/lib/owner-receipt.js | 4 ++-- 5 files changed, 16 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index fe444c51..d5d0c8bd 100644 --- a/README.md +++ b/README.md @@ -2,25 +2,7 @@ TrustShell reads a **public GitHub Actions run** from GitHub and sets what your AI agent *said* beside what the run *reports*. Once installed, the only network call is to `api.github.com`: no account, no API key, no TrustShell server, and nothing of yours is uploaded. `GITHUB_TOKEN` is optional and only raises GitHub's rate limit. Public runs only, for now. -**Try Inspect:** [the five-minute challenge](https://trustshell.dev/inspect). One true claim and one false one about a pinned run, the evidence that decides each, and an invitation to break it. - -## See it catch a false claim - -No setup, and no URL of your own. This command works as written in bash, zsh and Windows PowerShell. The run is TrustShell's own CI, pinned, and it passed. The sentence cites that green run as proof for a different commit. - -```bash -npx @hyperdag/trustshell@latest check https://github.com/DealAppSeo/trustshell/actions/runs/39619591025 --claims "The check workflow succeeded on commit 5d1c5e3f57e1, run 39619591025." -``` - -You should see this, abridged, and exit code `1`: - -``` -### Claims: 1 supported · 1 contradicted · 1 not checked -- **CONTRADICTED** `commit 5d1c5e3f57e1` - - **Why** The claim cites commit 5d1c5e3f57e1; the inspected run is on 392797a69b6a. -``` - -Check the answer without TrustShell: [the run on GitHub](https://github.com/DealAppSeo/trustshell/actions/runs/39619591025) shows commit `392797a`. +**Try Inspect:** [the five-minute challenge](https://trustshell.dev/inspect). One true claim and one false one about a pinned run, the evidence that decides each, and an invitation to break it. No run URL yet? This one line needs none and works as written in bash, zsh and PowerShell. It cites our own green CI run as proof for a different commit, so it prints CONTRADICTED and exits `1`, and [the run on GitHub](https://github.com/DealAppSeo/trustshell/actions/runs/39619591025) shows the real commit, `392797a`: `npx @hyperdag/trustshell@latest check https://github.com/DealAppSeo/trustshell/actions/runs/39619591025 --claims "The check workflow succeeded on commit 5d1c5e3f57e1, run 39619591025."` ## Check your own CI run diff --git a/dist/lib/kernel-client.d.ts b/dist/lib/kernel-client.d.ts index b7cd2eb0..5d53f047 100644 --- a/dist/lib/kernel-client.d.ts +++ b/dist/lib/kernel-client.d.ts @@ -191,7 +191,10 @@ export declare class KernelRefusedError extends TrustShellError { export declare function kernelCanonicalJson(value: unknown): string; /** keccak256 of the canonical JSON of the arguments. Needs the optional `ethers` peer. */ export declare function hashKernelArgs(args: unknown): Promise; -/** keccak256 of the canonical JSON of a receipt without `header.receipt_hash`. */ +/** + * keccak256 of the canonical JSON of a receipt without `header.receipt_hash`. Uses @noble/hashes, a + * direct dependency, so sealing and verifying a receipt work on a cold install with no `ethers`. + */ export declare function kernelReceiptHash(receipt: KernelReceipt): Promise; /** * The pinned public keys, lower-cased: `trustedSigners`, else TRUSTSHELL_KERNEL_TRUSTED_SIGNERS. [] when diff --git a/dist/lib/kernel-client.js b/dist/lib/kernel-client.js index fcb0a0b2..46b7e232 100644 --- a/dist/lib/kernel-client.js +++ b/dist/lib/kernel-client.js @@ -56,6 +56,8 @@ exports.kernelReceipt = kernelReceipt; * an unsigned receipt is then a downgrade, so pin only after the engine is signing. */ const node_crypto_1 = require("node:crypto"); +const sha3_1 = require("@noble/hashes/sha3"); +const utils_1 = require("@noble/hashes/utils"); const optional_ethers_1 = require("./optional-ethers"); const trustshell_1 = require("./trustshell"); const api_url_1 = require("./api-url"); @@ -98,11 +100,13 @@ async function hashKernelArgs(args) { const { keccak256, toUtf8Bytes } = await (0, optional_ethers_1.loadEthers)('Hashing kernel arguments'); return keccak256(toUtf8Bytes(kernelCanonicalJson(args))); } -/** keccak256 of the canonical JSON of a receipt without `header.receipt_hash`. */ +/** + * keccak256 of the canonical JSON of a receipt without `header.receipt_hash`. Uses @noble/hashes, a + * direct dependency, so sealing and verifying a receipt work on a cold install with no `ethers`. + */ async function kernelReceiptHash(receipt) { - const { keccak256, toUtf8Bytes } = await (0, optional_ethers_1.loadEthers)('Hashing a kernel receipt'); const { receipt_hash: _omit, ...header } = receipt.header; - return keccak256(toUtf8Bytes(kernelCanonicalJson({ ...receipt, header }))); + return `0x${(0, utils_1.bytesToHex)((0, sha3_1.keccak_256)((0, utils_1.utf8ToBytes)(kernelCanonicalJson({ ...receipt, header }))))}`; } // ── authenticity (kernel-receipt/2) ───────────────────────────────────────────────────────────── const SIG_HEX = /^0x[0-9a-f]{128}$/; // lower-case only: one signed receipt, one encoding (as the engine) diff --git a/dist/lib/owner-receipt.d.ts b/dist/lib/owner-receipt.d.ts index a8ef0dd6..5f514c90 100644 --- a/dist/lib/owner-receipt.d.ts +++ b/dist/lib/owner-receipt.d.ts @@ -51,8 +51,8 @@ export interface SealableReceipt { * 2. signature = Ed25519 over `DOMAIN + canonical(receipt-without-receipt_hash-and-signature)`, 0x+128 hex; * 3. receipt_hash = keccak256 over the canonical receipt WITH the signature but WITHOUT receipt_hash. * The caller supplies the body; any `schema`/`version`/`signer`/`signature`/`receipt_hash` already on the - * header are overwritten (you cannot pre-stamp the sealing fields). Needs keccak (ethers peer or the - * @noble path); never makes a network call. + * header are overwritten (you cannot pre-stamp the sealing fields). Keccak comes from @noble/hashes, so + * this works without the optional ethers peer. Never makes a network call. */ export declare function signOwnerReceipt(body: SealableReceipt, key: KeyObject): Promise; /** The provenance stamp on a receipt, or null when it has none (an engine receipt carries none). */ diff --git a/dist/lib/owner-receipt.js b/dist/lib/owner-receipt.js index 6646735f..9d8a9fda 100644 --- a/dist/lib/owner-receipt.js +++ b/dist/lib/owner-receipt.js @@ -66,8 +66,8 @@ function installKeyRootHex(key) { * 2. signature = Ed25519 over `DOMAIN + canonical(receipt-without-receipt_hash-and-signature)`, 0x+128 hex; * 3. receipt_hash = keccak256 over the canonical receipt WITH the signature but WITHOUT receipt_hash. * The caller supplies the body; any `schema`/`version`/`signer`/`signature`/`receipt_hash` already on the - * header are overwritten (you cannot pre-stamp the sealing fields). Needs keccak (ethers peer or the - * @noble path); never makes a network call. + * header are overwritten (you cannot pre-stamp the sealing fields). Keccak comes from @noble/hashes, so + * this works without the optional ethers peer. Never makes a network call. */ async function signOwnerReceipt(body, key) { if (!body || typeof body !== 'object' || typeof body.header !== 'object' || body.header === null) { From b4c81131196346d7513a2d8b63135673026bcf24 Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Sun, 8 Nov 2026 14:09:46 -0800 Subject: [PATCH 4/4] fix(test): build the CLI where its runtime dependencies resolve The egress check and the learn-hook suite compiled the CLI into the OS temp directory. Nothing there can find node_modules, so once the CLI had a real runtime dependency (@noble/hashes) every command exited 1 with no output. Build under node_modules/.cache, which is how an install lays it out. The assertions are unchanged. Co-Authored-By: Claude Opus 5.5 --- scripts/check-egress.cjs | 7 +++++-- tests/learn-hook.test.ts | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/scripts/check-egress.cjs b/scripts/check-egress.cjs index 5b407d3f..fc31f4c7 100644 --- a/scripts/check-egress.cjs +++ b/scripts/check-egress.cjs @@ -30,7 +30,7 @@ 'use strict'; const { spawnSync } = require('node:child_process'); -const { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync, appendFileSync } = require('node:fs'); +const { mkdirSync, mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync, appendFileSync } = require('node:fs'); const { tmpdir } = require('node:os'); const { dirname, join } = require('node:path'); const http = require('node:http'); @@ -423,7 +423,10 @@ function main() { if (none.length === 0) notChecked('egress table parsed but named no no-network commands — refusing a vacuous pass'); if (githubOnly.length === 0) notChecked('egress table parsed but named no api.github.com command — refusing a vacuous pass'); - const outDir = mkdtempSync(join(tmpdir(), 'ts-egress-build-')); + // Inside node_modules, as a real install is, so the CLI's runtime dependencies resolve. + const buildRoot = join(ROOT, 'node_modules', '.cache'); + mkdirSync(buildRoot, { recursive: true }); + const outDir = mkdtempSync(join(buildRoot, 'ts-egress-build-')); const compiled = compileCli(outDir); if (!compiled.ok) { try { diff --git a/tests/learn-hook.test.ts b/tests/learn-hook.test.ts index 64b956fc..04e16a32 100644 --- a/tests/learn-hook.test.ts +++ b/tests/learn-hook.test.ts @@ -7,7 +7,7 @@ */ import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { insertMemory, openMemoryDb } from '../src/memory/local-store'; @@ -24,7 +24,10 @@ let outDir = ''; let cli = ''; beforeAll(() => { - outDir = mkdtempSync(join(tmpdir(), 'ts-learn-hook-build-')); + // Inside node_modules, as a real install is, so the CLI's runtime dependencies resolve. + const buildRoot = join(ROOT, 'node_modules', '.cache'); + mkdirSync(buildRoot, { recursive: true }); + outDir = mkdtempSync(join(buildRoot, 'ts-learn-hook-build-')); const tsc = require.resolve('typescript/bin/tsc', { paths: [ROOT] }); const r = spawnSync(process.execPath, [tsc, '--project', join(ROOT, 'tsconfig.sdk.json'), '--outDir', outDir], { cwd: ROOT, encoding: 'utf8' }); if (r.status !== 0) throw new Error(`tsc failed (exit ${r.status}): ${(r.stdout || r.stderr).slice(0, 800)}`);