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.comhold 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.
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 fileFor 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).
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.
python3 atpp.py check-config --cyclonedx mcp-aibom.jsonIt 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.
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:… --strictIt 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-configfails a pull request that adds an MCP server needing review (fail-on-unscanned: truealso fails servers outside the official registry). - ToolHive:
toolhive/atf_receipts.py serve --gate <server> --gate-image <ref@sha256:…>answers ToolHive's validating webhook withallowed: falsewhile the gate denies. Setfailure_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.
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.jsondenies 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.jsonturns the entry into a ToolHive permission profile; run it withthv 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.
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.jsonWhat 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.
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 onlyIt checks, in order:
- the signature against the key pinned inside
atpp.py, and that the key is not revoked; - that the registry manifest still hashes to what was scanned;
- 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; - that a committed
.atpp/badge.svgis byte-for-byte the badge whose hash was signed; - 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; - 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.
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.
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.
python3 atpp.py verify-runtime <tenant>/<agent> --pin .atpp/workspace-key.jsonA 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.
python3 atpp.py disputes [<namespace>/<server>]Every disputed score and its outcome is a signed, append-only record in disputes/.
# .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 insteadThe action uses no third-party actions and passes inputs through environment variables only.
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. Seeredteam/.
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.
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.
[email protected] · disclosure policy
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.