Skip to content
bl014hPublic

About

Check an MCP server's signed scan without trusting Redthread: in-toto/DSSE claims, pinned keys, artifact digests, Sigstore provenance, a daily public log and a launch gate.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

atpp — check an ATPP badge claim without trusting Redthread

Any README badge is an image fetched from somebody's server, and the picture can show whatever that server decides. So an ATPP (Agentic Trust & Protection Platform) claim does not live in the image. It lives in a signed file:

  • an in-toto Statement v1 in a DSSE envelope, signed with Ed25519;
  • bound to the sha256 of the exact MCP registry manifest that was scanned, which you can re-hash yourself from registry.modelcontextprotocol.io;
  • signed on our scan host. The servers behind trust.redthreadsec.com hold only the public key, so a compromised web server, DNS record or deploy cannot mint a claim that verifies.

A level-1 claim is a static scan of a published manifest ("passed N of M published checks on DATE"). It is not a runtime claim and says nothing about what a server does once installed.

The scanner that produces these claims is open — its methodology, every check, and how to run it yourself are in SCANNER.md.

Check the MCP servers you already installed

pip install cryptography
curl -sO https://raw.githubusercontent.com/bl014h/atpp/main/atpp.py
python3 atpp.py check-config                  # Claude Desktop, Claude Code, Cursor, VS Code, Windsurf configs
python3 atpp.py check-config path/to/mcp.json # or any config file

For each configured server it finds the official-registry listing behind the package or URL (from the signed registry index), then prints its signed score and flags a listing swap: the name now reaches a different domain or package owner than when it was first published (research). ok = passed every check, !! = review before trusting, ? = not in the registry, so nobody has scanned it. Your config file is read locally and never uploaded; the script downloads the public index, then fetches the signed claim for each registry listing it matched (so our server sees those public listing names, nothing else).

Gate MCP tool calls in Claude Code and Cursor (preview)

Both tools run your own command before an MCP tool is used. Point that hook at atpp, and every call is checked against the server's signed scan first (the verdict is cached for an hour, the index for six):

Claude Code (~/.claude/settings.json or .claude/settings.json):

{"hooks": {"PreToolUse": [{"matcher": "mcp__.*",
  "hooks": [{"type": "command", "command": "python3 /path/to/atpp.py hook claude"}]}]}}

Cursor (~/.cursor/hooks.json or .cursor/hooks.json):

{"version": 1, "hooks": {"beforeMCPExecution": [
  {"command": "python3 /path/to/atpp.py hook cursor", "failClosed": true}]}}

A server whose claim fails a gate check (listing swap, missing version, foreign build provenance) is denied with the reason and its verify link. Options: --policy org.json (deny anything off your allow-list), --strict (deny any failed check), --unscanned ask|deny (servers outside the registry; default: no opinion). A check that cannot run denies. This gates on the published listing and artifacts, not on runtime behaviour.

An AIBOM of the MCP servers you use

python3 atpp.py check-config --cyclonedx mcp-aibom.json

It writes a CycloneDX 1.6 BOM: one component per resolved package (purl, SHA-512/SHA-256 hashes, the repository named in its Sigstore build provenance), one service per remote endpoint, and the signed scan status, score and verify link as properties. Run it in CI across your repositories to keep an inventory.

Gate a launch (preview)

python3 atpp.py gate io.github.example/server                                  # exit 0 allow, 1 deny
python3 atpp.py gate io.github.example/server --image ghcr.io/o/i@sha256:… --strict

It denies when the claim does not verify or has expired, when the listing swapped, when a listed version does not exist, when build provenance names another repository, or when the image digest you are about to run is not one the scan resolved. --strict also denies any failed check and an image not pinned by digest. A check that cannot run denies; it never passes by default.

Three places to use it, all opt-in:

  • Before you launch: atpp gate … && <your launch command>.
  • CI: the action in this repo with mode: check-config fails a pull request that adds an MCP server needing review (fail-on-unscanned: true also fails servers outside the official registry).
  • ToolHive: toolhive/atf_receipts.py serve --gate <server> --gate-image <ref@sha256:…> answers ToolHive's validating webhook with allowed: false while the gate denies. Set failure_policy: fail, or an outage of the sidecar turns the gate off.

This is a preview of a decision, not a sandbox: it checks the published listing and the artifact digest against a static scan. It does not watch what a server does once it runs.

Your policy: which servers, which bytes, what they may reach

The scan can say what a listing points at. Only you can say what a server is allowed to do. Keep that in one file you own:

{"servers": {"io.github.example/server": {"image": "ghcr.io/example/server@sha256:…",
                                           "egress": ["api.github.com"], "read": ["/workspace"], "write": []}},
 "strict": false}
  • atpp gate <server> --policy org.json denies any server not on the list and any image other than the pinned one, on top of the scan checks.
  • atpp profile <server> --policy org.json > profile.json turns the entry into a ToolHive permission profile; run it with thv run --isolate-network --permission-profile profile.json <server>, so ToolHive enforces the network allow-list and file mounts at runtime.
  • The ToolHive sidecar takes the same file: serve --gate <server> --gate-policy org.json.

Nothing in the signed claim grants a permission. A third-party scanner cannot know what a server should reach, so egress and file access come only from your policy.

Keyless co-signature (Sigstore)

Each day's manifest (it commits to the hash of every claim issued that day) is also signed with Sigstore keyless signing by .github/workflows/keyless.yml in this repo: GitHub's OIDC identity for that workflow gets a short-lived certificate, and the signature is recorded in Rekor. The workflow first checks that the manifest is the one in log/ and that the pinned scan key signed it.

pip install sigstore==4.5.0
python -m sigstore verify identity --bundle log/DATE.manifest.json.sigstore.json \
  --cert-identity https://github.com/bl014h/atpp/.github/workflows/keyless.yml@refs/heads/main \
  --cert-oidc-issuer https://token.actions.githubusercontent.com log/DATE.manifest.json

What this adds and what it does not: a second signature from an identity with no long-lived key, in a format Sigstore tooling (cosign, policy controllers) already understands. It does not yet replace the Ed25519 scan key, which still signs each claim on the scan host; moving the scan and its signing into CI is the step that removes that key.

Verify

pip install cryptography
curl -sO https://raw.githubusercontent.com/bl014h/atpp/main/atpp.py
python3 atpp.py verify io.github.example/server      # the current claim for a registry listing
python3 atpp.py verify path/to/a/repo                # a repo with a pinned .atpp/ folder
python3 atpp.py verify path/to/a/repo --offline      # signature, badge and expiry only

It checks, in order:

  1. the signature against the key pinned inside atpp.py, and that the key is not revoked;
  2. that the registry manifest still hashes to what was scanned;
  3. for a repo: that the claim belongs to this repository (the server's registry listing must declare this repo's GitHub remote), so .atpp/ files copied from another server fail;
  4. that a committed .atpp/badge.svg is byte-for-byte the badge whose hash was signed;
  5. that the claim is in the latest daily manifest, that the manifest matches the hash committed to log/ here, and that the day is anchored in the Sigstore Rekor transparency log, so a compromised server cannot replay an older, better-scoring claim;
  6. the expiry.

Options: --offline (1, 4 and 6 only) · --no-log · --expect <namespace>/<server>.

Exit codes: 0 valid and current · 1 invalid · 2 valid but expired · 3 could not check.

Let your agent check before it installs (MCP)

atpp_mcp.py is an MCP server (stdio, only needs cryptography) that gives an agent two tools: verify_mcp_server (the current signed claim for a registry listing) and verify_repo_atpp (a repo's pinned .atpp/ files). They run the same checks as atpp.py verify and return a verdict: valid, expired, invalid, no_signed_claim or could_not_check.

{"mcpServers": {"atpp": {"command": "python3", "args": ["/path/to/atpp_mcp.py"]}}}

A valid result means "this exact registry manifest passed N of M published static checks on DATE", never "safe". Text a server's publisher controls comes back only as detail_untrusted, with control characters stripped and length capped, and nothing from a rejected claim is echoed.

Pinned badges (the default)

python3 atpp.py badge io.github.example/server .     # verifies first, then writes .atpp/

writes .atpp/badge.svg and .atpp/attestation.dsse.json and prints the README line. Commit both. Your README then renders with no request to us, every change is a commit you review, and the badge states its version and date, so at worst it is old and says how old. A live badge (https://trust.redthreadsec.com/badge/mcp/<namespace>/<server>.svg) is available if you prefer one that follows each re-scan; on GitHub it lags by about 10 minutes because of the image cache.

Runtime agents (levels 2/3)

python3 atpp.py verify-runtime <tenant>/<agent> --pin .atpp/workspace-key.json

A runtime claim is signed by the workspace's key, which our app serves. The first run pins that key in the file you name (commit it); any later run fails if the served key differs, so a compromised server cannot swap the key to vouch for an agent. Exit 1 if the agent is revoked, 2 if there is no current claim.

Disputes

python3 atpp.py disputes [<namespace>/<server>]

Every disputed score and its outcome is a signed, append-only record in disputes/.

Keep it current in CI

# .github/workflows/atpp.yml — refresh the pinned badge weekly; the PR is yours to review
on: { schedule: [{ cron: "0 6 * * 1" }], workflow_dispatch: {} }
permissions: { contents: write, pull-requests: write }
jobs:
  atpp:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
      - uses: bl014h/atpp@<commit-sha>        # pin by SHA: a tag can be moved, which is the problem badges have
        with: { server: io.github.you/your-server }        # mode: verify fails the job instead

The action uses no third-party actions and passes inputs through environment variables only.

Watched from outside

  • canary, hourly: live badges are well-formed plain SVG, print exactly the signed claim, verify end to end, carry security headers; a signed publish landed within the last day; the camo-proxied copies GitHub readers see are safe.
  • redteam, daily: replays the README-badge proof of concept and every attack we know against production. See redteam/.

Keys

Trust is pinned to one offline root key. It signs nothing but the scan-key list (atpp-keylist.dsse.json): which scan keys are valid, for which dates, and which are revoked, with a serial number. atpp.py embeds a root-signed copy and pins only the root, so a scan key can be rotated or revoked without anyone updating the script; an older list (lower serial) is refused.

role keyid Ed25519 public key sha256 fingerprint valid
root (offline) atpp-root-2026 s/lMHiubuPh/z/YWGNcxkvqL+o7KcY0WFdKWq3s7Nuc= f5ba2588c2767bb8 —
scan atpp-scan-2026-10 20P9I6y6X6mQztS3IJTU/4CNaqg/FjcpouXkPsrCyVo= a210db7bf7f8b382 2026-10-05 → 2027-10-05

A claim signed by a scan key outside its validity window fails. The unsigned https://trust.redthreadsec.com/.well-known/atpp-keys.json mirrors this for humans and can only revoke.

Log

Every day the scanner signs a manifest listing the sha256 of every envelope it issued, commits that manifest's sha256 to log/ (log/latest.json and log/<date>.json), and records a signed anchor of it in Sigstore Rekor, a public transparency log nobody here controls. The manifest itself is served at https://trust.redthreadsec.com/v/log/<date>.json. A swapped, backdated or rolled-back claim will not match a manifest whose hash is already in this repo's history and in Rekor.

Report a problem

[email protected] · disclosure policy

License

Apache-2.0. Copyright 2026 Redthread Security Inc. See LICENSE and NOTICE. The signing keys that turn a local score into a dated, logged attestation are not part of this release — anyone can reproduce a score and verify ours, but only Redthread can sign the daily claim.

About

Check an MCP server's signed scan without trusting Redthread: in-toto/DSSE claims, pinned keys, artifact digests, Sigstore provenance, a daily public log and a launch gate.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages