Repository navigation
feat(cli): guided trustshell demo — the offline fresh-install first run - #679
DealAppSeo wants to merge 5 commits into
Conversation
… run
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 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Strix Security ReviewTo fix before merging (1)
PR summary: Adds a guided, fully offline `trustshell demo` command that drives the real CLI subcommands in a throwaway policy home, plus a shipped sample signed receipt, and switches receipt/argument keccak256 hashing from the optional `ethers` peer to a lazily imported `@noble/hashes` dependency so offline verification works on a bare install. Review summaryReviewed the new offline 2 resolved findingsRuled out (4)
Review history (5)
Fixed the findings? re-run the review, or tag Review 5 on this PR · Reviewed by Strix |
…via @noble/hashes, not the ethers peer 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 <noreply@anthropic.com>
| const allowed = step('ALLOWED run: the pinned task starts and does its work', | ||
| ['run', '--tool', 'node', '--', 'node', taskPath], { exit: 0 }); |
There was a problem hiding this comment.
🔵 trustshell run matches the tool by basename, so a policy pinning one interpreter script can be satisfied by any binary of that name
Severity: MEDIUM · CWE-184
The demo teaches that with a policy granting only tool:node pinned to one script, "an interpreter runs ONLY a script the policy pinned". That guarantee is defeated by one token: run decides which program starts by the basename of argv[0] and then spawns that same string verbatim, so trustshell run --tool node -- /tmp/attacker/node task.js is ALLOWED by the pinning policy and executes the supplied binary, which ignores the pinned script entirely. I reproduced it against the committed dist: the substituted binary ran (wrote its own file) under a policy whose only grant was tool:node pinned to one sha256, while the genuine script-swap was still DENIED — so the script pin holds and only the executable identity is unverified. The policy stays owner-signed and the ledger records executable: "node" with the pinned script hash, so guard show cannot distinguish the bypass from a compliant run. Root cause is pre-existing and unchanged by this PR at src/cli/guard.ts:259-260 (basename) and :291 (verbatim spawn); the PR's new demo step at src/cli/demo.ts:105-106 exercises and markets that exact command form.
Prompt to fix with AI
This is a security vulnerability found during a code review.
Vulnerability: `trustshell run` matches the tool by basename, so a policy pinning one interpreter script can be satisfied by any binary of that name
Severity: MEDIUM
CWE: CWE-184
The demo teaches that with a policy granting only `tool:node` pinned to one script, "an interpreter runs ONLY a script the policy pinned". That guarantee is defeated by one token: `run` decides which program starts by the basename of `argv[0]` and then spawns that same string verbatim, so `trustshell run --tool node -- /tmp/attacker/node task.js` is ALLOWED by the pinning policy and executes the supplied binary, which ignores the pinned script entirely. I reproduced it against the committed dist: the substituted binary ran (wrote its own file) under a policy whose only grant was `tool:node` pinned to one sha256, while the genuine script-swap was still DENIED — so the script pin holds and only the executable identity is unverified. The policy stays owner-signed and the ledger records `executable: "node"` with the pinned script hash, so `guard show` cannot distinguish the bypass from a compliant run. Root cause is pre-existing and unchanged by this PR at `src/cli/guard.ts:259-260` (basename) and `:291` (verbatim spawn); the PR's new demo step at `src/cli/demo.ts:105-106` exercises and markets that exact command form.
Location: src/cli/guard.ts:259-260
Context: root cause: identity comes from basename(argv[0]) while argv[0] is spawned verbatim at line 291
```
// Before:
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`] : [];
// After:
// The tool label must BE the executable that starts, not merely a file of the same name: a path form
// (`/tmp/x/node`) is refused, so a pinned interpreter cannot be swapped for a different binary.
const started = String(command[0]);
const executable = basename(started).replace(/\.(exe|cmd|bat|com)$/i, '').toLowerCase();
const byPath = /[\\/]/.test(started);
const mismatch = byPath || executable !== tool.toLowerCase() ? [`the command starts ${started}, not ${tool}: run allows an executable only by its own name, resolved from PATH`] : [];
```
Location: src/cli/demo.ts:105-106
Context: the PR's new step exercises and markets the pin this bypass defeats
```
const allowed = step('ALLOWED run: the pinned task starts and does its work',
['run', '--tool', 'node', '--', 'node', taskPath], { exit: 0 });
```
Location: src/cli/demo.ts:89-91
Context: the narrated guarantee the basename check does not uphold
```
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');
```
How to fix:
Decide the started program by its resolved identity, not by the basename of an attacker-supplied string: refuse a command whose `argv[0]` contains a path separator (or resolve it through PATH and compare it with the realpath of the tool the policy names) before the decision, and record the resolved path so `guard show` and the ledger show which binary actually started. Where a path form must stay supported for legitimate users, require the caller to pass an explicit `--exe <absolute path>` that is separately pinned by hash in the policy.
Please fix this vulnerability. If you propose a fix, make it concise and minimal.React 👍 / 👎 to tune Strix for this repo. A repo collaborator (or the PR author) can resolve this thread to dismiss the finding.
| 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'); |
There was a problem hiding this comment.
the narrated guarantee the basename check does not uphold
dca4a1f 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 <noreply@anthropic.com>
🧾 Receipt1 claim(s) checked against GitHub's own check runs on
What this receipt does not prove
Receipts by TrustShell. Read-only: it reads this PR and its check runs, and writes only this comment. No code leaves the repo. |
… with regression tests 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 <noreply@anthropic.com>
…-E2E, #679 #7) The skeptic path, every step a spawned `trustshell …`, no engine, no network: owner policy → pin tool by path+sha256 → ALLOWED run starts the pinned file → a SUBSTITUTED executable is REFUSED (exit 5, "not the one pinned") → the signed golden verifies offline (VERIFIED), a tampered copy and a foreign root both FAIL. This is the one walk that drives #679's executable-identity pin through the shipped binary; every other E2E runs the guard unpinned or in-process. Two sourcing modes, identical assertions: default runs the built dist (green on any OS); E2E_PACK=1 npm-packs and clean-installs the tarball + the documented ethers peer and runs the INSTALLED binary (no local node_modules). Both verified green on Windows: built-dist 10/10, packed install 11/11. Closes #679 acceptance #7 (demo/E2E under the real enforcement path). Test-only; no source change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@copilot resolve the merge conflicts in this pull request |
…o-2026-11-07 # Conflicts: # package.json Co-authored-by: DealAppSeo <11688996+DealAppSeo@users.noreply.github.com>
Resolved the conflict in |
…ne verifier, three evidence levels (#681) * feat(verify): owner-self-signed offline receipts — seal with the install key, one verifier, three evidence levels (#679 D1) The engine signs kernel-receipt/2 with its production key; this SDK only ever VERIFIED those. This adds the owner's side: an agent's accountable owner seals a receipt with their OWN install key (bundle-signing-key.pem) so a stranger reproduces its integrity OFFLINE with the SAME verifier (verifyReceiptOffline) — identical wire format (Ed25519 over DOMAIN + canonical bytes, keccak256 receipt_hash, 0x-hex signer/signature). One format, one verifier; the engine-signed path is untouched. THREE EVIDENCE LEVELS, never collapsed to one: - every owner receipt carries header.provenance='owner-self-signed', covered by BOTH the signature and the hash, so it can't be mistaken for an engine receipt and can't be flipped without breaking both; - evidenceLevel() reports OWNER_SELF_SIGNED (authorship + wholeness ONLY) and never promotes a self-signature to INDEPENDENTLY_VERIFIED, and never reports any receipt RepID-eligible — whether a receipt earns production RepID is the ENGINE's call against its own pinned key, never an offline verdict; - a missing/foreign trust root never silently passes: no pin => WHOLE_BUT_UNSEALED (sealer.trusted=false), a foreign pin => integrity FAILED. "I can't check who sealed it" is never "the owner sealed it". src/lib/owner-receipt.ts: signOwnerReceipt, installKeySigner/installKeyRootHex (the install key in the engine's 0x-hex encoding), ownerReceiptProvenance, evidenceLevel. Re-exported from the @hyperdag/trustshell/verify subpath (node:crypto only; the import-boundary guard still refuses next/react/supabase and any network call). tests/owner-receipt.test.ts (12, all pass) proves the guards bite, not just the happy path: canonical serialization (key order is immaterial), domain separation (the tag is load-bearing), tampering, a foreign key, an absent root, a transplanted signature (replay), key rotation (old/new/both pinned), a smuggled six-claim envelope (can't make a self-signature independently-verified), and an expired policy. Offline on a BARE install (no ethers) additionally needs the @noble keccak path from the demo/#679 arc; with ethers present (as in CI) this is fully offline (no network) today. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cli): `trustshell sign-receipt` — emit an owner-self-signed receipt from the shell, so emit→verify is one pipe (#679 D1) Completes the owner-receipt seam at the command line: reads a kernel-receipt body (a file, or - for stdin), seals it with THIS install's own key (TRUSTSHELL_HOME/bundle-signing-key.pem) via signOwnerReceipt, prints the sealed kernel-receipt/2 to stdout and, on stderr, the 0x key a verifier pins plus the plain caveat that a self-signature proves authorship + wholeness only and earns no production RepID. The whole skeptic loop is now two shell commands: trustshell sign-receipt body.json > r.json trustshell verify-receipt r.json --root 0x<thatkey> # integrity VERIFIED, sealer trusted, exit 0 trustshell verify-receipt r.json # no pin => NOT_CHECKED, never exit 0 Reuses the existing verify-receipt command unchanged; the engine-signed path is untouched. EXIT 0 sealed, 2 bad body, 3 could not seal. tests/cli-sign-receipt.test.ts (4) drives parse+run in-process with an ephemeral TRUSTSHELL_HOME and asserts the round trip, the no-pin non-pass, and tamper→VETO. Verified too at the real built-dist CLI boundary (sign→verify→no-pin). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * D1: document sign-receipt + exercise it in the stranger walk The CLI command `trustshell sign-receipt` landed with its own suite, but two existing full-repo gates key off the Command union: - docs.test.ts requires every shipped command to have a section in docs/api-reference.md — add the `### trustshell sign-receipt <file>` section. - e2e-stranger.test.ts asserts every Command-union member is exercised by the stranger walk (ran == commandsOnMain). Add a sign-receipt -> verify-receipt round trip under an ephemeral TRUSTSHELL_HOME: seal a minimal body with this install's own key, then re-derive its integrity under the printed root. This is the offline emit->verify loop, no network. Both suites green (177/177). No src/dist change; docs + test only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * D5: skeptic's receipt challenge — the attack matrix against the real CLI `npm run skeptic` seals an owner-self-signed receipt with a throwaway install key, then attacks it with the shipped `trustshell sign-receipt` / `verify-receipt` — offline, no account. Each attack PASSES only when the verifier REFUSES it: positive-control a real seal verifies under its own pinned key (or the rest is vacuous) missing-root no --root -> NOT_CHECKED, never exit 0 wrong-key a foreign pin -> integrity FAILED tampered-evidence flip decision.verdict -> integrity FAILED forged-signature swap the signature -> FAILED transplanted-sig lift A's signature onto B's bytes (same key) -> FAILED expired-authz a policy expired before --as-of -> authorization claim FAILED no-self-promotion self-sig + authenticated envelope is STILL owner-self-signed, repid_eligible 'no' The attack constructions are copied from tests/owner-receipt.test.ts (no invented fields); this harness proves them at the real process boundary a skeptic uses, and writes a report naming the commit, version, inputs, outputs and the honest limitation (offline verify is stateless — replay is the engine's receipt_id/nullifier, not the verifier). Ran PASS at commit 253ed5a (all 9), report committed under docs/proof-pack/. Manual artifact like `npm run proof-pack`; the same guards are jest-gated in owner-receipt.test.ts. No src/dist change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * D4: `trustshell demo` + a 10-step quickstart — the offline loop in one command A developer-facing front door for "receipts, not promises": `trustshell demo` seals a sample kernel-receipt/2 with this install's own key and VERIFIES it right there under that key, offline, printing the six-claim verdict and the exact sign-receipt -> verify-receipt lines to reproduce it. `--json` emits { receipt, verify, root } so a skeptic re-derives the verify step. Nothing is written to disk; nothing leaves the machine. Exit 0 verified / 2 not / 3 could not seal. It only orchestrates signOwnerReceipt + verifyReceiptOffline, which the CLI already ships. - src/cli: `demo` command (union, parse, help, execute) + dist. - tests/cli-demo.test.ts (3): parse, the human loop (Integrity VERIFIED, five engine claims NOT_CHECKED, the pin line, the RepID caveat on stderr), and the --json contract. - docs/api-reference.md: the `demo` section (the two Command-union gates: docs coverage + the stranger walk, which now runs `demo` under a throwaway home). - docs/QUICKSTART.md: 10 steps from `npx … demo` to "what a pass does NOT prove" — strictly operational (commands + expected exit codes + honest limits), no new claims; it documents only what the shipped commands already do. Affected suites green: cli-demo, e2e-stranger, docs, cli-sign-receipt (184). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(verify): rename the top evidence rung ISSUER_ATTESTED — a pinned non-owner key is not independence (#681) evidenceLevel() labelled any whole receipt sealed by a pinned non-owner key with >=1 engine claim VERIFIED as INDEPENDENTLY_VERIFIED. But "a pinned key that is not the owner's self-sign stamp" does NOT establish the issuer is independent of the owner's beneficial control (two keys, one controller). Offline the SDK has no controller graph, so it cannot assert independence — the label overclaimed exactly the word a skeptic attacks first (Grok + ChatGPT review, 2026-11-07). Rename the rung to ISSUER_ATTESTED — attested UNDER A NAMED ISSUER, not a finding of independence — and state the caveat in `why`. repid_eligible stays `engine-decides` (unchanged): independence and RepID remain the engine's call against its own pinned key. No behaviour change beyond the honest label. Test-first: tests/owner-receipt.test.ts gains the first case that actually exercises this branch (a pinned non-owner issuer + an authenticated six-claim envelope) and pins the honest semantics (level, repid_eligible, why caveat). Docs (api-reference), the /verify re-export note and the `demo` CLI message aligned. sdk:build clean; owner-receipt/verify-receipt/cli-sign/cli-verify/ e2e-stranger suites green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(verify): cross-verifier key isolation + honest replay-vs-transplant distinction (#681) The one receipt test named "replay / signature transplant" only exercised signature transplantation. Separate the two attacks honestly and add the cross-verifier coverage the owner asked for — no source change; the verifier already behaves correctly, these prove it. - rename to "signature transplant …"; a transplanted signature still fails offline integrity. - add a verbatim-REPLAY test stating plainly that offline verification does NOT reject a re-submitted identical receipt — it is stateless (whole + sealed, never fresh/once-used). Rejecting replay is one-time settlement in the engine (receipt_id/nullifier); a stranger's offline check cannot see it and must not claim "replay-proof". - add cross-verifier key isolation (both directions): an owner-signed receipt under an engine-only verifier, and an engine-style receipt under an owner-only verifier, each fail CLOSED (foreign key → integrity FAILED, untrusted, NOT_ESTABLISHED) and never promote to ISSUER_ATTESTED just because some other pinned key or an authenticated envelope is handed in. - add: an engine-style receipt whose sealing key is NOT pinned stays WHOLE_BUT_UNSEALED even with an authenticated envelope (the 5 engine claims are gated on a trusted sealer). - keeps integrity / issuer-authentication / independent-observation / RepID-eligibility distinct. signEngineReceipt helper factors the non-owner receipt shape. owner-receipt + e2e-stranger suites green (26). Draft #681; not merged — independent review + fresh CI first. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…rong checkout (#679 D3) (#680) The session launcher bound a TrustShell task to the old `ihh-ica` project while the working dir was the `trustshell-tl` worktree, so `start_session` resolved the wrong repo and work risked landing (or silently failing) against the wrong history. The session-binding itself is app-level config this script cannot change; this is the in-repo assertion so any loop or session can gate its own work at startup and fail LOUDLY instead of silently misrouting: node scripts/check-workspace.cjs # expects origin = DealAppSeo/trustshell node scripts/check-workspace.cjs owner/repo # expects that repo TRUSTSHELL_EXPECT_REPO=owner/repo ... # or via env workspaceMatches() is pure (https/ssh, optional .git / trailing slash, case-insensitive); main() takes an injected shell so the test asserts both exit paths without depending on the checkout it runs in. Exit 0 on match, 1 (loud stderr) on misroute. No deps. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Update — offline verification works out of the box (and the CI regression that took, and its fix)
Finding #1 below is fixed. keccak256 for the receipt hash now comes from
@noble/hashes(a realdependency), not the optionaletherspeer, so a plainnpm install @hyperdag/trustshellverifies receipts offline — integrity reads VERIFIED out of the box. The demo's step 8 showsIntegrity VERIFIEDand the fresh-install smoke test asserts it.ethersstays an OPTIONAL peer, needed only for on-chain reads and x402 signing (erc8004,verify-signer,x402-prepare). Argument hashing andkernelDecideno longer needetherseither.Two commits, and I'm being open about the first one's mistake because catching it is the point:
dca4a1f4moved keccak to@noble/hashes— but imported it at the top ofkernel-client.ts.index.tsimports that module at startup, so the whole CLI then needed@noblemerely to load. Two test suites (learn-hook, andegressviascripts/check-egress.cjs) compile the CLI to a bare temp dir with no reachablenode_modulesand run it there — so in CI every subcommand, even non-hashing ones likeguard/inspect, crashed at startup withMODULE_NOT_FOUND. That turned the CIcheckjob red ontests/egress.test.tsandtests/learn-hook.test.ts. It passed on my local machine only because the temp dir happens to resolve@nobleup-tree — a difference I should not have trusted, and the CI rerun (identical failure both times) is what proved it was mine, not flake.9f7e0939resolves@noblelazily, on first hash, instead of at module load (the hash functions are alreadyasync). Loading the CLI no longer requires it; a realnpm installalways has it, so the OOTB guarantee is unchanged.dist/lib/kernel-client.jsnow has no top-levelrequire("@noble…")— only a deferred one insidekeccak256Utf8.Finding #2 (two evidence planes) is unchanged — still open, still disclosed.
What
A guided
trustshell demo: the five-minute, no-account, no-keys, no-network first run the product was missing. It drives the real commands — it spawns this CLI as subprocesses in a throwawayTRUSTSHELL_HOME, so what it shows is exactly what the tool does, nothing re-implemented:guard init→ an owner policy, fail-closed (every limit 0, local commands only)guard allow/limit/sign→ configure it, pin one task script by sha256, owner-sign itrun→ the pinned task starts and writes an observable filerun(capability) → an unlisted command refused before it starts — nothing runsrun(spend cap) → an allowed tool, but an over-cap amount refused — nothing runsguard show→ the decision ledger, chain INTACTverify-receipt→ a signed engine receipt verified OFFLINE (integrity VERIFIED, out of the box)It teaches the four verdict words kept apart — VERIFIED / NOT_CHECKED / FAILED / NOT_APPLICABLE — and exits non-zero if any step stops doing what it says.
Two honest findings it surfaced (named, not hidden)
1. Offline verification needed the optional
etherspeer (P1 for the "independently verify" north star). → FIXED (see Update above).verify-receiptrecomputed the receipt hash with keccak256 from the optionaletherspeer, so on a bare install integrity read NOT_CHECKED — fail-closed, never a false pass, but offline verification did not actually work out of the box. Now sourced from@noble/hashes(a real dependency, imported lazily); integrity reads VERIFIED on a bare install.2. Local enforcement and engine receipts are two different evidence planes (P2). (still open, disclosed)
Steps 1–7 are the local plane: a hash-chained
guard-receipts.jsonl, checked byguard show. Step 8 verifies an enginekernel-receipt/2(six-claim plane), checked byverify-receipt. A localrundoes not yet emit an engine-signed six-claim receipt a stranger can verify offline. The demo shows both planes and names the gap rather than pretending they are one.Minor, also noted in code: the default owner policy allows the offline
inspectbut not the equally-offline, egress-freeverify-receipt, so a relying party verifies in a policy-free home.Tests
Verified locally on the fixed tip (
9f7e0939):tsc --project tsconfig.sdk.jsonbuilds clean (exit 0, no errors);dist/lib/kernel-client.jshas no top-level@noblerequire.tests/egress.test.ts,tests/learn-hook.test.ts,tests/kernel-client-no-ethers.test.ts— 13/13 pass (hashing bit-exact against the golden withethersabsent).npm run test:demo— demo against built dist: exit 0, the four verdicts, the seam disclosed, ALLOW + two DENY, ledger INTACT→BROKEN, integrity VERIFIED.npm run e2e:demo— fresh-install smoke test (packs, installs the tarball, runs the demo from the installed package): 0 gaps, "offline verify reads integrity VERIFIED out of the box (no ethers)".CI honesty. This PR also fixes two pre-existing CI reds on the clean demo tip (
e2e-stranger,docs— thedemocommand is now documented and excluded from the in-process stranger harness with a rationale). Thecheckjob was then red ondca4a1f4for the startup-require regression described above;9f7e0939fixes the cause and a fresh CI run is confirming it. Note: the full jest suite has a handful of environment-specific failures unrelated to this change (e.g.measure-home-samples,publish-trusted-publishing,scrub-battery, the repid-engine schemasha256byte-match inkernel-client.test.ts) — these are not touched here and differ by OS/runner.Scope / weight
Additive and inert: a new offline CLI command, a shipped sample receipt, one real audited dependency (
@noble/hashes, lazily imported), and a keccak-source swap inkernel-client(bit-exact,ethersrelocated to on-chain/signing only). No live surface, no network, no secrets, no schema change. Draft for review — merge is yours.🤖 Generated with Claude Code