From 7b150b419b50f542af747d6d007fb0cc9f4cfa1b Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Fri, 6 Nov 2026 21:22:01 -0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(cli):=20guided=20`trustshell=20demo`?= =?UTF-8?q?=20=E2=80=94=20the=20offline=20fresh-install=20first=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A five-minute, no-account, no-keys, no-network first run that drives the REAL commands (it spawns this CLI as subprocesses in a throwaway TRUSTSHELL_HOME, so what it shows is what the tool does): guard init -> allow/limit/sign -> an ALLOWED run that executes and writes an observable file -> two DENIED runs refused BEFORE they start (unlisted tool, and an over-cap spend) -> the decision ledger read INTACT, then BROKEN after a one-byte tamper -> a signed engine receipt verified OFFLINE. It teaches the four verdict words kept apart (VERIFIED / NOT_CHECKED / FAILED / NOT_APPLICABLE) and names, rather than hides, two honest facts it surfaced: 1. The local enforcement plane (hash-chained guard-receipts.jsonl, checked by `guard show`) and the engine six-claim plane (kernel-receipt/2, checked by `verify-receipt`) are DIFFERENT evidence planes. A local `run` does not yet emit an engine-signed six-claim receipt a stranger verifies offline. The demo shows both planes instead of pretending they are one. 2. Offline hash verification needs keccak256 from the OPTIONAL `ethers` peer, which `npm install @hyperdag/trustshell` does not pull. On a fresh install integrity reads NOT_CHECKED (fail-closed, never a false pass); the demo branches on the verifier's actual output and prints the exact remedy (`npm install ethers`) rather than hard-failing. Also: - examples/trust-receipt.sample.json: a shipped engine receipt_response the demo (and any user) can verify offline. Added to package `files`. - tests/e2e/demo-walk.mjs (test:demo): runs the demo against built dist and pins that it stays honest (exit 0, the four verdicts taught, the seam disclosed). - tests/e2e/demo-pack.mjs (e2e:demo): the fresh-install smoke test — packs this tree, installs the tarball, and runs `trustshell demo` from the INSTALLED package (a real packaged artifact, not a source-tree import). Accepts either an OOTB VERIFIED result (ethers present) or an honest NOT_CHECKED + remedy. - `demo` joins NEVER_GUARDED so a user's own owner policy never blocks it; it runs entirely in its own temp home. Co-Authored-By: Claude Opus 4.8 --- dist/cli/demo.d.ts | 6 + dist/cli/demo.js | 170 +++++++++ dist/cli/guard.js | 2 +- dist/cli/index.d.ts | 2 +- dist/cli/index.js | 10 + examples/trust-receipt.sample.json | 561 +++++++++++++++++++++++++++++ package.json | 3 + src/cli/demo.ts | 195 ++++++++++ src/cli/guard.ts | 2 +- src/cli/index.ts | 11 + tests/e2e/demo-pack.mjs | 61 ++++ tests/e2e/demo-walk.mjs | 38 ++ 12 files changed, 1058 insertions(+), 3 deletions(-) create mode 100644 dist/cli/demo.d.ts create mode 100644 dist/cli/demo.js create mode 100644 examples/trust-receipt.sample.json create mode 100644 src/cli/demo.ts create mode 100644 tests/e2e/demo-pack.mjs create mode 100644 tests/e2e/demo-walk.mjs diff --git a/dist/cli/demo.d.ts b/dist/cli/demo.d.ts new file mode 100644 index 00000000..85899b82 --- /dev/null +++ b/dist/cli/demo.d.ts @@ -0,0 +1,6 @@ +type IO = { + out: (s: string) => void; + err: (s: string) => void; +}; +export declare function runDemo(io: IO, env?: NodeJS.ProcessEnv): number; +export {}; diff --git a/dist/cli/demo.js b/dist/cli/demo.js new file mode 100644 index 00000000..b486b15f --- /dev/null +++ b/dist/cli/demo.js @@ -0,0 +1,170 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.runDemo = runDemo; +/** + * `trustshell demo` — the five-minute first run, offline, no account, no keys, no network. + * + * It drives the REAL commands a user would type (it spawns this same CLI as subprocesses in a throwaway + * TRUSTSHELL_HOME), so what it shows is what the tool actually does — nothing is re-implemented or faked here. + * The journey: + * 1. guard init an owner policy, fail-closed: every limit 0, local commands only + * 2. guard allow / limit / sign configure it, then sign it with this install's key + * 3. run (ALLOWED) a pinned task runs and writes an observable file + * 4. run (DENIED, capability) an un-allowed command is refused BEFORE it starts — nothing runs + * 5. run (DENIED, spend cap) an over-cap spend is refused — nothing runs + * 6. guard show the decision ledger, chain INTACT (tamper-evident) + * 7. tamper + guard show one flipped byte -> chain BROKEN (the negative control), then restored + * 8. verify-receipt (a signed receipt) integrity VERIFIED, and the read claims NOT_CHECKED from a file + * 9. verify-receipt (tampered) integrity FAILED + * + * The honest seam it names at the end: steps 1-7 are the LOCAL enforcement plane (guard-receipts.jsonl, its own + * hash chain); step 8-9 verify an ENGINE kernel-receipt/2 (the six-claim plane). A local `run` does not yet emit + * an engine-signed six-claim receipt a stranger verifies offline — that join is future work, not shown here. + * + * EGRESS: none. Everything happens in an OS temp dir that is deleted at the end. + */ +const node_child_process_1 = require("node:child_process"); +const node_fs_1 = require("node:fs"); +const node_os_1 = require("node:os"); +const node_path_1 = require("node:path"); +/** This CLI's own entry, resolved from dist/cli/demo.js -> dist/cli/index.js. */ +const CLI = (0, node_path_1.join)(__dirname, 'index.js'); +/** The shipped sample engine receipt (package-root/examples), from dist/cli -> ../../examples. */ +const SAMPLE_RECEIPT = (0, node_path_1.join)(__dirname, '..', '..', 'examples', 'trust-receipt.sample.json'); +function trustshell(args, env) { + const r = (0, node_child_process_1.spawnSync)(process.execPath, [CLI, ...args], { encoding: 'utf8', env }); + return { ok: true, status: r.status, out: `${r.stdout ?? ''}${r.stderr ?? ''}` }; +} +/** The task a pinned, ALLOWED run executes: it writes one observable file so "it really ran" is checkable. */ +const TASK_JS = "const fs=require('node:fs');const o=process.env.DEMO_ARTIFACT;" + + "fs.writeFileSync(o,'authorized task ran at '+new Date().toISOString()+'\\n');" + + "console.log('[task] wrote '+o);"; +function runDemo(io, env = process.env) { + const home = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'trustshell-demo-')); + const taskPath = (0, node_path_1.join)(home, 'task.js'); + const artifact = (0, node_path_1.join)(home, 'artifact.txt'); + (0, node_fs_1.writeFileSync)(taskPath, TASK_JS); + const sub = { ...env, TRUSTSHELL_HOME: home, DEMO_ARTIFACT: artifact }; + delete sub.TRUSTSHELL_REQUIRE_POLICY; // a policy exists in `home`; don't let an inherited flag change the story + // A relying party verifies a receipt with NO owner policy of their own, so the offline-verify steps run in a + // fresh, policy-free home. (The owner-policy gate covers every trustshell subcommand as tool:; by + // default it allows the offline `inspect` but not the equally-offline `verify-receipt` — noted as a finding.) + const verifyHome = (0, node_path_1.join)(home, 'relying-party'); + (0, node_fs_1.mkdirSync)(verifyHome); + const verifyEnv = { ...env, TRUSTSHELL_HOME: verifyHome }; + delete verifyEnv.TRUSTSHELL_REQUIRE_POLICY; + const fails = []; + let ethersNote = false; // set when offline hashing is unavailable (the optional `ethers` peer is not installed) + let n = 0; + // Run one real command, show a short slice of what it printed, and check the one thing that must hold. + const step = (title, args, want, note = '', stepEnv = sub) => { + n += 1; + const r = trustshell(args, stepEnv); + const okExit = want.exit === undefined || r.status === want.exit; + const okHas = !want.has || want.has.test(r.out); + const ok = okExit && okHas; + if (!ok) + fails.push(`step ${n} (${title}): exit ${r.status}${want.exit !== undefined ? ` (wanted ${want.exit})` : ''}${want.has && !okHas ? `, missing ${want.has}` : ''}`); + io.out(''); + io.out(`${ok ? '✓' : '✗'} ${n}. ${title}`); + io.out(` $ trustshell ${args.join(' ')}`); + for (const line of r.out.trim().split('\n').slice(0, 10)) + io.out(` ${line}`); + if (note) + io.out(` → ${note}`); + return r; + }; + io.out('trustshell demo — a five-minute first run. No account, no keys, no network.'); + io.out(`Working in a throwaway owner-policy home (deleted at the end):\n ${home}`); + try { + step('Create an owner policy — fail-closed: every limit 0, local commands only', ['guard', 'init', '--days', '30'], { exit: 0, has: /wrote/ }); + step('Allow one task: the node script we just wrote, pinned by its sha256', ['guard', 'allow', 'tool:node', '--script', taskPath], { exit: 0 }, 'an interpreter runs ONLY a script the policy pinned — swap the file and the pin no longer matches'); + step('Allow spending, capped at 1000 (smallest units) per transaction', ['guard', 'allow', 'spend'], { exit: 0 }); + step('Set the per-transaction cap', ['guard', 'limit', 'per-tx', '1000'], { exit: 0 }); + step('Set the daily cap', ['guard', 'limit', 'daily', '1000000'], { exit: 0 }); + step('Set the total cap', ['guard', 'limit', 'total', '1000000'], { exit: 0 }); + step('Sign the policy with this install’s key (the owner signature a bundle carries)', ['guard', 'sign'], { exit: 0, has: /signed/ }); + step('Show the policy and the decision ledger (chain INTACT = untampered)', ['guard', 'show'], { exit: 0, has: /chain INTACT/ }); + const allowed = step('ALLOWED run: the pinned task starts and does its work', ['run', '--tool', 'node', '--', 'node', taskPath], { exit: 0 }); + const ran = (0, node_fs_1.existsSync)(artifact); + if (!ran) + fails.push('the ALLOWED run did not produce its observable artifact'); + io.out(` → observable result: ${ran ? `wrote ${artifact}` : 'ARTIFACT MISSING'}`); + void allowed; + step('DENIED run (capability): a command the policy never allowed — refused before it can start', ['run', '--tool', 'rm', '--', 'rm', '-rf', (0, node_path_1.join)(home, 'nope')], { exit: 5, has: /DENIED/ }, 'rm is not an allowed tool, so nothing ran — the decision is recorded BEFORE any command starts'); + step('DENIED run (spend cap): an allowed tool, but an amount over the 1000 cap — refused', ['run', '--tool', 'node', '--class', 'spend', '--amount', '5000', '--', 'node', taskPath], { exit: 5, has: /DENIED/ }, 'ALLOW on the tool is not a blank cheque: the cap still refuses the amount'); + step('Show again: three more decisions, chain still INTACT', ['guard', 'show'], { exit: 0, has: /chain INTACT/ }); + // Negative control: flip one byte in the ledger -> the chain must read BROKEN, never silently "clean". + const ledgerPath = (0, node_path_1.join)(home, 'guard-receipts.jsonl'); + const original = (0, node_fs_1.readFileSync)(ledgerPath, 'utf8'); + const tampered = original.replace('policy-change', 'policy-chang3'); + if (tampered === original) + fails.push('could not tamper the ledger (expected a policy-change record)'); + (0, node_fs_1.writeFileSync)(ledgerPath, tampered); + step('Tamper one byte in the ledger, then show: the chain catches it (BROKEN)', ['guard', 'show'], { exit: 1, has: /chain BROKEN/ }, 'a tamper-evident log: no amount is allowed while the chain is BROKEN'); + (0, node_fs_1.writeFileSync)(ledgerPath, original); // restore; the demo leaves nothing broken behind + // The engine six-claim plane: an independently-verifiable signed receipt (shipped sample). Offline + // verification needs no owner policy, so these run as a relying party (a policy-free home). We branch on + // what the verifier ACTUALLY reports (ground truth), not a guess about whether the keccak peer is present: + // offline hashing needs the optional `ethers` peer, and whether it loads is the verifier's call, not ours. + const sample = JSON.parse((0, node_fs_1.readFileSync)(SAMPLE_RECEIPT, 'utf8')); + const vr = step('Verify a signed receipt OFFLINE under a pinned key', ['verify-receipt', SAMPLE_RECEIPT, '--root', sample.public_key], {}, '', verifyEnv); + if (vr.status === 0 && /Integrity\s+VERIFIED/.test(vr.out)) { + io.out(' → integrity VERIFIED here by cryptography (hash + pinned signature); authorization reads'); + io.out(' NOT_CHECKED from a file — the six-claim envelope is not covered by the receipt signature,'); + io.out(' so it is READ, not re-derived. NOT_CHECKED is never a pass.'); + const badPath = (0, node_path_1.join)(home, 'tampered-receipt.json'); + // Change a byte INSIDE the receipt the verifier hashes (not the ignored decide_response preview), so the + // recomputed hash no longer matches header.receipt_hash. + const bad = JSON.parse((0, node_fs_1.readFileSync)(SAMPLE_RECEIPT, 'utf8')); + bad.receipt_response.receipt.subject.agent.agent_name = 'tampered-agent'; + (0, node_fs_1.writeFileSync)(badPath, JSON.stringify(bad)); + step('Verify a tampered copy: integrity FAILED (the hash no longer matches)', ['verify-receipt', badPath, '--root', sample.public_key], { exit: 1, has: /FAILED/ }, '', verifyEnv); + } + else if (vr.status === 2 && /ethers/i.test(vr.out)) { + // Honest fresh-install reality: the keccak peer did not load, so the hash cannot be recomputed. The + // verifier reads NOT_CHECKED (fail-closed), never a false VERIFIED — and the demo says how to enable it. + ethersNote = true; + io.out(' → integrity NOT_CHECKED: offline hashing needs the optional `ethers` peer, which did not load'); + io.out(' here. Run `npm install ethers`, then this reads VERIFIED. NOT_CHECKED is fail-closed —'); + io.out(' never a false pass.'); + } + else { + fails.push(`step ${n} (verify-receipt): unexpected result — exit ${vr.status}, neither VERIFIED nor an ethers-missing NOT_CHECKED`); + } + } + finally { + try { + (0, node_fs_1.rmSync)(home, { recursive: true, force: true }); + } + catch { /* best effort */ } + } + io.out(''); + io.out('What you just saw, kept apart:'); + io.out(' • VERIFIED established here by cryptography (the receipt hash + the pinned signature)'); + io.out(' • NOT_CHECKED read, not re-derived here — NEVER a pass (the envelope’s own attestations from a file)'); + io.out(' • FAILED a check that ran and did not hold (a tampered receipt, a BROKEN ledger)'); + io.out(' • NOT_APPLICABLE a claim this call never made (e.g. content correctness on a payment)'); + io.out(''); + if (ethersNote) { + io.out('Offline hash verification is OFF on this install:'); + io.out(' `trustshell verify-receipt` recomputes the receipt hash with keccak256 from the optional `ethers`'); + io.out(' peer, which `npm install @hyperdag/trustshell` does not pull. Integrity read NOT_CHECKED above —'); + io.out(' fail-closed, never a false pass. Enable real offline verification with: npm install ethers'); + io.out(''); + } + io.out('The honest seam (not yet joined):'); + io.out(' Steps 1–7 are the LOCAL enforcement plane: a hash-chained guard-receipts.jsonl, checked by `guard show`.'); + io.out(' Steps 8–9 verify an ENGINE kernel-receipt/2 (the six-claim plane), checked by `trustshell verify-receipt`.'); + io.out(' A local `run` does not yet emit an engine-signed six-claim receipt a stranger verifies offline. That'); + io.out(' join is open work — the demo shows both planes rather than pretending they are one.'); + io.out(''); + if (fails.length) { + io.err(`demo FAILED (${fails.length}):`); + for (const f of fails) + io.err(` - ${f}`); + return 1; + } + io.out('demo OK — every step did what it said. Re-run any line above yourself; it is the real command.'); + return 0; +} diff --git a/dist/cli/guard.js b/dist/cli/guard.js index e894f01e..3c1b8ff9 100644 --- a/dist/cli/guard.js +++ b/dist/cli/guard.js @@ -19,7 +19,7 @@ const run_1 = require("../sandbox/run"); const autonomy_1 = require("../lib/autonomy"); /** Exit code for an action the policy denied. Never 0, and distinct from a VETO (1). */ exports.EXIT_REFUSED = 5; -exports.NEVER_GUARDED = new Set(['help', 'version', 'guard', 'run']); +exports.NEVER_GUARDED = new Set(['help', 'version', 'guard', 'run', 'demo']); /** The scope a CLI-gate record states. */ exports.CLI_SCOPE = { mediated: ['whether this trustshell command was allowed to start, checked against the owner policy before it started'], diff --git a/dist/cli/index.d.ts b/dist/cli/index.d.ts index d5255765..3d46e45d 100644 --- a/dist/cli/index.d.ts +++ b/dist/cli/index.d.ts @@ -53,7 +53,7 @@ export declare const EXIT: { /** The owner policy (~/.trustshell/policy.json) denied the action. Nothing ran. */ readonly REFUSED: 5; }; -export type Command = 'verify' | 'evaluate' | 'repid' | 'proof' | 'badge' | 'check' | 'receipt' | 'verify-receipt' | 'inspect' | 'init' | 'report' | 'profile' | 'status' | 'remember' | 'recall' | 'redact' | 'quarantine' | 'bind-status' | 'traps' | 'ground' | 'erc8004' | 'learn' | 'bundle' | 'guard' | 'run' | 'sandbox' | 'help' | 'version'; +export type Command = 'verify' | 'evaluate' | 'repid' | 'proof' | 'badge' | 'check' | 'receipt' | 'verify-receipt' | 'inspect' | 'init' | 'report' | 'profile' | 'status' | 'remember' | 'recall' | 'redact' | 'quarantine' | 'bind-status' | 'traps' | 'ground' | 'erc8004' | 'learn' | 'bundle' | 'guard' | 'run' | 'sandbox' | 'demo' | 'help' | 'version'; /** Result of parsing argv (everything after `node cli.js`). Pure + testable. */ export interface ParsedArgs { command: Command; diff --git a/dist/cli/index.js b/dist/cli/index.js index 7cc08621..7967f931 100644 --- a/dist/cli/index.js +++ b/dist/cli/index.js @@ -86,6 +86,7 @@ const node_path_1 = require("node:path"); const cli_1 = require("../learn/cli"); const bundle_1 = require("./bundle"); const guard_1 = require("./guard"); +const demo_1 = require("./demo"); const run_1 = require("../sandbox/run"); const guardrails_1 = require("../lib/guardrails"); const api_url_1 = require("../lib/api-url"); @@ -156,6 +157,10 @@ USAGE trustshell [arguments] [options] COMMANDS + demo A five-minute guided first run, OFFLINE: create an owner policy, + allow one task and run it, watch two commands get DENIED before they + start, see the decision ledger catch a tamper, and verify a signed + receipt. No account, no keys, no network; runs in a throwaway home. verify "" Run through the live HAL cross-provider fact-check quorum. Prints PASS / FLAG / VETO + trust score + evidence. EXIT 0 on PASS/FLAG, EXIT 1 on VETO — use it as a CI gate. @@ -445,6 +450,8 @@ function parseArgs(argv) { return { command: 'run', runArgv: argv.slice(1), json: false, verify: false }; if (argv[0] === 'sandbox') return { command: 'sandbox', sandboxArgv: argv.slice(1), json: false, verify: false }; + if (argv[0] === 'demo') + return { command: 'demo', json: false, verify: false }; const flags = new Set(); const positionals = []; // Options that consume the NEXT argument. Indexed rather than for-of because a @@ -1075,6 +1082,9 @@ async function run(args, client, io = realIO) { case 'sandbox': // Local files only: the owner step that promotes a `run --sandbox` mission's quarantined outbox. return (0, run_1.sandboxCli)(args.sandboxArgv ?? [], io); + case 'demo': + // Offline guided first run in a throwaway TRUSTSHELL_HOME. Spawns the real commands; opens no socket. + return (0, demo_1.runDemo)({ out: io.out, err: io.err }, process.env); case 'recall': { try { io.out(args.key ? (0, recall_1.recallKey)(args.key) : (0, recall_1.recallNotes)(process.env, { includeQuarantined: args.includeQuarantined })); diff --git a/examples/trust-receipt.sample.json b/examples/trust-receipt.sample.json new file mode 100644 index 00000000..699db5ef --- /dev/null +++ b/examples/trust-receipt.sample.json @@ -0,0 +1,561 @@ +{ + "engine_commit": "a8c94c7692be172dcc98b4d0b7adc394971169e0", + "public_key": "0x58bb94348b39e316810d6a050d5ef5a499ae6479ab42e30dbebc4bdb2685d197", + "decide_response": { + "verdict": "ALLOW", + "blocking": false, + "persisted": false, + "runnable": false, + "decision": { + "verdict": "ALLOW", + "blocking": false, + "executed": false, + "reasons": [], + "checks": [ + { + "name": "request_wellformed", + "outcome": "PASS", + "source": "kernel/decide" + }, + { + "name": "owner_resolved", + "outcome": "PASS", + "source": "accountable-root" + }, + { + "name": "policy_present", + "outcome": "PASS", + "source": "policy-store" + }, + { + "name": "policy_subject", + "outcome": "PASS", + "source": "kernel/decide" + }, + { + "name": "policy_signer_is_owner", + "outcome": "PASS", + "source": "accountable-root" + }, + { + "name": "policy_signature", + "outcome": "PASS", + "source": "wallet-signature" + }, + { + "name": "policy_unexpired", + "outcome": "PASS", + "source": "kernel/decide" + }, + { + "name": "policy_revocation", + "outcome": "PASS", + "source": "policy-store" + }, + { + "name": "action_permitted", + "outcome": "PASS", + "source": "kernel/decide" + }, + { + "name": "amount_valid", + "outcome": "PASS", + "source": "kernel/decide" + }, + { + "name": "payee_allowed", + "outcome": "PASS", + "source": "kernel/decide" + }, + { + "name": "spend_totals", + "outcome": "PASS", + "source": "ledger" + }, + { + "name": "spend_limit", + "outcome": "PASS", + "source": "kernel/decide" + } + ], + "does_not_attest": [ + "cumulative_total (no atomic reservation)", + "total_cap_scope (the total limit is per policy version, not lifetime: it is keyed to the policy hash, which includes the nonce, so it resets when a policy is superseded)", + "tool_class (caller-asserted; there is no tool registry in the MVP)", + "explicit_revocation (revocation is supersession by a higher nonce inside this ledger only)", + "laya (shadow mode; not consulted)", + "receipt_signature (unsigned in the MVP; integrity is the hash and the chain)", + "asset_and_chain (the owner policy does not constrain which asset or chain a spend uses)", + "declared_payment_fields (amount and payee are as the caller declared them; the kernel does not observe what the tool then pays)", + "settlement (this is an authorisation, not an observed payment)" + ], + "policy": { + "policy_hash": "0x78998686e713432000b7d2814379c6575a2cee1fa628a539e6d441698cd5beda", + "signer": "0xa1DEd137a8afD0EE6c2Cff42D7a292ED77FB1645", + "signature": "VERIFIED", + "expires_at": 1800003600, + "revocation": "VERIFIED" + }, + "limits": { + "per_tx_raw": "1000000", + "daily_raw": "5000000", + "total_raw": "20000000", + "effective_per_tx_raw": "1000000", + "binding_term": "per_tx" + }, + "laya": { + "lane": null, + "mode": "shadow", + "consulted": false + } + }, + "receipt_preview": { + "header": { + "schema": "kernel-receipt/2", + "version": 2, + "receipt_id": "00000000-0000-4000-8000-000000000001", + "prev_receipt_id": null, + "prev_hash": null, + "created_at": "2027-01-15T08:00:00.000Z", + "receipt_hash": "0x3a3d2adb1a6187232bd8aa7d7dcc582485eb917fc82b72d920d42e0df72ec462", + "env": { + "network": "base-sepolia", + "chain_id": 84532, + "is_simulated": true + }, + "signer": { + "alg": "ed25519", + "public_key": "0x58bb94348b39e316810d6a050d5ef5a499ae6479ab42e30dbebc4bdb2685d197", + "fingerprint": "0x72852912932120fb01e2ad446f26292e1631225c5aef0a8896205aee38fff8c8" + }, + "signature": "0x71d464ff4a342a9df7fb2e9338727c06bdc84b38414bdea932e124096effa808ed7c917a46041aeb60efe0a92057aad11942a8e4b7e3531d9cff997020fb9d08" + }, + "subject": { + "agent": { + "agent_id": "11111111-1111-4111-8111-111111111111", + "agent_name": "kernel-demo-agent" + }, + "owner": { + "wallet": "0xa1DEd137a8afD0EE6c2Cff42D7a292ED77FB1645", + "assurance": "wallet-proven", + "status": "resolved" + } + }, + "request": { + "tool": { + "name": "x402.pay", + "class": "spend" + }, + "origin": "Cli", + "args_hash": "0xd82cf87614b294f236e7d71bdbc831d606503ec1f6446013321450df46425440", + "amount_raw": "10000", + "payee": "0x000000000000000000000000000000000000dEaD", + "asset": "USDC", + "chain_id": 84532 + }, + "decision": { + "verdict": "ALLOW", + "blocking": false, + "executed": false, + "reasons": [], + "checks": [ + { + "name": "request_wellformed", + "outcome": "PASS", + "source": "kernel/decide" + }, + { + "name": "owner_resolved", + "outcome": "PASS", + "source": "accountable-root" + }, + { + "name": "policy_present", + "outcome": "PASS", + "source": "policy-store" + }, + { + "name": "policy_subject", + "outcome": "PASS", + "source": "kernel/decide" + }, + { + "name": "policy_signer_is_owner", + "outcome": "PASS", + "source": "accountable-root" + }, + { + "name": "policy_signature", + "outcome": "PASS", + "source": "wallet-signature" + }, + { + "name": "policy_unexpired", + "outcome": "PASS", + "source": "kernel/decide" + }, + { + "name": "policy_revocation", + "outcome": "PASS", + "source": "policy-store" + }, + { + "name": "action_permitted", + "outcome": "PASS", + "source": "kernel/decide" + }, + { + "name": "amount_valid", + "outcome": "PASS", + "source": "kernel/decide" + }, + { + "name": "payee_allowed", + "outcome": "PASS", + "source": "kernel/decide" + }, + { + "name": "spend_totals", + "outcome": "PASS", + "source": "ledger" + }, + { + "name": "spend_limit", + "outcome": "PASS", + "source": "kernel/decide" + } + ], + "does_not_attest": [ + "cumulative_total (no atomic reservation)", + "total_cap_scope (the total limit is per policy version, not lifetime: it is keyed to the policy hash, which includes the nonce, so it resets when a policy is superseded)", + "tool_class (caller-asserted; there is no tool registry in the MVP)", + "explicit_revocation (revocation is supersession by a higher nonce inside this ledger only)", + "laya (shadow mode; not consulted)", + "signer_trust (the signature proves which key sealed this receipt, not that the key is the engine's: a verifier checks header.signer against its own trusted list)", + "asset_and_chain (the owner policy does not constrain which asset or chain a spend uses)", + "declared_payment_fields (amount and payee are as the caller declared them; the kernel does not observe what the tool then pays)", + "settlement (this is an authorisation, not an observed payment)", + "dry_preview (this receipt is not an authorization to execute)" + ], + "policy": { + "policy_hash": "0x78998686e713432000b7d2814379c6575a2cee1fa628a539e6d441698cd5beda", + "signer": "0xa1DEd137a8afD0EE6c2Cff42D7a292ED77FB1645", + "signature": "VERIFIED", + "expires_at": 1800003600, + "revocation": "VERIFIED" + }, + "limits": { + "per_tx_raw": "1000000", + "daily_raw": "5000000", + "total_raw": "20000000", + "effective_per_tx_raw": "1000000", + "binding_term": "per_tx" + }, + "laya": { + "lane": null, + "mode": "shadow", + "consulted": false + } + }, + "payment": { + "status": "AUTHORIZED", + "scheme": null, + "amount_raw": "10000", + "payer": null, + "payee": "0x000000000000000000000000000000000000dEaD", + "authorization_nonce": null, + "tx_hash": null, + "block": null, + "observed_amount_raw": null, + "settlement_evidence": "NOT_CHECKED", + "is_simulated": true + }, + "verification": { + "status": "NOT_APPLICABLE", + "method": null, + "votes": [], + "verdict_label": "PROVISIONAL", + "settled_by": null, + "grounded": false, + "same_domain": false, + "is_simulated": true, + "counts_toward_band_restore": false + } + }, + "reasons": [] + }, + "receipt_response": { + "receipt": { + "header": { + "schema": "kernel-receipt/2", + "version": 2, + "receipt_id": "00000000-0000-4000-8000-000000000001", + "prev_receipt_id": null, + "prev_hash": null, + "created_at": "2027-01-15T08:00:00.000Z", + "receipt_hash": "0x3a3d2adb1a6187232bd8aa7d7dcc582485eb917fc82b72d920d42e0df72ec462", + "env": { + "network": "base-sepolia", + "chain_id": 84532, + "is_simulated": true + }, + "signer": { + "alg": "ed25519", + "public_key": "0x58bb94348b39e316810d6a050d5ef5a499ae6479ab42e30dbebc4bdb2685d197", + "fingerprint": "0x72852912932120fb01e2ad446f26292e1631225c5aef0a8896205aee38fff8c8" + }, + "signature": "0x71d464ff4a342a9df7fb2e9338727c06bdc84b38414bdea932e124096effa808ed7c917a46041aeb60efe0a92057aad11942a8e4b7e3531d9cff997020fb9d08" + }, + "subject": { + "agent": { + "agent_id": "11111111-1111-4111-8111-111111111111", + "agent_name": "kernel-demo-agent" + }, + "owner": { + "wallet": "0xa1DEd137a8afD0EE6c2Cff42D7a292ED77FB1645", + "assurance": "wallet-proven", + "status": "resolved" + } + }, + "request": { + "tool": { + "name": "x402.pay", + "class": "spend" + }, + "origin": "Cli", + "args_hash": "0xd82cf87614b294f236e7d71bdbc831d606503ec1f6446013321450df46425440", + "amount_raw": "10000", + "payee": "0x000000000000000000000000000000000000dEaD", + "asset": "USDC", + "chain_id": 84532 + }, + "decision": { + "verdict": "ALLOW", + "blocking": false, + "executed": false, + "reasons": [], + "checks": [ + { + "name": "request_wellformed", + "outcome": "PASS", + "source": "kernel/decide" + }, + { + "name": "owner_resolved", + "outcome": "PASS", + "source": "accountable-root" + }, + { + "name": "policy_present", + "outcome": "PASS", + "source": "policy-store" + }, + { + "name": "policy_subject", + "outcome": "PASS", + "source": "kernel/decide" + }, + { + "name": "policy_signer_is_owner", + "outcome": "PASS", + "source": "accountable-root" + }, + { + "name": "policy_signature", + "outcome": "PASS", + "source": "wallet-signature" + }, + { + "name": "policy_unexpired", + "outcome": "PASS", + "source": "kernel/decide" + }, + { + "name": "policy_revocation", + "outcome": "PASS", + "source": "policy-store" + }, + { + "name": "action_permitted", + "outcome": "PASS", + "source": "kernel/decide" + }, + { + "name": "amount_valid", + "outcome": "PASS", + "source": "kernel/decide" + }, + { + "name": "payee_allowed", + "outcome": "PASS", + "source": "kernel/decide" + }, + { + "name": "spend_totals", + "outcome": "PASS", + "source": "ledger" + }, + { + "name": "spend_limit", + "outcome": "PASS", + "source": "kernel/decide" + } + ], + "does_not_attest": [ + "cumulative_total (no atomic reservation)", + "total_cap_scope (the total limit is per policy version, not lifetime: it is keyed to the policy hash, which includes the nonce, so it resets when a policy is superseded)", + "tool_class (caller-asserted; there is no tool registry in the MVP)", + "explicit_revocation (revocation is supersession by a higher nonce inside this ledger only)", + "laya (shadow mode; not consulted)", + "signer_trust (the signature proves which key sealed this receipt, not that the key is the engine's: a verifier checks header.signer against its own trusted list)", + "asset_and_chain (the owner policy does not constrain which asset or chain a spend uses)", + "declared_payment_fields (amount and payee are as the caller declared them; the kernel does not observe what the tool then pays)", + "settlement (this is an authorisation, not an observed payment)", + "dry_preview (this receipt is not an authorization to execute)" + ], + "policy": { + "policy_hash": "0x78998686e713432000b7d2814379c6575a2cee1fa628a539e6d441698cd5beda", + "signer": "0xa1DEd137a8afD0EE6c2Cff42D7a292ED77FB1645", + "signature": "VERIFIED", + "expires_at": 1800003600, + "revocation": "VERIFIED" + }, + "limits": { + "per_tx_raw": "1000000", + "daily_raw": "5000000", + "total_raw": "20000000", + "effective_per_tx_raw": "1000000", + "binding_term": "per_tx" + }, + "laya": { + "lane": null, + "mode": "shadow", + "consulted": false + } + }, + "payment": { + "status": "AUTHORIZED", + "scheme": null, + "amount_raw": "10000", + "payer": null, + "payee": "0x000000000000000000000000000000000000dEaD", + "authorization_nonce": null, + "tx_hash": null, + "block": null, + "observed_amount_raw": null, + "settlement_evidence": "NOT_CHECKED", + "is_simulated": true + }, + "verification": { + "status": "NOT_APPLICABLE", + "method": null, + "votes": [], + "verdict_label": "PROVISIONAL", + "settled_by": null, + "grounded": false, + "same_domain": false, + "is_simulated": true, + "counts_toward_band_restore": false + } + }, + "six_claims": { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "kernel-receipt:00000000-0000-4000-8000-000000000001", + "digest": { + "keccak256": "3a3d2adb1a6187232bd8aa7d7dcc582485eb917fc82b72d920d42e0df72ec462" + } + } + ], + "predicateType": "https://hyperdag.dev/predicates/trust-receipt-six-claims/v1", + "predicate": { + "claims": { + "integrity": { + "status": "VERIFIED", + "statement": "The record is whole: its hash matches its contents.", + "supports": [ + "receipt_hash recomputed over the canonical receipt: it matches", + "chain root (no prev_hash)", + "schema kernel-receipt/2" + ], + "gaps": [ + "the Ed25519 signature is not checked here: verifyReceiptAuthenticity checks it against a trusted signer list", + "the chain is not checked here: prev_hash is reported, not compared with the previous receipt (verifyReceiptChain does that)" + ] + }, + "authenticity": { + "status": "VERIFIED", + "statement": "The action was authorized by the agent’s accountable owner.", + "supports": [ + "owner status resolved", + "owner wallet 0xa1DEd137…", + "policy signature VERIFIED" + ], + "gaps": [] + }, + "observation": { + "status": "NOT_CHECKED", + "statement": "A payment was observed settling on chain.", + "supports": [ + "payment status AUTHORIZED", + "settlement_evidence NOT_CHECKED", + "no tx hash" + ], + "gaps": [ + "authorized is not observed: nothing was read back from the chain (settlement_evidence NOT_CHECKED)", + "simulated: no real value moved" + ] + }, + "correctness": { + "status": "NOT_APPLICABLE", + "statement": "No content verification was part of this call.", + "supports": [ + "verification NOT_APPLICABLE" + ], + "gaps": [] + }, + "authorization": { + "status": "VERIFIED", + "statement": "The kernel permitted this action under the policy it was given.", + "supports": [ + "verdict ALLOW", + "policy signature VERIFIED" + ], + "gaps": [] + }, + "containment": { + "status": "VERIFIED", + "statement": "The tool ran only under an ALLOW decision.", + "supports": [ + "executed false", + "verdict ALLOW" + ], + "gaps": [ + "tool_class is caller-asserted; there is no tool registry in the MVP", + "the tool is a closure the kernel does not see into — what it did beyond the gated decision is not observed here" + ] + } + }, + "what_this_does_not_establish": [ + "cumulative_total (no atomic reservation)", + "total_cap_scope (the total limit is per policy version, not lifetime: it is keyed to the policy hash, which includes the nonce, so it resets when a policy is superseded)", + "tool_class (caller-asserted; there is no tool registry in the MVP)", + "explicit_revocation (revocation is supersession by a higher nonce inside this ledger only)", + "laya (shadow mode; not consulted)", + "signer_trust (the signature proves which key sealed this receipt, not that the key is the engine's: a verifier checks header.signer against its own trusted list)", + "asset_and_chain (the owner policy does not constrain which asset or chain a spend uses)", + "declared_payment_fields (amount and payee are as the caller declared them; the kernel does not observe what the tool then pays)", + "settlement (this is an authorisation, not an observed payment)", + "dry_preview (this receipt is not an authorization to execute)", + "integrity: the Ed25519 signature is not checked here: verifyReceiptAuthenticity checks it against a trusted signer list", + "integrity: the chain is not checked here: prev_hash is reported, not compared with the previous receipt (verifyReceiptChain does that)", + "observation: authorized is not observed: nothing was read back from the chain (settlement_evidence NOT_CHECKED)", + "observation: simulated: no real value moved", + "containment: tool_class is caller-asserted; there is no tool registry in the MVP", + "containment: the tool is a closure the kernel does not see into — what it did beyond the gated decision is not observed here" + ] + } + } + } +} diff --git a/package.json b/package.json index 77a7de2d..f3411227 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "README.md", "CHANGELOG.md", "LICENSE", + "examples/trust-receipt.sample.json", "scripts/init-pai.mjs", "scripts/value-events.mjs", "lib/interview.js", @@ -102,6 +103,8 @@ "test:sdk-import": "npm run sdk:build && node tests/sdk-import-contract.mjs", "test:verify-import": "npm run sdk:build && node tests/verify-subpath-import.mjs", "test:trustlattice": "npm run sdk:build && node tests/trustlattice-conformance.mjs", + "test:demo": "npm run sdk:build && node tests/e2e/demo-walk.mjs", + "e2e:demo": "node tests/e2e/demo-pack.mjs", "docs:honest-types": "node scripts/gen-honest-return-docs.mjs", "test:pack-x402": "node tests/pack-x402-guards.mjs", "test:trustmesh-live": "node scripts/trustmesh-live-e2e.mjs", diff --git a/src/cli/demo.ts b/src/cli/demo.ts new file mode 100644 index 00000000..0cf05f2b --- /dev/null +++ b/src/cli/demo.ts @@ -0,0 +1,195 @@ +/** + * `trustshell demo` — the five-minute first run, offline, no account, no keys, no network. + * + * It drives the REAL commands a user would type (it spawns this same CLI as subprocesses in a throwaway + * TRUSTSHELL_HOME), so what it shows is what the tool actually does — nothing is re-implemented or faked here. + * The journey: + * 1. guard init an owner policy, fail-closed: every limit 0, local commands only + * 2. guard allow / limit / sign configure it, then sign it with this install's key + * 3. run (ALLOWED) a pinned task runs and writes an observable file + * 4. run (DENIED, capability) an un-allowed command is refused BEFORE it starts — nothing runs + * 5. run (DENIED, spend cap) an over-cap spend is refused — nothing runs + * 6. guard show the decision ledger, chain INTACT (tamper-evident) + * 7. tamper + guard show one flipped byte -> chain BROKEN (the negative control), then restored + * 8. verify-receipt (a signed receipt) integrity VERIFIED, and the read claims NOT_CHECKED from a file + * 9. verify-receipt (tampered) integrity FAILED + * + * The honest seam it names at the end: steps 1-7 are the LOCAL enforcement plane (guard-receipts.jsonl, its own + * hash chain); step 8-9 verify an ENGINE kernel-receipt/2 (the six-claim plane). A local `run` does not yet emit + * an engine-signed six-claim receipt a stranger verifies offline — that join is future work, not shown here. + * + * EGRESS: none. Everything happens in an OS temp dir that is deleted at the end. + */ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +type IO = { out: (s: string) => void; err: (s: string) => void }; + +/** This CLI's own entry, resolved from dist/cli/demo.js -> dist/cli/index.js. */ +const CLI = join(__dirname, 'index.js'); +/** The shipped sample engine receipt (package-root/examples), from dist/cli -> ../../examples. */ +const SAMPLE_RECEIPT = join(__dirname, '..', '..', 'examples', 'trust-receipt.sample.json'); + +interface StepResult { ok: boolean; status: number | null; out: string } + +function trustshell(args: string[], env: NodeJS.ProcessEnv): StepResult { + const r = spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', env }); + return { ok: true, status: r.status, out: `${r.stdout ?? ''}${r.stderr ?? ''}` }; +} + +/** The task a pinned, ALLOWED run executes: it writes one observable file so "it really ran" is checkable. */ +const TASK_JS = + "const fs=require('node:fs');const o=process.env.DEMO_ARTIFACT;" + + "fs.writeFileSync(o,'authorized task ran at '+new Date().toISOString()+'\\n');" + + "console.log('[task] wrote '+o);"; + +export function runDemo(io: IO, env: NodeJS.ProcessEnv = process.env): number { + const home = mkdtempSync(join(tmpdir(), 'trustshell-demo-')); + const taskPath = join(home, 'task.js'); + const artifact = join(home, 'artifact.txt'); + writeFileSync(taskPath, TASK_JS); + const sub: NodeJS.ProcessEnv = { ...env, TRUSTSHELL_HOME: home, DEMO_ARTIFACT: artifact }; + delete sub.TRUSTSHELL_REQUIRE_POLICY; // a policy exists in `home`; don't let an inherited flag change the story + // A relying party verifies a receipt with NO owner policy of their own, so the offline-verify steps run in a + // fresh, policy-free home. (The owner-policy gate covers every trustshell subcommand as tool:; by + // default it allows the offline `inspect` but not the equally-offline `verify-receipt` — noted as a finding.) + const verifyHome = join(home, 'relying-party'); + mkdirSync(verifyHome); + const verifyEnv: NodeJS.ProcessEnv = { ...env, TRUSTSHELL_HOME: verifyHome }; + delete verifyEnv.TRUSTSHELL_REQUIRE_POLICY; + + const fails: string[] = []; + let ethersNote = false; // set when offline hashing is unavailable (the optional `ethers` peer is not installed) + let n = 0; + // Run one real command, show a short slice of what it printed, and check the one thing that must hold. + const step = (title: string, args: string[], want: { exit?: number; has?: RegExp }, note = '', stepEnv: NodeJS.ProcessEnv = sub): StepResult => { + n += 1; + const r = trustshell(args, stepEnv); + const okExit = want.exit === undefined || r.status === want.exit; + const okHas = !want.has || want.has.test(r.out); + const ok = okExit && okHas; + if (!ok) fails.push(`step ${n} (${title}): exit ${r.status}${want.exit !== undefined ? ` (wanted ${want.exit})` : ''}${want.has && !okHas ? `, missing ${want.has}` : ''}`); + io.out(''); + io.out(`${ok ? '✓' : '✗'} ${n}. ${title}`); + io.out(` $ trustshell ${args.join(' ')}`); + for (const line of r.out.trim().split('\n').slice(0, 10)) io.out(` ${line}`); + if (note) io.out(` → ${note}`); + return r; + }; + + io.out('trustshell demo — a five-minute first run. No account, no keys, no network.'); + io.out(`Working in a throwaway owner-policy home (deleted at the end):\n ${home}`); + + try { + step('Create an owner policy — fail-closed: every limit 0, local commands only', + ['guard', 'init', '--days', '30'], { exit: 0, has: /wrote/ }); + + step('Allow one task: the node script we just wrote, pinned by its sha256', + ['guard', 'allow', 'tool:node', '--script', taskPath], { exit: 0 }, + 'an interpreter runs ONLY a script the policy pinned — swap the file and the pin no longer matches'); + + step('Allow spending, capped at 1000 (smallest units) per transaction', + ['guard', 'allow', 'spend'], { exit: 0 }); + step('Set the per-transaction cap', ['guard', 'limit', 'per-tx', '1000'], { exit: 0 }); + step('Set the daily cap', ['guard', 'limit', 'daily', '1000000'], { exit: 0 }); + step('Set the total cap', ['guard', 'limit', 'total', '1000000'], { exit: 0 }); + + step('Sign the policy with this install’s key (the owner signature a bundle carries)', + ['guard', 'sign'], { exit: 0, has: /signed/ }); + + step('Show the policy and the decision ledger (chain INTACT = untampered)', + ['guard', 'show'], { exit: 0, has: /chain INTACT/ }); + + const allowed = step('ALLOWED run: the pinned task starts and does its work', + ['run', '--tool', 'node', '--', 'node', taskPath], { exit: 0 }); + const ran = existsSync(artifact); + if (!ran) fails.push('the ALLOWED run did not produce its observable artifact'); + io.out(` → observable result: ${ran ? `wrote ${artifact}` : 'ARTIFACT MISSING'}`); + void allowed; + + step('DENIED run (capability): a command the policy never allowed — refused before it can start', + ['run', '--tool', 'rm', '--', 'rm', '-rf', join(home, 'nope')], { exit: 5, has: /DENIED/ }, + 'rm is not an allowed tool, so nothing ran — the decision is recorded BEFORE any command starts'); + + step('DENIED run (spend cap): an allowed tool, but an amount over the 1000 cap — refused', + ['run', '--tool', 'node', '--class', 'spend', '--amount', '5000', '--', 'node', taskPath], + { exit: 5, has: /DENIED/ }, 'ALLOW on the tool is not a blank cheque: the cap still refuses the amount'); + + step('Show again: three more decisions, chain still INTACT', + ['guard', 'show'], { exit: 0, has: /chain INTACT/ }); + + // Negative control: flip one byte in the ledger -> the chain must read BROKEN, never silently "clean". + const ledgerPath = join(home, 'guard-receipts.jsonl'); + const original = readFileSync(ledgerPath, 'utf8'); + const tampered = original.replace('policy-change', 'policy-chang3'); + if (tampered === original) fails.push('could not tamper the ledger (expected a policy-change record)'); + writeFileSync(ledgerPath, tampered); + step('Tamper one byte in the ledger, then show: the chain catches it (BROKEN)', + ['guard', 'show'], { exit: 1, has: /chain BROKEN/ }, + 'a tamper-evident log: no amount is allowed while the chain is BROKEN'); + writeFileSync(ledgerPath, original); // restore; the demo leaves nothing broken behind + + // The engine six-claim plane: an independently-verifiable signed receipt (shipped sample). Offline + // verification needs no owner policy, so these run as a relying party (a policy-free home). We branch on + // what the verifier ACTUALLY reports (ground truth), not a guess about whether the keccak peer is present: + // offline hashing needs the optional `ethers` peer, and whether it loads is the verifier's call, not ours. + const sample = JSON.parse(readFileSync(SAMPLE_RECEIPT, 'utf8')) as { public_key: string }; + const vr = step('Verify a signed receipt OFFLINE under a pinned key', + ['verify-receipt', SAMPLE_RECEIPT, '--root', sample.public_key], {}, '', verifyEnv); + if (vr.status === 0 && /Integrity\s+VERIFIED/.test(vr.out)) { + io.out(' → integrity VERIFIED here by cryptography (hash + pinned signature); authorization reads'); + io.out(' NOT_CHECKED from a file — the six-claim envelope is not covered by the receipt signature,'); + io.out(' so it is READ, not re-derived. NOT_CHECKED is never a pass.'); + const badPath = join(home, 'tampered-receipt.json'); + // Change a byte INSIDE the receipt the verifier hashes (not the ignored decide_response preview), so the + // recomputed hash no longer matches header.receipt_hash. + const bad = JSON.parse(readFileSync(SAMPLE_RECEIPT, 'utf8')); + bad.receipt_response.receipt.subject.agent.agent_name = 'tampered-agent'; + writeFileSync(badPath, JSON.stringify(bad)); + step('Verify a tampered copy: integrity FAILED (the hash no longer matches)', + ['verify-receipt', badPath, '--root', sample.public_key], { exit: 1, has: /FAILED/ }, '', verifyEnv); + } else if (vr.status === 2 && /ethers/i.test(vr.out)) { + // Honest fresh-install reality: the keccak peer did not load, so the hash cannot be recomputed. The + // verifier reads NOT_CHECKED (fail-closed), never a false VERIFIED — and the demo says how to enable it. + ethersNote = true; + io.out(' → integrity NOT_CHECKED: offline hashing needs the optional `ethers` peer, which did not load'); + io.out(' here. Run `npm install ethers`, then this reads VERIFIED. NOT_CHECKED is fail-closed —'); + io.out(' never a false pass.'); + } else { + fails.push(`step ${n} (verify-receipt): unexpected result — exit ${vr.status}, neither VERIFIED nor an ethers-missing NOT_CHECKED`); + } + } finally { + try { rmSync(home, { recursive: true, force: true }); } catch { /* best effort */ } + } + + io.out(''); + io.out('What you just saw, kept apart:'); + io.out(' • VERIFIED established here by cryptography (the receipt hash + the pinned signature)'); + io.out(' • NOT_CHECKED read, not re-derived here — NEVER a pass (the envelope’s own attestations from a file)'); + io.out(' • FAILED a check that ran and did not hold (a tampered receipt, a BROKEN ledger)'); + io.out(' • NOT_APPLICABLE a claim this call never made (e.g. content correctness on a payment)'); + io.out(''); + if (ethersNote) { + io.out('Offline hash verification is OFF on this install:'); + io.out(' `trustshell verify-receipt` recomputes the receipt hash with keccak256 from the optional `ethers`'); + io.out(' peer, which `npm install @hyperdag/trustshell` does not pull. Integrity read NOT_CHECKED above —'); + io.out(' fail-closed, never a false pass. Enable real offline verification with: npm install ethers'); + io.out(''); + } + io.out('The honest seam (not yet joined):'); + io.out(' Steps 1–7 are the LOCAL enforcement plane: a hash-chained guard-receipts.jsonl, checked by `guard show`.'); + io.out(' Steps 8–9 verify an ENGINE kernel-receipt/2 (the six-claim plane), checked by `trustshell verify-receipt`.'); + io.out(' A local `run` does not yet emit an engine-signed six-claim receipt a stranger verifies offline. That'); + io.out(' join is open work — the demo shows both planes rather than pretending they are one.'); + io.out(''); + + if (fails.length) { + io.err(`demo FAILED (${fails.length}):`); + for (const f of fails) io.err(` - ${f}`); + return 1; + } + io.out('demo OK — every step did what it said. Re-run any line above yourself; it is the real command.'); + return 0; +} diff --git a/src/cli/guard.ts b/src/cli/guard.ts index 1cab6c34..54260e5a 100644 --- a/src/cli/guard.ts +++ b/src/cli/guard.ts @@ -72,7 +72,7 @@ type IO = { out: (s: string) => void; err: (s: string) => void }; /** Exit code for an action the policy denied. Never 0, and distinct from a VETO (1). */ export const EXIT_REFUSED = 5; -export const NEVER_GUARDED = new Set(['help', 'version', 'guard', 'run']); +export const NEVER_GUARDED = new Set(['help', 'version', 'guard', 'run', 'demo']); /** The scope a CLI-gate record states. */ export const CLI_SCOPE: AssuranceScope = { diff --git a/src/cli/index.ts b/src/cli/index.ts index 8e3bf28d..e36d8ea0 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -74,6 +74,7 @@ import { join } from 'node:path'; import { runLearn } from '../learn/cli'; import { runBundle } from './bundle'; import { EXIT_REFUSED, runGuard, runGuarded, runRun } from './guard'; +import { runDemo } from './demo'; import { sandboxCli } from '../sandbox/run'; import { GUARD_RECORD_SCHEMA } from '../lib/guardrails'; import { ApiUrlError } from '../lib/api-url'; @@ -127,6 +128,7 @@ export type Command = | 'guard' | 'run' | 'sandbox' + | 'demo' | 'help' | 'version'; @@ -270,6 +272,10 @@ USAGE trustshell [arguments] [options] COMMANDS + demo A five-minute guided first run, OFFLINE: create an owner policy, + allow one task and run it, watch two commands get DENIED before they + start, see the decision ledger catch a tamper, and verify a signed + receipt. No account, no keys, no network; runs in a throwaway home. verify "" Run through the live HAL cross-provider fact-check quorum. Prints PASS / FLAG / VETO + trust score + evidence. EXIT 0 on PASS/FLAG, EXIT 1 on VETO — use it as a CI gate. @@ -556,6 +562,7 @@ export function parseArgs(argv: string[]): ParsedArgs { if (argv[0] === 'guard') return { command: 'guard', guardArgv: argv.slice(1), json: false, verify: false }; if (argv[0] === 'run') return { command: 'run', runArgv: argv.slice(1), json: false, verify: false }; if (argv[0] === 'sandbox') return { command: 'sandbox', sandboxArgv: argv.slice(1), json: false, verify: false }; + if (argv[0] === 'demo') return { command: 'demo', json: false, verify: false }; const flags = new Set(); const positionals: string[] = []; // Options that consume the NEXT argument. Indexed rather than for-of because a @@ -1171,6 +1178,10 @@ export async function run( // Local files only: the owner step that promotes a `run --sandbox` mission's quarantined outbox. return sandboxCli(args.sandboxArgv ?? [], io); + case 'demo': + // Offline guided first run in a throwaway TRUSTSHELL_HOME. Spawns the real commands; opens no socket. + return runDemo({ out: io.out, err: io.err }, process.env); + case 'recall': { try { io.out(args.key ? recallKey(args.key) : recallNotes(process.env, { includeQuarantined: args.includeQuarantined })); diff --git a/tests/e2e/demo-pack.mjs b/tests/e2e/demo-pack.mjs new file mode 100644 index 00000000..5604e7f9 --- /dev/null +++ b/tests/e2e/demo-pack.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +/** + * e2e:demo — the fresh-install smoke test for the guided first run. + * + * Packs THIS tree, installs the tarball into a throwaway project, and runs the guided `trustshell demo` from + * the INSTALLED package (a real packaged artifact under node_modules, never a source-tree import). This proves + * the five-minute first experience actually works for someone who just `npm install`ed the package: the shipped + * sample receipt (examples/trust-receipt.sample.json) is present, the enforcement journey runs, and the demo's + * own 15 step-checks all hold. Offline, no account, no network, no publish. + * + * Run via `npm run e2e:demo`. + */ +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, readdirSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const ROOT = resolve(import.meta.dirname, '../..'); +const dir = mkdtempSync(join(tmpdir(), 'ts-demo-pack-')); +const findings = []; +const note = (ok, what, detail = '') => { + findings.push({ ok, what }); + console.log(`${ok ? 'OK ' : 'GAP '} ${what}${detail ? ` — ${detail}` : ''}`); +}; + +try { + execFileSync('npm', ['run', 'sdk:build'], { cwd: ROOT, stdio: 'inherit', shell: true }); + execFileSync('npm', ['pack', '--pack-destination', dir], { cwd: ROOT, stdio: 'inherit', shell: true }); + const tgz = readdirSync(dir).find((f) => f.endsWith('.tgz')); + if (!tgz) throw new Error('npm pack produced no tarball'); + spawnSync('npm', ['init', '-y'], { cwd: dir, encoding: 'utf8', shell: true }); + const inst = spawnSync('npm', ['install', join(dir, tgz)], { cwd: dir, encoding: 'utf8', shell: true, timeout: 120_000 }); + note(inst.status === 0, 'install packed tarball', inst.stderr?.slice(0, 200)); + + const pkgRoot = join(dir, 'node_modules', '@hyperdag', 'trustshell'); + note(existsSync(join(pkgRoot, 'examples', 'trust-receipt.sample.json')), 'sample receipt ships in the package'); + const cli = join(pkgRoot, 'dist', 'cli', 'index.js'); + note(existsSync(cli), 'CLI entry ships in the package'); + + // The whole point: a freshly-installed user runs ONE command and sees the real enforcement + verify journey. + const demo = spawnSync(process.execPath, [cli, 'demo'], { cwd: dir, encoding: 'utf8', timeout: 120_000, windowsHide: true }); + const out = `${demo.stdout ?? ''}${demo.stderr ?? ''}`; + note(demo.status === 0, 'trustshell demo exits 0 from the installed package', `exit=${demo.status}`); + note(/ALLOWED/.test(out) && (out.match(/DENIED/g) ?? []).length >= 2, 'ALLOW + two DENY decisions shown'); + note(/chain INTACT/.test(out) && /chain BROKEN/.test(out), 'ledger INTACT then BROKEN (tamper caught)'); + // A fresh install has no `ethers` peer, so offline hash verification reads NOT_CHECKED (fail-closed) and the + // demo must say how to enable it. With `ethers` present it reads VERIFIED. Either is honest; a false VERIFIED + // without the peer, or a silent NOT_CHECKED with no remedy, is not. + const verifiedOotb = /Integrity\s+VERIFIED/.test(out); + const honestNotChecked = /Integrity\s+NOT_CHECKED/.test(out) && /npm install ethers/.test(out); + note(verifiedOotb || honestNotChecked, 'offline verify is honest', verifiedOotb ? 'integrity VERIFIED (ethers present)' : 'integrity NOT_CHECKED + ethers remedy shown (fresh install)'); + note(/honest seam/i.test(out), 'the honest seam is disclosed'); +} catch (e) { + note(false, 'e2e:demo crashed', e instanceof Error ? e.message : String(e)); +} finally { + rmSync(dir, { recursive: true, force: true }); +} + +const failed = findings.filter((f) => !f.ok); +console.log(`\n${failed.length ? 'FAIL' : 'PASS'}: demo fresh-install smoke — ${failed.length} gap(s)`); +process.exit(failed.length ? 1 : 0); diff --git a/tests/e2e/demo-walk.mjs b/tests/e2e/demo-walk.mjs new file mode 100644 index 00000000..7758dd32 --- /dev/null +++ b/tests/e2e/demo-walk.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +/** + * test:demo — run the guided `trustshell demo` against the built dist and assert it stays honest. + * + * `trustshell demo` self-checks every step and exits non-zero on any failure, so the load-bearing assertion + * is its exit code. On top of that we pin the things a refactor must not silently drop: the four verdict words + * are taught, and the "honest seam" disclosure (local enforcement plane vs engine six-claim plane) is printed. + * Offline, no network, no account — the demo runs entirely in a throwaway home it deletes. + * + * Run via `npm run test:demo` (which builds dist first). + */ +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const REPO = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); +const CLI = join(REPO, 'dist', 'cli', 'index.js'); + +let failures = 0; +const ok = (m) => console.log(`OK ${m}`); +const fail = (m) => { console.error(`FAIL ${m}`); failures += 1; }; +const assert = (c, m) => (c ? ok(m) : fail(m)); + +const r = spawnSync(process.execPath, [CLI, 'demo'], { encoding: 'utf8', timeout: 120_000, windowsHide: true }); +const out = `${r.stdout ?? ''}${r.stderr ?? ''}`; + +assert(r.status === 0, `demo exits 0 (its own 15 step-checks all held) — got ${r.status}`); +assert(/demo OK/.test(out), 'demo prints its OK summary'); +// The enforcement journey actually happened (not just narrated): +assert(/ALLOWED/.test(out), 'an ALLOWED run is shown'); +assert((out.match(/DENIED/g) ?? []).length >= 2, 'at least two DENIED runs are shown (capability + spend cap)'); +assert(/chain INTACT/.test(out) && /chain BROKEN/.test(out), 'the ledger reads INTACT, then BROKEN after a tamper'); +// The four verdict words are taught, and the honesty disclosure is not dropped: +for (const w of ['VERIFIED', 'NOT_CHECKED', 'FAILED', 'NOT_APPLICABLE']) assert(out.includes(w), `teaches ${w}`); +assert(/honest seam/i.test(out), 'the honest seam (two evidence planes, not yet joined) is disclosed'); + +console.log(`\n${failures === 0 ? 'PASS' : 'FAIL'}: demo walk — ${failures} failure(s)`); +process.exit(failures === 0 ? 0 : 1); From dca4a1f458b8d2ef2a38627275f85bbf7b732f3a Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Fri, 6 Nov 2026 22:10:09 -0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(sdk):=20offline=20receipt=20verificatio?= =?UTF-8?q?n=20works=20out=20of=20the=20box=20=E2=80=94=20keccak=20via=20@?= =?UTF-8?q?noble/hashes,=20not=20the=20ethers=20peer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The receipt hash (hashKernelArgs, kernelReceiptHash) was keccak256 from the OPTIONAL `ethers` peer, which `npm install @hyperdag/trustshell` does not pull. So on a bare install, offline `verify-receipt` could not recompute the hash and integrity read NOT_CHECKED (fail-closed), and a cold `kernelDecide` refused with `ethers_missing`. keccak256 now comes from @noble/hashes — an audited, zero-dependency package added as a real dependency. Hashing, decide, and offline verification all work with no `ethers`: the guided `trustshell demo` and the fresh-install smoke test now read integrity VERIFIED out of the box. `ethers` stays an OPTIONAL peer, needed only for on-chain reads and x402 payment signing (erc8004, verify-signer, x402-prepare) — never for hashing. Proven bit-exact against the golden receipt_hash with ethers absent. The no-ethers test is rewritten to the new reality; the demo and its e2e smoke test assert VERIFIED rather than tolerating NOT_CHECKED; the stranger e2e excludes `demo` from its in-process enumeration (demo drives the built CLI, covered by test:demo and e2e:demo); docs corrected (api-reference gains the demo command, cold-install sim tombstoned, STATUS row updated). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 3 ++ STATUS.md | 2 +- dist/cli/demo.js | 49 +++++------------- dist/lib/kernel-client.d.ts | 4 +- dist/lib/kernel-client.js | 32 ++++++------ docs/api-reference.md | 21 ++++++++ docs/stranger-sims/cold-install.md | 2 + package-lock.json | 1 + package.json | 3 +- src/cli/demo.ts | 52 ++++++------------- src/lib/kernel-client.ts | 31 +++++------ tests/e2e-stranger.test.ts | 7 ++- tests/e2e/demo-pack.mjs | 9 ++-- tests/kernel-client-no-ethers.test.ts | 74 ++++++++++++++------------- 14 files changed, 142 insertions(+), 148 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d23098..5e783b40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to the `@hyperdag/trustshell` package. `package.json` on main is `1.9.0-dev.0`: main is ahead of the published 1.8.0, and nothing below is in any npm release. +- **Offline receipt verification works on a bare `npm install` — no optional peer.** keccak256 for the receipt hash now comes from `@noble/hashes` (an audited, zero-dependency package added as a real `dependency`), not from the optional `ethers` peer. So `trustshell verify-receipt` (and `@hyperdag/trustshell/verify`) recompute a receipt's `integrity` and read `VERIFIED` on a fresh install, where before, with `ethers` absent, integrity read `NOT_CHECKED` (fail-closed). Argument hashing and `kernelDecide` no longer need `ethers` either, so a cold install no longer refuses a decide with `ethers_missing`. **`ethers` stays an OPTIONAL peer, now needed only for on-chain reads and x402 payment signing** (`erc8004`, `verify-signer`, `x402-prepare`) — never for hashing. Bit-exact against the golden `receipt_hash` with `ethers` absent. src/lib/kernel-client.ts; tests/kernel-client-no-ethers.test.ts (rewritten to the new reality), tests/trustlattice-conformance.mjs (18 vectors), tests/verify-subpath-import.mjs. +- **`trustshell demo`: a guided ~five-minute first run, offline.** No account, no keys, no network. In a throwaway policy home it deletes, it writes an owner policy, allows a pinned task and a capped spend, signs, runs an ALLOWED command, shows two DENIED ones (capability + spend cap), tampers the hash-chained ledger so `guard show` reads `INTACT` then `BROKEN`, and verifies a shipped signed receipt OFFLINE (integrity `VERIFIED`, authorization `NOT_CHECKED`) plus a tampered copy (`FAILED`). It self-checks every step and exits non-zero on any failure. Teaches the four verdict words and discloses the still-open seam between the local enforcement plane and the engine six-claim plane. src/cli/demo.ts; `npm run test:demo`, and the fresh-install smoke test `npm run e2e:demo` (packs, installs the tarball, runs the demo from the installed package). + - **Fix: `guardedX402Payment` decides every refusal before it writes the pre-pay receipt.** The order was receipt, then `buildX402Payment`, so a payment the Guard denied (#665), the cap refused, or a bad address, key or nonce stopped left a receipt for a payment that never happened. Now it is decide (`prepareX402Payment`: amount, cap, address, key, typed data, then the Guard's spend decision), receipt, sign. One Guard decision and one count per payment, as before. **Behaviour change:** if the receipt writer then throws, nothing is signed but the amount stays counted in the Guard ledger (the ledger is append-only and has no release record), so it can read higher than what was signed, never lower; before, a receipt failure counted nothing. `buildX402Payment` is unchanged for direct callers. `prepareX402Payment` (src/lib/x402-prepare.ts) is internal: the package root does not export it, and the `sign()` it returns is single-use (a second call throws), so one Guard decision buys one signature. tests/guard-before-prepay-receipt.test.ts, tests/x402-prepare-internal.test.ts. - **Guard + Carry, experimental (#593).** `trustshell guard` and `trustshell run` add a local owner policy enforced only on the paths they mediate. `trustshell bundle` adds a signed (not encrypted) bundle that carries a profile, a policy and selected notes to another host. These are local guardrails for cooperative agents, not a tamper-resistant boundary: anyone who can run the CLI as the owner can change the policy, and OS commands are not mediated (MCP tool calls are, since IS-P0-5 below). - **Guard now decides every MCP tool call (IS-P0-5, containment row C3).** `trustshell-mcp` read no policy, so an agent acting through MCP did what the CLI gate refused. Now each tool call is decided by the CLI's own decision and recorded in the same `guard-receipts.jsonl` (origin `Mcp`) before the tool runs, from the policy in the server's `TRUSTSHELL_HOME`, re-read per call (so `guard kill` stops the next call without a restart). Each tool maps to the CLI command it mirrors (`MCP_GUARD_TOOLS` in `src/mcp/index.ts`: `recall` is `tool:recall`, `get_repid` is `tool:repid`, `inspect_run` and `check_claim` are `tool:check`). A policy that does not parse, is killed or expired, or cannot be read refuses; no policy at all changes nothing, as at the CLI. **Behaviour change:** an MCP user with a restrictive policy is now refused where they were not; under the policy `guard init` writes, only the local tools (ground, learn, quarantine, recall, remember) run over MCP. tests/mcp-guard.test.ts. diff --git a/STATUS.md b/STATUS.md index 96807730..e92ff423 100644 --- a/STATUS.md +++ b/STATUS.md @@ -25,7 +25,7 @@ Outcomes are VERIFIED / NOT_CHECKED / FAILED, each with the command that produce | 6 | Stale committed `dist/` | VERIFIED | `dist/` rebuilt and committed; `tests/sdk-dist-fresh.test.ts` fails if it ever differs from `tsc` output (negative control run: fails on a stale or missing file) | | 7 | Coverage where there was none: `depositStake`, the faucet, the passport guard | VERIFIED | `tests/deposit-stake.test.ts`, `tests/faucet.test.ts`, `tests/erc8004-display.test.ts` | | + | Practice stake only after the agent is bound | VERIFIED (stubbed engine) | e2e: unbound = button held; unreadable bind record = held ("Not checked is not bound"); bound = one deposit sent with no tx hash and no signature | -| + | A cold install without `ethers` refuses in the open | VERIFIED | `tests/kernel-client-no-ethers.test.ts`; the sim shows the refusal now says `npm i ethers@^6` (it said only `MissingDependencyError`) | +| + | A cold install without `ethers` hashes, decides, and verifies receipts offline | VERIFIED | `tests/kernel-client-no-ethers.test.ts`; keccak256 is bundled (`@noble/hashes`), so integrity reads VERIFIED on a bare `npm install`. `ethers` stays optional, for on-chain reads and payment signing only | ## Facts that were checked, and how diff --git a/dist/cli/demo.js b/dist/cli/demo.js index b486b15f..2fb90524 100644 --- a/dist/cli/demo.js +++ b/dist/cli/demo.js @@ -54,7 +54,6 @@ function runDemo(io, env = process.env) { const verifyEnv = { ...env, TRUSTSHELL_HOME: verifyHome }; delete verifyEnv.TRUSTSHELL_REQUIRE_POLICY; const fails = []; - let ethersNote = false; // set when offline hashing is unavailable (the optional `ethers` peer is not installed) let n = 0; // Run one real command, show a short slice of what it printed, and check the one thing that must hold. const step = (title, args, want, note = '', stepEnv = sub) => { @@ -104,34 +103,21 @@ function runDemo(io, env = process.env) { step('Tamper one byte in the ledger, then show: the chain catches it (BROKEN)', ['guard', 'show'], { exit: 1, has: /chain BROKEN/ }, 'a tamper-evident log: no amount is allowed while the chain is BROKEN'); (0, node_fs_1.writeFileSync)(ledgerPath, original); // restore; the demo leaves nothing broken behind // The engine six-claim plane: an independently-verifiable signed receipt (shipped sample). Offline - // verification needs no owner policy, so these run as a relying party (a policy-free home). We branch on - // what the verifier ACTUALLY reports (ground truth), not a guess about whether the keccak peer is present: - // offline hashing needs the optional `ethers` peer, and whether it loads is the verifier's call, not ours. + // verification needs no owner policy, so these run as a relying party (a policy-free home). It needs no + // optional peer either — keccak256 is bundled (@noble/hashes), so a bare `npm install` verifies out of + // the box. Integrity here MUST read VERIFIED; anything else is a real regression, not a soft degradation. const sample = JSON.parse((0, node_fs_1.readFileSync)(SAMPLE_RECEIPT, 'utf8')); - const vr = step('Verify a signed receipt OFFLINE under a pinned key', ['verify-receipt', SAMPLE_RECEIPT, '--root', sample.public_key], {}, '', verifyEnv); - if (vr.status === 0 && /Integrity\s+VERIFIED/.test(vr.out)) { - io.out(' → integrity VERIFIED here by cryptography (hash + pinned signature); authorization reads'); - io.out(' NOT_CHECKED from a file — the six-claim envelope is not covered by the receipt signature,'); - io.out(' so it is READ, not re-derived. NOT_CHECKED is never a pass.'); - const badPath = (0, node_path_1.join)(home, 'tampered-receipt.json'); - // Change a byte INSIDE the receipt the verifier hashes (not the ignored decide_response preview), so the - // recomputed hash no longer matches header.receipt_hash. - const bad = JSON.parse((0, node_fs_1.readFileSync)(SAMPLE_RECEIPT, 'utf8')); - bad.receipt_response.receipt.subject.agent.agent_name = 'tampered-agent'; - (0, node_fs_1.writeFileSync)(badPath, JSON.stringify(bad)); - step('Verify a tampered copy: integrity FAILED (the hash no longer matches)', ['verify-receipt', badPath, '--root', sample.public_key], { exit: 1, has: /FAILED/ }, '', verifyEnv); - } - else if (vr.status === 2 && /ethers/i.test(vr.out)) { - // Honest fresh-install reality: the keccak peer did not load, so the hash cannot be recomputed. The - // verifier reads NOT_CHECKED (fail-closed), never a false VERIFIED — and the demo says how to enable it. - ethersNote = true; - io.out(' → integrity NOT_CHECKED: offline hashing needs the optional `ethers` peer, which did not load'); - io.out(' here. Run `npm install ethers`, then this reads VERIFIED. NOT_CHECKED is fail-closed —'); - io.out(' never a false pass.'); - } - else { - fails.push(`step ${n} (verify-receipt): unexpected result — exit ${vr.status}, neither VERIFIED nor an ethers-missing NOT_CHECKED`); - } + step('Verify a signed receipt OFFLINE under a pinned key', ['verify-receipt', SAMPLE_RECEIPT, '--root', sample.public_key], { exit: 0, has: /Integrity\s+VERIFIED/ }, '', verifyEnv); + io.out(' → integrity VERIFIED here by cryptography (hash + pinned signature); authorization reads'); + io.out(' NOT_CHECKED from a file — the six-claim envelope is not covered by the receipt signature,'); + io.out(' so it is READ, not re-derived. NOT_CHECKED is never a pass.'); + const badPath = (0, node_path_1.join)(home, 'tampered-receipt.json'); + // Change a byte INSIDE the receipt the verifier hashes (not the ignored decide_response preview), so the + // recomputed hash no longer matches header.receipt_hash. + const bad = JSON.parse((0, node_fs_1.readFileSync)(SAMPLE_RECEIPT, 'utf8')); + bad.receipt_response.receipt.subject.agent.agent_name = 'tampered-agent'; + (0, node_fs_1.writeFileSync)(badPath, JSON.stringify(bad)); + step('Verify a tampered copy: integrity FAILED (the hash no longer matches)', ['verify-receipt', badPath, '--root', sample.public_key], { exit: 1, has: /FAILED/ }, '', verifyEnv); } finally { try { @@ -146,13 +132,6 @@ function runDemo(io, env = process.env) { io.out(' • FAILED a check that ran and did not hold (a tampered receipt, a BROKEN ledger)'); io.out(' • NOT_APPLICABLE a claim this call never made (e.g. content correctness on a payment)'); io.out(''); - if (ethersNote) { - io.out('Offline hash verification is OFF on this install:'); - io.out(' `trustshell verify-receipt` recomputes the receipt hash with keccak256 from the optional `ethers`'); - io.out(' peer, which `npm install @hyperdag/trustshell` does not pull. Integrity read NOT_CHECKED above —'); - io.out(' fail-closed, never a false pass. Enable real offline verification with: npm install ethers'); - io.out(''); - } io.out('The honest seam (not yet joined):'); io.out(' Steps 1–7 are the LOCAL enforcement plane: a hash-chained guard-receipts.jsonl, checked by `guard show`.'); io.out(' Steps 8–9 verify an ENGINE kernel-receipt/2 (the six-claim plane), checked by `trustshell verify-receipt`.'); diff --git a/dist/lib/kernel-client.d.ts b/dist/lib/kernel-client.d.ts index b7cd2eb0..759a13ce 100644 --- a/dist/lib/kernel-client.d.ts +++ b/dist/lib/kernel-client.d.ts @@ -189,9 +189,9 @@ export declare class KernelRefusedError extends TrustShellError { } /** Object keys sorted at every depth, arrays in order, no whitespace. Identical to the engine's. */ export declare function kernelCanonicalJson(value: unknown): string; -/** keccak256 of the canonical JSON of the arguments. Needs the optional `ethers` peer. */ +/** keccak256 of the canonical JSON of the arguments. Hashing happens locally and needs no optional 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`. Needs no optional peer. */ 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..2447e7ee 100644 --- a/dist/lib/kernel-client.js +++ b/dist/lib/kernel-client.js @@ -23,7 +23,7 @@ exports.kernelReceipt = kernelReceipt; * - a body that is not JSON, or has an unexpected shape → REFUSE * - verdict DENY or NOT_CHECKED → REFUSE (and the kernel's reasons come back) * - verdict ALLOW that does not agree with itself, or is not BOUND TO THIS REQUEST → REFUSE - * - the arguments could not be hashed (ethers missing) → REFUSE + * - the arguments could not be hashed → REFUSE * * "Bound to this request" matters: an ALLOW for a different args_hash, tool, agent or amount is * somebody else's ALLOW. The answer carries a receipt preview; we check it names exactly what we @@ -56,9 +56,16 @@ 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 optional_ethers_1 = require("./optional-ethers"); +const sha3_1 = require("@noble/hashes/sha3"); +const utils_1 = require("@noble/hashes/utils"); const trustshell_1 = require("./trustshell"); const api_url_1 = require("./api-url"); +/** + * keccak256 of a UTF-8 string → `0x` + 64 lower-case hex. The same hash the engine computes (a shared + * test vector pins the two). Deliberately dependency-light: an audited zero-dep keccak, NOT the heavy + * optional `ethers` peer, so offline receipt verification works on a bare `npm install @hyperdag/trustshell`. + */ +const keccak256Utf8 = (s) => `0x${(0, utils_1.bytesToHex)((0, sha3_1.keccak_256)(new TextEncoder().encode(s)))}`; exports.KERNEL_RECEIPT_SCHEMA = 'kernel-receipt/1'; /** The signed receipt: the same body plus header.signer and header.signature. */ exports.KERNEL_RECEIPT_SCHEMA_SIGNED = 'kernel-receipt/2'; @@ -93,16 +100,14 @@ function kernelCanonicalJson(value) { .join(',') + '}'); } -/** keccak256 of the canonical JSON of the arguments. Needs the optional `ethers` peer. */ +/** keccak256 of the canonical JSON of the arguments. Hashing happens locally and needs no optional peer. */ async function hashKernelArgs(args) { - const { keccak256, toUtf8Bytes } = await (0, optional_ethers_1.loadEthers)('Hashing kernel arguments'); - return keccak256(toUtf8Bytes(kernelCanonicalJson(args))); + return keccak256Utf8(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`. Needs no optional peer. */ 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 keccak256Utf8(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) @@ -325,13 +330,8 @@ async function kernelDecide(input, opts = {}) { return { allowed: true, verdict: 'ALLOW', source: 'kernel', args_hash: argsHash, receipt: receipt, reasons: [], authenticity }; } catch (e) { - // A missing optional ethers is a refusal that SAYS WHAT TO RUN. It used to read only - // "(MissingDependencyError)", which refused correctly and left a stranger on a cold install with - // no way to know why (MEASURED 2026-10-12, a fresh `npm i` of this package). The message is a - // fixed string from optional-ethers.ts, so quoting it leaks nothing. - if (e instanceof optional_ethers_1.MissingDependencyError) - return client('ethers_missing', e.message); - // Anything else we did not anticipate is a refusal too. + // Hashing is local and dependency-light, and the kernel is reached with the global fetch, so the + // decide path needs no optional peer. Anything we did not anticipate is still a refusal. const name = e?.name ?? 'Error'; return client('client_error', `the kernel client failed (${name}); refusing`); } @@ -443,8 +443,6 @@ async function kernelReceipt(receiptId, opts = {}) { return { ok: true, receipt_id: receiptId, receipt, authenticity, six_claims: note ? null : env, six_claims_note: note }; } catch (e) { - if (e instanceof optional_ethers_1.MissingDependencyError) - return fail('ethers_missing', e.message); const name = e?.name ?? 'Error'; return fail('client_error', `reading the receipt failed (${name})`); } diff --git a/docs/api-reference.md b/docs/api-reference.md index dda7a83e..aea4b263 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -172,6 +172,27 @@ file; `init` writes `.trustshell/profile.md`, which only `report` reads. Other commands keep their own codes, listed under each one below. +### `trustshell demo` + +A guided ~five-minute first run, **offline — no account, no keys, no network**. It works entirely in a +throwaway policy home it creates and deletes, so it changes nothing on your machine. In order it: writes +an owner policy (every limit `0`, local commands only); allows one `node` task pinned by its sha256 and a +`spend` class capped per-transaction; signs the policy; runs the ALLOWED task (which writes an observable +file); shows two DENIED runs (a tool the policy never allowed, and an allowed tool over its spend cap); +tampers one byte in the hash-chained decision ledger so `guard show` reads `INTACT` then `BROKEN`; and +verifies a shipped signed receipt OFFLINE — integrity `VERIFIED` by hash and pinned signature, +authorization `NOT_CHECKED` because the six-claim envelope is not covered by the receipt signature — plus +a tampered copy (`FAILED`). Offline verification needs no optional peer. It self-checks every step. + +| Outcome | Exit | +|---|---| +| every step held | `0` | +| a step did not hold (the summary lists which) | non-zero | + +```console +$ trustshell demo +``` + ### `trustshell verify ""` diff --git a/docs/stranger-sims/cold-install.md b/docs/stranger-sims/cold-install.md index 573bb834..f6eb8e84 100644 --- a/docs/stranger-sims/cold-install.md +++ b/docs/stranger-sims/cold-install.md @@ -7,6 +7,8 @@ Outcomes are VERIFIED / NOT_CHECKED / FAILED. Every block below is pasted from a A stranger with an empty folder. `ethers` is an **optional** peer: `npm i` does not install it, so a leg that needs it must fail in the open. +> **Superseded in part, 2026-11-06.** §3 below is a real 1.6.0 run and stays as the historical record, but its behaviour no longer holds: keccak256 is now a bundled dependency (`@noble/hashes`), not the `ethers` peer, so **hashing arguments, deciding, and verifying a receipt offline all work with no `ethers`** — the `ethers_missing` refusal in §3 is gone. `ethers` remains optional and is needed only for on-chain reads and payment signing (x402). See `tests/kernel-client-no-ethers.test.ts`. + ## 1. The published package, LIVE (npm registry, then the live engine) ``` diff --git a/package-lock.json b/package-lock.json index d901dfe4..7523997b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@hyperdag/proof-verifier": "^0.2.0", "@modelcontextprotocol/sdk": "^1.29.0", + "@noble/hashes": "^1.3.2", "zod": "^3.25.76" }, "bin": { diff --git a/package.json b/package.json index f3411227..ac8f47d2 100644 --- a/package.json +++ b/package.json @@ -113,10 +113,11 @@ "test:proof-pack-mutants": "node tests/e2e/proof-pack-mutants.mjs", "search:index": "zg index . --embedding local/potion-retrieval-32m --ignore-file .zvec-grep-ignore" }, - "comment:deps": "DEPENDENCY DIET 2026-06-15: the SDK (src/lib/) imports NONE of next/react/supabase/lucide/idb — they are Next.js-APP baggage that SDK consumers were inheriting (puppeteer/sharp/googleapis arrive transitively via next, ~1898 pkgs / ~7min install). The published package ships only dist/ (files[]), so the only true SDK runtime dep is @hyperdag/proof-verifier (dynamically imported, fails gracefully). App-runtime deps moved to devDependencies → they are still installed for `next build`/dev (full install), but `npm install @hyperdag/trustshell` no longer pulls the Next.js tree. NOTE: this confirms the SDK should ultimately be its OWN package (also resolves the merge-base caveat) — flagged for Sean.", + "comment:deps": "DEPENDENCY DIET 2026-06-15: the SDK (src/lib/) imports NONE of next/react/supabase/lucide/idb — they are Next.js-APP baggage that SDK consumers were inheriting (puppeteer/sharp/googleapis arrive transitively via next, ~1898 pkgs / ~7min install). The published package ships only dist/ (files[]), so the SDK's runtime deps are @hyperdag/proof-verifier (dynamically imported, fails gracefully) and @noble/hashes (an audited, zero-dependency keccak256 so offline receipt verification works on a bare `npm install` — ethers stays an OPTIONAL peer used only for on-chain reads and payment signing, never for hashing). App-runtime deps moved to devDependencies → they are still installed for `next build`/dev (full install), but `npm install @hyperdag/trustshell` no longer pulls the Next.js tree. NOTE: this confirms the SDK should ultimately be its OWN package (also resolves the merge-base caveat) — flagged for Sean.", "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/cli/demo.ts b/src/cli/demo.ts index 0cf05f2b..ae689b3f 100644 --- a/src/cli/demo.ts +++ b/src/cli/demo.ts @@ -61,7 +61,6 @@ export function runDemo(io: IO, env: NodeJS.ProcessEnv = process.env): number { delete verifyEnv.TRUSTSHELL_REQUIRE_POLICY; const fails: string[] = []; - let ethersNote = false; // set when offline hashing is unavailable (the optional `ethers` peer is not installed) let n = 0; // Run one real command, show a short slice of what it printed, and check the one thing that must hold. const step = (title: string, args: string[], want: { exit?: number; has?: RegExp }, note = '', stepEnv: NodeJS.ProcessEnv = sub): StepResult => { @@ -132,34 +131,24 @@ export function runDemo(io: IO, env: NodeJS.ProcessEnv = process.env): number { writeFileSync(ledgerPath, original); // restore; the demo leaves nothing broken behind // The engine six-claim plane: an independently-verifiable signed receipt (shipped sample). Offline - // verification needs no owner policy, so these run as a relying party (a policy-free home). We branch on - // what the verifier ACTUALLY reports (ground truth), not a guess about whether the keccak peer is present: - // offline hashing needs the optional `ethers` peer, and whether it loads is the verifier's call, not ours. + // verification needs no owner policy, so these run as a relying party (a policy-free home). It needs no + // optional peer either — keccak256 is bundled (@noble/hashes), so a bare `npm install` verifies out of + // the box. Integrity here MUST read VERIFIED; anything else is a real regression, not a soft degradation. const sample = JSON.parse(readFileSync(SAMPLE_RECEIPT, 'utf8')) as { public_key: string }; - const vr = step('Verify a signed receipt OFFLINE under a pinned key', - ['verify-receipt', SAMPLE_RECEIPT, '--root', sample.public_key], {}, '', verifyEnv); - if (vr.status === 0 && /Integrity\s+VERIFIED/.test(vr.out)) { - io.out(' → integrity VERIFIED here by cryptography (hash + pinned signature); authorization reads'); - io.out(' NOT_CHECKED from a file — the six-claim envelope is not covered by the receipt signature,'); - io.out(' so it is READ, not re-derived. NOT_CHECKED is never a pass.'); - const badPath = join(home, 'tampered-receipt.json'); - // Change a byte INSIDE the receipt the verifier hashes (not the ignored decide_response preview), so the - // recomputed hash no longer matches header.receipt_hash. - const bad = JSON.parse(readFileSync(SAMPLE_RECEIPT, 'utf8')); - bad.receipt_response.receipt.subject.agent.agent_name = 'tampered-agent'; - writeFileSync(badPath, JSON.stringify(bad)); - step('Verify a tampered copy: integrity FAILED (the hash no longer matches)', - ['verify-receipt', badPath, '--root', sample.public_key], { exit: 1, has: /FAILED/ }, '', verifyEnv); - } else if (vr.status === 2 && /ethers/i.test(vr.out)) { - // Honest fresh-install reality: the keccak peer did not load, so the hash cannot be recomputed. The - // verifier reads NOT_CHECKED (fail-closed), never a false VERIFIED — and the demo says how to enable it. - ethersNote = true; - io.out(' → integrity NOT_CHECKED: offline hashing needs the optional `ethers` peer, which did not load'); - io.out(' here. Run `npm install ethers`, then this reads VERIFIED. NOT_CHECKED is fail-closed —'); - io.out(' never a false pass.'); - } else { - fails.push(`step ${n} (verify-receipt): unexpected result — exit ${vr.status}, neither VERIFIED nor an ethers-missing NOT_CHECKED`); - } + step('Verify a signed receipt OFFLINE under a pinned key', + ['verify-receipt', SAMPLE_RECEIPT, '--root', sample.public_key], + { exit: 0, has: /Integrity\s+VERIFIED/ }, '', verifyEnv); + io.out(' → integrity VERIFIED here by cryptography (hash + pinned signature); authorization reads'); + io.out(' NOT_CHECKED from a file — the six-claim envelope is not covered by the receipt signature,'); + io.out(' so it is READ, not re-derived. NOT_CHECKED is never a pass.'); + const badPath = join(home, 'tampered-receipt.json'); + // Change a byte INSIDE the receipt the verifier hashes (not the ignored decide_response preview), so the + // recomputed hash no longer matches header.receipt_hash. + const bad = JSON.parse(readFileSync(SAMPLE_RECEIPT, 'utf8')); + bad.receipt_response.receipt.subject.agent.agent_name = 'tampered-agent'; + writeFileSync(badPath, JSON.stringify(bad)); + step('Verify a tampered copy: integrity FAILED (the hash no longer matches)', + ['verify-receipt', badPath, '--root', sample.public_key], { exit: 1, has: /FAILED/ }, '', verifyEnv); } finally { try { rmSync(home, { recursive: true, force: true }); } catch { /* best effort */ } } @@ -171,13 +160,6 @@ export function runDemo(io: IO, env: NodeJS.ProcessEnv = process.env): number { io.out(' • FAILED a check that ran and did not hold (a tampered receipt, a BROKEN ledger)'); io.out(' • NOT_APPLICABLE a claim this call never made (e.g. content correctness on a payment)'); io.out(''); - if (ethersNote) { - io.out('Offline hash verification is OFF on this install:'); - io.out(' `trustshell verify-receipt` recomputes the receipt hash with keccak256 from the optional `ethers`'); - io.out(' peer, which `npm install @hyperdag/trustshell` does not pull. Integrity read NOT_CHECKED above —'); - io.out(' fail-closed, never a false pass. Enable real offline verification with: npm install ethers'); - io.out(''); - } io.out('The honest seam (not yet joined):'); io.out(' Steps 1–7 are the LOCAL enforcement plane: a hash-chained guard-receipts.jsonl, checked by `guard show`.'); io.out(' Steps 8–9 verify an ENGINE kernel-receipt/2 (the six-claim plane), checked by `trustshell verify-receipt`.'); diff --git a/src/lib/kernel-client.ts b/src/lib/kernel-client.ts index 817420ec..4b6345d5 100644 --- a/src/lib/kernel-client.ts +++ b/src/lib/kernel-client.ts @@ -12,7 +12,7 @@ * - a body that is not JSON, or has an unexpected shape → REFUSE * - verdict DENY or NOT_CHECKED → REFUSE (and the kernel's reasons come back) * - verdict ALLOW that does not agree with itself, or is not BOUND TO THIS REQUEST → REFUSE - * - the arguments could not be hashed (ethers missing) → REFUSE + * - the arguments could not be hashed → REFUSE * * "Bound to this request" matters: an ALLOW for a different args_hash, tool, agent or amount is * somebody else's ALLOW. The answer carries a receipt preview; we check it names exactly what we @@ -45,10 +45,18 @@ * an unsigned receipt is then a downgrade, so pin only after the engine is signing. */ import { createHash, createPublicKey, verify } from 'node:crypto'; -import { loadEthers, MissingDependencyError } from './optional-ethers'; +import { keccak_256 } from '@noble/hashes/sha3'; +import { bytesToHex } from '@noble/hashes/utils'; import { TrustShellError } from './trustshell'; import { ApiUrlError, isHttps, resolveApiBase } from './api-url'; +/** + * keccak256 of a UTF-8 string → `0x` + 64 lower-case hex. The same hash the engine computes (a shared + * test vector pins the two). Deliberately dependency-light: an audited zero-dep keccak, NOT the heavy + * optional `ethers` peer, so offline receipt verification works on a bare `npm install @hyperdag/trustshell`. + */ +const keccak256Utf8 = (s: string): string => `0x${bytesToHex(keccak_256(new TextEncoder().encode(s)))}`; + export const KERNEL_RECEIPT_SCHEMA = 'kernel-receipt/1' as const; /** The signed receipt: the same body plus header.signer and header.signature. */ export const KERNEL_RECEIPT_SCHEMA_SIGNED = 'kernel-receipt/2' as const; @@ -190,17 +198,15 @@ export function kernelCanonicalJson(value: unknown): string { ); } -/** keccak256 of the canonical JSON of the arguments. Needs the optional `ethers` peer. */ +/** keccak256 of the canonical JSON of the arguments. Hashing happens locally and needs no optional peer. */ export async function hashKernelArgs(args: unknown): Promise { - const { keccak256, toUtf8Bytes } = await loadEthers('Hashing kernel arguments'); - return keccak256(toUtf8Bytes(kernelCanonicalJson(args))); + return keccak256Utf8(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`. Needs no optional peer. */ 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 keccak256Utf8(kernelCanonicalJson({ ...receipt, header })); } // ── authenticity (kernel-receipt/2) ───────────────────────────────────────────────────────────── @@ -411,12 +417,8 @@ export async function kernelDecide(input: KernelCallInput, opts: KernelClientOpt } return { allowed: true, verdict: 'ALLOW', source: 'kernel', args_hash: argsHash, receipt: receipt as KernelReceipt, reasons: [], authenticity }; } catch (e) { - // A missing optional ethers is a refusal that SAYS WHAT TO RUN. It used to read only - // "(MissingDependencyError)", which refused correctly and left a stranger on a cold install with - // no way to know why (MEASURED 2026-10-12, a fresh `npm i` of this package). The message is a - // fixed string from optional-ethers.ts, so quoting it leaks nothing. - if (e instanceof MissingDependencyError) return client('ethers_missing', e.message); - // Anything else we did not anticipate is a refusal too. + // Hashing is local and dependency-light, and the kernel is reached with the global fetch, so the + // decide path needs no optional peer. Anything we did not anticipate is still a refusal. const name = (e as { name?: string })?.name ?? 'Error'; return client('client_error', `the kernel client failed (${name}); refusing`); } @@ -546,7 +548,6 @@ export async function kernelReceipt(receiptId: string, opts: KernelClientOptions const note = sixClaimsFault(env, receipt); return { ok: true, receipt_id: receiptId, receipt, authenticity, six_claims: note ? null : (env as SixClaimStatement), six_claims_note: note }; } catch (e) { - if (e instanceof MissingDependencyError) return fail('ethers_missing', e.message); const name = (e as { name?: string })?.name ?? 'Error'; return fail('client_error', `reading the receipt failed (${name})`); } diff --git a/tests/e2e-stranger.test.ts b/tests/e2e-stranger.test.ts index 45d76501..540f56df 100644 --- a/tests/e2e-stranger.test.ts +++ b/tests/e2e-stranger.test.ts @@ -392,6 +392,11 @@ describe('stranger e2e on current main', () => { }); afterAll(() => { - expect([...ran].sort()).toEqual([...commandsOnMain()].sort()); + // `demo` orchestrates the BUILT CLI in subprocesses (it shells out to `dist/cli/index.js`), so it cannot + // run inside this in-process harness. It has its own coverage: `npm run test:demo` runs the guided walk + // against the built dist, and `npm run e2e:demo` packs the package and runs it from a fresh install. Every + // OTHER command must be exercised above. + const required = [...commandsOnMain()].filter((c) => c !== 'demo'); + expect([...ran].sort()).toEqual(required.sort()); }); }); diff --git a/tests/e2e/demo-pack.mjs b/tests/e2e/demo-pack.mjs index 5604e7f9..cc889d40 100644 --- a/tests/e2e/demo-pack.mjs +++ b/tests/e2e/demo-pack.mjs @@ -43,12 +43,9 @@ try { note(demo.status === 0, 'trustshell demo exits 0 from the installed package', `exit=${demo.status}`); note(/ALLOWED/.test(out) && (out.match(/DENIED/g) ?? []).length >= 2, 'ALLOW + two DENY decisions shown'); note(/chain INTACT/.test(out) && /chain BROKEN/.test(out), 'ledger INTACT then BROKEN (tamper caught)'); - // A fresh install has no `ethers` peer, so offline hash verification reads NOT_CHECKED (fail-closed) and the - // demo must say how to enable it. With `ethers` present it reads VERIFIED. Either is honest; a false VERIFIED - // without the peer, or a silent NOT_CHECKED with no remedy, is not. - const verifiedOotb = /Integrity\s+VERIFIED/.test(out); - const honestNotChecked = /Integrity\s+NOT_CHECKED/.test(out) && /npm install ethers/.test(out); - note(verifiedOotb || honestNotChecked, 'offline verify is honest', verifiedOotb ? 'integrity VERIFIED (ethers present)' : 'integrity NOT_CHECKED + ethers remedy shown (fresh install)'); + // A fresh install bundles keccak256 (@noble/hashes is a real dependency, not the optional `ethers` peer), + // so offline hash verification reads VERIFIED out of the box — no `npm install ethers`, no account, no network. + note(/Integrity\s+VERIFIED/.test(out), 'offline verify reads integrity VERIFIED out of the box (no ethers)'); note(/honest seam/i.test(out), 'the honest seam is disclosed'); } catch (e) { note(false, 'e2e:demo crashed', e instanceof Error ? e.message : String(e)); diff --git a/tests/kernel-client-no-ethers.test.ts b/tests/kernel-client-no-ethers.test.ts index 25d1c08b..c23d7134 100644 --- a/tests/kernel-client-no-ethers.test.ts +++ b/tests/kernel-client-no-ethers.test.ts @@ -1,23 +1,44 @@ /** - * A cold install has no `ethers` (it is an optional peer). The kernel client must then REFUSE, and the - * refusal must say what to run, in its own words, instead of naming an error class. Measured on a - * fresh `npm i` of this package on 2026-10-12: it refused correctly but said only - * "the kernel client failed (MissingDependencyError); refusing". + * A cold install has no `ethers`: it is an OPTIONAL peer, needed only for on-chain reads and payment + * signing — never for hashing. keccak256 now comes from @noble/hashes (audited, bundled with the SDK), + * so on a bare `npm i @hyperdag/trustshell` the client can hash arguments, recompute a receipt's hash, + * and verify a receipt offline. Before 2026-11-06 the hash path loaded ethers, so a cold install could + * do none of that and the decide path refused with `ethers_missing`. This pins the new reality: with + * ethers absent, hashing and the decide flow work, and nothing reports `ethers_missing`. */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const golden = JSON.parse(readFileSync(join(__dirname, 'fixtures', 'kernel-receipt-v2.golden.json'), 'utf8')); +const HEX32 = /^0x[0-9a-f]{64}$/; + afterEach(() => { jest.resetModules(); jest.dontMock('ethers'); }); -describe('kernelDecide on a machine without ethers', () => { - it('refuses, sends nothing, and says to run npm i ethers@^6', async () => { - jest.doMock('ethers', () => { - const e = new Error("Cannot find module 'ethers'") as Error & { code?: string }; - e.code = 'MODULE_NOT_FOUND'; - throw e; - }); +/** Make any attempt to load `ethers` fail, exactly as it would on a cold install. */ +const hideEthers = () => + jest.doMock('ethers', () => { + const e = new Error("Cannot find module 'ethers'") as Error & { code?: string }; + e.code = 'MODULE_NOT_FOUND'; + throw e; + }); + +describe('kernel-client on a machine without ethers', () => { + it('recomputes a signed receipt hash bit-for-bit (keccak is bundled, not from ethers)', async () => { + hideEthers(); + const { kernelReceiptHash } = await import('../src/lib/kernel-client'); + const receipt = golden.receipt_response.receipt; + expect(await kernelReceiptHash(receipt)).toBe(receipt.header.receipt_hash); + }); + + it('hashes args locally and reaches the kernel — no ethers_missing refusal', async () => { + hideEthers(); const { kernelDecide } = await import('../src/lib/kernel-client'); - const fetchImpl = jest.fn(); + const fetchImpl = jest.fn(async () => { + throw new Error('offline'); // the network is down; the point is the client GOT this far + }); const out = await kernelDecide( { agent: { agent_id: '00000000-0000-4000-8000-000000000001', agent_name: 'cold' }, @@ -31,28 +52,11 @@ describe('kernelDecide on a machine without ethers', () => { ); expect(out.allowed).toBe(false); if (out.allowed) return; - expect(out.verdict).toBe('NOT_CHECKED'); - expect(out.source).toBe('client'); - expect(out.reasons).toHaveLength(1); - expect(out.reasons[0]!.code).toBe('ethers_missing'); - expect(out.reasons[0]!.detail).toMatch(/npm i ethers@\^6/); - expect(out.http_status).toBeNull(); - // Nothing left the process: it never got as far as asking the kernel. - expect(fetchImpl).not.toHaveBeenCalled(); - }); - - it('requireKernelAllow throws with the same reason, so the refusal cannot be ignored', async () => { - jest.doMock('ethers', () => { - const e = new Error("Cannot find module 'ethers'") as Error & { code?: string }; - e.code = 'MODULE_NOT_FOUND'; - throw e; - }); - const { requireKernelAllow } = await import('../src/lib/kernel-client'); - await expect( - requireKernelAllow( - { agent: { agent_id: 'a', agent_name: 'a' }, tool: { name: 't', class: 'read' }, origin: 'Cli', args: { x: 1 } }, - { apiUrl: 'https://engine.test', fetch: jest.fn() as never }, - ), - ).rejects.toMatchObject({ code: 'KERNEL_REFUSED', message: expect.stringContaining('ethers_missing') }); + // It hashed the arguments without ethers (a real 0x+64 hex args_hash) and then actually tried the network. + expect(out.args_hash).toMatch(HEX32); + expect(fetchImpl).toHaveBeenCalledTimes(1); + // The refusal is about the network, NOT a missing peer. + expect(out.reasons.map((r) => r.code)).not.toContain('ethers_missing'); + expect(out.reasons[0]!.code).toBe('network_error'); }); }); From 9f7e0939fd8d8cd8182029c0b1a008988282e24b Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Fri, 6 Nov 2026 22:33:17 -0800 Subject: [PATCH 3/4] fix(sdk): import @noble lazily so a CLI built to a bare dir still starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dca4a1f4 moved keccak to @noble/hashes but imported it at the TOP of kernel-client.ts. index.ts imports kernel-client at startup, so the whole CLI then needed @noble merely to load. The self-build test harnesses (learn-hook, scripts/check-egress.cjs) compile the CLI to a temp dir with no reachable node_modules and run it there — so every subcommand, even non-hashing ones like `guard` and `inspect`, crashed at startup with MODULE_NOT_FOUND in CI (it only resolved locally because the temp dir walks up to a node_modules on this machine). That turned the CI `check` job red on tests/egress.test.ts and tests/learn-hook.test.ts. Resolve @noble on first hash instead of at module load (the hash functions are already async). A real `npm install` always has @noble, so OOTB offline verify-receipt is unchanged — the fresh-install smoke test still reads integrity VERIFIED. Loading the CLI no longer requires it. Verified: dist/lib/kernel-client.js now has no top-level require of @noble (only a deferred one inside keccak256Utf8); egress + learn-hook + kernel-client-no-ethers pass (13/13); e2e:demo fresh-install smoke 0 gaps. Co-Authored-By: Claude Opus 4.8 --- dist/lib/kernel-client.js | 56 +++++++++++++++++++++++++++++++++++---- src/lib/kernel-client.ts | 23 ++++++++++++---- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/dist/lib/kernel-client.js b/dist/lib/kernel-client.js index 2447e7ee..6a893946 100644 --- a/dist/lib/kernel-client.js +++ b/dist/lib/kernel-client.js @@ -1,4 +1,37 @@ "use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); Object.defineProperty(exports, "__esModule", { value: true }); exports.SIX_CLAIMS_PREDICATE_TYPE = exports.SIX_CLAIM_KEYS = exports.KernelRefusedError = exports.KERNEL_TRUSTED_SIGNERS_ENV = exports.KERNEL_RECEIPT_SIGNATURE_DOMAIN = exports.KERNEL_RECEIPT_SCHEMA_SIGNED = exports.KERNEL_RECEIPT_SCHEMA = void 0; exports.kernelCanonicalJson = kernelCanonicalJson; @@ -56,16 +89,29 @@ 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 trustshell_1 = require("./trustshell"); const api_url_1 = require("./api-url"); /** * keccak256 of a UTF-8 string → `0x` + 64 lower-case hex. The same hash the engine computes (a shared - * test vector pins the two). Deliberately dependency-light: an audited zero-dep keccak, NOT the heavy - * optional `ethers` peer, so offline receipt verification works on a bare `npm install @hyperdag/trustshell`. + * test vector pins the two). Uses an audited zero-dep keccak (@noble/hashes, a real dependency), NOT the + * heavy optional `ethers` peer, so offline receipt verification works on a bare `npm install @hyperdag/trustshell`. + * + * Imported LAZILY, and this matters: index.ts imports this module at startup, so a TOP-LEVEL require of + * @noble would make the whole CLI need @noble merely to start. A CLI compiled to a bare directory with no + * reachable node_modules (our self-build test harnesses — learn-hook, check-egress — build to a temp dir + * exactly this way) must still start and run its non-hashing commands. So we resolve @noble on first hash, + * not at load. A real `npm install` always has it; nothing about the OOTB offline-verify guarantee changes. */ -const keccak256Utf8 = (s) => `0x${(0, utils_1.bytesToHex)((0, sha3_1.keccak_256)(new TextEncoder().encode(s)))}`; +let _keccak256; +let _bytesToHex; +async function keccak256Utf8(s) { + if (!_keccak256 || !_bytesToHex) { + const [sha3, utils] = await Promise.all([Promise.resolve().then(() => __importStar(require('@noble/hashes/sha3'))), Promise.resolve().then(() => __importStar(require('@noble/hashes/utils')))]); + _keccak256 = sha3.keccak_256; + _bytesToHex = utils.bytesToHex; + } + return `0x${_bytesToHex(_keccak256(new TextEncoder().encode(s)))}`; +} exports.KERNEL_RECEIPT_SCHEMA = 'kernel-receipt/1'; /** The signed receipt: the same body plus header.signer and header.signature. */ exports.KERNEL_RECEIPT_SCHEMA_SIGNED = 'kernel-receipt/2'; diff --git a/src/lib/kernel-client.ts b/src/lib/kernel-client.ts index 4b6345d5..3de821ba 100644 --- a/src/lib/kernel-client.ts +++ b/src/lib/kernel-client.ts @@ -45,17 +45,30 @@ * 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 } from '@noble/hashes/utils'; import { TrustShellError } from './trustshell'; import { ApiUrlError, isHttps, resolveApiBase } from './api-url'; /** * keccak256 of a UTF-8 string → `0x` + 64 lower-case hex. The same hash the engine computes (a shared - * test vector pins the two). Deliberately dependency-light: an audited zero-dep keccak, NOT the heavy - * optional `ethers` peer, so offline receipt verification works on a bare `npm install @hyperdag/trustshell`. + * test vector pins the two). Uses an audited zero-dep keccak (@noble/hashes, a real dependency), NOT the + * heavy optional `ethers` peer, so offline receipt verification works on a bare `npm install @hyperdag/trustshell`. + * + * Imported LAZILY, and this matters: index.ts imports this module at startup, so a TOP-LEVEL require of + * @noble would make the whole CLI need @noble merely to start. A CLI compiled to a bare directory with no + * reachable node_modules (our self-build test harnesses — learn-hook, check-egress — build to a temp dir + * exactly this way) must still start and run its non-hashing commands. So we resolve @noble on first hash, + * not at load. A real `npm install` always has it; nothing about the OOTB offline-verify guarantee changes. */ -const keccak256Utf8 = (s: string): string => `0x${bytesToHex(keccak_256(new TextEncoder().encode(s)))}`; +let _keccak256: ((m: Uint8Array) => Uint8Array) | undefined; +let _bytesToHex: ((b: Uint8Array) => string) | undefined; +async function keccak256Utf8(s: string): Promise { + if (!_keccak256 || !_bytesToHex) { + const [sha3, utils] = await Promise.all([import('@noble/hashes/sha3'), import('@noble/hashes/utils')]); + _keccak256 = sha3.keccak_256; + _bytesToHex = utils.bytesToHex; + } + return `0x${_bytesToHex(_keccak256(new TextEncoder().encode(s)))}`; +} export const KERNEL_RECEIPT_SCHEMA = 'kernel-receipt/1' as const; /** The signed receipt: the same body plus header.signer and header.signature. */ From 2bf4baf25b72a2506eeb0564a7eaacbf51f9284d Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Sat, 7 Nov 2026 00:27:45 -0800 Subject: [PATCH 4/4] fix(guard): close the three Strix findings on #679 (CWE-184/693/807), with regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves all three current Strix review findings on PR #679. Each fix has a test that fails before it and passes after; no finding dismissed on a green CI alone. A. Executable-identity bypass (MEDIUM, CWE-184) — src/cli/guard.ts `run` derived the executable from basename(command[0]) then spawned command[0] verbatim, so `run --tool node -- /tmp/attacker/node pinned.js` matched tool:node, held the script pin, and ran the attacker's binary (which ignores the pinned script). `run` now names an executable only by its BARE name, resolved on PATH; a path- or drive-qualified command[0] is refused (new pure helper executableIdentity). The one existing test that encoded the old "same file by absolute path = same pin" behaviour is updated to the safe contract. Regression: tests/run-executable-identity.test.ts. ponytail: an off-PATH binary now needs its tool on PATH; a per-tool executable pin (path+sha256, like scripts) is the rung to add when a user needs one. The residual PATH/symlink-substitution vector is an owner-level attacker, out of the guard's model. B. Demo policy bypass (LOW, CWE-693) — src/cli/guard.ts `demo` was in NEVER_GUARDED, so a deny-all policy (or TRUSTSHELL_REQUIRE_POLICY with no policy) still let it spawn an interpreter, unaudited. Removed from the exempt set: demo is weighed as tool:demo, refused under fail-closed settings, still allowed on a no-policy first run (warn-and-allow). Regression: tests/demo-gated.test.ts. C. Self-pinning trust root (LOW, CWE-807) — src/cli/demo.ts The offline-verify step read the trusted root out of the receipt it verified (sample.public_key), making the check tautological while narrating "pinned signature". The root is now a constant pinned in the demo source, asserted to equal the shipped sample's signer, and passed as --root to both verify-receipt calls; narration corrected to say the root is pinned in the demo, not read from the receipt. dist rebuilt (sdk:build); demo walk green (npm run test:demo). Co-Authored-By: Claude Opus 4.8 --- dist/cli/demo.js | 18 +++++- dist/cli/guard.d.ts | 14 +++++ dist/cli/guard.js | 37 ++++++++++-- src/cli/demo.ts | 18 +++++- src/cli/guard.ts | 39 ++++++++++-- tests/demo-gated.test.ts | 46 +++++++++++++++ tests/guard-interpreter-pins.test.ts | 9 ++- tests/run-executable-identity.test.ts | 85 +++++++++++++++++++++++++++ 8 files changed, 247 insertions(+), 19 deletions(-) create mode 100644 tests/demo-gated.test.ts create mode 100644 tests/run-executable-identity.test.ts diff --git a/dist/cli/demo.js b/dist/cli/demo.js index 2fb90524..a11545fb 100644 --- a/dist/cli/demo.js +++ b/dist/cli/demo.js @@ -31,6 +31,12 @@ const node_path_1 = require("node:path"); const CLI = (0, node_path_1.join)(__dirname, 'index.js'); /** The shipped sample engine receipt (package-root/examples), from dist/cli -> ../../examples. */ const SAMPLE_RECEIPT = (0, node_path_1.join)(__dirname, '..', '..', 'examples', 'trust-receipt.sample.json'); +/** + * The trusted root is pinned HERE, out of band — never read out of the receipt being verified (Strix CWE-807, #679): + * a receipt that names its own signer proves nothing, and pinning the key it carries would make every self-signed + * forgery read VERIFIED. This is the published sealing key of the shipped sample receipt. + */ +const SAMPLE_RECEIPT_ROOT = '0x58bb94348b39e316810d6a050d5ef5a499ae6479ab42e30dbebc4bdb2685d197'; function trustshell(args, env) { const r = (0, node_child_process_1.spawnSync)(process.execPath, [CLI, ...args], { encoding: 'utf8', env }); return { ok: true, status: r.status, out: `${r.stdout ?? ''}${r.stderr ?? ''}` }; @@ -106,9 +112,15 @@ function runDemo(io, env = process.env) { // verification needs no owner policy, so these run as a relying party (a policy-free home). It needs no // optional peer either — keccak256 is bundled (@noble/hashes), so a bare `npm install` verifies out of // the box. Integrity here MUST read VERIFIED; anything else is a real regression, not a soft degradation. + // The pin comes from the code above, not from the artifact: assert the shipped sample's signer IS that key, so a + // swapped sample fails loudly here instead of silently re-pinning the check to whatever key the file carries. const sample = JSON.parse((0, node_fs_1.readFileSync)(SAMPLE_RECEIPT, 'utf8')); - step('Verify a signed receipt OFFLINE under a pinned key', ['verify-receipt', SAMPLE_RECEIPT, '--root', sample.public_key], { exit: 0, has: /Integrity\s+VERIFIED/ }, '', verifyEnv); - io.out(' → integrity VERIFIED here by cryptography (hash + pinned signature); authorization reads'); + if (sample.public_key.toLowerCase() !== SAMPLE_RECEIPT_ROOT.toLowerCase()) { + fails.push(`the shipped sample receipt is signed by ${sample.public_key}, not the out-of-band pinned root ${SAMPLE_RECEIPT_ROOT}`); + } + step('Verify a signed receipt OFFLINE under a pinned key', ['verify-receipt', SAMPLE_RECEIPT, '--root', SAMPLE_RECEIPT_ROOT], { exit: 0, has: /Integrity\s+VERIFIED/ }, '', verifyEnv); + io.out(' → integrity VERIFIED here by cryptography (hash + a root pinned in the demo, NOT read from the'); + io.out(' receipt); authorization reads'); io.out(' NOT_CHECKED from a file — the six-claim envelope is not covered by the receipt signature,'); io.out(' so it is READ, not re-derived. NOT_CHECKED is never a pass.'); const badPath = (0, node_path_1.join)(home, 'tampered-receipt.json'); @@ -117,7 +129,7 @@ function runDemo(io, env = process.env) { const bad = JSON.parse((0, node_fs_1.readFileSync)(SAMPLE_RECEIPT, 'utf8')); bad.receipt_response.receipt.subject.agent.agent_name = 'tampered-agent'; (0, node_fs_1.writeFileSync)(badPath, JSON.stringify(bad)); - step('Verify a tampered copy: integrity FAILED (the hash no longer matches)', ['verify-receipt', badPath, '--root', sample.public_key], { exit: 1, has: /FAILED/ }, '', verifyEnv); + step('Verify a tampered copy: integrity FAILED (the hash no longer matches)', ['verify-receipt', badPath, '--root', SAMPLE_RECEIPT_ROOT], { exit: 1, has: /FAILED/ }, '', verifyEnv); } finally { try { diff --git a/dist/cli/guard.d.ts b/dist/cli/guard.d.ts index fe261811..1027df6d 100644 --- a/dist/cli/guard.d.ts +++ b/dist/cli/guard.d.ts @@ -62,6 +62,20 @@ export declare const RUN_USAGE = "trustshell run --tool [--class read|oth */ export declare const RUN_POWERSHELL = "In Windows PowerShell write the -- quoted, '--' (trustshell run --tool git '--' git status), or call trustshell.cmd: PowerShell drops a bare -- before trustshell sees it."; /** `trustshell run`. The command's own output goes to the terminal; the card goes to stderr (or --json to stdout). */ +/** + * PURE. The executable a `run` command names, and every reason that name may not stand for `tool`. + * `run` names an executable by its BARE name and lets the OS resolve it on PATH at spawn; a path- or + * drive-qualified command[0] is refused, because a binary elsewhere on disk that merely shares a basename + * must not satisfy a grant for the tool (Strix CWE-184, #679: `run --tool node -- /tmp/x/node s.js` otherwise + * matched `tool:node`, held the script pin, then spawned /tmp/x/node, which ignores the pinned script). + * ponytail: an off-PATH binary now needs its tool on PATH; a per-tool executable pin (path + sha256 in the + * policy, like `scripts`) is the rung to add when a user needs one. The residual PATH/symlink-substitution + * vector is an owner-level attacker (who can edit the policy anyway), out of the guard's model. + */ +export declare function executableIdentity(command0: string, tool: string): { + executable: string; + reasons: string[]; +}; export declare function runRun(argv: string[], io: IO, env?: NodeJS.ProcessEnv, spawn?: Spawner, sandboxExec?: Exec): number; export declare const GUARD_USAGE = "trustshell guard init [--force] [--days N]\ntrustshell guard show [--json]\ntrustshell guard check --tool [--class read|other|post|spend] [--amount ] [--json]\ntrustshell guard allow \u00B7 guard deny (spend | post | tool | tool:)\ntrustshell guard allow|deny tool: --script (pin or unpin one script by sha256: node, python, py, sh, bash, dash, zsh, ksh, powershell, pwsh)\ntrustshell guard limit per-tx|daily|total \u00B7 guard expire \u00B7 guard kill \u00B7 guard sign\ntrustshell guard require-sandbox on|off (on: trustshell run refuses without --sandbox)\ntrustshell guard level [--json] \u00B7 guard level shadow|enforce|off \u00B7 guard level after L1|L2|L3 \ntrustshell guard level record [--json] \u00B7 guard level revoke (SHADOW by default)"; /** The disclaimer every `guard level` reading carries. */ diff --git a/dist/cli/guard.js b/dist/cli/guard.js index 3c1b8ff9..753a3102 100644 --- a/dist/cli/guard.js +++ b/dist/cli/guard.js @@ -5,6 +5,7 @@ exports.recordPolicyChange = recordPolicyChange; exports.plannedAction = plannedAction; exports.runGuarded = runGuarded; exports.formatRunCard = formatRunCard; +exports.executableIdentity = executableIdentity; exports.runRun = runRun; exports.zeroLimitNote = zeroLimitNote; exports.runGuard = runGuard; @@ -19,7 +20,11 @@ const run_1 = require("../sandbox/run"); const autonomy_1 = require("../lib/autonomy"); /** Exit code for an action the policy denied. Never 0, and distinct from a VETO (1). */ exports.EXIT_REFUSED = 5; -exports.NEVER_GUARDED = new Set(['help', 'version', 'guard', 'run', 'demo']); +// Only self-governing commands skip the owner-policy gate: help/version print, and guard/run ARE the gate. `demo` +// is NOT one of them (Strix CWE-693, #679): it spawns an interpreter on a task script of its own, so it is weighed +// as tool:demo and refused under a deny-all policy or TRUSTSHELL_REQUIRE_POLICY. With no policy it still runs +// (warn-and-allow), which is the no-account first-run path; its own steps run in a separate throwaway home. +exports.NEVER_GUARDED = new Set(['help', 'version', 'guard', 'run']); /** The scope a CLI-gate record states. */ exports.CLI_SCOPE = { mediated: ['whether this trustshell command was allowed to start, checked against the owner policy before it started'], @@ -142,6 +147,28 @@ exports.RUN_USAGE = 'trustshell run --tool [--class read|other|post|spend exports.RUN_POWERSHELL = "In Windows PowerShell write the -- quoted, '--' (trustshell run --tool git '--' git status), or call trustshell.cmd: PowerShell drops a bare -- before trustshell sees it."; const isHelp = (a) => a === '--help' || a === '-h' || a === 'help'; /** `trustshell run`. The command's own output goes to the terminal; the card goes to stderr (or --json to stdout). */ +/** + * PURE. The executable a `run` command names, and every reason that name may not stand for `tool`. + * `run` names an executable by its BARE name and lets the OS resolve it on PATH at spawn; a path- or + * drive-qualified command[0] is refused, because a binary elsewhere on disk that merely shares a basename + * must not satisfy a grant for the tool (Strix CWE-184, #679: `run --tool node -- /tmp/x/node s.js` otherwise + * matched `tool:node`, held the script pin, then spawned /tmp/x/node, which ignores the pinned script). + * ponytail: an off-PATH binary now needs its tool on PATH; a per-tool executable pin (path + sha256 in the + * policy, like `scripts`) is the rung to add when a user needs one. The residual PATH/symlink-substitution + * vector is an owner-level attacker (who can edit the policy anyway), out of the guard's model. + */ +function executableIdentity(command0, tool) { + const raw = String(command0); + const executable = (0, node_path_1.basename)(raw).replace(/\.(exe|cmd|bat|com)$/i, '').toLowerCase(); + const reasons = []; + if (/[\\/]/.test(raw) || /^[A-Za-z]:/.test(raw)) { + reasons.push(`the command names ${tool} by path (${raw.slice(0, 60)}), not by its bare name: run resolves the tool on PATH, so a binary elsewhere cannot stand in for it; put ${tool} on PATH and name it plainly`); + } + if (executable !== tool.toLowerCase()) { + reasons.push(`the command starts ${executable}, not ${tool}: run allows an executable only by its own name`); + } + return { executable, reasons }; +} function runRun(argv, io, env = process.env, spawn = nodeSpawner, sandboxExec = mission_1.nodeExec) { const dash = argv.indexOf('--'); const opts = dash === -1 ? argv : argv.slice(0, dash); @@ -195,11 +222,11 @@ function runRun(argv, io, env = process.env, spawn = nodeSpawner, sandboxExec = const shadow = (0, guardrails_1.homeShadowWarning)(env); if (shadow) io.err(shadow); - // The tool label must BE the executable that would start: with only tool:inspect allowed, - // `run --tool inspect -- node anything.js` used to run node (Lane D, 2026-10-21). The guard bounds which + // The tool label must BE the executable that would start, named by its bare name (resolved on PATH): + // `run --tool inspect -- node anything.js` must not run node (Lane D, 2026-10-21), and `run --tool node -- /x/node` + // must not run a binary elsewhere that only shares the basename (Strix CWE-184, #679). The guard bounds which // executable starts, not what that executable then does (a script, its arguments). - const executable = (0, node_path_1.basename)(String(command[0])).replace(/\.(exe|cmd|bat|com)$/i, '').toLowerCase(); - const mismatch = executable !== tool.toLowerCase() ? [`the command starts ${executable}, not ${tool}: run allows an executable only by its own name`] : []; + const { executable, reasons: mismatch } = executableIdentity(String(command[0]), tool); // C5: an interpreter starts only with a script the policy pins. The file it would open is hashed here; whether that // hash is pinned is decided under the policy lock. An inline, stdin, module or loader form is refused outright. const c5 = (0, guardrails_1.interpreterCheck)(executable, command.slice(1), env); diff --git a/src/cli/demo.ts b/src/cli/demo.ts index ae689b3f..20a6c9e7 100644 --- a/src/cli/demo.ts +++ b/src/cli/demo.ts @@ -31,6 +31,12 @@ type IO = { out: (s: string) => void; err: (s: string) => void }; const CLI = join(__dirname, 'index.js'); /** The shipped sample engine receipt (package-root/examples), from dist/cli -> ../../examples. */ const SAMPLE_RECEIPT = join(__dirname, '..', '..', 'examples', 'trust-receipt.sample.json'); +/** + * The trusted root is pinned HERE, out of band — never read out of the receipt being verified (Strix CWE-807, #679): + * a receipt that names its own signer proves nothing, and pinning the key it carries would make every self-signed + * forgery read VERIFIED. This is the published sealing key of the shipped sample receipt. + */ +const SAMPLE_RECEIPT_ROOT = '0x58bb94348b39e316810d6a050d5ef5a499ae6479ab42e30dbebc4bdb2685d197'; interface StepResult { ok: boolean; status: number | null; out: string } @@ -134,11 +140,17 @@ export function runDemo(io: IO, env: NodeJS.ProcessEnv = process.env): number { // verification needs no owner policy, so these run as a relying party (a policy-free home). It needs no // optional peer either — keccak256 is bundled (@noble/hashes), so a bare `npm install` verifies out of // the box. Integrity here MUST read VERIFIED; anything else is a real regression, not a soft degradation. + // The pin comes from the code above, not from the artifact: assert the shipped sample's signer IS that key, so a + // swapped sample fails loudly here instead of silently re-pinning the check to whatever key the file carries. const sample = JSON.parse(readFileSync(SAMPLE_RECEIPT, 'utf8')) as { public_key: string }; + if (sample.public_key.toLowerCase() !== SAMPLE_RECEIPT_ROOT.toLowerCase()) { + fails.push(`the shipped sample receipt is signed by ${sample.public_key}, not the out-of-band pinned root ${SAMPLE_RECEIPT_ROOT}`); + } step('Verify a signed receipt OFFLINE under a pinned key', - ['verify-receipt', SAMPLE_RECEIPT, '--root', sample.public_key], + ['verify-receipt', SAMPLE_RECEIPT, '--root', SAMPLE_RECEIPT_ROOT], { exit: 0, has: /Integrity\s+VERIFIED/ }, '', verifyEnv); - io.out(' → integrity VERIFIED here by cryptography (hash + pinned signature); authorization reads'); + io.out(' → integrity VERIFIED here by cryptography (hash + a root pinned in the demo, NOT read from the'); + io.out(' receipt); authorization reads'); io.out(' NOT_CHECKED from a file — the six-claim envelope is not covered by the receipt signature,'); io.out(' so it is READ, not re-derived. NOT_CHECKED is never a pass.'); const badPath = join(home, 'tampered-receipt.json'); @@ -148,7 +160,7 @@ export function runDemo(io: IO, env: NodeJS.ProcessEnv = process.env): number { bad.receipt_response.receipt.subject.agent.agent_name = 'tampered-agent'; writeFileSync(badPath, JSON.stringify(bad)); step('Verify a tampered copy: integrity FAILED (the hash no longer matches)', - ['verify-receipt', badPath, '--root', sample.public_key], { exit: 1, has: /FAILED/ }, '', verifyEnv); + ['verify-receipt', badPath, '--root', SAMPLE_RECEIPT_ROOT], { exit: 1, has: /FAILED/ }, '', verifyEnv); } finally { try { rmSync(home, { recursive: true, force: true }); } catch { /* best effort */ } } diff --git a/src/cli/guard.ts b/src/cli/guard.ts index 54260e5a..26ce3a31 100644 --- a/src/cli/guard.ts +++ b/src/cli/guard.ts @@ -72,7 +72,11 @@ type IO = { out: (s: string) => void; err: (s: string) => void }; /** Exit code for an action the policy denied. Never 0, and distinct from a VETO (1). */ export const EXIT_REFUSED = 5; -export const NEVER_GUARDED = new Set(['help', 'version', 'guard', 'run', 'demo']); +// Only self-governing commands skip the owner-policy gate: help/version print, and guard/run ARE the gate. `demo` +// is NOT one of them (Strix CWE-693, #679): it spawns an interpreter on a task script of its own, so it is weighed +// as tool:demo and refused under a deny-all policy or TRUSTSHELL_REQUIRE_POLICY. With no policy it still runs +// (warn-and-allow), which is the no-account first-run path; its own steps run in a separate throwaway home. +export const NEVER_GUARDED = new Set(['help', 'version', 'guard', 'run']); /** The scope a CLI-gate record states. */ export const CLI_SCOPE: AssuranceScope = { @@ -213,6 +217,31 @@ export const RUN_POWERSHELL = "In Windows PowerShell write the -- quoted, '--' ( const isHelp = (a: string | undefined) => a === '--help' || a === '-h' || a === 'help'; /** `trustshell run`. The command's own output goes to the terminal; the card goes to stderr (or --json to stdout). */ +/** + * PURE. The executable a `run` command names, and every reason that name may not stand for `tool`. + * `run` names an executable by its BARE name and lets the OS resolve it on PATH at spawn; a path- or + * drive-qualified command[0] is refused, because a binary elsewhere on disk that merely shares a basename + * must not satisfy a grant for the tool (Strix CWE-184, #679: `run --tool node -- /tmp/x/node s.js` otherwise + * matched `tool:node`, held the script pin, then spawned /tmp/x/node, which ignores the pinned script). + * ponytail: an off-PATH binary now needs its tool on PATH; a per-tool executable pin (path + sha256 in the + * policy, like `scripts`) is the rung to add when a user needs one. The residual PATH/symlink-substitution + * vector is an owner-level attacker (who can edit the policy anyway), out of the guard's model. + */ +export function executableIdentity(command0: string, tool: string): { executable: string; reasons: string[] } { + const raw = String(command0); + const executable = basename(raw).replace(/\.(exe|cmd|bat|com)$/i, '').toLowerCase(); + const reasons: string[] = []; + if (/[\\/]/.test(raw) || /^[A-Za-z]:/.test(raw)) { + reasons.push( + `the command names ${tool} by path (${raw.slice(0, 60)}), not by its bare name: run resolves the tool on PATH, so a binary elsewhere cannot stand in for it; put ${tool} on PATH and name it plainly`, + ); + } + if (executable !== tool.toLowerCase()) { + reasons.push(`the command starts ${executable}, not ${tool}: run allows an executable only by its own name`); + } + return { executable, reasons }; +} + export function runRun(argv: string[], io: IO, env: NodeJS.ProcessEnv = process.env, spawn: Spawner = nodeSpawner, sandboxExec: Exec = nodeExec): number { const dash = argv.indexOf('--'); const opts = dash === -1 ? argv : argv.slice(0, dash); @@ -253,11 +282,11 @@ export function runRun(argv: string[], io: IO, env: NodeJS.ProcessEnv = process. const dir = trustshellHome(env); const shadow = homeShadowWarning(env); if (shadow) io.err(shadow); - // The tool label must BE the executable that would start: with only tool:inspect allowed, - // `run --tool inspect -- node anything.js` used to run node (Lane D, 2026-10-21). The guard bounds which + // The tool label must BE the executable that would start, named by its bare name (resolved on PATH): + // `run --tool inspect -- node anything.js` must not run node (Lane D, 2026-10-21), and `run --tool node -- /x/node` + // must not run a binary elsewhere that only shares the basename (Strix CWE-184, #679). The guard bounds which // executable starts, not what that executable then does (a script, its arguments). - const executable = basename(String(command[0])).replace(/\.(exe|cmd|bat|com)$/i, '').toLowerCase(); - const mismatch = executable !== tool.toLowerCase() ? [`the command starts ${executable}, not ${tool}: run allows an executable only by its own name`] : []; + const { executable, reasons: mismatch } = executableIdentity(String(command[0]), tool); // C5: an interpreter starts only with a script the policy pins. The file it would open is hashed here; whether that // hash is pinned is decided under the policy lock. An inline, stdin, module or loader form is refused outright. const c5 = interpreterCheck(executable, command.slice(1), env); diff --git a/tests/demo-gated.test.ts b/tests/demo-gated.test.ts new file mode 100644 index 00000000..8542c291 --- /dev/null +++ b/tests/demo-gated.test.ts @@ -0,0 +1,46 @@ +/** + * `trustshell demo` is weighed by the owner policy like any other spawning command — it is NOT exempt from the gate. + * + * Regression for the Strix CWE-693 finding on PR #679: `demo` was in NEVER_GUARDED, so an enforced deny-all policy + * (and TRUSTSHELL_REQUIRE_POLICY with no policy) still let it spawn an interpreter, unaudited. Now it is gated as + * tool:demo: refused under fail-closed settings, and still allowed on a no-policy first run (warn-and-allow). + * inner() standing in for the real demo body: the point is whether the gate lets it start. + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { EXIT_REFUSED, NEVER_GUARDED, runGuarded } from '../src/cli/guard'; +import { defaultPolicy, POLICY_FILE, renderPolicyDocument, type LocalPolicy } from '../src/lib/guardrails'; + +describe('demo is gated by the owner policy, not exempt (CWE-693, #679)', () => { + let home = ''; + const io = { out: () => undefined, err: () => undefined }; + const demoArgs = { command: 'demo' } as Parameters[0]; + const gate = (env: Record = {}) => { + let ran = 0; + const r = runGuarded(demoArgs, async () => ((ran += 1), 0), io, { ...process.env, TRUSTSHELL_HOME: home, ...env }); + return r.then((code) => ({ code, ran })); + }; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'ts-demo-gate-')); + }); + afterEach(() => rmSync(home, { recursive: true, force: true })); + + it('demo is not in the never-guarded set', () => { + expect(NEVER_GUARDED.has('demo')).toBe(false); + }); + + it('a deny-all owner policy refuses demo before it runs', async () => { + writeFileSync(join(home, POLICY_FILE), renderPolicyDocument({ policy: { ...defaultPolicy(), actions: [] } as LocalPolicy, signature: null })); + expect(await gate()).toEqual({ code: EXIT_REFUSED, ran: 0 }); + }); + + it('TRUSTSHELL_REQUIRE_POLICY with no policy refuses demo (fail closed)', async () => { + expect(await gate({ TRUSTSHELL_REQUIRE_POLICY: '1' })).toEqual({ code: EXIT_REFUSED, ran: 0 }); + }); + + it('with no policy (no-account first run, warn-and-allow) demo still runs', async () => { + expect(await gate()).toEqual({ code: 0, ran: 1 }); + }); +}); diff --git a/tests/guard-interpreter-pins.test.ts b/tests/guard-interpreter-pins.test.ts index 8ff9732a..74cf2b6e 100644 --- a/tests/guard-interpreter-pins.test.ts +++ b/tests/guard-interpreter-pins.test.ts @@ -99,9 +99,12 @@ describe('C5: an allowed interpreter runs only scripts the policy pins by sha256 // The run records hash the script; they never carry its path (the command is recorded by hash, never by its text). expect(JSON.stringify(records().filter((r) => r.kind !== 'policy-change'))).not.toContain('ok.js'); - // The same file by its absolute path, or through a full path to the interpreter, is the same pin. - expect(runIt(['--tool', 'node', '--', '/usr/local/bin/node', join(work, 'ok.js')])).toBe(0); - expect(started).toHaveLength(2); + // The interpreter named by a PATH is now refused (Strix CWE-184, #679): a binary elsewhere that only shares the + // basename "node" must not satisfy tool:node. The tool is named plainly and resolved on PATH; the script may still + // be an absolute path. Was previously ALLOWED ("same file by absolute path = same pin") — that was the bypass surface. + expect(runIt(['--tool', 'node', '--', '/usr/local/bin/node', join(work, 'ok.js')])).toBe(EXIT_REFUSED); + expect(started).toHaveLength(1); // only the bare-name run above spawned + expect(last().authorized.reasons.join(' ')).toMatch(/by path/); }); it('an unpinned script is refused before it starts, with a reason that states the rule', () => { diff --git a/tests/run-executable-identity.test.ts b/tests/run-executable-identity.test.ts new file mode 100644 index 00000000..c3f528dd --- /dev/null +++ b/tests/run-executable-identity.test.ts @@ -0,0 +1,85 @@ +/** + * `trustshell run` binds the executable to the --tool NAME, resolved on PATH at spawn. A path- or drive-qualified + * command[0] is refused, so a binary elsewhere on disk cannot satisfy a grant by sharing a basename. + * + * Regression for the Strix CWE-184 finding on PR #679: `run --tool node -- /tmp/attacker/node script.js` was ALLOWED + * (basename "node" matched the grant, the script pin held) and then `spawn(command[0])` ran the attacker's binary + * VERBATIM, which ignores the pinned script. Pre-fix, the by-path case below returns 0 and the spawner runs it; + * post-fix it is EXIT_REFUSED and the spawner is never touched. Fake spawner + temp home: no real interpreter starts. + */ +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { CliIO } from '../src/cli'; +import { EXIT_REFUSED, executableIdentity, runRun, type Spawner } from '../src/cli/guard'; +import { defaultPolicy, POLICY_FILE, GUARD_RECEIPTS_FILE, renderPolicyDocument, type GuardRecord, type LocalPolicy } from '../src/lib/guardrails'; + +const sha = (s: string) => createHash('sha256').update(s, 'utf8').digest('hex'); +const STARTUP_VARS = ['NODE_OPTIONS', 'BASH_ENV', 'ENV', 'ZDOTDIR', 'PYTHONSTARTUP', 'PYTHONINSPECT', 'PYTHONPATH', 'PYTHONHOME']; + +describe('run binds the executable to the tool name, not a spoofable basename (CWE-184, #679)', () => { + const prevCwd = process.cwd(); + let root = ''; + let home = ''; + let work = ''; + let started: string[][] = []; + const io: CliIO = { out: () => undefined, err: () => undefined }; + const spawner: Spawner = (cmd, args) => (started.push([cmd, ...args]), { status: 0, signal: null }); + const env = (): NodeJS.ProcessEnv => { + const e: NodeJS.ProcessEnv = { ...process.env, TRUSTSHELL_HOME: home }; + for (const k of STARTUP_VARS) delete e[k]; + return e; + }; + const real = (f: string) => realpathSync.native(join(work, f)); + const lastReasons = () => { + const recs = readFileSync(join(home, GUARD_RECEIPTS_FILE), 'utf8').trim().split('\n').map((l) => JSON.parse(l) as GuardRecord); + return (recs.pop() as GuardRecord).authorized.reasons.join(' '); + }; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'ts-exeid-')); + home = join(root, 'home'); + work = join(root, 'work'); + mkdirSync(work, { recursive: true }); + writeFileSync(join(work, 'ok.js'), 'console.log("pinned")\n'); + mkdirSync(home, { recursive: true }); + const scripts = [{ tool: 'node', path: real('ok.js'), sha256: sha(readFileSync(join(work, 'ok.js'), 'utf8')) }]; + writeFileSync(join(home, POLICY_FILE), renderPolicyDocument({ policy: { ...defaultPolicy(), actions: ['tool:node'], scripts } as LocalPolicy, signature: null })); + process.chdir(work); + started = []; + }); + afterEach(() => { + process.chdir(prevCwd); + rmSync(root, { recursive: true, force: true }); + }); + + it('the genuine bare-name interpreter with a pinned script is ALLOWED and spawns', () => { + expect(runRun(['--tool', 'node', '--', 'node', 'ok.js'], io, env(), spawner)).toBe(0); + expect(started).toEqual([['node', 'ok.js']]); + }); + + it('an interpreter supplied BY PATH is REFUSED before anything spawns, even with the pinned script (the bypass)', () => { + const attacker = join(work, 'node'); // basename "node", but given as a path + expect(runRun(['--tool', 'node', '--', attacker, 'ok.js'], io, env(), spawner)).toBe(EXIT_REFUSED); + expect(started).toEqual([]); // the attacker binary never ran + expect(lastReasons()).toMatch(/path|bare name/); + }); + + it('a relative path and a Windows drive-qualified command are refused too', () => { + expect(runRun(['--tool', 'node', '--', './node', 'ok.js'], io, env(), spawner)).toBe(EXIT_REFUSED); + expect(runRun(['--tool', 'node', '--', 'sub/node', 'ok.js'], io, env(), spawner)).toBe(EXIT_REFUSED); + expect(runRun(['--tool', 'node', '--', 'C:\\x\\node.exe', 'ok.js'], io, env(), spawner)).toBe(EXIT_REFUSED); + expect(started).toEqual([]); + }); + + it('executableIdentity (PURE): a bare name matches; a path or a different name is refused', () => { + expect(executableIdentity('node', 'node').reasons).toEqual([]); + expect(executableIdentity('NODE.EXE', 'node').reasons).toEqual([]); // case + .exe stripped + expect(executableIdentity('/usr/local/bin/node', 'node').reasons.join(' ')).toMatch(/by path/); + expect(executableIdentity('./node', 'node').reasons.join(' ')).toMatch(/by path/); + expect(executableIdentity('sub/node', 'node').reasons.join(' ')).toMatch(/by path/); + expect(executableIdentity('C:\\x\\node.exe', 'node').reasons.join(' ')).toMatch(/by path/); + expect(executableIdentity('python', 'node').reasons.join(' ')).toMatch(/not node/); + }); +});