From de73ad4355fd409926977b130cf4eccd7b0a6a5e Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Fri, 23 Oct 2026 12:37:30 -0700 Subject: [PATCH 01/10] fix: say only what the code does; credit what we build on; one copyright holder (#619) Code first: - check.ts: the run check labelled the commit fact "The commit exists on the remote", but no lookup is done. It now says "GitHub reports the commit this run ran on" (dist rebuilt; only dist/lib/check.js changes). The label is in the evidence record, so the pinned digest moves, on purpose; recomputed independently with Python. Then the copy: - README: "The portable agentic trust harness" becomes what runs today (Inspect) and where it is going (Carry, reputation that moves with you), with a link to hyperdag-protocol/docs/WHY.md. "ZK-verifiable receipt" and "portable reputation" go; the range proof's score is public. Inspect is listed first. Credits for x402 (Coinbase) and Plonky3 (Polygon Labs); the ERC-8004 badge points at the EIP; the hub blurb replaces "Built on". - README: the commercial-use term the LICENSE does not contain is removed (Sean, 2026-10-23). - LICENSE appendix and README: copyright Sean Goodwin (Sean, 2026-10-23). - package.json description: Inspect first; ships with the next release. Co-authored-by: Claude Opus 5.5 (cherry picked from commit 4273170ef60cdf88820eaf1a24d04c2bff3e976f) --- LICENSE | 2 +- README.md | 33 +++++++++++++++++++-------------- dist/lib/check.js | 2 +- package.json | 2 +- src/lib/check.ts | 2 +- tests/action-evidence.test.ts | 5 ++++- 6 files changed, 27 insertions(+), 19 deletions(-) diff --git a/LICENSE b/LICENSE index a12236e9..f62adcd2 100644 --- a/LICENSE +++ b/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 DealApp Inc. + Copyright 2026 Sean Goodwin Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index da843ad0..50fdad85 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ That run exited 1 (1.6.0, 2026-10-07). The last line names who decided; your run [![npm](https://img.shields.io/npm/v/@hyperdag/trustshell)](https://www.npmjs.com/package/@hyperdag/trustshell) [![npm downloads](https://img.shields.io/npm/dm/@hyperdag/trustshell.svg)](https://www.npmjs.com/package/@hyperdag/trustshell) -[![Standard: ERC-8004](https://img.shields.io/badge/Standard-ERC--8004-blue)](https://github.com/DealAppSeo/hyperdag-protocol) +[![Standard: ERC-8004](https://img.shields.io/badge/Standard-ERC--8004-blue)](https://eips.ethereum.org/EIPS/eip-8004) [![Protocol: HyperDAG](https://img.shields.io/badge/Protocol-HyperDAG-purple)](https://github.com/DealAppSeo/hyperdag-protocol) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-green)](LICENSE) @@ -120,15 +120,20 @@ The Chrome extension in `extension/` does not import this package. It asks the s --- -## The portable agentic trust harness +## The trust harness: what runs today, and where it is going -Most "LLM trust" tools are *judges* β€” they score an output and leave the decision to you. TrustShell is a **fail-closed gate**: it can **refuse**, it hands back a **ZK-verifiable receipt** you check yourself (not our word), and it carries a **portable, earned RepID** that travels with the agent as you swap the model underneath. An unavailable check is never a pass. That is the difference between *another LLM judge* and a *trust rail*. +Most "LLM trust" tools are *judges*: they score an output and leave the decision to you. TrustShell is built to **fail closed**. An unavailable check is never a pass. It hands you evidence you can check yourself, rather than our word. +- **Today:** Inspect checks an agent's claims about a CI run against GitHub's own record. Its record is canonical JSON with a SHA-256 digest; it is not signed yet. +- **Direction:** a harness that is yours. Your rules, memory and evidence stay with you as you swap the model underneath. Carry, which takes your profile, rules and notes to another machine, is built and not yet released. Reputation that moves with an agent is the aim, and it is not a property of this release. -**One `npm install` gives any agent three protocols in one wrapper:** +Why we build it, and the research for and against each part: [hyperdag-protocol/docs/WHY.md](https://github.com/DealAppSeo/hyperdag-protocol/blob/main/docs/WHY.md). -- βœ… **HAL cross-LLM verification** β€” `verifyOutput()` β€” real cross-provider fact-check quorum (keyless, live-verified) -- πŸ… **ERC-8004 portable reputation** β€” `getRepID()` / `presentProof()` β€” look up any agent's RepID score + tier, or present a client-verifiable range proof (keyless, live-verified) -- πŸ’Έ **x402 payments** β€” `executeA2A()` / `guardedX402Payment()` β€” agent-to-agent service purchase over EIP-3009 x402 (key-gated: blocked without an API key and a funded Base Sepolia wallet, which is why [Live vs paused](#live-vs-paused) lists it as `paused/blocked`) +**One `npm install` gives you:** + +- πŸ”Ž **Inspect** β€” `trustshell check --claims-file ` β€” checks what an agent said about a GitHub Actions run against GitHub's record (no account, no key; its only network call is to api.github.com) +- βœ… **A hosted second-opinion check** β€” `verifyOutput()` β€” two AI checkers read one sentence (keyless). Two agreeing checkers are a second opinion, not proof. +- πŸ… **ERC-8004 reputation** β€” `getRepID()` / `presentProof()` β€” look up an agent's RepID score and tier, or present a range proof anyone can check; the score itself is public (keyless, Base Sepolia test network) +- πŸ’Έ **x402 payments** ([x402](https://github.com/coinbase/x402) by Coinbase) β€” `executeA2A()` / `guardedX402Payment()` β€” agent-to-agent service purchase over EIP-3009 x402 (key-gated: blocked without an API key and a funded Base Sepolia wallet, which is why [Live vs paused](#live-vs-paused) lists it as `paused/blocked`) HAL verify and RepID lookup run against the live backend with **no key**. The x402 *pay* path is real but moves testnet value, so it needs credentials β€” we say so plainly, and never imply a live free purchase. @@ -196,7 +201,7 @@ Three states: **live** | **live-degraded** | **paused/blocked**. npm install @hyperdag/trustshell ``` -This is the one live, published install today. It delivers all three protocols in one wrapper β€” **HAL** verification, **ERC-8004** portable RepID, and **x402** agent-to-agent payments β€” against the live backend. +This is the one live, published install today. It delivers Inspect, the hosted **HAL** check, **ERC-8004** RepID reads and range proofs, and **x402** payments with a spend guard, against the live backend (anything on-chain is on the Base Sepolia test network). The package does not carry the Next.js/React site. The 1.8.0 tarball is 123 files: `dist/`, the command shims in `bin/`, `scripts/init-pai.mjs`, `scripts/value-events.mjs`, `lib/interview.js`, and the README, changelog and license. Runtime dependencies are `@hyperdag/proof-verifier` (dynamically imported; degrades gracefully if the optional WASM build is absent), `@modelcontextprotocol/sdk` and `zod` (for the `trustshell-mcp` server). `ethers` is an **optional** peer dependency: install it (`npm i ethers@^6`) only if you sign x402 payments or call `verifySigner`. (`npm view @hyperdag/trustshell@1.8.0 dependencies peerDependencies dist.fileCount`) @@ -204,9 +209,9 @@ The package does not carry the Next.js/React site. The 1.8.0 tarball is 123 file | If you're… | Install | What you get | |---|---|---| -| A developer building an agent/app **in code** | `npm install @hyperdag/trustshell` | The SDK β€” HAL verification + ERC-8004 RepID + x402 payments, in your TypeScript/JS | +| A developer building an agent/app **in code** | `npm install @hyperdag/trustshell` | The SDK β€” Inspect, HAL verification, ERC-8004 RepID reads and x402 payments, in your TypeScript/JS | | Using an **AI tool** (Claude Desktop, Cursor, Claude Code), **no code** | `npm i -g @hyperdag/trustshell@1.8.0`, then point the tool at the `trustshell-mcp` bin (below) | The checks as AI-callable tools | -| Only verifying **ZK proofs** client-side | `npm install @hyperdag/proof-verifier` | Standalone Plonky3 proof checking. It is a dependency of `@hyperdag/trustshell`, so installing that installs this too β€” rarely installed directly | +| Only verifying **ZK proofs** client-side | `npm install @hyperdag/proof-verifier` | Standalone checking of [Plonky3](https://github.com/Plonky3/Plonky3) proofs (Plonky3 is Polygon Labs' proof system). It is a dependency of `@hyperdag/trustshell`, so installing that installs this too β€” rarely installed directly | **Most people want `@hyperdag/trustshell`. The SDK, the CLI and the MCP server are all in that one package. `proof-verifier` is a separate package it depends on: npm installs it alongside, and the published tarball contains none of its files.** @@ -451,7 +456,7 @@ trustshell verify "$(cat CHANGELOG_CLAIM.txt)" || { The check subcommand reads a public GitHub Actions run URL from the **public** GitHub API and prints what GitHub can confirm about it β€” that the run finished, that its conclusion was -success, that **every job** passed, and that the commit exists on the remote. +success, that **every job** passed, and which commit it ran on, as GitHub reports it. A run that is still queued or in progress is `INCONCLUSIVE`, never `FAILED` β€” an unfinished build has not failed, it has not answered. Likewise, if GitHub reports more jobs than it returned, the @@ -792,7 +797,7 @@ Register your agent at **[repid.dev/start](https://repid.dev/start)**. Browse li ## Model card -A portable trust harness so AI has to earn it. +A trust harness so AI has to earn it. Read-only card at [app/model-card/page.tsx](app/model-card/page.tsx). Columns: family, host, Honesty A, Help B. Honesty A is **FIXTURE** counts of TRUE, FALSE, and NOT_CHECKED from [`fixtures/hal-last-week.fixture.json`](fixtures/hal-last-week.fixture.json). Help B is **no ratings**. There is no human-ratings table in this repo. @@ -813,6 +818,6 @@ HyperDAG Protocol β€” the trust layer TrustShell builds on β€” is moving toward ## License -Apache 2.0 β€” see [LICENSE](LICENSE). Patent rights, if any, are granted under the Apache 2.0 patent grant clause. Commercial use of the (experimental) Pythagorean Comma Veto methodology in closed-source systems requires written permission from DealApp Inc. +Apache 2.0 β€” see [LICENSE](LICENSE). Copyright 2026 Sean Goodwin. Patent rights, if any, are granted under the Apache 2.0 patent grant clause. -Built on [HyperDAG Protocol](https://github.com/DealAppSeo/hyperdag-protocol). ERC-8004 compatible. Micah 6:8. +**Part of the HyperDAG trust stack.** How the pieces fit: [BUILDERS.md](https://github.com/DealAppSeo/hyperdag-protocol/blob/main/BUILDERS.md#how-the-pieces-fit). Credits and licences, including [ERC-8004](https://eips.ethereum.org/EIPS/eip-8004) by its authors, [x402](https://github.com/coinbase/x402) by Coinbase and [Plonky3](https://github.com/Plonky3/Plonky3) by Polygon Labs: [ACKNOWLEDGEMENTS.md](https://github.com/DealAppSeo/hyperdag-protocol/blob/main/ACKNOWLEDGEMENTS.md). Micah 6:8. diff --git a/dist/lib/check.js b/dist/lib/check.js index f55a0979..e189f685 100644 --- a/dist/lib/check.js +++ b/dist/lib/check.js @@ -203,7 +203,7 @@ async function runCheck(url, env = process.env) { ? `${failed.length} of ${jobList.length} jobs did not pass: ${failed.map((j) => j.name).join(', ')}` : plural(jobList.length, 'job'), }, - { ok: Boolean(sha), label: 'The commit exists on the remote', detail: sha }, + { ok: Boolean(sha), label: 'GitHub reports the commit this run ran on', detail: sha }, ], does_not_prove: exports.DOES_NOT_PROVE, run: { diff --git a/package.json b/package.json index bf753da5..c46f2147 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hyperdag/trustshell", "version": "1.8.0", - "description": "Trust SDK for AI agents β€” HAL hallucination filtering, portable RepID, and agent-to-agent service purchase with verifiable receipts, against the live HyperDAG backend.", + "description": "Check what your AI agent says it did against the evidence. Inspect checks an agent's claims about a GitHub Actions run against GitHub's own record, with no account; also a hosted second-opinion check, local memory, an MCP server and an SDK.", "license": "Apache-2.0", "publishConfig": { "access": "public" diff --git a/src/lib/check.ts b/src/lib/check.ts index 4680c4ee..6e24d2d5 100644 --- a/src/lib/check.ts +++ b/src/lib/check.ts @@ -258,7 +258,7 @@ export async function runCheck(url: string, env: Record j.name).join(', ')}` : plural(jobList.length, 'job'), }, - { ok: Boolean(sha), label: 'The commit exists on the remote', detail: sha }, + { ok: Boolean(sha), label: 'GitHub reports the commit this run ran on', detail: sha }, ], does_not_prove: DOES_NOT_PROVE, run: { diff --git a/tests/action-evidence.test.ts b/tests/action-evidence.test.ts index 4ec2cd51..9efcb82c 100644 --- a/tests/action-evidence.test.ts +++ b/tests/action-evidence.test.ts @@ -928,7 +928,10 @@ describe('record digest (RFC 8785 + SHA-256)', () => { }); }); -const GOLDEN_GREEN_NARROW = 'cb114733d44e731213d02d78349bad4afcb1d23236f563b31ff42a18ea5c6455'; +// Moved 2026-10-23, on purpose: the run check's commit label said "The commit exists on the remote", but no lookup +// is done; it now says "GitHub reports the commit this run ran on". The label is in the record, so the digest moved. +// Recomputed independently with Python's json.dumps(sort_keys=True) | sha256 on the same record: same value. +const GOLDEN_GREEN_NARROW = '9b7c9646ccec6cf5dff69bd066ed41f13ed6b1e769347323e4431c5faceb9d97'; // ── the card ────────────────────────────────────────────────────────────────────────────────────── From 653ec1b2c70f682ace4b116866c37e3246759e56 Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Fri, 23 Oct 2026 19:33:20 -0700 Subject: [PATCH 02/10] fix(mcp,sdk): OFFLINE=1 now stops verify_output, verify and evaluate from sending the text (loop sprint 1) (#623) The audit found that MCP verify_output returned a live PASS with OFFLINE=1: only TRUSTSHELL_LOCAL_ONLY=1 stopped it. TrustShell.score is where every HAL send goes through (MCP verify, evaluate and verify_output, SDK verifyOutput and evaluate, wrap-execute), and it looked only at local-only. It now stops on OFFLINE=1 too, with the same words the CLI uses. Test first: tests/mcp-offline-sends-nothing.test.ts fails on main (4 of 9: the three MCP tools and the SDK each sent a request and answered PASS) and passes here. The 5 that pass on main are the controls: with OFFLINE unset the same calls do reach HAL. dist/ rebuilt (5 files), docs/getting-started.md OFFLINE row and CHANGELOG updated. Co-authored-by: Claude Sonnet 5.5 (cherry picked from commit 37f24027ac0ed2662f28fe364f5169d34060daf5) --- dist/lib/claim.d.ts | 3 +- dist/lib/claim.js | 5 +- dist/lib/local-only.d.ts | 12 +++ dist/lib/local-only.js | 17 +++- dist/lib/trustshell.js | 7 +- docs/getting-started.md | 2 +- src/lib/claim.ts | 4 +- src/lib/local-only.ts | 18 +++++ src/lib/trustshell.ts | 9 ++- tests/mcp-offline-sends-nothing.test.ts | 102 ++++++++++++++++++++++++ 10 files changed, 166 insertions(+), 13 deletions(-) create mode 100644 tests/mcp-offline-sends-nothing.test.ts diff --git a/dist/lib/claim.d.ts b/dist/lib/claim.d.ts index a9a47379..f3564214 100644 --- a/dist/lib/claim.d.ts +++ b/dist/lib/claim.d.ts @@ -1,10 +1,11 @@ +import { OFFLINE_REASON } from './local-only'; export declare const CLAIM_LABELS: readonly ["pass", "veto", "not-checked"]; export type ClaimLabel = (typeof CLAIM_LABELS)[number]; /** The SDK's default backend origin (`TrustShell` constructor). Kept identical on purpose. */ export declare const DEFAULT_API_URL = "https://repid-engine-production.up.railway.app"; export declare const CLASSIFY_PATH = "/api/v1/classify"; /** Why a check was not sent under OFFLINE=1. The CLI's `verify` and `check ` print the same words. */ -export declare const OFFLINE_REASON = "OFFLINE=1 is set, so nothing was sent"; +export { OFFLINE_REASON }; /** Stop waiting after this. A later answer is not-checked, never a pass. */ export declare const CLAIM_TIMEOUT_MS = 6000; export interface ClaimResult { diff --git a/dist/lib/claim.js b/dist/lib/claim.js index 041f78e0..dcaa9463 100644 --- a/dist/lib/claim.js +++ b/dist/lib/claim.js @@ -38,12 +38,11 @@ exports.isUrlOperand = isUrlOperand; const redact_1 = require("../memory/redact"); const api_url_1 = require("./api-url"); const local_only_1 = require("./local-only"); +Object.defineProperty(exports, "OFFLINE_REASON", { enumerable: true, get: function () { return local_only_1.OFFLINE_REASON; } }); exports.CLAIM_LABELS = ['pass', 'veto', 'not-checked']; /** The SDK's default backend origin (`TrustShell` constructor). Kept identical on purpose. */ exports.DEFAULT_API_URL = 'https://repid-engine-production.up.railway.app'; exports.CLASSIFY_PATH = '/api/v1/classify'; -/** Why a check was not sent under OFFLINE=1. The CLI's `verify` and `check ` print the same words. */ -exports.OFFLINE_REASON = 'OFFLINE=1 is set, so nothing was sent'; /** Stop waiting after this. A later answer is not-checked, never a pass. */ exports.CLAIM_TIMEOUT_MS = 6000; /** A label is one short word. A body larger than this is not an answer. */ @@ -296,7 +295,7 @@ async function classifyClaim(text, opts = {}) { // SEC F3: OFFLINE=1 sends nothing. Every caller (CLI check, MCP check_claim, quarantine release) comes through here. const env = opts.env ?? (typeof process !== 'undefined' ? process.env : {}); if (env.OFFLINE === '1') - return mark({ label: 'not-checked', latency_ms: 0, reason: exports.OFFLINE_REASON }); + return mark({ label: 'not-checked', latency_ms: 0, reason: local_only_1.OFFLINE_REASON }); // SEC F2: local-only sends nothing (CLI check, MCP check_claim and quarantine release all come here). if ((0, local_only_1.localOnly)(opts.localOnly, opts.env)) return mark({ label: 'not-checked', latency_ms: 0, reason: local_only_1.LOCAL_ONLY_REASON }); diff --git a/dist/lib/local-only.d.ts b/dist/lib/local-only.d.ts index 3d8ee73d..fe45d186 100644 --- a/dist/lib/local-only.d.ts +++ b/dist/lib/local-only.d.ts @@ -23,6 +23,18 @@ * MCP tool return does not change in a stub. */ export declare const LOCAL_ONLY_REASON = "local-only is on (TRUSTSHELL_LOCAL_ONLY=1 or localOnly: true), so nothing was sent"; +/** + * Why a check was not sent under OFFLINE=1. The CLI's `verify` and `check `, `classifyClaim` + * (so `check ""` and MCP check_claim) and `TrustShell.score` (so SDK verifyOutput and the + * MCP verify_output, verify and evaluate) all print these words. + */ +export declare const OFFLINE_REASON = "OFFLINE=1 is set, so nothing was sent"; +/** + * True when OFFLINE=1 is in the environment. The older, CLI-era switch: same promise as local-only + * for the text-sending paths, but the CLI also uses it to skip backend reads (status, bind-status), + * which local-only does not. A path that sends your text honours both. + */ +export declare function offline(env?: Record): boolean; /** Which outside providers saw the text. [] = nothing was sent. 'unknown' = the engine did not say. */ export type SeenBy = string[] | 'unknown'; /** True when the caller asked for local-only, by option or by env. */ diff --git a/dist/lib/local-only.js b/dist/lib/local-only.js index 3afdc778..67cefb2e 100644 --- a/dist/lib/local-only.js +++ b/dist/lib/local-only.js @@ -1,6 +1,7 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.LOCAL_ONLY_REASON = void 0; +exports.OFFLINE_REASON = exports.LOCAL_ONLY_REASON = void 0; +exports.offline = offline; exports.localOnly = localOnly; exports.seenByFromHal = seenByFromHal; /** @@ -28,6 +29,20 @@ exports.seenByFromHal = seenByFromHal; * MCP tool return does not change in a stub. */ exports.LOCAL_ONLY_REASON = 'local-only is on (TRUSTSHELL_LOCAL_ONLY=1 or localOnly: true), so nothing was sent'; +/** + * Why a check was not sent under OFFLINE=1. The CLI's `verify` and `check `, `classifyClaim` + * (so `check ""` and MCP check_claim) and `TrustShell.score` (so SDK verifyOutput and the + * MCP verify_output, verify and evaluate) all print these words. + */ +exports.OFFLINE_REASON = 'OFFLINE=1 is set, so nothing was sent'; +/** + * True when OFFLINE=1 is in the environment. The older, CLI-era switch: same promise as local-only + * for the text-sending paths, but the CLI also uses it to skip backend reads (status, bind-status), + * which local-only does not. A path that sends your text honours both. + */ +function offline(env = typeof process !== 'undefined' ? process.env : {}) { + return env.OFFLINE === '1'; +} /** True when the caller asked for local-only, by option or by env. */ function localOnly(explicit, env = typeof process !== 'undefined' ? process.env : {}) { return explicit === true || env.TRUSTSHELL_LOCAL_ONLY === '1'; diff --git a/dist/lib/trustshell.js b/dist/lib/trustshell.js index f9d06d87..d995139d 100644 --- a/dist/lib/trustshell.js +++ b/dist/lib/trustshell.js @@ -216,7 +216,10 @@ class TrustShell { } async score(response, options = {}) { // SEC F2: local-only sends nothing. NOT_CHECKED, no trust earned, and nobody saw the text. - if ((0, local_only_1.localOnly)(this.config.localOnly)) { + // OFFLINE=1 says the same (it used to stop only the CLI's own `verify`, so the MCP verify_output + // and the SDK still sent the text: loop sprint 1, 2026-10-23). Every HAL send comes through here. + const stopReason = (0, local_only_1.localOnly)(this.config.localOnly) ? local_only_1.LOCAL_ONLY_REASON : (0, local_only_1.offline)() ? local_only_1.OFFLINE_REASON : null; + if (stopReason) { return { trustScore: 0, halScore: 0, @@ -225,7 +228,7 @@ class TrustShell { flaggedHallucination: false, provider: 'none', model: 'none', - decisionReason: `NOT_CHECKED β€” ${local_only_1.LOCAL_ONLY_REASON}`, + decisionReason: `NOT_CHECKED β€” ${stopReason}`, evidence: [], seenBy: [], }; diff --git a/docs/getting-started.md b/docs/getting-started.md index bf0a6328..56d4f965 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -204,7 +204,7 @@ Environment variables the code in `src/` reads (`grep -rn 'env' src/`): | `TRUSTSHELL_HOME` | SDK (`guardedX402Payment` audit log) | Directory for the local spend audit log. Default `~/.trustshell`. | | `TRUSTSHELL_LOCAL_ONLY` | SDK (HAL verify, classify), CLI `verify` / `check ""`, MCP `verify_output` / `check_claim` | Since 1.8.0. `1` sends none of your text anywhere: each of those answers `NOT_CHECKED` before any network call. The SDK option `localOnly: true` does the same. Off by default. A HAL result's `seenBy` lists the providers the engine says saw the text (`[]` when nothing was sent, `unknown` when the engine does not say). | | `TRUSTSHELL_LAYA` | CLI `verify` | `local` or `engine` runs a Laya lane before HAL, which can exit `2` (cheap lane, nothing sent to HAL) or `4` (ASK). Unset: straight to the HAL quorum. | -| `OFFLINE` | CLI `status`, `bind-status`, `check`, `verify`; MCP `status`, `check_claim` | `OFFLINE=1` sends nothing: the backend reads are skipped and reported `NOT_CHECKED`, and `check` (both forms) and `verify` print `NOT_CHECKED` and exit 2 before any network call. | +| `OFFLINE` | CLI `status`, `bind-status`, `check`, `verify`; MCP `status`, `check_claim`, `inspect_run`, `verify_output` (and its aliases `verify`, `evaluate`); SDK `verifyOutput` and `evaluate` | `OFFLINE=1` sends nothing: the backend reads are skipped and reported `NOT_CHECKED`, and `check` (both forms) and `verify` print `NOT_CHECKED` and exit 2 before any network call. The MCP verify tools and the SDK answer `NOT_CHECKED` with `seenBy: []` (nobody saw the text). Before this fix they still sent it; only `TRUSTSHELL_LOCAL_ONLY=1` stopped them. | Two more are read by the CLI. `status` prints a true `can_stake` as `live` only when `SAYS_STAKE_LIVE` is exactly `true`; otherwise it reads `shadow β€” not live`. And the CLI removes the diff --git a/src/lib/claim.ts b/src/lib/claim.ts index 5b2c8e6e..0eb20dba 100644 --- a/src/lib/claim.ts +++ b/src/lib/claim.ts @@ -21,7 +21,7 @@ */ import { redact } from '../memory/redact'; import { resolveApiBase } from './api-url'; -import { LOCAL_ONLY_REASON, localOnly } from './local-only'; +import { LOCAL_ONLY_REASON, OFFLINE_REASON, localOnly } from './local-only'; export const CLAIM_LABELS = ['pass', 'veto', 'not-checked'] as const; export type ClaimLabel = (typeof CLAIM_LABELS)[number]; @@ -30,7 +30,7 @@ export type ClaimLabel = (typeof CLAIM_LABELS)[number]; export const DEFAULT_API_URL = 'https://repid-engine-production.up.railway.app'; export const CLASSIFY_PATH = '/api/v1/classify'; /** Why a check was not sent under OFFLINE=1. The CLI's `verify` and `check ` print the same words. */ -export const OFFLINE_REASON = 'OFFLINE=1 is set, so nothing was sent'; +export { OFFLINE_REASON }; /** Stop waiting after this. A later answer is not-checked, never a pass. */ export const CLAIM_TIMEOUT_MS = 6000; /** A label is one short word. A body larger than this is not an answer. */ diff --git a/src/lib/local-only.ts b/src/lib/local-only.ts index 99a7d126..d8195c77 100644 --- a/src/lib/local-only.ts +++ b/src/lib/local-only.ts @@ -24,6 +24,24 @@ */ export const LOCAL_ONLY_REASON = 'local-only is on (TRUSTSHELL_LOCAL_ONLY=1 or localOnly: true), so nothing was sent'; +/** + * Why a check was not sent under OFFLINE=1. The CLI's `verify` and `check `, `classifyClaim` + * (so `check ""` and MCP check_claim) and `TrustShell.score` (so SDK verifyOutput and the + * MCP verify_output, verify and evaluate) all print these words. + */ +export const OFFLINE_REASON = 'OFFLINE=1 is set, so nothing was sent'; + +/** + * True when OFFLINE=1 is in the environment. The older, CLI-era switch: same promise as local-only + * for the text-sending paths, but the CLI also uses it to skip backend reads (status, bind-status), + * which local-only does not. A path that sends your text honours both. + */ +export function offline( + env: Record = typeof process !== 'undefined' ? process.env : {}, +): boolean { + return env.OFFLINE === '1'; +} + /** Which outside providers saw the text. [] = nothing was sent. 'unknown' = the engine did not say. */ export type SeenBy = string[] | 'unknown'; diff --git a/src/lib/trustshell.ts b/src/lib/trustshell.ts index 3f90e158..7cf1258e 100644 --- a/src/lib/trustshell.ts +++ b/src/lib/trustshell.ts @@ -9,7 +9,7 @@ import { loadEthers } from './optional-ethers'; import { createHash } from 'node:crypto'; import { redact } from '../memory/redact'; import { isHttps, resolveApiBase } from './api-url'; -import { LOCAL_ONLY_REASON, localOnly, seenByFromHal, type SeenBy } from './local-only'; +import { LOCAL_ONLY_REASON, OFFLINE_REASON, localOnly, offline, seenByFromHal, type SeenBy } from './local-only'; import { assertOriginCanPay } from './origin'; import type { AgentTurnOrigin } from './origin'; import { @@ -728,7 +728,10 @@ export class TrustShell { async score(response: string, options: ScoreOptions = {}): Promise { // SEC F2: local-only sends nothing. NOT_CHECKED, no trust earned, and nobody saw the text. - if (localOnly(this.config.localOnly)) { + // OFFLINE=1 says the same (it used to stop only the CLI's own `verify`, so the MCP verify_output + // and the SDK still sent the text: loop sprint 1, 2026-10-23). Every HAL send comes through here. + const stopReason = localOnly(this.config.localOnly) ? LOCAL_ONLY_REASON : offline() ? OFFLINE_REASON : null; + if (stopReason) { return { trustScore: 0, halScore: 0, @@ -737,7 +740,7 @@ export class TrustShell { flaggedHallucination: false, provider: 'none', model: 'none', - decisionReason: `NOT_CHECKED β€” ${LOCAL_ONLY_REASON}`, + decisionReason: `NOT_CHECKED β€” ${stopReason}`, evidence: [], seenBy: [], }; diff --git a/tests/mcp-offline-sends-nothing.test.ts b/tests/mcp-offline-sends-nothing.test.ts new file mode 100644 index 00000000..282ff136 --- /dev/null +++ b/tests/mcp-offline-sends-nothing.test.ts @@ -0,0 +1,102 @@ +/** + * Loop sprint 1 (claims-vs-code, 2026-10-23). "OFFLINE=1 sends nothing" was true for the CLI + * (`check`, `verify`, pinned by tests/offline-sends-nothing.test.ts) and for the MCP `check_claim`, + * `inspect_run` and `status`. It was not true for the MCP `verify_output` (and its aliases `verify` + * and `evaluate`) or the SDK call under them: `TrustShell.score` only looked at + * TRUSTSHELL_LOCAL_ONLY, so the text went to HAL. The audit (CLAIMS_CODE_AUDIT.md, "found in + * passing") got a live PASS from `verify_output` with OFFLINE=1. + * + * These drive the real MCP server over a REAL TrustShell client with global fetch stubbed, so a + * send cannot hide behind a mock client. The control runs prove the instrument can see a send: + * with OFFLINE unset the same calls reach the stub and come back PASS. + */ +import { createServer } from '../src/mcp/index'; +import { TrustShell } from '../src/lib/trustshell'; + +const ENGINE = 'https://engine-offline.test'; +const TEXT = 'paste-your-own-claim-77b1'; +const HAL = { + decision: 'clean', + hal_score: 0.05, + mode: 'fact-check', + signals: { families_used: 2, providers_used: 2 }, + provider_responses: [{ provider: 'groq', verdict: 'TRUE' }], +}; + +describe('OFFLINE=1 sends nothing from MCP verify_output / verify / evaluate and the SDK', () => { + const prevOffline = process.env.OFFLINE; + const prevLocal = process.env.TRUSTSHELL_LOCAL_ONLY; + const prevFetch = global.fetch; + let calls: string[]; + + beforeEach(() => { + delete process.env.OFFLINE; + delete process.env.TRUSTSHELL_LOCAL_ONLY; + calls = []; + global.fetch = (async (input: RequestInfo | URL) => { + calls.push(String(input)); + return new Response(JSON.stringify(HAL), { status: 200, headers: { 'content-type': 'application/json' } }); + }) as typeof fetch; + }); + + afterEach(() => { + global.fetch = prevFetch; + if (prevOffline === undefined) delete process.env.OFFLINE; + else process.env.OFFLINE = prevOffline; + if (prevLocal === undefined) delete process.env.TRUSTSHELL_LOCAL_ONLY; + else process.env.TRUSTSHELL_LOCAL_ONLY = prevLocal; + }); + + const tools = ['verify_output', 'verify', 'evaluate'] as const; + + it.each(tools)('control: with OFFLINE unset, MCP %s reaches HAL and answers PASS', async (name) => { + const server: any = createServer(new TrustShell({ apiUrl: ENGINE })); + const r = await server._registeredTools[name].handler({ text: TEXT }); + expect(calls).toHaveLength(1); + expect(r.content[0].text).toContain('PASS'); + }); + + it.each(tools)('OFFLINE=1: MCP %s answers NOT_CHECKED, never PASS, and sends nothing', async (name) => { + process.env.OFFLINE = '1'; + const server: any = createServer(new TrustShell({ apiUrl: ENGINE })); + const r = await server._registeredTools[name].handler({ text: TEXT }); + const text: string = r.content[0].text; + expect(calls).toEqual([]); + expect(text).toContain('NOT_CHECKED'); + expect(text).not.toMatch(/"verdict":\s*"PASS"/); + expect(text).toContain('OFFLINE=1'); + // Nobody saw the text: the same field the local-only result sets. + expect(JSON.stringify(JSON.parse(text).untrusted_data.seenBy)).toBe('[]'); + expect(text).not.toContain(TEXT); + }); + + it('OFFLINE=1: the SDK (verifyOutput and evaluate) is NOT_CHECKED, ok false, nothing sent', async () => { + process.env.OFFLINE = '1'; + const shell = new TrustShell({ apiUrl: ENGINE }); + for (const r of [await shell.verifyOutput(TEXT), await shell.evaluate(TEXT)]) { + expect(r.verdict).toBe('NOT_CHECKED'); + expect(r.ok).toBe(false); + expect(r.trustScore).toBe(0); + expect(r.seenBy).toEqual([]); + expect(r.decisionReason).toContain('OFFLINE=1'); + } + expect(calls).toEqual([]); + }); + + it('only the value 1 turns it on (OFFLINE=0 and an empty OFFLINE still send)', async () => { + const shell = new TrustShell({ apiUrl: ENGINE }); + process.env.OFFLINE = '0'; + expect((await shell.verifyOutput(TEXT)).verdict).toBe('PASS'); + process.env.OFFLINE = ''; + expect((await shell.verifyOutput(TEXT)).verdict).toBe('PASS'); + expect(calls).toHaveLength(2); + }); + + it('TRUSTSHELL_LOCAL_ONLY keeps its own reason when both are set', async () => { + process.env.OFFLINE = '1'; + process.env.TRUSTSHELL_LOCAL_ONLY = '1'; + const r = await new TrustShell({ apiUrl: ENGINE }).verifyOutput(TEXT); + expect(r.verdict).toBe('NOT_CHECKED'); + expect(calls).toEqual([]); + }); +}); From 9d0207ad4bc4dc97818aa8566867dad904d4905d Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Sat, 24 Oct 2026 06:30:25 -0700 Subject: [PATCH 03/10] fix(profile): profile show prints the profile and its problem lines as text (MEDIUM) (#637) profile show printed profile.md verbatim and its "problem:" lines quoted the bad value from the file. A profile a trusted bundle signer wrote into profile.md (bundle import) reached the owner's terminal with its control and bidi bytes, while bundle context scrubs the same text. Both prints (and the refusal list an edit prints) now go through cleanCodePoints; newlines are kept. Test first: tests/antifragile-profile-show-scrub.test.ts failed on main on the control-byte assertion alone (its controls held) and passes with the fix. Corpus fixture af-review-profile-show-escape. Co-authored-by: Claude Sonnet 5.5 (cherry picked from commit 034bd6dae40146fb45948b80a20526a70048ef20) --- dist/cli/profile.js | 8 ++- src/cli/profile.ts | 8 ++- tests/antifragile-profile-show-scrub.test.ts | 74 ++++++++++++++++++++ 3 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 tests/antifragile-profile-show-scrub.test.ts diff --git a/dist/cli/profile.js b/dist/cli/profile.js index 54b09ed5..ef08aaae 100644 --- a/dist/cli/profile.js +++ b/dist/cli/profile.js @@ -19,6 +19,7 @@ exports.terminalAsk = terminalAsk; const profile_1 = require("../lib/profile"); const init_1 = require("../lib/init"); const remember_1 = require("./remember"); +const action_evidence_1 = require("../lib/action-evidence"); exports.PROFILE_PATH = `${init_1.TRUSTSHELL_DIR}/${init_1.PROFILE_FILE}`; const SECTION_ALIASES = { value: 'values', @@ -195,9 +196,10 @@ async function runProfile(op, io, fs, json = false) { else if (!exists) io.out(`NO_PROFILE ${exports.PROFILE_PATH} does not exist. \`trustshell init\` creates a blank one.`); else { - io.out(text.replace(/\s+$/, '')); + // The file may hold text someone else wrote (a profile imported from a signed bundle), and a problem line quotes the bad value: print both as text. + io.out((0, action_evidence_1.cleanCodePoints)(text.replace(/\s+$/, ''), true)); for (const pr of parsed.problems) - io.err(`problem: ${pr.field}: ${pr.detail}`); + io.err(`problem: ${pr.field}: ${(0, action_evidence_1.cleanCodePoints)(pr.detail)}`); } return 0; } @@ -205,7 +207,7 @@ async function runProfile(op, io, fs, json = false) { if (parsed.problems.length) { io.err(`${exports.PROFILE_PATH} has problems; fix them by hand first, nothing was written:`); for (const pr of parsed.problems) - io.err(` ${pr.field}: ${pr.detail}`); + io.err(` ${pr.field}: ${(0, action_evidence_1.cleanCodePoints)(pr.detail)}`); return 1; } let profile = parsed.profile; diff --git a/src/cli/profile.ts b/src/cli/profile.ts index 5dfb6041..bfc80eed 100644 --- a/src/cli/profile.ts +++ b/src/cli/profile.ts @@ -26,6 +26,7 @@ import { } from '../lib/profile'; import { TRUSTSHELL_DIR, PROFILE_FILE } from '../lib/init'; import { refusedValue } from './remember'; +import { cleanCodePoints } from '../lib/action-evidence'; export const PROFILE_PATH = `${TRUSTSHELL_DIR}/${PROFILE_FILE}`; @@ -224,8 +225,9 @@ export async function runProfile(op: ProfileOp, io: ProfileIo, fs: ProfileFs, js if (json) io.out(JSON.stringify({ path: PROFILE_PATH, exists, ...parsed }, null, 2)); else if (!exists) io.out(`NO_PROFILE ${PROFILE_PATH} does not exist. \`trustshell init\` creates a blank one.`); else { - io.out(text.replace(/\s+$/, '')); - for (const pr of parsed.problems) io.err(`problem: ${pr.field}: ${pr.detail}`); + // The file may hold text someone else wrote (a profile imported from a signed bundle), and a problem line quotes the bad value: print both as text. + io.out(cleanCodePoints(text.replace(/\s+$/, ''), true)); + for (const pr of parsed.problems) io.err(`problem: ${pr.field}: ${cleanCodePoints(pr.detail)}`); } return 0; } @@ -233,7 +235,7 @@ export async function runProfile(op: ProfileOp, io: ProfileIo, fs: ProfileFs, js // Never rewrite a file this build cannot read cleanly: the rewrite could drop what it misread. if (parsed.problems.length) { io.err(`${PROFILE_PATH} has problems; fix them by hand first, nothing was written:`); - for (const pr of parsed.problems) io.err(` ${pr.field}: ${pr.detail}`); + for (const pr of parsed.problems) io.err(` ${pr.field}: ${cleanCodePoints(pr.detail)}`); return 1; } diff --git a/tests/antifragile-profile-show-scrub.test.ts b/tests/antifragile-profile-show-scrub.test.ts new file mode 100644 index 00000000..bc3738a2 --- /dev/null +++ b/tests/antifragile-profile-show-scrub.test.ts @@ -0,0 +1,74 @@ +/** + * Antifragile night, lane A, cycle 2 (2026-10-24). `profile show` prints profile.md verbatim, and the "problem:" lines + * echo the bad value from the file ("unknown value \"\""). A profile a trusted bundle signer wrote into + * profile.md (bundle import) therefore reached the owner's terminal raw, while `bundle context` scrubs the same text. + * Corpus fixture af-review-profile-show-escape. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseArgs, run, type CliIO } from '../src/cli'; + +const c = (...cps: number[]) => String.fromCodePoint(...cps); +const CONTROL = new RegExp(`[${c(0x00)}-${c(0x09)}${c(0x0b)}-${c(0x1f)}${c(0x7f)}-${c(0x9f)}${c(0x200b)}-${c(0x200f)}${c(0x202a)}-${c(0x202e)}${c(0x2066)}-${c(0x2069)}]`, 'u'); +const HOSTILE = `ops${c(0x1b)}[2J${c(0x1b)}[32mALL CLEAR${c(0x202e)}`; + +describe('antifragile: profile show prints the profile as text', () => { + const prevEnv = { ...process.env }; + const prevCwd = process.cwd(); + let root = ''; + let out: string[] = []; + let err: string[] = []; + const io: CliIO = { out: (s) => out.push(s), err: (s) => err.push(s) }; + const text = () => `${out.join('\n')}\n${err.join('\n')}`; + async function cli(...argv: string[]): Promise { + out = []; + err = []; + return run(parseArgs(argv), {} as never, io); + } + const profile = (body: string) => { + mkdirSync(join(root, 'work', '.trustshell'), { recursive: true }); + writeFileSync(join(root, 'work', '.trustshell', 'profile.md'), body); + }; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'ts-antifragile-')); + mkdirSync(join(root, 'work'), { recursive: true }); + process.chdir(join(root, 'work')); + process.env.TRUSTSHELL_HOME = join(root, 'home'); + process.env.TRUSTSHELL_MEMORY = join(root, 'home', 'memory.sqlite'); + }); + afterEach(() => { + process.chdir(prevCwd); + process.env = { ...prevEnv }; + rmSync(root, { recursive: true, force: true }); + }); + + it('control: a plain profile is shown unchanged, newlines and all', async () => { + const body = '---\ntrustshell_profile: 1\n---\n\n# Identity\nA person who likes tea.\n'; + profile(body); + expect(await cli('profile', 'show')).toBe(0); + expect(out.join('\n')).toContain('A person who likes tea.'); + expect(out.join('\n').split('\n').length).toBeGreaterThan(3); + }); + + it('a hostile line in the profile body is shown with no control or bidi byte', async () => { + profile(`---\ntrustshell_profile: 1\n---\n\n# Identity\n${HOSTILE}\n`); + expect(await cli('profile', 'show')).toBe(0); + expect(out.join('\n')).toMatch(/Identity\nops/); // still there, as text + expect(text()).not.toMatch(CONTROL); + }); + + it('the "problem:" lines do not replay the bad value the file held', async () => { + profile(`---\ntrustshell_profile: 1\n---\n\n# Trust settings\nautonomy: ${HOSTILE}\n`); + expect(await cli('profile', 'show')).toBe(0); + expect(text()).toMatch(/problem: autonomy: unknown value/); // control + expect(text()).not.toMatch(CONTROL); + }); + + it('an edit that is refused because of problems does not replay them either', async () => { + profile(`---\ntrustshell_profile: ${HOSTILE}\n---\n`); + expect(await cli('profile', 'add', 'values', 'be kind')).toBe(1); + expect(text()).toMatch(/has problems/); // control + expect(text()).not.toMatch(CONTROL); + }); +}); From 29899f2da2ac284787969418973ce8632811effb Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Sat, 24 Oct 2026 06:53:44 -0700 Subject: [PATCH 04/10] fix(redact): bare Groq, Cerebras, xAI, Perplexity, Fireworks, Supabase, Tavily and Replicate keys are credential shapes (MEDIUM) (#642) The detector is a list of shapes (redact.ts says so) and did not know these providers' bare keys, although it refused an OpenAI-style key, a GitHub token and a labelled GROQ_API_KEY=. remember, and so bundle export ("a bundle never carries credentials"), stored them, and the redactor left them in text bound for the hosted engine through check "". PROVIDER_KEYS carries each key's real body length, not PREFIXED_TOKEN's 8, so an identifier like fw_update_package stays readable (the test pins ten such words). Added in both copies of the list, src/memory/redact.ts and extension/scrub.js, at the same position; scrub-parity passes. Test first: tests/antifragile-credential-shapes.test.ts failed 16 of 18 on main (8 keys through two doors; the control and the false-positive guard held) and passes. Corpus fixture af-review-remember-accepts-provider-keys. Co-authored-by: Claude Sonnet 5.5 (cherry picked from commit 55c9e5a5e43c1b3c9c89cdf57d0ede66237fa47d) --- dist/memory/redact.js | 9 +++- extension/scrub.js | 4 ++ src/memory/redact.ts | 9 +++- tests/antifragile-credential-shapes.test.ts | 60 +++++++++++++++++++++ 4 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 tests/antifragile-credential-shapes.test.ts diff --git a/dist/memory/redact.js b/dist/memory/redact.js index 7243ddfc..af8e5285 100644 --- a/dist/memory/redact.js +++ b/dist/memory/redact.js @@ -94,6 +94,12 @@ const HEX_64 = /\b(?:0x)?[0-9a-fA-F]{64}\b/g; /** Shared with remember refusal. Token bodies are assumed to be at least 8 characters. */ exports.PREFIXED_TOKEN = /\b(?:pypi-|npm_|hf_|whsec_|glsoat-|glagent-|glptt-|gloas-|glrt-|gldt-|glpat-|ghs_|ghr_|ghu_|gho_|xoxc-|xoxs-|xoxr-|xoxe-|xoxa-|xoxp-|xoxb-)[A-Za-z0-9_-]{8,}\b/; const PREFIXED_TOKENS = new RegExp(exports.PREFIXED_TOKEN.source, 'g'); +/** + * Bare provider API keys this ecosystem is built on, which the list above did not know and the loop's own scrubber did: Groq `gsk_`, Cerebras + * `csk-`, xAI `xai-`, Perplexity `pplx-`, Fireworks `fw_`, Supabase `sbp_`, Tavily `tvly-`, Replicate `r8_`. Each carries its key's real body length + * rather than PREFIXED_TOKEN's 8: `fw_update_package` is an identifier, not a key. A labelled `GROQ_API_KEY=...` was always caught. + */ +const PROVIDER_KEYS = /\b(?:(?:gsk_|csk-|xai-|pplx-)[A-Za-z0-9]{40,}|fw_[A-Za-z0-9]{20,}|sbp_[0-9a-f]{40}|tvly-[A-Za-z0-9-]{20,}|r8_[A-Za-z0-9]{30,})\b/g; /** Personal data. Each shape needs its separators, so plain numbers in a claim are left alone. */ /** * An email address, bounded by the limits of an address itself (RFC 5321): the local part up to 64 characters, @@ -256,7 +262,7 @@ function isBareAwsSecret(candidate) { */ function containsSecret(value) { const s = String(value ?? ''); - const shaped = [SECRET, DB_URL, BEARER, EYJ, SK, STRIPE, AWS_KEY_ID, GOOGLE_API_KEY, GOOGLE_OAUTH, WEBHOOK_SIGNING, EXTENDED_KEY, WIF_KEY, SOLANA_BASE58, GHP, GITHUB_PAT, HF, WEBHOOK_URL, CREDENTIAL_ASSIGNMENT, HEX_64, PREFIXED_TOKENS] + const shaped = [SECRET, DB_URL, BEARER, EYJ, SK, STRIPE, AWS_KEY_ID, GOOGLE_API_KEY, GOOGLE_OAUTH, WEBHOOK_SIGNING, EXTENDED_KEY, WIF_KEY, SOLANA_BASE58, GHP, GITHUB_PAT, HF, WEBHOOK_URL, CREDENTIAL_ASSIGNMENT, HEX_64, PREFIXED_TOKENS, PROVIDER_KEYS] .some((re) => { re.lastIndex = 0; const hit = re.test(s); @@ -287,6 +293,7 @@ function redact(value) { .replace(HF, '') .replace(WEBHOOK_SIGNING, '') .replace(PREFIXED_TOKENS, '') + .replace(PROVIDER_KEYS, '') .replace(EXTENDED_KEY, '') .replace(WIF_KEY, '') .replace(CREDENTIAL_ASSIGNMENT, '$1$2$3') diff --git a/extension/scrub.js b/extension/scrub.js index d859ea40..740a9780 100644 --- a/extension/scrub.js +++ b/extension/scrub.js @@ -185,6 +185,9 @@ /\b((?:[A-Za-z0-9]{1,20}[_-]){0,4}(?:password|passwd|pwd|secret|api[_-]?key|apikey|api[_-]?secret|access[_-]?token|auth[_-]?token|refresh[_-]?token|session[_-]?token|client[_-]?secret|webhook[_-]?secret|signing[_-]?secret|secret[_-]?key|private[_-]?key|aws_secret_access_key))(["']?)(\s*[=:]\s*)(["']?)[^\s"',;]{6,}\4/gi; const HEX_64 = /\b(?:0x)?[0-9a-fA-F]{64}\b/g; const PREFIXED_TOKENS = /\b(?:pypi-|npm_|hf_|whsec_|glsoat-|glagent-|glptt-|gloas-|glrt-|gldt-|glpat-|ghs_|ghr_|ghu_|gho_|xoxc-|xoxs-|xoxr-|xoxe-|xoxa-|xoxp-|xoxb-)[A-Za-z0-9_-]{8,}\b/g; + // Bare provider API keys (Groq, Cerebras, xAI, Perplexity, Fireworks, Supabase, Tavily, Replicate), each at its key's real body length + // rather than 8: `fw_update_package` is an identifier, not a key (see PROVIDER_KEYS in src/memory/redact.ts). + const PROVIDER_KEYS = /\b(?:(?:gsk_|csk-|xai-|pplx-)[A-Za-z0-9]{40,}|fw_[A-Za-z0-9]{20,}|sbp_[0-9a-f]{40}|tvly-[A-Za-z0-9-]{20,}|r8_[A-Za-z0-9]{30,})\b/g; // Bounded by the limits of an address (local part 64, domain 253, top-level label 63); the unbounded form was // quadratic on a long run with no `@`, and this runs on the page's main thread (see EMAIL in src/memory/redact.ts). const EMAIL = /\b[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,253}\.[A-Za-z]{2,63}\b/g; @@ -318,6 +321,7 @@ .replace(HF, '') .replace(WEBHOOK_SIGNING, '') .replace(PREFIXED_TOKENS, '') + .replace(PROVIDER_KEYS, '') .replace(EXTENDED_KEY, '') .replace(WIF_KEY, '') .replace(CREDENTIAL_ASSIGNMENT, '$1$2$3') diff --git a/src/memory/redact.ts b/src/memory/redact.ts index 15717752..0155b48c 100644 --- a/src/memory/redact.ts +++ b/src/memory/redact.ts @@ -91,6 +91,12 @@ const HEX_64 = /\b(?:0x)?[0-9a-fA-F]{64}\b/g; /** Shared with remember refusal. Token bodies are assumed to be at least 8 characters. */ export const PREFIXED_TOKEN = /\b(?:pypi-|npm_|hf_|whsec_|glsoat-|glagent-|glptt-|gloas-|glrt-|gldt-|glpat-|ghs_|ghr_|ghu_|gho_|xoxc-|xoxs-|xoxr-|xoxe-|xoxa-|xoxp-|xoxb-)[A-Za-z0-9_-]{8,}\b/; const PREFIXED_TOKENS = new RegExp(PREFIXED_TOKEN.source, 'g'); +/** + * Bare provider API keys this ecosystem is built on, which the list above did not know and the loop's own scrubber did: Groq `gsk_`, Cerebras + * `csk-`, xAI `xai-`, Perplexity `pplx-`, Fireworks `fw_`, Supabase `sbp_`, Tavily `tvly-`, Replicate `r8_`. Each carries its key's real body length + * rather than PREFIXED_TOKEN's 8: `fw_update_package` is an identifier, not a key. A labelled `GROQ_API_KEY=...` was always caught. + */ +const PROVIDER_KEYS = /\b(?:(?:gsk_|csk-|xai-|pplx-)[A-Za-z0-9]{40,}|fw_[A-Za-z0-9]{20,}|sbp_[0-9a-f]{40}|tvly-[A-Za-z0-9-]{20,}|r8_[A-Za-z0-9]{30,})\b/g; /** Personal data. Each shape needs its separators, so plain numbers in a claim are left alone. */ /** @@ -252,7 +258,7 @@ function isBareAwsSecret(candidate: string): boolean { */ export function containsSecret(value: string): boolean { const s = String(value ?? ''); - const shaped = [SECRET, DB_URL, BEARER, EYJ, SK, STRIPE, AWS_KEY_ID, GOOGLE_API_KEY, GOOGLE_OAUTH, WEBHOOK_SIGNING, EXTENDED_KEY, WIF_KEY, SOLANA_BASE58, GHP, GITHUB_PAT, HF, WEBHOOK_URL, CREDENTIAL_ASSIGNMENT, HEX_64, PREFIXED_TOKENS] + const shaped = [SECRET, DB_URL, BEARER, EYJ, SK, STRIPE, AWS_KEY_ID, GOOGLE_API_KEY, GOOGLE_OAUTH, WEBHOOK_SIGNING, EXTENDED_KEY, WIF_KEY, SOLANA_BASE58, GHP, GITHUB_PAT, HF, WEBHOOK_URL, CREDENTIAL_ASSIGNMENT, HEX_64, PREFIXED_TOKENS, PROVIDER_KEYS] .some((re) => { re.lastIndex = 0; const hit = re.test(s); @@ -285,6 +291,7 @@ export function redact(value: string): string { .replace(HF, '') .replace(WEBHOOK_SIGNING, '') .replace(PREFIXED_TOKENS, '') + .replace(PROVIDER_KEYS, '') .replace(EXTENDED_KEY, '') .replace(WIF_KEY, '') .replace(CREDENTIAL_ASSIGNMENT, '$1$2$3') diff --git a/tests/antifragile-credential-shapes.test.ts b/tests/antifragile-credential-shapes.test.ts new file mode 100644 index 00000000..367ff7aa --- /dev/null +++ b/tests/antifragile-credential-shapes.test.ts @@ -0,0 +1,60 @@ +/** + * Antifragile night, lane A, cycle 3 (2026-10-24). The credential detector is a list of SHAPES (src/memory/redact.ts says + * so). It refused an OpenAI-style key, a GitHub token, a labelled `GROQ_API_KEY=` -- and stored, or sent on, a bare Groq, + * Cerebras, xAI, Perplexity, Fireworks, Supabase, Tavily or Replicate key: the providers this ecosystem is built on, and + * shapes the loop's own scrubber already knew. `remember` and `bundle export` rely on the same detector ("a bundle never + * carries credentials"), and the redactor runs on text before `check ""` sends it to the hosted engine. + * Corpus fixture af-review-remember-accepts-provider-keys. + * + * Every key below is fake and assembled from parts at runtime, so no secret-shaped literal sits in this file. + */ +import { refusedValue } from '../src/cli/remember'; +import { containsSecret, redact } from '../src/memory/redact'; + +const scrubJs = require('../extension/scrub.js') as { redact: (v: unknown) => string }; +const j = (...p: string[]) => p.join(''); +const body = (n: number, alphabet: string) => Array.from({ length: n }, (_, i) => alphabet[i % alphabet.length]).join(''); +const AN = 'aB1'; +const HEX = 'abcdef0123456789'; + +const KEYS: Array<[string, string]> = [ + ['Groq', j('gs', 'k_', body(52, AN))], + ['Cerebras', j('cs', 'k-', body(48, 'ab1'))], + ['xAI', j('xa', 'i-', body(80, AN))], + ['Perplexity', j('pp', 'lx-', body(48, AN))], + ['Fireworks', j('f', 'w_', body(24, AN))], + ['Supabase', j('sb', 'p_', body(40, HEX))], + ['Tavily', j('tv', 'ly-dev-', body(28, AN))], + ['Replicate', j('r', '8_', body(37, AN))], +]; + +describe('antifragile: a bare provider API key is a credential shape', () => { + it('control: shapes the detector already knows are still refused and removed', () => { + const known = j('s', 'k-', body(48, AN)); + expect(refusedValue(known)).toBe(true); + expect(redact(`my key ${known} ok`)).not.toContain(known); + }); + + it.each(KEYS)('%s key: remember refuses it, and redact removes it', (_name, key) => { + expect(refusedValue(key)).toBe(true); + expect(containsSecret(key)).toBe(true); + const out = redact(`please check that ${key} is rotated`); + expect(out).not.toContain(key); + expect(out).toContain('please check that'); + }); + + it.each(KEYS)('%s key: the Chrome extension port removes it too (one scrubber, every door)', (_name, key) => { + const text = `a note with ${key} in it`; + expect(scrubJs.redact(text)).toBe(redact(text)); + expect(scrubJs.redact(text)).not.toContain(key); + }); + + it('the label that names a key is not enough on its own to refuse an unrelated word', () => { + // Ordinary words and identifiers that merely share a prefix with a key stay readable: over-redaction is safe, but a + // `fw_update_package` in a claim about a build should survive. + for (const t of ['fw_update_package', 'fw_checked_status_flag', 'gsk_', 'gsk_short', 'csk-ok', 'xai-grok', 'pplx-api', 'sbp_12345', 'tvly-x', 'r8_ready_to_go']) { + expect(refusedValue(t)).toBe(false); + expect(redact(`the value is ${t} today`)).toBe(`the value is ${t} today`); + } + }); +}); From ff7694aa921fa49e11ea8b27fb819f027ea3956d Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Sat, 24 Oct 2026 09:18:40 -0700 Subject: [PATCH 05/10] fix(cli,learn): the part of #647 that 1.8.0 needs (F3, F4, F9), for 1.8.1 Ported by hand from #647 (1e03d80d), which also changes guard, run and bundle. 1.8.0 ships none of those, so only these three fixes come across: - F3 `learn inject` with nothing to inject says why on stderr (stdout, what a hook injects, stays empty); the README example passes --check ship-tests. - F4 the CLI drops Node's "SQLite is an experimental feature" warning, only it. The SDK and the MCP server leave the host's warnings alone. - F9 a refused TRUSTSHELL_API_URL prints one line with the next step, no stack trace; exit code still 3. The line names recall, not guard (not in 1.8.0). quietSqliteWarning and fatalLine are byte-identical to main apart from that one word. tests/release-1.8.1-first-run.test.ts is the F3/F4/F9 subset of tests/harness-first-run.test.ts (the F9 dist case runs `recall`, not `guard show`). It does not compile against 4b01ec34 (the functions do not exist there) and passes here, 12 of 12 with the dist case run. dist rebuilt with `npm run sdk:build` (3 files change). Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- dist/cli/index.d.ts | 14 +++ dist/cli/index.js | 35 +++++- dist/learn/cli.js | 5 + src/cli/index.ts | 34 +++++- src/learn/cli.ts | 4 + tests/release-1.8.1-first-run.test.ts | 153 ++++++++++++++++++++++++++ 7 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 tests/release-1.8.1-first-run.test.ts diff --git a/README.md b/README.md index 50fdad85..f1261aa6 100644 --- a/README.md +++ b/README.md @@ -629,11 +629,12 @@ trustshell learn check add --job ship --id ship-tests --cmd "npm test" trustshell learn correct --job ship --check ship-tests "Run the linter before you say done." # eval case 1, then lesson 1 (candidate) trustshell learn accept 1 -trustshell learn inject --job ship # what examples/learn-hook/user-prompt-submit.mjs prints each turn +trustshell learn inject --job ship --check ship-tests # what the hook prints on a turn judged by ship-tests trustshell learn check run ship-tests # the only writer of a check outcome: the command's exit code trustshell learn report ``` +- **A lesson scoped to a check prints only on a turn that names that check.** The correction above is scoped to `ship-tests`, so `inject --job ship` with no `--check` prints nothing to stdout and says why on stderr; the hook passes the check from `TRUSTSHELL_LEARN_CHECK`. - **Only your words reach the model.** A correction is stored verbatim, untrusted and quarantined, until you `accept` it. Declined, held, expired (30 days) and other jobs' lessons never print. At most 5 lessons and 400 tokens per turn, counted as `loop_tokens`. - **The agent does not grade itself.** `learn record` and the MCP `learn_record` store the agent's outcome where no ruler reads it. Only `learn check run`, the command you registered, writes the outcome the rulers use. A failed check on a turn that carried a lesson retires that lesson, if it is scoped to that check or to no check. A lesson scoped to a check is only shown on turns judged by that check. Only you bring a retired lesson back: `learn reaccept ` shows why it was retired and needs you at a terminal to type the id back. - **Hooks.** `examples/learn-hook/user-prompt-submit.mjs` runs `inject` on every prompt (set `TRUSTSHELL_LEARN_JOB`, `TRUSTSHELL_LEARN_CHECK` for the check the turn is judged by, and `TRUSTSHELL_LEARN_MODEL` / `TRUSTSHELL_LEARN_FAMILY` so the episode carries its model). `examples/learn-hook/stop.mjs` fills in the turn's model, family and tokens from the transcript at the end of the turn. diff --git a/dist/cli/index.d.ts b/dist/cli/index.d.ts index 58ad20f7..c150b244 100644 --- a/dist/cli/index.d.ts +++ b/dist/cli/index.d.ts @@ -225,6 +225,20 @@ export declare function readClaimsFile(path: string, fs?: Pick; +/** + * Node 22 prints `ExperimentalWarning: SQLite is an experimental feature and might change at any time` the first + * time `node:sqlite` loads, so every memory command (remember, recall, quarantine, learn) ended with two + * stderr lines that read like an error (stranger walk, 2026-10-23, Git Bash and PowerShell 5.1). The CLI drops + * that ONE warning; every other warning, and every other ExperimentalWarning, still prints. Only the CLI process + * does this: the SDK and the MCP server leave the host's warnings alone. + */ +export declare function quietSqliteWarning(proc?: Pick): void; +/** + * PURE. The last line a crashed command prints. A refused TRUSTSHELL_API_URL is the caller's setting, not a crash: + * it printed a stack trace, even for local-only commands (recall), with no next step (stranger + * walk, 2026-10-23). It now prints the reason and what to change; the exit code is still 3. + */ +export declare function fatalLine(e: unknown): string; /** Entry point: parse argv, run, exit with the returned code. */ export declare function main(argv?: string[]): Promise; /** diff --git a/dist/cli/index.js b/dist/cli/index.js index c0172bcf..2c434c25 100644 --- a/dist/cli/index.js +++ b/dist/cli/index.js @@ -20,6 +20,8 @@ exports.formatQuarantineRecord = formatQuarantineRecord; exports.run = run; exports.readClaimsFile = readClaimsFile; exports.runClaimCheck = runClaimCheck; +exports.quietSqliteWarning = quietSqliteWarning; +exports.fatalLine = fatalLine; exports.main = main; exports.isEntryPath = isEntryPath; /** @@ -75,6 +77,7 @@ const kernel_client_1 = require("../lib/kernel-client"); const ground_1 = require("../lib/ground"); const node_path_1 = require("node:path"); const cli_1 = require("../learn/cli"); +const api_url_1 = require("../lib/api-url"); /** Exit codes β€” a small, stable contract so CI scripts can branch on them. */ exports.EXIT = { /** HAL PASS (or soft FLAG) β€” safe to proceed. */ @@ -1420,8 +1423,37 @@ async function runClaimCheck(sentence, json, io) { return claim_1.CLAIM_EXIT.error; } } +/** + * Node 22 prints `ExperimentalWarning: SQLite is an experimental feature and might change at any time` the first + * time `node:sqlite` loads, so every memory command (remember, recall, quarantine, learn) ended with two + * stderr lines that read like an error (stranger walk, 2026-10-23, Git Bash and PowerShell 5.1). The CLI drops + * that ONE warning; every other warning, and every other ExperimentalWarning, still prints. Only the CLI process + * does this: the SDK and the MCP server leave the host's warnings alone. + */ +function quietSqliteWarning(proc = process) { + const emit = proc.emitWarning; + proc.emitWarning = function (warning, ...rest) { + const type = warning instanceof Error ? warning.name : typeof rest[0] === 'string' ? rest[0] : rest[0]?.type; + const text = typeof warning === 'string' ? warning : warning?.message; + if (type === 'ExperimentalWarning' && /^SQLite is an experimental feature\b/.test(String(text))) + return; + return emit.apply(this, [warning, ...rest]); + }; +} +/** + * PURE. The last line a crashed command prints. A refused TRUSTSHELL_API_URL is the caller's setting, not a crash: + * it printed a stack trace, even for local-only commands (recall), with no next step (stranger + * walk, 2026-10-23). It now prints the reason and what to change; the exit code is still 3. + */ +function fatalLine(e) { + if (e instanceof api_url_1.ApiUrlError) { + return `trustshell: ${e.message}. Unset TRUSTSHELL_API_URL, or point it at an https URL. Every command reads it today, even local ones such as recall.`; + } + return `trustshell: fatal: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`; +} /** Entry point: parse argv, run, exit with the returned code. */ async function main(argv = process.argv.slice(2)) { + quietSqliteWarning(); const args = parseArgs(argv); const code = await run(args, makeClient()); process.exit(code); @@ -1474,8 +1506,7 @@ const isEntry = (() => { })(); if (isEntry) { main().catch((e) => { - const detail = e instanceof Error ? (e.stack ?? e.message) : String(e); - process.stderr.write((0, scrub_print_1.scrubPrinted)(`trustshell: fatal: ${detail}\n`)); + process.stderr.write((0, scrub_print_1.scrubPrinted)(`${fatalLine(e)}\n`)); process.exit(exports.EXIT.RUNTIME); }); } diff --git a/dist/learn/cli.js b/dist/learn/cli.js index 58da6394..fb576b73 100644 --- a/dist/learn/cli.js +++ b/dist/learn/cli.js @@ -266,6 +266,11 @@ async function dispatch(argv, io, env, tty) { } if (r && r.text) io.out(r.text); + // The job exists and nothing applies: stdout stays empty (it is what a hook injects), and stderr, which + // the example hook drops, says where to look. The README's own example accepted a lesson scoped to + // ship-tests and then injected with no --check, and got nothing, silently (stranger walk, 2026-10-23). + else if (r) + io.err(`learn inject: no lesson applies to this turn of job ${opt.job}, so nothing was printed. A lesson scoped to a check prints only on a turn that names it (--check ); trustshell learn lessons --job ${opt.job} shows each lesson's job/check.`); return 0; } case 'probe': { diff --git a/src/cli/index.ts b/src/cli/index.ts index 3af8375a..26f87f5b 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -68,6 +68,7 @@ import { kernelReceipt, SIX_CLAIM_KEYS, type KernelReceiptRead } from '../lib/ke import { groundClaims, splitPages, type GroundPage, type GroundResult, type GroundStatus } from '../lib/ground'; import { join } from 'node:path'; import { runLearn } from '../learn/cli'; +import { ApiUrlError } from '../lib/api-url'; /** Exit codes β€” a small, stable contract so CI scripts can branch on them. */ export const EXIT = { @@ -1485,8 +1486,38 @@ export async function runClaimCheck( } } +/** + * Node 22 prints `ExperimentalWarning: SQLite is an experimental feature and might change at any time` the first + * time `node:sqlite` loads, so every memory command (remember, recall, quarantine, learn) ended with two + * stderr lines that read like an error (stranger walk, 2026-10-23, Git Bash and PowerShell 5.1). The CLI drops + * that ONE warning; every other warning, and every other ExperimentalWarning, still prints. Only the CLI process + * does this: the SDK and the MCP server leave the host's warnings alone. + */ +export function quietSqliteWarning(proc: Pick = process): void { + const emit = proc.emitWarning; + proc.emitWarning = function (this: unknown, warning: string | Error, ...rest: unknown[]) { + const type = warning instanceof Error ? warning.name : typeof rest[0] === 'string' ? rest[0] : (rest[0] as { type?: string } | undefined)?.type; + const text = typeof warning === 'string' ? warning : warning?.message; + if (type === 'ExperimentalWarning' && /^SQLite is an experimental feature\b/.test(String(text))) return; + return (emit as (...a: unknown[]) => void).apply(this, [warning, ...rest]); + } as NodeJS.Process['emitWarning']; +} + +/** + * PURE. The last line a crashed command prints. A refused TRUSTSHELL_API_URL is the caller's setting, not a crash: + * it printed a stack trace, even for local-only commands (recall), with no next step (stranger + * walk, 2026-10-23). It now prints the reason and what to change; the exit code is still 3. + */ +export function fatalLine(e: unknown): string { + if (e instanceof ApiUrlError) { + return `trustshell: ${e.message}. Unset TRUSTSHELL_API_URL, or point it at an https URL. Every command reads it today, even local ones such as recall.`; + } + return `trustshell: fatal: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`; +} + /** Entry point: parse argv, run, exit with the returned code. */ export async function main(argv: string[] = process.argv.slice(2)): Promise { + quietSqliteWarning(); const args = parseArgs(argv); const code = await run(args, makeClient()); process.exit(code); @@ -1539,8 +1570,7 @@ const isEntry = (() => { if (isEntry) { main().catch((e) => { - const detail = e instanceof Error ? (e.stack ?? e.message) : String(e); - process.stderr.write(scrubPrinted(`trustshell: fatal: ${detail}\n`)); + process.stderr.write(scrubPrinted(`${fatalLine(e)}\n`)); process.exit(EXIT.RUNTIME); }); } diff --git a/src/learn/cli.ts b/src/learn/cli.ts index 63072cf2..7aa9f16c 100644 --- a/src/learn/cli.ts +++ b/src/learn/cli.ts @@ -278,6 +278,10 @@ async function dispatch(argv: string[], io: LearnIO, env: NodeJS.ProcessEnv, tty return 0; } if (r && r.text) io.out(r.text); + // The job exists and nothing applies: stdout stays empty (it is what a hook injects), and stderr, which + // the example hook drops, says where to look. The README's own example accepted a lesson scoped to + // ship-tests and then injected with no --check, and got nothing, silently (stranger walk, 2026-10-23). + else if (r) io.err(`learn inject: no lesson applies to this turn of job ${opt.job}, so nothing was printed. A lesson scoped to a check prints only on a turn that names it (--check ); trustshell learn lessons --job ${opt.job} shows each lesson's job/check.`); return 0; } case 'probe': { diff --git a/tests/release-1.8.1-first-run.test.ts b/tests/release-1.8.1-first-run.test.ts new file mode 100644 index 00000000..ce86ee40 --- /dev/null +++ b/tests/release-1.8.1-first-run.test.ts @@ -0,0 +1,153 @@ +/** + * 1.8.1: the parts of #647's first-run polish that touch code already in 1.8.0 (F3 learn inject, F4 the SQLite + * warning, F9 a refused TRUSTSHELL_API_URL). Ported from tests/harness-first-run.test.ts on main, minus every case + * that needs guard, run or bundle, which 1.8.0 does not ship. Each fails on 4b01ec34 (the 1.8.0 source). + * Every test uses a temp TRUSTSHELL_HOME and memory file; the real home is never read or written. + */ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fatalLine, quietSqliteWarning } from '../src/cli'; +import { ApiUrlError } from '../src/lib/api-url'; +import { runLearn } from '../src/learn/cli'; + +describe('F3: learn inject with nothing to inject says why, on stderr only', () => { + const prevEnv = { ...process.env }; + let home = ''; + let out: string[] = []; + let err: string[] = []; + const io = { out: (s: string) => out.push(s), err: (s: string) => err.push(s) }; + const env = () => ({ ...process.env, TRUSTSHELL_HOME: home, TRUSTSHELL_MEMORY: join(home, 'memory.sqlite') }); + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'ts-181-first-run-')); + process.env.TRUSTSHELL_HOME = home; + out = []; + err = []; + }); + afterEach(() => { + process.env = { ...prevEnv }; + rmSync(home, { recursive: true, force: true }); + }); + + it("the README's own steps: a lesson scoped to ship-tests prints with --check ship-tests; without it, stdout is empty and stderr says why", async () => { + const e = env(); + expect(await runLearn(['job', 'add', 'ship', '--mode', 'delegate', 'When I ship a change, I want the tests green, so I can merge.'], io, e)).toBe(0); + expect(await runLearn(['check', 'add', '--job', 'ship', '--id', 'ship-tests', '--cmd', 'npm test'], io, e)).toBe(0); + expect(await runLearn(['correct', '--job', 'ship', '--check', 'ship-tests', 'Run the linter before you say done.'], io, e)).toBe(0); + expect(await runLearn(['accept', '1'], io, e)).toBe(0); + out = []; + err = []; + expect(await runLearn(['inject', '--job', 'ship'], io, e)).toBe(0); + expect(out).toEqual([]); // what a hook injects: nothing + expect(err.join('\n')).toContain('no lesson applies to this turn of job ship'); + expect(err.join('\n')).toContain('--check '); + out = []; + err = []; + expect(await runLearn(['inject', '--job', 'ship', '--check', 'ship-tests'], io, e)).toBe(0); + expect(out.join('\n')).toContain('Run the linter before you say done.'); + expect(err).toEqual([]); + }); + + it('an unknown job still prints nothing at all (the documented no-op)', async () => { + const e = env(); + expect(await runLearn(['job', 'add', 'ship', '--mode', 'delegate', 'When I ship, I want green tests, so I can merge.'], io, e)).toBe(0); + out = []; + err = []; + expect(await runLearn(['inject', '--job', 'nope'], io, e)).toBe(0); + expect(out).toEqual([]); + expect(err).toEqual([]); + }); + + it('the README example injects with --check ship-tests', () => { + const readme = readFileSync(join(__dirname, '..', 'README.md'), 'utf8'); + expect(readme).toContain('trustshell learn inject --job ship --check ship-tests'); + }); +}); + +describe('F9: a refused TRUSTSHELL_API_URL is one line with the next step', () => { + it('a refused TRUSTSHELL_API_URL is one line with the next step, never a stack trace', () => { + const line = fatalLine(new ApiUrlError('TRUSTSHELL_API_URL must be https (plain http only for localhost), so nothing was sent to http://example.com')); + expect(line).toContain('Unset TRUSTSHELL_API_URL, or point it at an https URL'); + expect(line).not.toContain('fatal'); + expect(line).not.toMatch(/\n\s+at /); + }); + + it('any other crash still prints "fatal" and its stack (control)', () => { + expect(fatalLine(new Error('boom'))).toMatch(/^trustshell: fatal: Error: boom\n\s+at /); + }); + + it('the built CLI: `recall` with an http TRUSTSHELL_API_URL exits 3 with the one line, no stack', () => { + const dir = mkdtempSync(join(tmpdir(), 'ts-181-first-run-url-')); + try { + const r = spawnSync(process.execPath, [join(__dirname, '..', 'dist', 'cli', 'index.js'), 'recall'], { + cwd: dir, + encoding: 'utf8', + env: { ...process.env, TRUSTSHELL_HOME: dir, TRUSTSHELL_MEMORY: join(dir, 'memory.sqlite'), TRUSTSHELL_API_URL: 'http://example.com' }, + }); + expect(r.status).toBe(3); + expect(r.stderr).toContain('Unset TRUSTSHELL_API_URL'); + expect(r.stderr).not.toMatch(/\n\s+at /); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("F4: the CLI drops Node's SQLite ExperimentalWarning, and only that warning", () => { + const fake = () => { + const seen: unknown[][] = []; + const proc = { emitWarning: ((...a: unknown[]) => void seen.push(a)) as unknown as NodeJS.Process['emitWarning'] }; + quietSqliteWarning(proc); + return { proc, seen }; + }; + + it('the SQLite experimental warning is dropped, as a string and as an Error', () => { + const { proc, seen } = fake(); + proc.emitWarning('SQLite is an experimental feature and might change at any time', 'ExperimentalWarning'); + const e = new Error('SQLite is an experimental feature and might change at any time'); + e.name = 'ExperimentalWarning'; + proc.emitWarning(e); + expect(seen).toEqual([]); + }); + + it.each([ + ['another experimental feature', ['WASI is an experimental feature and might change at any time', 'ExperimentalWarning']], + ['the same text as another type', ['SQLite is an experimental feature and might change at any time', 'DeprecationWarning']], + ['an options object of another type', ['SQLite is an experimental feature', { type: 'Warning' }]], + ['a plain warning', ['something else']], + ])('%s still prints', (_n, args) => { + const { proc, seen } = fake(); + (proc.emitWarning as unknown as (...a: unknown[]) => void)(...args); + expect(seen).toEqual([args]); + }); + + // The built CLI, spawned the way npm's bin runs it, with Node's warnings left ON. + const cli = join(__dirname, '..', 'dist', 'cli', 'index.js'); + const spawnEnv = (extra: Record) => { + const e: NodeJS.ProcessEnv = { ...process.env, ...extra }; + delete e.NODE_NO_WARNINGS; + delete e.NODE_OPTIONS; + return e; + }; + // Control: does this Node warn on node:sqlite at all? If not, the dist test below proves nothing, so it is SKIPPED. + const nodeWarns = /ExperimentalWarning: SQLite/.test( + spawnSync(process.execPath, ['-e', "require('node:sqlite')"], { encoding: 'utf8', env: spawnEnv({}) }).stderr ?? '', + ); + (nodeWarns ? it : it.skip)('`trustshell recall` from dist prints no SQLite warning (this Node warns on a bare require: control)', () => { + const dir = mkdtempSync(join(tmpdir(), 'ts-181-first-run-dist-')); + try { + const r = spawnSync(process.execPath, [cli, 'recall'], { + cwd: dir, + encoding: 'utf8', + env: spawnEnv({ TRUSTSHELL_HOME: dir, TRUSTSHELL_MEMORY: join(dir, 'memory.sqlite') }), + }); + expect(r.status).toBe(0); + expect(r.stderr).not.toContain('ExperimentalWarning'); + expect(r.stdout).toContain('do_not_send COUNT 0'); // it ran, and it opened the memory file + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); From f43b84a34366200ddbc17af61ea847bd3f7b50ea Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Sat, 24 Oct 2026 09:18:54 -0700 Subject: [PATCH 06/10] fix(profile): name 1.8.0's own way in for a foreign profile.md, for 1.8.1 #637's comment pointed at a profile imported from a signed bundle; 1.8.0 has no bundle. The way in for 1.8.0 is the path itself: profile show reads ./.trustshell/profile.md from the working folder, so a cloned repository can carry one. Comment only (and its line in dist/cli/profile.js). Co-Authored-By: Claude Opus 5.5 --- dist/cli/profile.js | 2 +- src/cli/profile.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dist/cli/profile.js b/dist/cli/profile.js index ef08aaae..9d14f4e1 100644 --- a/dist/cli/profile.js +++ b/dist/cli/profile.js @@ -196,7 +196,7 @@ async function runProfile(op, io, fs, json = false) { else if (!exists) io.out(`NO_PROFILE ${exports.PROFILE_PATH} does not exist. \`trustshell init\` creates a blank one.`); else { - // The file may hold text someone else wrote (a profile imported from a signed bundle), and a problem line quotes the bad value: print both as text. + // The file sits in the working folder (./.trustshell/profile.md), so a cloned repository can carry one someone else wrote, and a problem line quotes the bad value: print both as text. io.out((0, action_evidence_1.cleanCodePoints)(text.replace(/\s+$/, ''), true)); for (const pr of parsed.problems) io.err(`problem: ${pr.field}: ${(0, action_evidence_1.cleanCodePoints)(pr.detail)}`); diff --git a/src/cli/profile.ts b/src/cli/profile.ts index bfc80eed..33461434 100644 --- a/src/cli/profile.ts +++ b/src/cli/profile.ts @@ -225,7 +225,7 @@ export async function runProfile(op: ProfileOp, io: ProfileIo, fs: ProfileFs, js if (json) io.out(JSON.stringify({ path: PROFILE_PATH, exists, ...parsed }, null, 2)); else if (!exists) io.out(`NO_PROFILE ${PROFILE_PATH} does not exist. \`trustshell init\` creates a blank one.`); else { - // The file may hold text someone else wrote (a profile imported from a signed bundle), and a problem line quotes the bad value: print both as text. + // The file sits in the working folder (./.trustshell/profile.md), so a cloned repository can carry one someone else wrote, and a problem line quotes the bad value: print both as text. io.out(cleanCodePoints(text.replace(/\s+$/, ''), true)); for (const pr of parsed.problems) io.err(`problem: ${pr.field}: ${cleanCodePoints(pr.detail)}`); } From f4a2beb2c8631fdfb7da0cce4aec9bc199d4e5a6 Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Sat, 24 Oct 2026 09:18:57 -0700 Subject: [PATCH 07/10] docs(readme): the A2A "verifiable receipt" lines say what the SDK reads, for 1.8.1 Three README lines (which npm shows) said the A2A loop ends in a "verifiable settlement receipt" / "verifiable receipt". The SDK verifies nothing there: getContractStatus and pollUntilSettled read the backend's contract row (status, provider, price, x402_payment_id as settlementId, result) and hand it back. The lines now say the backend's record, read and not verified. main still carries the same three lines (README.md "Discover -> buy -> receipt", "The full A2A loop", the RepID stack diagram); this narrows them for the 1.8.1 tarball only. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f1261aa6..ad6cd60c 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ TrustShell gives an AI agent (or the dev building one) three things against the 1. **Verify an output** β€” run any text through a real cross-provider HAL fact-check quorum and get a `PASS` / `FLAG` / `VETO` / `NOT_CHECKED` verdict with evidence. `NOT_CHECKED` means HAL did not decide, and it is never a pass. **No API key.** 2. **Look up reputation** β€” fetch any agent's current RepID score + tier. **No API key.** -3. **Discover β†’ buy β†’ receipt** β€” browse the live agent-service marketplace, purchase a service agent-to-agent, and poll for a verifiable settlement receipt. **Needs an API key + a funded Base Sepolia wallet** (it moves real testnet value). +3. **Discover β†’ buy β†’ receipt** β€” browse the live agent-service marketplace, purchase a service agent-to-agent, and poll the backend for the contract's settlement status (`pollUntilSettled`: the backend's record, which this package reads and does not verify). **Needs an API key + a funded Base Sepolia wallet** (it moves real testnet value). ### What runs keyless vs. what needs a key @@ -305,7 +305,7 @@ The private key signs locally and never leaves the process. ## Discover β†’ buy β†’ receipt (agent-to-agent) -The full A2A loop: find a verified service, buy it, get a verifiable receipt. Discovery is keyless; the **purchase** half **moves real Base Sepolia testnet value**, so that half needs an API key and a funded wallet. +The full A2A loop: find a verified service, buy it, and read back the backend's contract record (status and settlement id; this package does not verify them). Discovery is keyless; the **purchase** half **moves real Base Sepolia testnet value**, so that half needs an API key and a funded wallet. ```ts import { TrustShell, guardedX402Payment } from '@hyperdag/trustshell'; @@ -749,7 +749,7 @@ ERC-8004 Identity Registry ← who is the agent? RepID Score ← has it earned trust? (this package) β”‚ β–Ό - x402 Payments ← autonomous action + verifiable receipt + x402 Payments ← autonomous action + the backend's settlement record ``` RepID is the middle layer β€” the behavioral credential that makes the agent economy accountable. From e8c178ce2ad1f6210195ab4fe28bd79904f63caf Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Sat, 24 Oct 2026 09:20:30 -0700 Subject: [PATCH 08/10] release: 1.8.1-rc.0, fixes to code 1.8.0 already ships Cut from 4b01ec34 (the 1.8.0 source), not from main. Carries #619, #623, #637, #642 and #647's F3/F4/F9, plus the README A2A lines. Nothing that needs Guard, Carry, bundle, run or inspect_run, which 1.8.0 does not have. - package.json / package-lock.json: 1.8.0 -> 1.8.1-rc.0. - CHANGELOG: a 1.8.1-rc.0 section above 1.8.0. - check:doc-version VERIFIED (a prerelease also accepts NPM_LATEST, 1.8.0, so the @1.8.0 install pins in the docs stay correct while latest is 1.8.0). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 38 ++++++++++++++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f877e45b..220171e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,44 @@ All notable changes to the `@hyperdag/trustshell` package. +## 1.8.1-rc.0 β€” 2026-10-24 (release candidate) + +A patch for 1.8.0: fixes to code that 1.8.0 already ships, and nothing new. It is cut from +`4b01ec34` (the 1.8.0 source), not from main, so main's Guard + Carry, `trustshell run`, `bundle` +and the MCP `inspect_run` are not in it. The MCP server still lists the same 20 tools. + +- **Fix: `OFFLINE=1` now stops the MCP `verify_output` (and `verify`, `evaluate`) and the SDK + `verifyOutput`/`evaluate` (#623).** In 1.8.0 they sent the text to HAL under `OFFLINE=1`; only + `TRUSTSHELL_LOCAL_ONLY=1` stopped them. Now they answer `NOT_CHECKED` with `seenBy: []` and no + request leaves. The CLI `verify` and `check` and the MCP `check_claim` already did this in 1.8.0. + (`TrustShell.score` in `src/lib/trustshell.ts`; tests/mcp-offline-sends-nothing.test.ts.) +- **Fix: `profile show` prints `./.trustshell/profile.md` and its problem lines as text (#637).** + The file sits in the working folder, so a cloned repository can carry one, and its control and + bidi characters reached the terminal raw. Newlines are kept. + (tests/antifragile-profile-show-scrub.test.ts.) +- **Fix: bare Groq, Cerebras, xAI, Perplexity, Fireworks, Supabase, Tavily and Replicate keys are + credential shapes (#642).** `remember` refused a labelled `GROQ_API_KEY=...` but stored a bare + `gsk_...` key, and `redact`, which runs before `check ""` sends anything, left it in. + Each shape uses its key's real length, so an identifier like `fw_update_package` stays readable. + (`src/memory/redact.ts`; tests/antifragile-credential-shapes.test.ts.) +- **Three CLI fixes from #647 (F3, F4, F9; the rest of #647 is guard, run and bundle).** `learn + inject` with no lesson for the turn says why on stderr, and stdout stays empty. The CLI drops + Node's `SQLite is an experimental feature` warning, and only that one. A refused + `TRUSTSHELL_API_URL` prints one line saying what to change instead of a stack trace (exit 3 as + before). (tests/release-1.8.1-first-run.test.ts.) +- **The `check` record says only what was looked up (#619).** The run check labelled its commit + fact "The commit exists on the remote", but no lookup is made; it now reads "GitHub reports the + commit this run ran on". The label is part of the `--claims` evidence record, so a 1.8.1 record + of the same run and claims has a different SHA-256 digest from a 1.8.0 one. +- **README, package description and LICENSE (#619, and the A2A lines).** The README no longer says + "ZK-verifiable receipt", "portable reputation" or "portable agentic trust harness", and no + longer carries a commercial-use term that the LICENSE does not contain; it credits x402 + (Coinbase) and Plonky3 (Polygon Labs). The A2A lines no longer promise a "verifiable settlement + receipt": `pollUntilSettled` reads the backend's contract record and does not verify it. The npm + description leads with Inspect. LICENSE and README name the copyright holder as Sean Goodwin + (Sean, 2026-10-23). +- **Package size.** 123 files, the same as 1.8.0. Runtime dependencies are unchanged. + ## 1.8.0 β€” 2026-10-22 1.8.0 is 1.8.0-rc.0 (the section below) promoted to npm `latest`, plus one fix, #596. A plain diff --git a/package-lock.json b/package-lock.json index 7e30c76c..9139b658 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hyperdag/trustshell", - "version": "1.8.0", + "version": "1.8.1-rc.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hyperdag/trustshell", - "version": "1.8.0", + "version": "1.8.1-rc.0", "license": "Apache-2.0", "dependencies": { "@hyperdag/proof-verifier": "^0.2.0", diff --git a/package.json b/package.json index c46f2147..f8a1ed46 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hyperdag/trustshell", - "version": "1.8.0", + "version": "1.8.1-rc.0", "description": "Check what your AI agent says it did against the evidence. Inspect checks an agent's claims about a GitHub Actions run against GitHub's own record, with no account; also a hosted second-opinion check, local memory, an MCP server and an SDK.", "license": "Apache-2.0", "publishConfig": { From 1624050978bcba0b253990ae8ce2924c78305a5b Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Sat, 24 Oct 2026 09:50:35 -0700 Subject: [PATCH 09/10] release: 1.8.1, the rc.0 above with its version set (publish from this commit) 1.8.1-rc.0 (e8c178ce) was packed, installed cold and checked: OFFLINE=1 control, the 1.8.0 stranger checks, the 20 MCP tools and proof-pack 70/70. This commit changes no package code. It sets the version so publish-sdk.yml derives dist-tag `latest` (it refuses a prerelease there), and it moves the docs' current-version claims to 1.8.1, which check-doc-version requires of a plain release. - package.json / package-lock.json: 1.8.1-rc.0 -> 1.8.1. - CHANGELOG: a 1.8.1 section above rc.0 (kept as history). - Install pins @1.8.0 -> @1.8.1 and the npm `latest` rows in README, PRODUCT, docs (STATUS, WALKTHROUGH, getting-started, api-reference, HUMAN_TESTNET, JOIN_KIT, install/*), examples/quickstart, skills (SKILL.md, skill.json), and the three tests that pin those strings. - Not changed: the site pages, components and lib/npm-latest.ts. They deploy from main, and NPM_LATEST moves only after `npm view` returns 1.8.1. check:doc-version VERIFIED. jest minus tests/e2e: the same 7 failures in 6 suites as the 1.8.0 source on this Windows PC, none new. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++++ PRODUCT.md | 2 +- README.md | 16 ++++++++-------- docs/HUMAN_TESTNET.md | 2 +- docs/JOIN_KIT.md | 2 +- docs/STATUS.md | 2 +- docs/WALKTHROUGH.md | 4 ++-- docs/api-reference.md | 2 +- docs/getting-started.md | 2 +- docs/install/claude-code.md | 2 +- docs/install/claude-desktop.md | 2 +- docs/install/cursor.md | 2 +- examples/quickstart/QUICKSTART.md | 6 +++--- package-lock.json | 4 ++-- package.json | 2 +- skills/trustshell/SKILL.md | 8 ++++---- skills/trustshell/skill.json | 4 ++-- tests/install-docs.test.ts | 6 +++--- tests/join-kit.test.ts | 2 +- tests/register-not-minted.test.ts | 2 +- 20 files changed, 42 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 220171e9..63757c47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to the `@hyperdag/trustshell` package. +## 1.8.1 β€” 2026-10-24 + +1.8.1 is 1.8.1-rc.0 (the section below) with its version set to 1.8.1. No package code changed +between them: the published files differ only in `package.json`'s version, this section, and the +README's install pins and npm `latest` row, which now name 1.8.1. + ## 1.8.1-rc.0 β€” 2026-10-24 (release candidate) A patch for 1.8.0: fixes to code that 1.8.0 already ships, and nothing new. It is cut from diff --git a/PRODUCT.md b/PRODUCT.md index 204dbfad..e657a711 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -16,7 +16,7 @@ Developers building AI agents who need to check what an agent says it did before Owner-controlled authority, evidence-backed actions, portable context and earned autonomy, as one harness that works with any model. This is shown with a reproducible demo (the pinned-run challenge), never asserted as unique: no comparative research supports "nobody else does this". ## Operating Context -- `npx @hyperdag/trustshell@latest check --claims-file ` (Inspect): reads one public GitHub Actions run from api.github.com, no account, no key. Developer preview, in npm `latest` (1.8.0). +- `npx @hyperdag/trustshell@latest check --claims-file ` (Inspect): reads one public GitHub Actions run from api.github.com, no account, no key. Developer preview, in npm `latest` (1.8.1). - trustshell.dev: the hosted sentence check (sends text to the engine), `/inspect` (the challenge), `/docs`. - Developers arrive from GitHub, npm and their community channels; they evaluate by running a command, not by reading claims. diff --git a/README.md b/README.md index ad6cd60c..ea05a236 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ That run exited 1 (1.6.0, 2026-10-07). The last line names who decided; your run | | Today | |---|---| -| npm `latest` | **1.8.0** (`npm view @hyperdag/trustshell version`). | +| npm `latest` | **1.8.1** (`npm view @hyperdag/trustshell version`). | | HAL quorum | **3 answering / 5 configured** (MEASURED 2026-10-06, `GET /api/v1/hal/stats` β†’ `quorum_health`, 24-hour window). Not a constant 6. | | Chain | **Base Sepolia**, not mainnet. | | Grounding | **shadow** β€” does not mutate live RepID. | @@ -203,14 +203,14 @@ npm install @hyperdag/trustshell This is the one live, published install today. It delivers Inspect, the hosted **HAL** check, **ERC-8004** RepID reads and range proofs, and **x402** payments with a spend guard, against the live backend (anything on-chain is on the Base Sepolia test network). -The package does not carry the Next.js/React site. The 1.8.0 tarball is 123 files: `dist/`, the command shims in `bin/`, `scripts/init-pai.mjs`, `scripts/value-events.mjs`, `lib/interview.js`, and the README, changelog and license. Runtime dependencies are `@hyperdag/proof-verifier` (dynamically imported; degrades gracefully if the optional WASM build is absent), `@modelcontextprotocol/sdk` and `zod` (for the `trustshell-mcp` server). `ethers` is an **optional** peer dependency: install it (`npm i ethers@^6`) only if you sign x402 payments or call `verifySigner`. (`npm view @hyperdag/trustshell@1.8.0 dependencies peerDependencies dist.fileCount`) +The package does not carry the Next.js/React site. The 1.8.1 tarball is 123 files: `dist/`, the command shims in `bin/`, `scripts/init-pai.mjs`, `scripts/value-events.mjs`, `lib/interview.js`, and the README, changelog and license. Runtime dependencies are `@hyperdag/proof-verifier` (dynamically imported; degrades gracefully if the optional WASM build is absent), `@modelcontextprotocol/sdk` and `zod` (for the `trustshell-mcp` server). `ethers` is an **optional** peer dependency: install it (`npm i ethers@^6`) only if you sign x402 payments or call `verifySigner`. (`npm view @hyperdag/trustshell@1.8.1 dependencies peerDependencies dist.fileCount`) ### Which package do I install? | If you're… | Install | What you get | |---|---|---| | A developer building an agent/app **in code** | `npm install @hyperdag/trustshell` | The SDK β€” Inspect, HAL verification, ERC-8004 RepID reads and x402 payments, in your TypeScript/JS | -| Using an **AI tool** (Claude Desktop, Cursor, Claude Code), **no code** | `npm i -g @hyperdag/trustshell@1.8.0`, then point the tool at the `trustshell-mcp` bin (below) | The checks as AI-callable tools | +| Using an **AI tool** (Claude Desktop, Cursor, Claude Code), **no code** | `npm i -g @hyperdag/trustshell@1.8.1`, then point the tool at the `trustshell-mcp` bin (below) | The checks as AI-callable tools | | Only verifying **ZK proofs** client-side | `npm install @hyperdag/proof-verifier` | Standalone checking of [Plonky3](https://github.com/Plonky3/Plonky3) proofs (Plonky3 is Polygon Labs' proof system). It is a dependency of `@hyperdag/trustshell`, so installing that installs this too β€” rarely installed directly | **Most people want `@hyperdag/trustshell`. The SDK, the CLI and the MCP server are all in that one package. `proof-verifier` is a separate package it depends on: npm installs it alongside, and the published tarball contains none of its files.** @@ -220,7 +220,7 @@ The package does not carry the Next.js/React site. The 1.8.0 tarball is 123 file The MCP server is the `trustshell-mcp` bin inside `@hyperdag/trustshell`. A global install puts it on PATH: ```bash -npm i -g @hyperdag/trustshell@1.8.0 +npm i -g @hyperdag/trustshell@1.8.1 ``` Then add it to your Claude Desktop / Cursor / Claude Code config: @@ -235,7 +235,7 @@ The separate npm package `@hyperdag/trustshell-mcp` is older (1.0.0, published 2 ### Install straight from GitHub (no npm registry) β€” LIVE -The SDK also installs directly from the repo. That gives you `main`, which can run ahead of the published 1.8.0 β€” useful for pinning a commit or as a registry-independent fallback: +The SDK also installs directly from the repo. That gives you `main`, which can run ahead of the published 1.8.1 β€” useful for pinning a commit or as a registry-independent fallback: ```bash npm install github:DealAppSeo/trustshell @@ -243,7 +243,7 @@ npm install github:DealAppSeo/trustshell npm install github:DealAppSeo/trustshell# ``` -The package ships a committed `dist/`, so there is no build step on your side, and the lean-package guarantee holds (no Next.js/React tree is pulled). That `dist/` is built from `main`, which can run ahead of the published release. For exactly what npm serves, install `@hyperdag/trustshell@1.8.0`. +The package ships a committed `dist/`, so there is no build step on your side, and the lean-package guarantee holds (no Next.js/React tree is pulled). That `dist/` is built from `main`, which can run ahead of the published release. For exactly what npm serves, install `@hyperdag/trustshell@1.8.1`. --- @@ -724,10 +724,10 @@ Run `trustshell --help` for the full reference. ## Agent skill -OpenClaw, Claude, and Grok load [`skills/trustshell/SKILL.md`](skills/trustshell/SKILL.md). A global install of `@hyperdag/trustshell@1.8.0` puts `trustshell` and `trustshell-mcp` on PATH. Agents shell out to those bins. They do not call a new API. +OpenClaw, Claude, and Grok load [`skills/trustshell/SKILL.md`](skills/trustshell/SKILL.md). A global install of `@hyperdag/trustshell@1.8.1` puts `trustshell` and `trustshell-mcp` on PATH. Agents shell out to those bins. They do not call a new API. ```bash -npm i -g @hyperdag/trustshell@1.8.0 +npm i -g @hyperdag/trustshell@1.8.1 trustshell verify "" trustshell repid diff --git a/docs/HUMAN_TESTNET.md b/docs/HUMAN_TESTNET.md index 0bf315f6..aae02e79 100644 --- a/docs/HUMAN_TESTNET.md +++ b/docs/HUMAN_TESTNET.md @@ -2,7 +2,7 @@ Chain is Base Sepolia, chain id 84532. This is not mainnet. -1. Install: `npm i -g @hyperdag/trustshell@1.8.0` +1. Install: `npm i -g @hyperdag/trustshell@1.8.1` 2. Read `GET /api/v1/faucet/info` on the engine. `chain_id` is 84532. `dispenses` is false. 3. The faucet is external. The engine does not send ETH. 4. Fund a wallet from a faucet that response names. TrustShell does not send the ETH. diff --git a/docs/JOIN_KIT.md b/docs/JOIN_KIT.md index fc0334d3..f7a5f850 100644 --- a/docs/JOIN_KIT.md +++ b/docs/JOIN_KIT.md @@ -5,7 +5,7 @@ A portable trust harness. Autonomy is earned. One screen for an ERC-8004 agent. Verify a claim. Read the receipt line. ```bash -npm i -g @hyperdag/trustshell@1.8.0 +npm i -g @hyperdag/trustshell@1.8.1 trustshell verify "The capital of France is Paris." ``` diff --git a/docs/STATUS.md b/docs/STATUS.md index d989ae63..2e21a900 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -4,7 +4,7 @@ Updated 2026-10-06. Not a launch announcement. | Fact | Today | |---|---| -| npm `latest` | **1.8.0** (`npm view @hyperdag/trustshell version`). | +| npm `latest` | **1.8.1** (`npm view @hyperdag/trustshell version`). | | HAL quorum | **3 answering / 5 configured** (MEASURED 2026-10-06, `GET /api/v1/hal/stats` β†’ `quorum_health.answering_providers` / `configured_providers`, 24-hour window). Not a constant 6. | | Chain | **Base Sepolia** (84532), not mainnet. | | Grounding | **shadow**. Does not mutate live RepID. | diff --git a/docs/WALKTHROUGH.md b/docs/WALKTHROUGH.md index 306fee10..42e544fe 100644 --- a/docs/WALKTHROUGH.md +++ b/docs/WALKTHROUGH.md @@ -9,7 +9,7 @@ Screenshot placeholders below are names only. The pictures are not in this repo. ## 1. Install ```bash -npm i -g @hyperdag/trustshell@1.8.0 +npm i -g @hyperdag/trustshell@1.8.1 ``` Then: @@ -18,7 +18,7 @@ Then: trustshell --version ``` -You should see `1.8.0`. +You should see `1.8.1`. diff --git a/docs/api-reference.md b/docs/api-reference.md index 10b79b00..03d9a2fc 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -924,4 +924,4 @@ The SDK **throws** on every non-2xx; the public REST endpoints return JSON error --- -> Reflects the published `@hyperdag/trustshell` v1.8.0 surface and the production `repid-engine` deployment. Click-by-click CLI walkthrough: [`WALKTHROUGH.md`](./WALKTHROUGH.md). +> Reflects the published `@hyperdag/trustshell` v1.8.1 surface and the production `repid-engine` deployment. Click-by-click CLI walkthrough: [`WALKTHROUGH.md`](./WALKTHROUGH.md). diff --git a/docs/getting-started.md b/docs/getting-started.md index 56d4f965..70892a7c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -34,7 +34,7 @@ Verify the install: ```bash trustshell --version -# β†’ 1.8.0 (the installed package version) +# β†’ 1.8.1 (the installed package version) ``` ## 4. 60-second quickstart diff --git a/docs/install/claude-code.md b/docs/install/claude-code.md index 8587e44a..a60a8cb9 100644 --- a/docs/install/claude-code.md +++ b/docs/install/claude-code.md @@ -6,7 +6,7 @@ The package was `@hyperdag/trustshell@1.5.0`. `trustshell --version` printed 1.5 ## Install ``` -npm i -g @hyperdag/trustshell@1.8.0 +npm i -g @hyperdag/trustshell@1.8.1 ``` That provides `trustshell` and `trustshell-mcp`. diff --git a/docs/install/claude-desktop.md b/docs/install/claude-desktop.md index 10c673bf..4940ea2b 100644 --- a/docs/install/claude-desktop.md +++ b/docs/install/claude-desktop.md @@ -5,7 +5,7 @@ Claude Desktop 2.19675.0.0 is installed. Its config already had railway and gith ## Install ``` -npm i -g @hyperdag/trustshell@1.8.0 +npm i -g @hyperdag/trustshell@1.8.1 ``` ## Config diff --git a/docs/install/cursor.md b/docs/install/cursor.md index 60b202cd..4b371eaa 100644 --- a/docs/install/cursor.md +++ b/docs/install/cursor.md @@ -5,7 +5,7 @@ Cursor 3.23.12 is installed. The user `mcp.json` under `.cursor` was absent. The ## Install ``` -npm i -g @hyperdag/trustshell@1.8.0 +npm i -g @hyperdag/trustshell@1.8.1 ``` ## Config diff --git a/examples/quickstart/QUICKSTART.md b/examples/quickstart/QUICKSTART.md index 81e78f79..88f65164 100644 --- a/examples/quickstart/QUICKSTART.md +++ b/examples/quickstart/QUICKSTART.md @@ -15,11 +15,11 @@ in three lines, against the **live** repid-engine. The read paths below need **n npm install @hyperdag/trustshell ``` -The package leaves the site out: the 1.8.0 tarball is 123 files (`dist/`, the command shims in +The package leaves the site out: the 1.8.1 tarball is 123 files (`dist/`, the command shims in `bin/`, two scripts, `lib/interview.js`, and the README, changelog and license). `npm install` pulls `@hyperdag/proof-verifier`, `@modelcontextprotocol/sdk` and `zod` β€” no Next.js, React, or Supabase tree. `ethers` is an **optional** peer dependency: install it (`npm i ethers@^6`) only to -sign x402 payments or call `verifySigner`. (`npm view @hyperdag/trustshell@1.8.0 dependencies +sign x402 payments or call `verifySigner`. (`npm view @hyperdag/trustshell@1.8.1 dependencies peerDependencies dist.fileCount`; on 2026-10-06 a clean install added 96 packages in 5 s.) ## 2. Verify an agent output (no key required) @@ -48,7 +48,7 @@ A runnable copy is in this folder: **[`quickstart.mjs`](./quickstart.mjs)** β€” truthful and a false claim, looks up `trinity-shofet`, and prints the wall-clock time to the first verified call. From this repository the file imports `../../dist/lib/index.js` so `node examples/quickstart/quickstart.mjs` exercises the build committed in this checkout -(`package.json` 1.8.0) without installing from npm. After +(`package.json` 1.8.1) without installing from npm. After `npm install @hyperdag/trustshell` in *your* project, use the named import in the README. **Import note:** use the **named** import `import { TrustShell }`. It resolves cleanly from both diff --git a/package-lock.json b/package-lock.json index 9139b658..4b6e7f39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hyperdag/trustshell", - "version": "1.8.1-rc.0", + "version": "1.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hyperdag/trustshell", - "version": "1.8.1-rc.0", + "version": "1.8.1", "license": "Apache-2.0", "dependencies": { "@hyperdag/proof-verifier": "^0.2.0", diff --git a/package.json b/package.json index f8a1ed46..bbb811e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hyperdag/trustshell", - "version": "1.8.1-rc.0", + "version": "1.8.1", "description": "Check what your AI agent says it did against the evidence. Inspect checks an agent's claims about a GitHub Actions run against GitHub's own record, with no account; also a hosted second-opinion check, local memory, an MCP server and an SDK.", "license": "Apache-2.0", "publishConfig": { diff --git a/skills/trustshell/SKILL.md b/skills/trustshell/SKILL.md index a578df37..77b0d3fb 100644 --- a/skills/trustshell/SKILL.md +++ b/skills/trustshell/SKILL.md @@ -1,7 +1,7 @@ --- name: trustshell description: Fact-check a claim, read an agent's live RepID, verify its ZK proof, or print after-create and Honesty A by shelling out to the published bins. Use when the user asks to verify a claim, look up a RepID, check a proof, see can_verify, or attach the local trustshell MCP server. -metadata: {"openclaw":{"emoji":"πŸ›‘οΈ","requires":{"bins":["trustshell","trustshell-mcp"]},"install":[{"id":"npm","kind":"node","package":"@hyperdag/trustshell@1.8.0","bins":["trustshell","trustshell-mcp","hal"],"label":"Install @hyperdag/trustshell@1.8.0"}]}} +metadata: {"openclaw":{"emoji":"πŸ›‘οΈ","requires":{"bins":["trustshell","trustshell-mcp"]},"install":[{"id":"npm","kind":"node","package":"@hyperdag/trustshell@1.8.1","bins":["trustshell","trustshell-mcp","hal"],"label":"Install @hyperdag/trustshell@1.8.1"}]}} --- # Trustshell @@ -11,7 +11,7 @@ metadata: {"openclaw":{"emoji":"πŸ›‘οΈ","requires":{"bins":["trustshell","trust The bins must already be on PATH: ```bash -npm i -g @hyperdag/trustshell@1.8.0 +npm i -g @hyperdag/trustshell@1.8.1 ``` That install provides `trustshell`, `hal`, and `trustshell-mcp`. If `trustshell` is not on PATH, stop and say so. Do not install another package and do not start a server. @@ -64,11 +64,11 @@ The homepage opens on a check, by job. First, one real result with its date (a p The next section, `Get the check where you already use AI`, opens three ways in: - In Chrome: the extension stamps replies on ChatGPT, Claude, Gemini, Grok and DeepSeek. -- In Claude Desktop, Cursor or Claude Code: an MCP paste. It runs `trustshell-mcp` from `@hyperdag/trustshell@1.8.0` through `npx -y -p`, for someone without the global install. With the prerequisite above, use the block under MCP instead. The ChatGPT and Grok apps do not load MCP servers yet; on their websites the Chrome extension stamps replies. +- In Claude Desktop, Cursor or Claude Code: an MCP paste. It runs `trustshell-mcp` from `@hyperdag/trustshell@1.8.1` through `npx -y -p`, for someone without the global install. With the prerequisite above, use the block under MCP instead. The ChatGPT and Grok apps do not load MCP servers yet; on their websites the Chrome extension stamps replies. - In your terminal: one `check` of a sample sentence through `npx`. It exits 0 when it checks out, 1 when caught, 2 when not checked. To keep it installed: ```bash -npm i -g @hyperdag/trustshell@1.8.0 +npm i -g @hyperdag/trustshell@1.8.1 ``` After that, `Want an agent you can trust?` leads to /pai: three questions make an agent, and the same six-step bar as /bind shows the rest of the path (claim, fund, stake, trade, earn). diff --git a/skills/trustshell/skill.json b/skills/trustshell/skill.json index e5bb087c..5767b31f 100644 --- a/skills/trustshell/skill.json +++ b/skills/trustshell/skill.json @@ -18,9 +18,9 @@ { "id": "npm", "kind": "node", - "package": "@hyperdag/trustshell@1.8.0", + "package": "@hyperdag/trustshell@1.8.1", "bins": ["trustshell", "trustshell-mcp", "hal"], - "label": "Install @hyperdag/trustshell@1.8.0" + "label": "Install @hyperdag/trustshell@1.8.1" } ] } diff --git a/tests/install-docs.test.ts b/tests/install-docs.test.ts index 3d7edd5e..3b69cfd1 100644 --- a/tests/install-docs.test.ts +++ b/tests/install-docs.test.ts @@ -20,7 +20,7 @@ describe('install docs', () => { it('Claude Code records the version, the package, and the config it wrote', () => { expect(claude).toContain('2.1.157'); - expect(claude).toContain('npm i -g @hyperdag/trustshell@1.8.0'); + expect(claude).toContain('npm i -g @hyperdag/trustshell@1.8.1'); expect(claude).toContain('"command": "trustshell-mcp"'); expect(claude).toContain('Pending approval'); expect(claude).toContain('trustshell 1.5.0'); @@ -28,14 +28,14 @@ describe('install docs', () => { it('Cursor records the installed version and that the server was not loaded', () => { expect(cursor).toContain('3.23.12'); - expect(cursor).toContain('npm i -g @hyperdag/trustshell@1.8.0'); + expect(cursor).toContain('npm i -g @hyperdag/trustshell@1.8.1'); expect(cursor).toContain('"command": "trustshell-mcp"'); expect(cursor).toMatch(/not loaded/i); }); it('Claude Desktop records the installed version and that the live file was not changed', () => { expect(desktop).toContain('2.19675.0.0'); - expect(desktop).toContain('npm i -g @hyperdag/trustshell@1.8.0'); + expect(desktop).toContain('npm i -g @hyperdag/trustshell@1.8.1'); expect(desktop).toContain('"command": "trustshell-mcp"'); expect(desktop).toMatch(/not changed/i); expect(desktop).toMatch(/not loaded/i); diff --git a/tests/join-kit.test.ts b/tests/join-kit.test.ts index a57387c9..d1a296f7 100644 --- a/tests/join-kit.test.ts +++ b/tests/join-kit.test.ts @@ -9,7 +9,7 @@ describe('join kit', () => { it('is one screen: verify, a receipt line, and no stake now', () => { expect(doc).toContain('A portable trust harness. Autonomy is earned.'); expect(doc).toContain('ERC-8004'); - expect(doc).toContain('npm i -g @hyperdag/trustshell@1.8.0'); + expect(doc).toContain('npm i -g @hyperdag/trustshell@1.8.1'); expect(doc).toContain('trustshell verify "The capital of France is Paris."'); expect(doc).toContain('glm cerebras FALSE'); expect(doc).toContain('openai openai NOT_CHECKED'); diff --git a/tests/register-not-minted.test.ts b/tests/register-not-minted.test.ts index d5002b2c..8bdb9ba4 100644 --- a/tests/register-not-minted.test.ts +++ b/tests/register-not-minted.test.ts @@ -54,7 +54,7 @@ describe('skill shells out to trustshell', () => { for (const command of manifest.commands) { expect(skill).toContain(command); } - expect(skill).toContain('npm i -g @hyperdag/trustshell@1.8.0'); + expect(skill).toContain('npm i -g @hyperdag/trustshell@1.8.1'); expect(skill).not.toMatch(/npx @hyperdag\/trustshell@1\.6\.2/); expect(skill).not.toMatch(/staking is live/i); }); From f20ad3d9913db268a02cb07f3d5a69caeaa15ed5 Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Sat, 24 Oct 2026 07:54:15 -0700 Subject: [PATCH 10/10] ci(extension-e2e): pin the runner to ubuntu-24.04 (LOW, CI only) (#645) ubuntu-latest moved to 26.04 today (run 40102212111 got "Image: ubuntu-26.04"; run 40094806779 at 12:39 UTC got 24.04). Playwright 1.56.1 refuses it: "Playwright does not support chromium on ubuntu26.04-x64", so the extension job fails before any test runs, on every PR. Pinning keeps the 1.56.1 pin that matches the agent sandbox. e2e-honesty.yml installs Playwright ^1.62.1 and is not changed here. Co-authored-by: Claude Opus 5.5 (cherry picked from commit 3e163267f8c1bd87609ce332219678df17c89fcb) --- .github/workflows/extension-e2e.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/extension-e2e.yml b/.github/workflows/extension-e2e.yml index 62190f5b..cc29d3ad 100644 --- a/.github/workflows/extension-e2e.yml +++ b/.github/workflows/extension-e2e.yml @@ -33,7 +33,9 @@ permissions: jobs: extension: - runs-on: ubuntu-latest + # Pinned: ubuntu-latest became 26.04 on 2026-10-24, where Playwright 1.56.1 cannot install Chromium. + # Move this with the Playwright pin below, not before it. + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - uses: actions/checkout@v7