diff --git a/README.md b/README.md index b97c2917..d5d0c8bd 100644 --- a/README.md +++ b/README.md @@ -2,7 +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. +**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) { 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/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/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)); + }); +}); 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)}`);