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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/release-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Installs the packed release into an empty folder and drives it as a new developer would: no account, no key,
# no secret of ours. It runs on each operating system because the commands have to work the same in each shell.
name: release-e2e

on:
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
e2e:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: 22
# Exit 2 means NOT_CHECKED: shared runner addresses are often rate-limited by GitHub's anonymous API. That is
# reported as a warning and never as a pass; the step summary lists which steps did not run.
- name: Clean-install E2E
shell: bash
run: |
set +e
node scripts/release-e2e.mjs --out e2e-out
code=$?
node scripts/release-e2e-summary.mjs e2e-out >> "$GITHUB_STEP_SUMMARY"
if [ "$code" = "2" ]; then echo "::warning::some steps were NOT_CHECKED; see the summary"; exit 0; fi
exit $code
- uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-${{ matrix.os }}
path: e2e-out
if-no-files-found: ignore
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ All notable changes to the `@hyperdag/trustshell` package.

`package.json` on main is `1.9.0-dev.0`: main is ahead of the published 1.8.0, and nothing below is in any npm release.

- **`trustshell trust-report`: one report for testing your own agent.** It joins what the agent said about a GitHub Actions run (`check --out` records) with every command that went through `trustshell run`, and keeps the two apart. It says what to do next and what it cannot see. `trust-report verify` proves the folder is whole, matches its evidence and was sealed by a key you pin. See `examples/own-agent`.
- **Fix: `trustshell run` no longer prints "started" for a command that could not start.** On Windows it also says why a `.cmd` shim such as npm cannot be started directly.
- **Fix: `guardedX402Payment` decides every refusal before it writes the pre-pay receipt.** The order was receipt, then `buildX402Payment`, so a payment the Guard denied (#665), the cap refused, or a bad address, key or nonce stopped left a receipt for a payment that never happened. Now it is decide (`prepareX402Payment`: amount, cap, address, key, typed data, then the Guard's spend decision), receipt, sign. One Guard decision and one count per payment, as before. **Behaviour change:** if the receipt writer then throws, nothing is signed but the amount stays counted in the Guard ledger (the ledger is append-only and has no release record), so it can read higher than what was signed, never lower; before, a receipt failure counted nothing. `buildX402Payment` is unchanged for direct callers. `prepareX402Payment` (src/lib/x402-prepare.ts) is internal: the package root does not export it, and the `sign()` it returns is single-use (a second call throws), so one Guard decision buys one signature. tests/guard-before-prepay-receipt.test.ts, tests/x402-prepare-internal.test.ts.
- **Guard + Carry, experimental (#593).** `trustshell guard` and `trustshell run` add a local owner policy enforced only on the paths they mediate. `trustshell bundle` adds a signed (not encrypted) bundle that carries a profile, a policy and selected notes to another host. These are local guardrails for cooperative agents, not a tamper-resistant boundary: anyone who can run the CLI as the owner can change the policy, and OS commands are not mediated (MCP tool calls are, since IS-P0-5 below).
- **Guard now decides every MCP tool call (IS-P0-5, containment row C3).** `trustshell-mcp` read no policy, so an agent acting through MCP did what the CLI gate refused. Now each tool call is decided by the CLI's own decision and recorded in the same `guard-receipts.jsonl` (origin `Mcp`) before the tool runs, from the policy in the server's `TRUSTSHELL_HOME`, re-read per call (so `guard kill` stops the next call without a restart). Each tool maps to the CLI command it mirrors (`MCP_GUARD_TOOLS` in `src/mcp/index.ts`: `recall` is `tool:recall`, `get_repid` is `tool:repid`, `inspect_run` and `check_claim` are `tool:check`). A policy that does not parse, is killed or expired, or cannot be read refuses; no policy at all changes nothing, as at the CLI. **Behaviour change:** an MCP user with a restrictive policy is now refused where they were not; under the policy `guard init` writes, only the local tools (ground, learn, quarantine, recall, remember) run over MCP. tests/mcp-guard.test.ts.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,7 @@ to the HyperDAG backend's classify endpoint and nothing else):
| `ground` | **none** — reads the `--source` file. Since 1.7.0 | none |
| `init` | **none** — writes one local file. `--pai` runs `scripts/init-pai.mjs` (live register) | none |
| `report` | **none** — it has no fetch and no URL parameter | none |
| `trust-report` | **none** — reads the Guard records and the `check` records you name, and writes one new folder | none |
| `profile` | **none** — reads and writes `.trustshell/profile.md`. Since 1.8.0 | none |
| `remember` | **none** — writes the local memory file | none |
| `recall` | **none** — reads the local memory file | none |
Expand Down
3 changes: 3 additions & 0 deletions dist/cli/guard.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export interface Spawner {
(cmd: string, args: string[]): {
status: number | null;
signal: string | null;
error?: string;
};
}
export declare const RUN_USAGE = "trustshell run --tool <name> [--class read|other|post|spend] [--amount <n>] [--sandbox [--runtime runc|runsc]] [--json] -- <command> [args...]";
Expand All @@ -60,6 +61,8 @@ export declare const RUN_USAGE = "trustshell run --tool <name> [--class read|oth
* `run --tool git -- git status` arrived as `run --tool git git status` and failed "unexpected git" with no
* hint (stranger walk, 2026-10-23, PS 5.1). Quoted, or through the `.cmd` shim, the `--` survives.
*/
/** Windows starts only real executables without a shell, and `run` never uses a shell: that would let arguments start other commands. */
export declare const RUN_WINDOWS_SHIM = "On Windows a .cmd shim such as npm or npx cannot be started directly. Start the script it wraps instead: pin it, then trustshell run --tool node '--' node <script>.";
export declare const RUN_POWERSHELL = "In Windows PowerShell write the -- quoted, '--' (trustshell run --tool git '--' git status), or call trustshell.cmd: PowerShell drops a bare -- before trustshell sees it.";
/** `trustshell run`. The command's own output goes to the terminal; the card goes to stderr (or --json to stdout). */
export declare function runRun(argv: string[], io: IO, env?: NodeJS.ProcessEnv, spawn?: Spawner, sandboxExec?: Exec): number;
Expand Down
16 changes: 12 additions & 4 deletions dist/cli/guard.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.LEVEL_IS_NOT = exports.GUARD_USAGE = exports.RUN_POWERSHELL = exports.RUN_USAGE = exports.CHECK_SCOPE = exports.POLICY_SCOPE = exports.SANDBOX_RUN_SCOPE = exports.RUN_SCOPE = exports.CLI_SCOPE = exports.NEVER_GUARDED = exports.EXIT_REFUSED = void 0;
exports.LEVEL_IS_NOT = exports.GUARD_USAGE = exports.RUN_POWERSHELL = exports.RUN_WINDOWS_SHIM = exports.RUN_USAGE = exports.CHECK_SCOPE = exports.POLICY_SCOPE = exports.SANDBOX_RUN_SCOPE = exports.RUN_SCOPE = exports.CLI_SCOPE = exports.NEVER_GUARDED = exports.EXIT_REFUSED = void 0;
exports.recordPolicyChange = recordPolicyChange;
exports.plannedAction = plannedAction;
exports.runGuarded = runGuarded;
Expand Down Expand Up @@ -117,7 +117,11 @@ function formatRunCard(decision, outcome, doc = null, own = null) {
const by = d.authorized.policy_signed_by;
const signed = by ? (by === own ? 'owner-signed' : `signed by ${by.slice(0, 16)}…, NOT this install's key`) : doc?.signature ? guardrails_1.SIGNATURE_DOES_NOT_VERIFY : 'unsigned';
const ex = outcome?.executed;
const executed = ex && ex !== 'not run' ? `started; exited ${ex.exit_code ?? `on signal ${ex.signal}`} after ${ex.duration_ms} ms` : 'not run';
const executed = ex && ex !== 'not run'
? ex.exit_code === null && ex.signal === null
? `could not start (${ex.start_error ?? 'unknown error'})`
: `started; exited ${ex.exit_code ?? `on signal ${ex.signal}`} after ${ex.duration_ms} ms`
: 'not run';
const exe = d.requested.executable;
const pinnedExe = exe !== undefined && (doc?.policy.executables ?? []).some((p) => p.tool === exe);
const identity = exe === undefined
Expand All @@ -141,14 +145,16 @@ function formatRunCard(decision, outcome, doc = null, own = null) {
const nodeSpawner = (cmd, args) => {
const { spawnSync } = require('node:child_process');
const r = spawnSync(cmd, args, { stdio: 'inherit', windowsHide: true });
return { status: r.status, signal: r.signal ?? null };
return { status: r.status, signal: r.signal ?? null, error: r.error ? (r.error.code ?? r.error.message) : undefined };
};
exports.RUN_USAGE = 'trustshell run --tool <name> [--class read|other|post|spend] [--amount <n>] [--sandbox [--runtime runc|runsc]] [--json] -- <command> [args...]';
/**
* Windows PowerShell drops a bare `--` before it reaches npm's `trustshell.ps1` shim, so the documented
* `run --tool git -- git status` arrived as `run --tool git git status` and failed "unexpected git" with no
* hint (stranger walk, 2026-10-23, PS 5.1). Quoted, or through the `.cmd` shim, the `--` survives.
*/
/** Windows starts only real executables without a shell, and `run` never uses a shell: that would let arguments start other commands. */
exports.RUN_WINDOWS_SHIM = "On Windows a .cmd shim such as npm or npx cannot be started directly. Start the script it wraps instead: pin it, then trustshell run --tool node '--' node <script>.";
exports.RUN_POWERSHELL = "In Windows PowerShell write the -- quoted, '--' (trustshell run --tool git '--' git status), or call trustshell.cmd: PowerShell drops a bare -- before trustshell sees it.";
const isHelp = (a) => a === '--help' || a === '-h' || a === 'help';
/** `trustshell run`. The command's own output goes to the terminal; the card goes to stderr (or --json to stdout). */
Expand Down Expand Up @@ -260,10 +266,12 @@ function runRun(argv, io, env = process.env, spawn = nodeSpawner, sandboxExec =
kind: 'outcome',
requested: g.record.requested,
authorized: g.record.authorized,
executed: { exit_code: r.status, signal: r.signal, duration_ms: Date.now() - t0 },
executed: { exit_code: r.status, signal: r.signal, duration_ms: Date.now() - t0, ...(r.error ? { start_error: r.error } : {}) },
assurance_scope: exports.RUN_SCOPE,
});
(json ? io.out : io.err)(json ? JSON.stringify({ decision: g.record, outcome }, null, 2) : formatRunCard(g.record, outcome, g.loaded.state === 'ok' ? g.loaded.doc : null, own));
if (r.error === 'ENOENT' && process.platform === 'win32')
io.err(exports.RUN_WINDOWS_SHIM);
return r.status ?? 3;
}
/** C2: Guard has ALLOWED the command; it starts only in a mission container, never on this host. */
Expand Down
3 changes: 2 additions & 1 deletion dist/cli/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export declare const EXIT: {
/** The owner policy (~/.trustshell/policy.json) denied the action. Nothing ran. */
readonly REFUSED: 5;
};
export type Command = 'verify' | 'evaluate' | 'repid' | 'proof' | 'badge' | 'check' | 'receipt' | 'verify-receipt' | 'sign-receipt' | 'demo' | 'inspect' | 'init' | 'report' | 'profile' | 'status' | 'remember' | 'recall' | 'redact' | 'quarantine' | 'bind-status' | 'traps' | 'ground' | 'erc8004' | 'learn' | 'bundle' | 'guard' | 'run' | 'sandbox' | 'help' | 'version';
export type Command = 'verify' | 'evaluate' | 'repid' | 'proof' | 'badge' | 'check' | 'receipt' | 'verify-receipt' | 'sign-receipt' | 'demo' | 'inspect' | 'init' | 'report' | 'profile' | 'status' | 'remember' | 'recall' | 'redact' | 'quarantine' | 'bind-status' | 'traps' | 'ground' | 'erc8004' | 'learn' | 'bundle' | 'guard' | 'run' | 'trust-report' | 'sandbox' | 'help' | 'version';
/** Result of parsing argv (everything after `node cli.js`). Pure + testable. */
export interface ParsedArgs {
command: Command;
Expand Down Expand Up @@ -131,6 +131,7 @@ export interface ParsedArgs {
guardArgv?: string[];
/** run: everything after `run`, parsed by src/cli/guard.ts (its options, then `--`, then the command). */
runArgv?: string[];
trustReportArgv?: string[];
/** sandbox: everything after `sandbox`, parsed by src/sandbox/run.ts (promote <mission>). */
sandboxArgv?: string[];
/** profile: the parsed subcommand (show / add / set / remove / interview). */
Expand Down
16 changes: 16 additions & 0 deletions dist/cli/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ const guardrails_1 = require("../lib/guardrails");
const api_url_1 = require("../lib/api-url");
const install_key_1 = require("../lib/install-key");
const decode_text_1 = require("../lib/decode-text");
const trust_report_1 = require("./trust-report");
const owner_receipt_1 = require("../lib/owner-receipt");
/** Exit codes — a small, stable contract so CI scripts can branch on them. */
exports.EXIT = {
Expand Down Expand Up @@ -375,6 +376,16 @@ COMMANDS
The chain (default base).
[--payment <txHash>] The payment to check. Default: the feedbackHash, read as a
transaction. The feedback file is never fetched.
trust-report --out <new dir> [--record <record.json>]... [--bind "<tool or script>=<claim>"]...
One readable report and its evidence: what the agent said, checked against
GitHub (from check --out records), and every command that went through
trustshell run. It cannot see anything the agent did any other way. No
network. EXIT 0 every claim SUPPORTED, 1 a claim CONTRADICTED or a broken
record, 3 anything NOT_CHECKED. --bind pairs a claim with the command that
decides it.
trust-report verify <dir> [--root <key>]
Prove the folder is whole and matches its evidence. EXIT 0 VERIFIED, 1 FAILED,
2 NOT_CHECKED (no --root, so who made it was not checked).
report State what your log and your saved GitHub evidence TOGETHER
support, and where they disagree. NO NETWORK — evidence is a
file you produced. CONFIRMED (0) / INCONSISTENT (1) /
Expand Down Expand Up @@ -462,6 +473,8 @@ function parseArgs(argv) {
return { command: 'run', runArgv: argv.slice(1), json: false, verify: false };
if (argv[0] === 'sandbox')
return { command: 'sandbox', sandboxArgv: argv.slice(1), json: false, verify: false };
if (argv[0] === 'trust-report')
return { command: 'trust-report', trustReportArgv: argv.slice(1), json: false, verify: false };
const flags = new Set();
const positionals = [];
// Options that consume the NEXT argument. Indexed rather than for-of because a
Expand Down Expand Up @@ -1097,6 +1110,9 @@ async function run(args, client, io = realIO) {
case 'run':
// Starts the caller's command only after the policy allows it.
return (0, guard_1.runRun)(args.runArgv ?? [], io);
case 'trust-report':
// Local files only. Opens no socket.
return (0, trust_report_1.runTrustReport)(args.trustReportArgv ?? [], io);
case 'sandbox':
// Local files only: the owner step that promotes a `run --sandbox` mission's quarantined outbox.
return (0, run_1.sandboxCli)(args.sandboxArgv ?? [], io);
Expand Down
101 changes: 101 additions & 0 deletions dist/cli/trust-report.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { type SealableReceipt } from '../lib/owner-receipt';
export declare const TRUST_REPORT_SCHEMA: "dev.trustshell.trust-report/0.1-draft";
export declare const TRUST_REPORT_USAGE = "trustshell trust-report --out <new dir> [--record <record.json>]... [--bind \"<tool or script>=<claim>\"]... [--title <text>] [--json]\ntrustshell trust-report verify <dir> [--root <0x key>] [--json]";
export declare const NOT_MEDIATED_ALWAYS = "Anything the agent did without `trustshell run`: edits it made directly, its own network calls, commands in another terminal. This report cannot see them.";
export interface Bind {
claim: string;
decided_by: string;
tool: string | null;
script_sha256: string | null;
}
export interface InspectRow {
path: string;
run_url: string;
repo: string;
run_id: number;
head_sha: string;
conclusion: string | null;
verdict: string;
fetched_at: string;
digest: string;
digest_matches: boolean;
exit: number;
claims: {
status: string;
claim: string;
scope: string;
}[];
}
export interface CommandRow {
seq: number;
ts: string;
action: string;
script_sha256: string | null;
command_sha256: string | null;
decision: string;
reasons: string[];
result: string;
exit_code: number | null;
}
export interface BoundClaim {
claim: string;
decided_by: string;
status: 'SUPPORTED' | 'CONTRADICTED' | 'NOT_CHECKED';
why: string;
record_seq: number | null;
}
export interface TrustReport {
schema: typeof TRUST_REPORT_SCHEMA;
title: string;
generated_at: string;
generator: string;
inputs: {
binds: Bind[];
records: string[];
};
inspect: InspectRow[];
guard: {
policy: {
state: string;
sha256: string | null;
nonce: string | null;
signed: boolean;
actions: string[];
};
ledger: {
verdict: string;
count: number;
breaks: string[];
};
commands: CommandRow[];
mediated: string[];
not_mediated: string[];
};
bound_claims: BoundClaim[];
next_actions: string[];
exit: number;
}
export interface BuildInput {
title: string;
generatedAt: string;
generator: string;
binds: Bind[];
records: {
path: string;
text: string;
}[];
ledgerText: string;
policyText: string | null;
}
/** PURE. The whole report, from its inputs. `verify` calls this again to prove the report matches its evidence. */
export declare function buildTrustReport(input: BuildInput): TrustReport;
export declare function renderTrustReportMarkdown(r: TrustReport): string;
export declare function renderTrustReportHtml(r: TrustReport): string;
interface IO {
out: (s: string) => void;
err: (s: string) => void;
}
/** The sealed receipt names the manifest's sha256 in args_hash, so one signature covers every listed file. */
export declare function sealBody(manifestSha: string, createdAt: string): SealableReceipt;
export declare function runTrustReport(argv: string[], io: IO, env?: NodeJS.ProcessEnv, cwd?: string): Promise<number>;
export {};
Loading
Loading