Anchor
Health Pass
- License — License: Apache-2.0
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Community trust — 11 GitHub stars
Code Pass
- Code scan — Scanned 12 files during light audit, no dangerous patterns found
Permissions Pass
- Permissions — No dangerous permissions requested
No AI report is available for this listing yet.
Transparency-log-based provenance for MCP tool schemas, using Sigstore/Rekor to detect rug-pull, tool-poisoning, and tool-shadowing attacks.
Anchor
This is a prototype / proof of concept, not production software. It
has not been security-reviewed, its predicate types are not standardized
anywhere, and it should not be relied on to gate real tool approvals yet.
See Known limitations below.
Anchor is a CLI for detecting "rug pull" attacks on MCP (Model Context
Protocol) tools — the scenario where a client approves a tool's schema
once, and the tool's operator later silently changes that schema
(widening permissions, adding an exfiltration field, rewording the
description to something dangerous) without the client noticing.
Anchor proves provenance, not safety. It answers one narrow question:
"was this exact schema published by this exact identity, at this time,
and is that on the public record?" It does not — and is not meant to —
judge whether a schema's contents are safe. That's a separate problem.
[!WARNING]
anchor sign,anchor rotate-identity, andscripts/e2e_test.shall
submit a real attestation to the live, public Rekor transparency log
(rekor.sigstore.dev), under whatever OIDC identity you authenticate as
when prompted. This is not a sandbox or dry run — it's the same
production infrastructurecosignand every other Sigstore-based tool
uses. Transparency logs are append-only by design: once submitted, an
entry is permanent and public, and cannot be edited or deleted. Know
which account you're logging in with before you run these commands.anchor verifyis read-only and safe to run freely.
How it works
Canonicalize. Before hashing, a schema is transformed into its
RFC 8785 (JCS)
canonical form: sorted object keys, minimal whitespace, no cosmetic
noise. This means re-ordering keys or adding a trailing newline never
registers as a "change" — only a real difference in the JSON value
does. (internal/canon)Attest. Anchor builds a standard
in-toto v1 Statement
—subject= the tool's canonical schema hash,predicateType= one of
Anchor's two custom predicate types,predicate= the provenance
details (publisher identity, schema version, previous hash, timestamp).
(internal/predicate)Sign, keylessly. That Statement is handed to
cosignfor
Sigstore's keyless flow:
you authenticate with an OIDC identity (Google/GitHub/Microsoft/etc.),
Fulcio issues a short-lived certificate binding that identity to a
throwaway key, and the signed attestation is submitted to Rekor,
Sigstore's public append-only transparency log. Anchor doesn't
reimplement any of this cryptography — it shells out tocosignand
focuses its own Go code on statement construction and trust logic.
(internal/signer)Verify. Given a live schema file, Anchor canonicalizes and hashes
it, then checks that hash against what's on record for that tool:- hash unattested anywhere → BLOCKED, likely rug pull.
- hash attested, but by a different identity than last approved, and
no rotation record bridges them → FLAGGED, likely rug pull via
identity switch. - the tool's signed history isn't a single linear chain → FORKED,
the publisher has equivocated (see point 6). - hash attested by the same identity as before → OK, and any
content diff is shown for human review (attested ≠ silently
rubber-stamped).
Handle legitimate identity rotation. A publisher can hand off to a
new identity deliberately, by having their current identity sign a
separateidentity-rotationattestation naming the new one. Only then
doesverifytreat an identity change as authorized instead of
suspicious. A rotation also joins both identities into one publisher
lineage, so a handoff is a link in the chain rather than a second line
of history.Enforce a strict linear history. Each revision's
previousSchemaHashmust name the revision logged immediately before
it, and only one revision may claim to be the first. If two revisions
claim the same predecessor (a schema history fork), or one names a
predecessor that isn't retrievable (a gap),verifyprintsFORKED: divergent schema history detected, names the Rekor entries that
disagree, and exits non-zero. Re-signing identical content and
deliberate rollbacks are not forks. (internal/history)Bootstrap without local state. A client that never signed anything
can rebuild a tool's history from the public log withverify --bootstrap-from-identity <publisher>: Rekor is searched by signer
identity, Anchor filters for its own predicates and thattoolId,
follows attested rotations to the publisher's successor identities, and
runs the point-6 checks on the result. The identity must come from you,
never from the schema being verified — seedocs/DESIGN.md. (internal/bootstrap)
Status
Each claim below says how it was established. Where it says live, it
means against the real, public Sigstore/Rekor infrastructure, not a mock.
Where it says fixture-tested, it means unit tests over committed JSON
fixtures — chosen deliberately, because sign writes a permanent, public,
undeletable Rekor entry, and Anchor's own verification logic must not need
new ones in order to be tested.
✅
anchor sign— live. Produces a real Fulcio-issued certificate and a real
Rekor transparency log entry.✅
anchor verifyon an unmodified, attested schema — live. PrintsOK: schema matches latest attested revision.✅
anchor verifyon a silently mutated, unsigned schema — live. PrintsBLOCKED: unattested schema change detectedand exits non-zero. This
is the core rug-pull-detection claim, and it's been confirmed working.✅
anchor rotate-identity, and the resulting "identity changed, but an
attested rotation bridges it → OK" path, have been live-tested end to
end against the real, public Sigstore/Rekor infrastructure (see
Trying identity rotation).anchor verify
printedOK: schema matches latest attested revision.with an explicitPublisher identity rotated: <old> -> <new> (attested rotation, Rekor index <n>)line, rather than flagging the identity change as
suspicious.✅ Strict-linear
previousSchemaHashenforcement:verifywalks the
whole retrievable history and fails closed on a fork, a gap, or an
out-of-order revision. Covered by table-driven unit tests over JSON
fixtures ininternal/history/testdata(linear chain,
fork mid-chain, two competing first revisions, gap, partial history,
rotation accepted as a link from either end, rotation not signed by the
identity handing off, empty history, duplicate identical entry,
rollback, out-of-order, unrelated signer, case-differing signer), and
run live (2026-10-24) against this project's realsend_email_rotation_testhistory, where it passes the genuine
two-revision chain across an attested identity rotation — from both
ends of that rotation. With the client's known-good set to the
post-rotation identity it printedHistory: 2 attested revisions, one linear chain; with it set to the pre-rotation identity (the
second-client case in Trying identity
rotation) it printed the same line plus thePublisher identity rotated:acceptance. Both matter: the first
revision is signed by the old identity, so a lineage that only
followed rotations forwards would have excluded it and reported a false
gap.The
FORKEDverdict has not been produced from real signed forks on
the public log, and deliberately won't be: doing so would mean writing
permanent, public, undeletable Rekor entries purely to test Anchor's own
verification logic. The Rekor lookup sits behind an interface
(rekorapi.Client) precisely so it doesn't have to be.✅ History reconstruction from the log with no local state: implemented
and fixture-tested end to end ininternal/bootstrap/testdata— rebuilds
a chain, detects a fork and a gap rebuilt from entries alone, follows an
attested rotation to a successor identity, refuses an unsigned one,
filters out other predicate types and other tools, drops entries that
fail verification or whose certificate names a different signer, and
refuses rather than truncate a scan that hits its cap. Against the
live public Rekor instance it cannot currently complete — confirmed
again on 2026-10-24, read-only; see "Known limitations".⚠️ The "FLAGGED: publisher identity changed without an attested
rotation" path (identity switch with no rotation record) is exercised
by construction whenever the rotation path above hasn't been run first,
but hasn't been deliberately live-tested as its own scenario either.
Why a local .anchor/ state directory, if Rekor is public?
Rekor's public search API supports lookup by artifact hash, by signer
email, or by log index — not by arbitrary custom fields like Anchor'stoolId. There's no way to ask the public log "give me every attestation
for tool X." So, like a real MCP client would, Anchor keeps a local,
append-only pointer index (.anchor/log-index.json) recording which Rekor
log entries belong to which tool, plus a trust-on-first-use cache of the
last known-good revision per tool (.anchor/state.json).
Critically, the local files are never trusted blindly. Every verify
re-fetches the relevant entry live from the public Rekor instance and
re-runs cosign verify-blob-attestation against the cached bundle before
using it for any trust decision — the local index is only ever a set of
pointers into a log that remains the actual source of truth. A tampered or
deleted .anchor/ directory can make Anchor less useful (nothing to
compare against, or a forced fresh trust-on-first-use), but it can't
convince verify to accept something that was never actually
transparency-logged.
Prerequisites
- Go ≥ 1.23
- cosign
on yourPATH(Anchor shells out to it for all signing/verification
cryptography) - A browser (or a second device) to complete Sigstore's OIDC login when
anchor sign/anchor rotate-identityprompt for it
Every sign and rotate-identity call submits a real attestation to the
public production Rekor log (rekor.sigstore.dev), tied to whatever
OIDC identity you authenticate as. That's permanent — transparency logs
don't support deletion — so know which account you're signing in with
before you run these commands.
Commands
anchor sign --tool-id <id> --publisher-identity <id> --schema-version <v> <schema-file>
(flags must come before the trailing <schema-file> argument — that's a
property of Go's stdlib flag package, not a design choice)
Canonicalizes and hashes the schema, builds the in-toto Statement, and
signs it via Sigstore's keyless flow (this is where you'll be prompted to
authenticate). --publisher-identity is the identity you're about to log
in as — Anchor cross-checks it against what the issued certificate
actually says, so a typo or logging in as the wrong account is caught
rather than silently trusted. On success it prints the Rekor UUID and log
index, and updates local state.
anchor verify --tool-id <id> [--bootstrap-from-identity <id>] <schema-file>
Canonicalizes and hashes the live schema, then applies the logic described
above. Exits non-zero on BLOCKED, FLAGGED or FORKED — wire this into
an MCP client's tool-approval path and an unattested change, an identity
switch, or a non-linear history all fail closed instead of being silently
accepted.
--bootstrap-from-identity <id> rebuilds the tool's history from the
public Rekor log first, for a client with no local state.
[!IMPORTANT]
The identity you pass must come from you, or from out-of-band
configuration you control — never from the schema being verified or
anywhere else its publisher controls. An attacker who chooses which
identity you trust doesn't need to forge anything: they name their own,
and hand you a flawless, fully transparency-logged history that is
entirely theirs. No check here can catch that. A successful bootstrap
proves the history Rekor holds for that identity, not that the
identity is the tool's rightful publisher — it's trust-on-first-use, and
the output says so.
--max-bootstrap-entries <n> (default 2000) caps how many Rekor entries
one identity may have before the scan refuses. It refuses rather than
scanning a prefix: a history with revisions missing can look perfectly
linear, so a truncated scan could turn a fork into an OK.
anchor rotate-identity --tool-id <id> --new-identity <id>
Signs (with the tool's current known-good identity — you'll be prompted
to authenticate as that account specifically) an attestation authorizing--new-identity to publish schema revisions for this tool going forward.
A subsequent verify that sees the schema now signed by the new identity
will treat that as an attested rotation rather than a red flag.
All three commands accept --state-dir (default .anchor) if you want to
manage more than one client's state, or point at a specific project.
Running the end-to-end demo
scripts/e2e_test.sh signs the sample send_email tool schema
(sample_tool_schema.json), verifies it, then simulates a rug pull by
mutating the schema without signing the change, and verifies again to
show it gets blocked.
./scripts/e2e_test.sh [email protected]
Replace [email protected] with the identity you'll authenticate as during
the OIDC prompts (any Sigstore-supported OIDC provider — Google, GitHub,
Microsoft — works; it doesn't need to match your shell's notion of "you",
it just needs to match whichever account you log in with).
What you'll see:
- Sign — a browser/device-code OIDC login prompt, then a Rekor UUID
and log index printed on success. - Verify (unmodified) —
OK: schema matches latest attested revision. - Mutate — the script silently adds a
bccfield and tweaks the
description, without callinganchor sign. This is the rug pull. - Verify (mutated) —
BLOCKED: unattested schema change detected,
and the script exits with an explicit "rug-pull correctly detected"
message.
The whole thing runs in a temp directory (cleaned up on exit) with its own
throwaway --state-dir, so it won't touch anything in the repo.
Trying identity rotation
(Per Status: live-tested against the real Sigstore/Rekor
infrastructure. If you run this, two more real, permanent Rekor entries
get created — the rotation attestation and the re-signed schema.)
To see the third path — an identity change that Anchor accepts because
it's attested — sign the same tool again with a second OIDC identity
after running rotate-identity:
BIN=$(mktemp -d)/anchor
go build -o "$BIN" ./cmd/anchor
STATE=$(mktemp -d)/.anchor
SCHEMA=$(mktemp -d)/send_email.json
cp sample_tool_schema.json "$SCHEMA"
$BIN sign --tool-id send_email \
--publisher-identity [email protected] --schema-version 1.0.0 --state-dir "$STATE" "$SCHEMA"
# authenticate as [email protected] when prompted here:
$BIN rotate-identity --tool-id send_email \
--new-identity [email protected] --state-dir "$STATE"
# authenticate as [email protected] when prompted here:
$BIN sign --tool-id send_email \
--publisher-identity [email protected] --schema-version 1.0.1 --state-dir "$STATE" "$SCHEMA"
[!NOTE]
anchor signalways fast-forwards its own--state-dir's local
known-good cache to whatever it just signed (that's how a publisher's
own client stays in sync with its own latest revision). So runningverify --state-dir "$STATE"immediately after the commands above,
against that same$STATE, will just print the plain "OK: schema
matches latest attested revision." — the known-good identity was
already fast-forwarded to the new one by the secondsigncall, so
there's no identity mismatch left forverifyto reconcile.To actually see the rotation-acceptance branch fire, simulate a
second client — e.g. an MCP client that trusted the tool at v1.0.0
under the old identity and hasn't seen anything since — by pointingverifyat a different--state-dirwhosestate.jsonstill haspublisherIdentityset to[email protected](copy$STATE/log-index.json
into it unchanged, so it still has pointers to the real Rekor entries):VERIFIER=$(mktemp -d)/.anchor mkdir -p "$VERIFIER" cp "$STATE/log-index.json" "$VERIFIER/log-index.json" cat > "$VERIFIER/state.json" <<EOF {"send_email": {"toolId": "send_email", "schemaHash": "<v1.0.0's sha256:... from the first sign's output>", "publisherIdentity": "[email protected]", "schemaVersion": "1.0.0", "updatedAt": "<its timestamp>"}} EOF $BIN verify --tool-id send_email --state-dir "$VERIFIER" "$SCHEMA" # -> OK, and mentions the attested identity rotationEvery record
verifyuses here — the schema-manifest and the
identity-rotation attestation — is still re-verified cryptographically
and re-confirmed live on the public Rekor log; only the local
"which revision did I previously trust" pointer is hand-constructed,
exactly modeling what a second client's ownstate.jsonwould contain.
If you skip the rotate-identity step and just re-sign with a different
identity, the final verify will print FLAGGED: publisher identity changed without an attested rotation and exit non-zero instead.
Project layout
cmd/anchor/ CLI entry point and per-command flag handling
internal/canon/ RFC 8785 (JCS) canonicalization + SHA-256 hashing
internal/predicate/ in-toto v1 Statement + Anchor's two predicate types
internal/signer/ shells out to cosign; parses the returned bundle
internal/rekorapi/ read-only Rekor REST lookups, behind a Client interface
internal/history/ strict-linear-chain + fork detection over a tool's history
internal/bootstrap/ rebuilds a tool's history from Rekor with no local state
internal/state/ local .anchor/state.json + log-index.json
internal/diff/ human-readable diff between two schema revisions
internal/identity/ identity-string comparison (case-insensitive for email)
scripts/e2e_test.sh the rug-pull demo described above
sample_tool_schema.json the send_email fixture used by the demo
docs/ design rationale, architecture walkthrough, related work
See docs/DESIGN.md for the threat model and design
rationale, docs/ARCHITECTURE.md for how asign/verify call flows through the packages above, anddocs/RELATED_WORK.md for how this compares to
ETDI.
Known limitations (it's a prototype)
No support for key-based (non-keyless) signing, custom Rekor/Fulcio
instances, or offline/air-gapped verification.The local
log-index.jsongrows unbounded; a real client would want
pruning/pagination for tools with long histories.A fork is only detectable if both branches are retrievable. The
linearity check (now enforced — see Status) reasons over the
history a client can actually get hold of: its own local index, plus
whatever--bootstrap-from-identitydiscovers for the publisher's
identity and the identities it attested a rotation to. A second branch
published under some unrelated identity is not a fork at all in
Anchor's terms — it's an impostor, which is theFLAGGEDpath. A branch
hidden from both of those is simply invisible, and there is no way around
that short of Rekor being searchable bytoolId.The linearity check is slow for long histories. It re-verifies every
revision in the chain withcosign(a subprocess each) and re-fetches
the matched entry live from Rekor, so cost grows linearly with the number
of revisions. Fine for the handful a real tool accumulates; not fine for
thousands.Still open, blocked by Rekor:
--bootstrap-from-identitycannot
complete against the live public log. The code path is finished and
fixture-tested (see Status), and the blocker is upstream, not
in Anchor.cosign attest-blob --new-bundle-formatwrites adsse-kind
Rekor entry, and that entry kind stores only hashes — the public
instance does not return the DSSE payload itself. With no payload there
is no predicate, sotoolIdandpreviousSchemaHashcan't be read back
from Rekor, and the flag fails with an explanatory error rather than
pretending the identity published nothing.Re-confirmed read-only on 2026-10-24 against this project's own public
entries.GET /api/v1/log/entries/<uuid>for Rekor index 2787179932
returnsbody,integratedTime,logID,logIndex,verification—
and noattestationkey. The decoded body iskind: dsse,apiVersion: 0.0.1, whosespecholds exactlyenvelopeHash,payloadHash,signatures. The payload is not withheld by policy; the
entry type never contained it. Rekor can look up entries by schema
hash (the subject digest is indexed) and by signer identity, so it can
still confirm "this hash was attested by this identity" — it just can't
chain or filter by tool.Two ways out, neither taken here: distribute the signer's bundles
out-of-band (e.g. published beside the schema), so the predicate comes
from the bundle and Rekor is used only to confirm inclusion; or sign into
an entry kind that retains the attestation, which would mean changing
whatanchor signwrites and re-testing it against the live log.No security review has been done. Anchor's own predicate types
(https://anchor.dev/schema-manifest/v1,https://anchor.dev/identity-rotation/v1) are project-local
conventions, not registered or reviewed by anyone outside this repo.
License
Apache License 2.0 — see LICENSE.
Reviews (0)
Sign in to leave a review.
Leave a reviewNo results found