Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion dist/lib/kernel-client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
/** 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<string>;
/**
* The pinned public keys, lower-cased: `trustedSigners`, else TRUSTSHELL_KERNEL_TRUSTED_SIGNERS. [] when
Expand Down
10 changes: 7 additions & 3 deletions dist/lib/kernel-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions dist/lib/owner-receipt.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KernelReceiptV2>;
/** The provenance stamp on a receipt, or null when it has none (an engine receipt carries none). */
Expand Down
4 changes: 2 additions & 2 deletions dist/lib/owner-receipt.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
7 changes: 5 additions & 2 deletions scripts/check-egress.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 7 additions & 3 deletions src/lib/kernel-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -196,11 +198,13 @@ export async function hashKernelArgs(args: unknown): Promise<string> {
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<string> {
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) ─────────────────────────────────────────────────────────────
Expand Down
4 changes: 2 additions & 2 deletions src/lib/owner-receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KernelReceiptV2> {
if (!body || typeof body !== 'object' || typeof body.header !== 'object' || body.header === null) {
Expand Down
40 changes: 40 additions & 0 deletions tests/kernel-receipt-hash-no-ethers.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('ethers')>('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));
});
});
7 changes: 5 additions & 2 deletions tests/learn-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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)}`);
Expand Down
Loading