Skip to content

feat(cli): trust-report and clean-install E2E for testing your own agent - #686

Merged
DealAppSeo merged 4 commits into
mainfrom
feat/cc-2026-11-08-own-agent-e2e
Nov 9, 2026
Merged

DealAppSeo merged 4 commits into
mainfrom
feat/cc-2026-11-08-own-agent-e2e

Conversation

@DealAppSeo

@DealAppSeo DealAppSeo commented Nov 8, 2026 •

Copy link
Copy Markdown
Owner

Lets a developer test their own agent and get a report they can inspect.

What this adds

  • trustshell trust-report: one readable report and its evidence. 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.
  • trust-report verify: proves the folder is whole, matches its evidence and was sealed by a key you pin. MANIFEST.sha256 also works with plain sha256sum -c.
  • --bind "<tool or script>=<claim>": pairs a claim with the command that decides it. If that command ran with more than one command line, the claim is NOT_CHECKED, so a failure cannot be replaced by an easier run.
  • scripts/release-e2e.mjs: packs the release, installs it into an empty folder with no keys and runs each step in every shell, tracing the network. It needs no secret of ours.
  • examples/own-agent/README.md: the two paths, with commands that are the same in bash and PowerShell.

Also fixes trustshell run printing "started" for a command that could not start, and says why a .cmd shim cannot be started on Windows.

What it does not claim

The report cannot see anything the agent did without trustshell run, and says so in every rendering. The seal shows who made the report, not that the commands test what the claims say.

Evidence

  • Unit: 24 tests. 17 mutations of the verifier and the claim logic each turn a named test red.
  • release-e2e in CI: macOS 50 of 50 steps; Ubuntu and Windows 49 of 50, where the one NOT_CHECKED is the sandbox step because a container runtime is present. sandbox-mission.yml covers that path.
  • Controls: a refused script leaves no side effect and does when started outside TrustShell; the network tracer is shown to see a request; a changed report, ledger or added file fails; a rewritten manifest without the key fails.
  • A real run found the easier-run gap above: a last invocation that selected no tests exited 0 and counted as support.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
trustshell-landing Ready Ready Preview Nov 8, 2026 11:38pm UTC

Request Review

DealAppSeo and others added 4 commits November 8, 2026 15:37
…agent

Two paths, kept apart. Path 1 compares what an agent said with GitHub's
record of a run and needs no access to the agent. Path 2 covers only
commands started through trustshell run. trust-report joins them into one
readable report with its evidence, and verify proves the folder is whole,
matches that evidence and was sealed by a pinned key.

scripts/release-e2e.mjs packs the release, installs it into an empty folder
with no keys and runs each step in every shell, tracing the network.

run no longer prints started for a command that could not start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In a real run, a last invocation that selected no tests exited 0 and
counted as support. A claim paired with a command that ran with more
than one command line is now NOT_CHECKED.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@strix-security

strix-security Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

Strix Security Review

To fix before merging (5)

PR summary: Adds a `trustshell trust-report` command that builds a readable report and a sealed, verifiable evidence folder from `check --out` records and the Guard ledger, plus a `trust-report verify` subcommand, a clean-install release E2E harness with a CI workflow, and a fix so `run` no longer reports "started" for a command that could not start.

Review summary

This PR adds trustshell trust-report, a new local command that assembles a readable report plus a sealed evidence folder from check --out records and the Guard ledger, along with trust-report verify, a clean-install E2E harness, and a small fix to run's "could not start" card. The verifier's own seal→manifest→file chain holds up: I could not make verify return VERIFIED for a tampered folder or for a folder sealed by a key other than the pinned one, and absolute/.. paths in the manifest are contained.

The problems are in what the report treats as trustworthy evidence. The report is built to be handed to a third party, but every input it presents as proof is writable by the agent under test: the --record file is an unsigned local file whose only check is a hash recomputed from itself, yet it is rendered as "Compared with GitHub's own record"; the Guard ledger is an unkeyed hash chain, and the sealing key is read from $TRUSTSHELL_HOME — which the PR's own example places inside the working tree — with the resulting root printed as the value the recipient should pin, so a planted key plus a forged ledger produces a report that verifies as VERIFIED. A bound claim is also marked SUPPORTED from a run of any binary sharing the tool's basename, with the executable identity dropped from the report entirely. Separately, the new renderers leave two injection paths open: agent-controlled claim status is interpolated into trust-report.md outside the escaping helper (the existing injection test only covers claim text), and verify prints filenames taken from a stranger's report folder to the terminal without stripping escape sequences.

The CI harness and the LOCAL_TOOLS addition were reviewed and did not yield findings: the workflow is correctly scoped to pull_request with read-only permissions and no secrets, and guard allow is itself ungated so the extra default-policy entry grants no new capability.

Ruled out (9)
  • Can `trust-report verify` be made to return VERIFIED for a tampered folder or one not sealed by the pinned key?
    The seal covers the manifest, the manifest covers every file, and `verify` rebuilds the report and both renderings from the evidence; a foreign key, a rewritten manifest, an added file, a duplicate entry and a CRLF manifest all fail closed, and no `--root` yields exit 2 rather than a pass.
  • Can an unknown claim status in a record make the report exit 0 as a pass?
    It does (an unrecognised status leaves the tally counters at zero and `claimsExitCode` returns 0), but it grants an agent nothing it does not already have by writing the status `SUPPORTED`, so it is a robustness bug rather than an escalation; the same unvalidated field is covered by the Markdown-in…
  • Can a report folder make `verify` read files outside the folder?
    A symlink in the folder is followed and its target's content can appear in a rebuild error, but the folder must already be sealed with the author's own key, and the leaked bytes go only to the verifier's own output, so it is a low-impact local read rather than a verification bypass.
  • Do the uncaught exceptions in `verify` and `create` fail open?
    A manifest entry naming a directory, a malformed manifest line, and `--out` pointing at an existing regular file each crash the CLI with a non-zero exit and no verdict line, so they fail closed; they are robustness bugs, not security issues.
  • Does adding `trust-report` to `LOCAL_TOOLS` give an agent a new capability under the default policy?
    It only widens the default action list by one local-files-only command, and `guard` is itself ungated so any caller can already add that action; the name-only `run --tool` matching it relies on is identical for the twelve pre-existing entries.
  • Can `start_error` inject escape sequences into the terminal through the run card?
    It can carry bytes from the agent's own command name, but `run` uses `stdio: 'inherit'`, so the same agent already writes arbitrary bytes to the operator's terminal through any command that starts; the card adds no new channel.
  • Do the new CI harness and workflow introduce command injection or secret exposure?
    The `bash -c` interpolation of values from the package under test is genuinely injectable, but the harness exists to execute that package, the workflow runs on `pull_request` with `contents: read` and `persist-credentials: false`, no secrets are referenced, and both npm invocations pass `--ignore-s…
  • Does the new `check-egress` entry actually prove `trust-report` makes no network call?
    It only exercises the early return for a missing manifest, so the create and real-verify paths are never run under the egress stubs; the published no-network claim is true today (the file imports no network module), so this is a false-assurance gap in the guard rather than a live vulnerability.
  • Does the hand-committed `dist/` diverge from `src/` in a security-relevant way?
    `dist/cli/trust-report.js` is the compiled output of `src/cli/trust-report.ts` and the other dist edits match their sources, and the freshness gate `tests/sdk-dist-fresh.test.ts` is not modified by this PR.

Fixed the findings? re-run the review, or tag @strix-security in a PR comment to run a fresh review.

Review 1 on this PR · a9724a8 · requested Nov 9 01:50 UTC when the PR was opened


Reviewed by Strix
Re-run review · Configure security review settings

@strix-security strix-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strix flagged 5 new security findings below. See the pinned summary comment for the full PR status.

Comment thread src/cli/trust-report.ts
Comment on lines +529 to +530
if (f.includes('..') || !existsSync(p)) problems.push(`${f} is listed but missing`);
else if (sha256(readFileSync(p)) !== want) problems.push(`${f} changed after the report was made`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 trust-report verify prints attacker-controlled filenames from a stranger's report folder to the terminal without stripping escape sequences

Severity: MEDIUM · CWE-150

trustshell trust-report verify <dir> is designed to be run on a report folder a stranger sent you. Three of its problems entries interpolate a filename taken straight from that folder — from MANIFEST.sha256 lines, from the directory walk, and from report.json's inputs.records — and the problems are printed to stdout with no cleanCodePoints pass, unlike every other field in the file. A folder whose manifest entry or file name contains ANSI/OSC bytes therefore writes those bytes to the operator's terminal: the verdict line can be erased and rewritten, the window title spoofed, and the clipboard overwritten via OSC 52.

Suggested change
if (f.includes('..') || !existsSync(p)) problems.push(`${f} is listed but missing`);
else if (sha256(readFileSync(p)) !== want) problems.push(`${f} changed after the report was made`);
if (f.includes('..') || !existsSync(p)) problems.push(`${clean(f, 300)} is listed but missing`);
else if (sha256(readFileSync(p)) !== want) problems.push(`${clean(f, 300)} changed after the report was made`);
Prompt to fix with AI
This is a security vulnerability found during a code review.

Vulnerability: `trust-report verify` prints attacker-controlled filenames from a stranger's report folder to the terminal without stripping escape sequences
Severity: MEDIUM
CWE: CWE-150

`trustshell trust-report verify <dir>` is designed to be run on a report folder a stranger sent you. Three of its `problems` entries interpolate a filename taken straight from that folder — from `MANIFEST.sha256` lines, from the directory walk, and from `report.json`'s `inputs.records` — and the problems are printed to stdout with no `cleanCodePoints` pass, unlike every other field in the file. A folder whose manifest entry or file name contains ANSI/OSC bytes therefore writes those bytes to the operator's terminal: the verdict line can be erased and rewritten, the window title spoofed, and the clipboard overwritten via OSC 52.

Location: src/cli/trust-report.ts:529-530
Context: manifest entry name printed raw (primary sink)
```
// Before:
    if (f.includes('..') || !existsSync(p)) problems.push(`${f} is listed but missing`);
    else if (sha256(readFileSync(p)) !== want) problems.push(`${f} changed after the report was made`);
// After:
    if (f.includes('..') || !existsSync(p)) problems.push(`${clean(f, 300)} is listed but missing`);
    else if (sha256(readFileSync(p)) !== want) problems.push(`${clean(f, 300)} changed after the report was made`);
```

Location: src/cli/trust-report.ts:532-532
Context: directory-walk filename printed raw
```
// Before:
  for (const f of walk(dir)) if (f !== MANIFEST && f !== SEAL && !listed.has(f)) problems.push(`${f} is not in the manifest`);
// After:
  for (const f of walk(dir)) if (f !== MANIFEST && f !== SEAL && !listed.has(f)) problems.push(`${clean(f, 300)} is not in the manifest`);
```

Location: src/cli/trust-report.ts:569-569
Context: evidence path from report.json printed raw
```
// Before:
    for (const i of again.inspect) if (!i.digest_matches) problems.push(`${i.path}: its digest does not match its contents`);
// After:
    for (const i of again.inspect) if (!i.digest_matches) problems.push(`${clean(i.path, 300)}: its digest does not match its contents`);
```

How to fix:
Pass every string that originates in the report folder through the existing `clean()` helper (which wraps `cleanCodePoints`) before it enters the `problems` array, exactly as the rest of `trust-report.ts` already does for `action`, `reasons`, `claim`, `scope`, `run_url` and the seal error. Apply it at the three interpolation sites in `verify()`: the manifest loop, the directory walk, and the per-record digest check. Consider also cleaning `inspectRow`'s `path` at the point it is stored, so any future rendering of that field is safe by construction.

Please fix this vulnerability. If you propose a fix, make it concise and minimal.

React 👍 / 👎 to tune Strix for this repo. A repo collaborator (or the PR author) can resolve this thread to dismiss the finding.

Comment thread src/cli/trust-report.ts
if (f.includes('..') || !existsSync(p)) problems.push(`${f} is listed but missing`);
else if (sha256(readFileSync(p)) !== want) problems.push(`${f} changed after the report was made`);
}
for (const f of walk(dir)) if (f !== MANIFEST && f !== SEAL && !listed.has(f)) problems.push(`${f} is not in the manifest`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

directory-walk filename printed raw

Suggested change
for (const f of walk(dir)) if (f !== MANIFEST && f !== SEAL && !listed.has(f)) problems.push(`${f} is not in the manifest`);
for (const f of walk(dir)) if (f !== MANIFEST && f !== SEAL && !listed.has(f)) problems.push(`${clean(f, 300)} is not in the manifest`);

Comment thread src/cli/trust-report.ts
if (renderTrustReportMarkdown(again) !== readLimited(join(dir, 'trust-report.md'))) problems.push('trust-report.md does not match what its evidence produces');
if (renderTrustReportHtml(again) !== readLimited(join(dir, 'trust-report.html'))) problems.push('trust-report.html does not match what its evidence produces');
if (again.guard.ledger.verdict === 'BROKEN') problems.push('the Guard record chain is broken');
for (const i of again.inspect) if (!i.digest_matches) problems.push(`${i.path}: its digest does not match its contents`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

evidence path from report.json printed raw

Suggested change
for (const i of again.inspect) if (!i.digest_matches) problems.push(`${i.path}: its digest does not match its contents`);
for (const i of again.inspect) if (!i.digest_matches) problems.push(`${clean(i.path, 300)}: its digest does not match its contents`);

Comment thread src/cli/trust-report.ts
Comment on lines +47 to +60
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 }[];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 trust-report presents an unauthenticated local record file as GitHub's own record

Severity: MEDIUM · CWE-345

trustshell trust-report --record <file> accepts any local file whose record.schema equals ACTION_EVIDENCE_SCHEMA and whose embedded digest.value matches a SHA-256 the tool recomputes from that same file. Nothing ties the file to check --out, to GitHub, or to any key, so an agent under test can write evidence/record.json itself (or overwrite the one check produced) with any repo, run id, conclusion and claim statuses, recompute the digest, and the report renders those claims as SUPPORTED under the note "Compared with GitHub's own record, read <fetched_at>", exits 0 with "Nothing here contradicts the agent", and trust-report verify returns VERIFIED for a recipient who pins the report author's key. The record's own does_not_prove entry stating the record is unsigned is never rendered, so a third-party recipient sees GitHub-verified evidence where there is only an unauthenticated local file.

Suggested change
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 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 }[];
/** The record's own limits, carried through so a reader is never shown a claim without them. */
caveats: string[];
}
Prompt to fix with AI
This is a security vulnerability found during a code review.

Vulnerability: trust-report presents an unauthenticated local record file as GitHub's own record
Severity: MEDIUM
CWE: CWE-345

`trustshell trust-report --record <file>` accepts any local file whose `record.schema` equals `ACTION_EVIDENCE_SCHEMA` and whose embedded `digest.value` matches a SHA-256 the tool recomputes from that same file. Nothing ties the file to `check --out`, to GitHub, or to any key, so an agent under test can write `evidence/record.json` itself (or overwrite the one `check` produced) with any repo, run id, conclusion and claim statuses, recompute the digest, and the report renders those claims as SUPPORTED under the note "Compared with GitHub's own record, read <fetched_at>", exits 0 with "Nothing here contradicts the agent", and `trust-report verify` returns VERIFIED for a recipient who pins the report author's key. The record's own `does_not_prove` entry stating the record is unsigned is never rendered, so a third-party recipient sees GitHub-verified evidence where there is only an unauthenticated local file.

Location: src/cli/trust-report.ts:47-60
Context: InspectRow carries no provenance or caveat field, so the record's own limits cannot reach the rendering
```
// Before:
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 }[];
}
// After:
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 }[];
  /** The record's own limits, carried through so a reader is never shown a claim without them. */
  caveats: string[];
}
```

Location: src/cli/trust-report.ts:183-202
Context: The only check on a --record file is a digest recomputed from the same file; the record's does_not_prove list is dropped
```
// Before:
function inspectRow(path: string, text: string): InspectRow {
  const parsed = JSON.parse(text) as { record?: ActionEvidenceRecord; digest?: RecordDigest };
  const record = parsed.record;
  if (!record || record.schema !== ACTION_EVIDENCE_SCHEMA) throw new Error(`${path} is not a check --claims record (${ACTION_EVIDENCE_SCHEMA})`);
  const o = record.observation;
  const digest = digestRecord(record).value;
  return {
    path,
    run_url: clean(o.url, 300),
    repo: `${clean(o.owner, 100)}/${clean(o.repo, 100)}`,
    run_id: o.run_id,
    head_sha: clean(o.head_sha, 80),
    conclusion: o.conclusion === null ? null : clean(o.conclusion, 40),
    verdict: clean(o.check.verdict, 40),
    fetched_at: clean(o.fetchedAt, 40),
    digest,
    digest_matches: parsed.digest?.value === digest,
    exit: claimsExitCode(record),
    claims: record.claims.map((c) => ({ status: c.status, claim: clean(c.claim, 300), scope: clean(c.scope, 400) })),
  };
// After:
function inspectRow(path: string, text: string): InspectRow {
  const parsed = JSON.parse(text) as { record?: ActionEvidenceRecord; digest?: RecordDigest };
  const record = parsed.record;
  if (!record || record.schema !== ACTION_EVIDENCE_SCHEMA) throw new Error(`${path} is not a check --claims record (${ACTION_EVIDENCE_SCHEMA})`);
  const o = record.observation;
  const digest = digestRecord(record).value;
  return {
    path,
    run_url: clean(o.url, 300),
    repo: `${clean(o.owner, 100)}/${clean(o.repo, 100)}`,
    run_id: o.run_id,
    head_sha: clean(o.head_sha, 80),
    conclusion: o.conclusion === null ? null : clean(o.conclusion, 40),
    verdict: clean(o.check.verdict, 40),
    fetched_at: clean(o.fetchedAt, 40),
    digest,
    digest_matches: parsed.digest?.value === digest,
    exit: claimsExitCode(record),
    claims: record.claims.map((c) => ({ status: c.status, claim: clean(c.claim, 300), scope: clean(c.scope, 400) })),
    caveats: (record.does_not_prove ?? []).map((s) => clean(s, 400)),
  };
```

Location: src/cli/trust-report.ts:285-286
Context: The rendered note asserts GitHub provenance that was never established, and the record's own caveats are not shown
```
// Before:
        note: `Compared with GitHub's own record, read ${i.fetched_at}: ${i.verdict}, conclusion ${i.conclusion ?? 'none'}, commit ${i.head_sha}. TrustShell needed no access to the agent or its keys. ${i.run_url}`,
        rows: i.claims.length ? i.claims.map((c) => ({ status: c.status, text: c.claim, detail: c.scope })) : [{ status: 'NOT_CHECKED', text: 'No claims were given for this run.' }],
// After:
        note: `Read from a local record file (${i.path}) that TrustShell did not fetch and cannot authenticate: ${i.verdict}, conclusion ${i.conclusion ?? 'none'}, commit ${i.head_sha}, as that file claims. Its digest only shows the file was not edited after it was written, not who wrote it. ${i.run_url}`,
        rows: [
          ...(i.claims.length ? i.claims.map((c) => ({ status: c.status, text: c.claim, detail: c.scope })) : [{ status: 'NOT_CHECKED', text: 'No claims were given for this run.' }]),
          ...i.caveats.map((text) => ({ text })),
        ],
```

How to fix:
Do not describe a `--record` file as GitHub's own record unless its provenance was actually established. Either re-fetch the run from `api.github.com` and compare the observation and claim statuses against the live answer, or label the section as an unauthenticated local file and state plainly that TrustShell did not fetch it and cannot tell who wrote it. Carry the record's own `does_not_prove` list (and its `assurance.cryptographic_integrity` value) into every rendering so the "not signed" caveat reaches the reader, and stop treating a self-computed digest as an integrity control: it only detects accidental edits, and anyone who can write the file can recompute it. If a record is meant to be trustworthy evidence, have `check --out` seal it with the install key and have `trust-report` verify that seal before rendering the claims.

Please fix this vulnerability. If you propose a fix, make it concise and minimal.

React 👍 / 👎 to tune Strix for this repo. A repo collaborator (or the PR author) can resolve this thread to dismiss the finding.

Comment thread src/cli/trust-report.ts
Comment on lines +183 to +202
function inspectRow(path: string, text: string): InspectRow {
const parsed = JSON.parse(text) as { record?: ActionEvidenceRecord; digest?: RecordDigest };
const record = parsed.record;
if (!record || record.schema !== ACTION_EVIDENCE_SCHEMA) throw new Error(`${path} is not a check --claims record (${ACTION_EVIDENCE_SCHEMA})`);
const o = record.observation;
const digest = digestRecord(record).value;
return {
path,
run_url: clean(o.url, 300),
repo: `${clean(o.owner, 100)}/${clean(o.repo, 100)}`,
run_id: o.run_id,
head_sha: clean(o.head_sha, 80),
conclusion: o.conclusion === null ? null : clean(o.conclusion, 40),
verdict: clean(o.check.verdict, 40),
fetched_at: clean(o.fetchedAt, 40),
digest,
digest_matches: parsed.digest?.value === digest,
exit: claimsExitCode(record),
claims: record.claims.map((c) => ({ status: c.status, claim: clean(c.claim, 300), scope: clean(c.scope, 400) })),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only check on a --record file is a digest recomputed from the same file; the record's does_not_prove list is dropped

Suggested change
function inspectRow(path: string, text: string): InspectRow {
const parsed = JSON.parse(text) as { record?: ActionEvidenceRecord; digest?: RecordDigest };
const record = parsed.record;
if (!record || record.schema !== ACTION_EVIDENCE_SCHEMA) throw new Error(`${path} is not a check --claims record (${ACTION_EVIDENCE_SCHEMA})`);
const o = record.observation;
const digest = digestRecord(record).value;
return {
path,
run_url: clean(o.url, 300),
repo: `${clean(o.owner, 100)}/${clean(o.repo, 100)}`,
run_id: o.run_id,
head_sha: clean(o.head_sha, 80),
conclusion: o.conclusion === null ? null : clean(o.conclusion, 40),
verdict: clean(o.check.verdict, 40),
fetched_at: clean(o.fetchedAt, 40),
digest,
digest_matches: parsed.digest?.value === digest,
exit: claimsExitCode(record),
claims: record.claims.map((c) => ({ status: c.status, claim: clean(c.claim, 300), scope: clean(c.scope, 400) })),
};
function inspectRow(path: string, text: string): InspectRow {
const parsed = JSON.parse(text) as { record?: ActionEvidenceRecord; digest?: RecordDigest };
const record = parsed.record;
if (!record || record.schema !== ACTION_EVIDENCE_SCHEMA) throw new Error(`${path} is not a check --claims record (${ACTION_EVIDENCE_SCHEMA})`);
const o = record.observation;
const digest = digestRecord(record).value;
return {
path,
run_url: clean(o.url, 300),
repo: `${clean(o.owner, 100)}/${clean(o.repo, 100)}`,
run_id: o.run_id,
head_sha: clean(o.head_sha, 80),
conclusion: o.conclusion === null ? null : clean(o.conclusion, 40),
verdict: clean(o.check.verdict, 40),
fetched_at: clean(o.fetchedAt, 40),
digest,
digest_matches: parsed.digest?.value === digest,
exit: claimsExitCode(record),
claims: record.claims.map((c) => ({ status: c.status, claim: clean(c.claim, 300), scope: clean(c.scope, 400) })),
caveats: (record.does_not_prove ?? []).map((s) => clean(s, 400)),
};

Comment thread src/cli/trust-report.ts
Comment on lines +442 to +445
const ledgerPath = join(home, GUARD_RECEIPTS_FILE);
const policyPath = join(home, POLICY_FILE);
const ledgerText = existsSync(ledgerPath) ? readLimited(ledgerPath) : '';
const policyText = existsSync(policyPath) ? readLimited(policyPath) : null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reads the unkeyed, agent-writable ledger that the report presents as evidence

Comment thread src/cli/trust-report.ts
Comment on lines +62 to +72
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 trust-report marks a bound claim SUPPORTED from a name-only run and hides the executable identity

Severity: MEDIUM · CWE-345

trustshell trust-report turns a trustshell run record into a sealed SUPPORTED verdict without recording which executable actually ran. bindMatches matches a --bind tool:<name> claim on the action label alone and CommandRow carries no executable identity, so a run of any file whose basename equals the tool name counts. Because run pins an executable only when the policy opts in (guard allow tool:<name> --exe <path>), an agent under test can drop a script named pytest in the working directory, run trustshell run --tool pytest -- ./pytest, and the report renders SUPPORTED All 412 tests pass and ALLOWED #3 tool:pytest: exited 0 — identical to a genuinely pinned run — while trust-report verify returns VERIFIED. The run card warns name-only (weaker) on stderr at run time, but that warning never reaches the artifact a third party reads, and the report's own "what this cannot see" list omits executable identity.

Suggested change
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 CommandRow {
seq: number;
ts: string;
action: string;
script_sha256: string | null;
command_sha256: string | null;
/** The executable name `run` derived from the command, and its sha256 only when the policy pinned it. */
executable: string | null;
executable_sha256: string | null;
decision: string;
reasons: string[];
result: string;
exit_code: number | null;
}
Prompt to fix with AI
This is a security vulnerability found during a code review.

Vulnerability: trust-report marks a bound claim SUPPORTED from a name-only run and hides the executable identity
Severity: MEDIUM
CWE: CWE-345

`trustshell trust-report` turns a `trustshell run` record into a sealed SUPPORTED verdict without recording which executable actually ran. `bindMatches` matches a `--bind tool:<name>` claim on the action label alone and `CommandRow` carries no executable identity, so a run of any file whose basename equals the tool name counts. Because `run` pins an executable only when the policy opts in (`guard allow tool:<name> --exe <path>`), an agent under test can drop a script named `pytest` in the working directory, run `trustshell run --tool pytest -- ./pytest`, and the report renders `SUPPORTED All 412 tests pass` and `ALLOWED #3 tool:pytest: exited 0` — identical to a genuinely pinned run — while `trust-report verify` returns VERIFIED. The run card warns `name-only (weaker)` on stderr at run time, but that warning never reaches the artifact a third party reads, and the report's own "what this cannot see" list omits executable identity.

Location: src/cli/trust-report.ts:62-72
Context: CommandRow drops the executable identity the record already holds
```
// Before:
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;
}
// After:
export interface CommandRow {
  seq: number;
  ts: string;
  action: string;
  script_sha256: string | null;
  command_sha256: string | null;
  /** The executable name `run` derived from the command, and its sha256 only when the policy pinned it. */
  executable: string | null;
  executable_sha256: string | null;
  decision: string;
  reasons: string[];
  result: string;
  exit_code: number | null;
}
```

Location: src/cli/trust-report.ts:147-157
Context: commandRows never copies requested.executable / executable_sha256
```
// Before:
    const req = r.requested as { action: string; script_sha256?: string; command_sha256?: string };
    rows.push({
      seq: r.seq,
      ts: r.ts,
      action: clean(req.action, 140),
      script_sha256: req.script_sha256 ?? null,
      command_sha256: req.command_sha256 ?? null,
      decision: r.authorized?.decision ?? 'UNKNOWN',
      reasons: (r.authorized?.reasons ?? []).map((x) => clean(x, 300)),
      ...resultOf(outcome, r.kind, r.authorized?.decision),
    });
// After:
    const req = r.requested as { action: string; script_sha256?: string; command_sha256?: string; executable?: string; executable_sha256?: string };
    rows.push({
      seq: r.seq,
      ts: r.ts,
      action: clean(req.action, 140),
      script_sha256: req.script_sha256 ?? null,
      command_sha256: req.command_sha256 ?? null,
      executable: req.executable === undefined ? null : clean(req.executable, 140),
      executable_sha256: req.executable_sha256 ?? null,
      decision: r.authorized?.decision ?? 'UNKNOWN',
      reasons: (r.authorized?.reasons ?? []).map((x) => clean(x, 300)),
      ...resultOf(outcome, r.kind, r.authorized?.decision),
    });
```

Location: src/cli/trust-report.ts:178-178
Context: SUPPORTED verdict with no mention that the executable was name-only
```
// Before:
  if (last.exit_code === 0) return { ...base, status: 'SUPPORTED', why: `the last time it ran through trustshell run it exited 0 (${scope}). That shows this command passed, not that it tests what the claim says.`, record_seq: last.seq };
// After:
  if (last.exit_code === 0) {
    const identity =
      b.script_sha256 || last.executable_sha256 !== null
        ? ''
        : ` The policy did not pin ${last.executable ?? b.tool} to a file, so any executable with that name would have counted: pin it with trustshell guard allow tool:${b.tool} --exe <path> and run it again.`;
    return { ...base, status: 'SUPPORTED', why: `the last time it ran through trustshell run it exited 0 (${scope}). That shows this command passed, not that it tests what the claim says.${identity}`, record_seq: last.seq };
  }
```

Location: src/cli/trust-report.ts:305-305
Context: Path 2 renders the action label only, never the executable or its pin state
```
// Before:
    rows: g.commands.map((c) => ({ status: c.decision, text: `#${c.seq} ${c.action}: ${c.result}`, detail: c.reasons.join('; ') || undefined })),
// After:
    rows: g.commands.map((c) => ({
      status: c.decision,
      text: `#${c.seq} ${c.action}: ${c.result}`,
      detail:
        [
          c.executable === null ? null : `starts ${c.executable} (${c.executable_sha256 === null ? 'name-only: not pinned to a file' : `pinned, sha256 ${c.executable_sha256.slice(0, 12)}…`})`,
          c.reasons.join('; ') || null,
        ]
          .filter(Boolean)
          .join('; ') || undefined,
    })),
```

How to fix:
Record the executable name, and its sha256 when the policy pins it, in each command row, and render that identity beside the command in both the Markdown and HTML report. When a claim is bound by tool name and the run recorded no pinned executable hash, say so in the claim's explanation and point the reader at `trustshell guard allow tool:<name> --exe <path>`. Treating a tool-name bind with no executable pin as NOT_CHECKED rather than SUPPORTED would close the gap outright; surfacing the identity is the minimum.

Please fix this vulnerability. If you propose a fix, make it concise and minimal.

React 👍 / 👎 to tune Strix for this repo. A repo collaborator (or the PR author) can resolve this thread to dismiss the finding.

Comment thread src/cli/trust-report.ts
Comment on lines +147 to +157
const req = r.requested as { action: string; script_sha256?: string; command_sha256?: string };
rows.push({
seq: r.seq,
ts: r.ts,
action: clean(req.action, 140),
script_sha256: req.script_sha256 ?? null,
command_sha256: req.command_sha256 ?? null,
decision: r.authorized?.decision ?? 'UNKNOWN',
reasons: (r.authorized?.reasons ?? []).map((x) => clean(x, 300)),
...resultOf(outcome, r.kind, r.authorized?.decision),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

commandRows never copies requested.executable / executable_sha256

Suggested change
const req = r.requested as { action: string; script_sha256?: string; command_sha256?: string };
rows.push({
seq: r.seq,
ts: r.ts,
action: clean(req.action, 140),
script_sha256: req.script_sha256 ?? null,
command_sha256: req.command_sha256 ?? null,
decision: r.authorized?.decision ?? 'UNKNOWN',
reasons: (r.authorized?.reasons ?? []).map((x) => clean(x, 300)),
...resultOf(outcome, r.kind, r.authorized?.decision),
});
const req = r.requested as { action: string; script_sha256?: string; command_sha256?: string; executable?: string; executable_sha256?: string };
rows.push({
seq: r.seq,
ts: r.ts,
action: clean(req.action, 140),
script_sha256: req.script_sha256 ?? null,
command_sha256: req.command_sha256 ?? null,
executable: req.executable === undefined ? null : clean(req.executable, 140),
executable_sha256: req.executable_sha256 ?? null,
decision: r.authorized?.decision ?? 'UNKNOWN',
reasons: (r.authorized?.reasons ?? []).map((x) => clean(x, 300)),
...resultOf(outcome, r.kind, r.authorized?.decision),
});

Comment thread src/cli/trust-report.ts
const variants = new Set(hits.filter((c) => c.decision === 'ALLOWED').map((c) => c.command_sha256));
if (variants.size > 1) return { ...base, status: 'NOT_CHECKED', why: `it ran with ${variants.size} different command lines, so no one run decides this. Run the exact command you trust, using a fresh TRUSTSHELL_HOME.`, record_seq: last.seq };
const scope = `record ${last.seq}`;
if (last.exit_code === 0) return { ...base, status: 'SUPPORTED', why: `the last time it ran through trustshell run it exited 0 (${scope}). That shows this command passed, not that it tests what the claim says.`, record_seq: last.seq };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUPPORTED verdict with no mention that the executable was name-only

Suggested change
if (last.exit_code === 0) return { ...base, status: 'SUPPORTED', why: `the last time it ran through trustshell run it exited 0 (${scope}). That shows this command passed, not that it tests what the claim says.`, record_seq: last.seq };
if (last.exit_code === 0) {
const identity =
b.script_sha256 || last.executable_sha256 !== null
? ''
: ` The policy did not pin ${last.executable ?? b.tool} to a file, so any executable with that name would have counted: pin it with trustshell guard allow tool:${b.tool} --exe <path> and run it again.`;
return { ...base, status: 'SUPPORTED', why: `the last time it ran through trustshell run it exited 0 (${scope}). That shows this command passed, not that it tests what the claim says.${identity}`, record_seq: last.seq };
}

Comment thread src/cli/trust-report.ts
note: g.commands.length
? `Only these. Policy ${g.policy.state}, ${g.policy.signed ? 'signed' : 'not signed'}, nonce ${g.policy.nonce ?? 'none'}. Record chain ${g.ledger.verdict}, ${g.ledger.count} records.`
: 'No command went through TrustShell, so this report says nothing about what the agent ran.',
rows: g.commands.map((c) => ({ status: c.decision, text: `#${c.seq} ${c.action}: ${c.result}`, detail: c.reasons.join('; ') || undefined })),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path 2 renders the action label only, never the executable or its pin state

Suggested change
rows: g.commands.map((c) => ({ status: c.decision, text: `#${c.seq} ${c.action}: ${c.result}`, detail: c.reasons.join('; ') || undefined })),
rows: g.commands.map((c) => ({
status: c.decision,
text: `#${c.seq} ${c.action}: ${c.result}`,
detail:
[
c.executable === null ? null : `starts ${c.executable} (${c.executable_sha256 === null ? 'name-only: not pinned to a file' : `pinned, sha256 ${c.executable_sha256.slice(0, 12)}…`})`,
c.reasons.join('; ') || null,
]
.filter(Boolean)
.join('; ') || undefined,
})),

@DealAppSeo
DealAppSeo merged commit d743430 into main Nov 9, 2026
18 checks passed

This branch was successfully deployed

1 active deployment
Preview — a9724a86 Deployed Nov 8, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant