Skip to content

Server-side complement to hash pinning #6

Open

Description

@vdineshk

Found rugsnare via the MCP security threads. Hash pinning is the right client-side answer to tool poisoning, and the attack corpus is a useful artifact.

Complementary angle from the server side: Dominion Observatory (https://dominionobservatory.com) indexes 47,500+ MCP servers and takes drift baselines of their tool descriptions and schemas.

The pair: rugsnare pins what you approved; Dominion shows what a server looks like now, including servers you haven't pinned yet. If you want to see what that looks like, name a few servers and I'll send a baseline report for each, free. Happy to talk about whether a rugsnare check mode against that data would be useful, or not.

Activity

  1. Paraphern commented on Oct 4, 2026

    @Paraphern
    Owner

    Thanks - the framing is fair: pre-install reconnaissance and post-approval pinning are different jobs, and both exist for a reason.

    Before any talk of a check mode, three questions about the data itself:

    1. Your baselines are described as "built from agent-reported telemetry." How do you defend that against poisoning (anyone reporting a fake manifest) and against per-client serving - a server handing different manifests to different clients is exactly the case our local chameleon check exists for? A third-party baseline can't distinguish "the server changed" from "the server showed someone else something else."

    2. Manifest history is "public and unsigned." What stops a rewrite of history? We sign receipts (Ed25519 hash-chain) and pin releases on-chain precisely because unsigned history doesn't qualify as evidence. Any integration would need signed, auditable records on your side.The index reads 47,544 on the site and 22,561 in your June dev.to post. Growth, or inconsistency? And from how many vantage points, at what cadence, are captures actually taken?

    3. On the offer - happy to take it, as a verification exercise rather than a trial. These five: @modelcontextprotocol/server-filesystem, server-github, server-fetch, server-memory, server-everything. We've backtested consecutive version pairs of the official servers and measured 140 contract changes across 66 pairs (report in the repo). Send baselines for those five and we'll compare against our own measurements. If they line up, there's a real conversation about an advisory-only "third-party intel" view - clearly labeled as not our verdict, never a trust root, since rugsnare verifies what the client in front of you actually sees. If they don't, no hard feelings.

    To be explicit: exchanging test data isn't an endorsement, and we'd ask that nothing public describes rugsnare as integrated with or partnered with Dominion Observatory unless agreed in writing first.

  2. vdineshk commented on Oct 4, 2026

    @vdineshk
    Author

    Thanks, fair questions. Straight answers:

    1. Poisoning and per-client serving: we have no defence against per-client serving. Our own probes come from one vantage point on a scheduled cron (every 20 minutes), and only a handful of endpoints (2 confirmed live today) are measured at all, so we cannot tell "server changed" from "server showed us something else." On poisoning: treat anything not from our own probes as unverified. Don't use us as a trust root. I agree with your framing.

    2. History is public and unsigned today, and I won't claim otherwise. Signed, auditable history is a gap we would have to close before it counts as evidence. On the numbers: 22,561 was the deduplicated registry index in our June post; 47,544 is the index now, mostly registry and project pages (about 39.5k project listings), not measured servers. Measured endpoints are a very small subset (/api/stats shows the split). I'm confirming how the index grew and will say more once I know.

    3. The five you named are npm stdio servers. We only track public HTTPS endpoints that answer a plain tools/list, so we have no baselines to send for them and can't do that exercise. If you have hosted HTTP endpoints you'd like compared, send me a list and I'll send private, timestamped snapshots to check against your own measurements.

    Agreed on no public integration or partner claims without written agreement.

  3. Paraphern commented on Oct 4, 2026

    @Paraphern
    Owner

    Straight answers appreciated - rarer than they should be, and it changes what this conversation is: two builders comparing notes, not an integration pitch.

    On the counter-offer: I'll pass, for the reason you named yourself. An unsigned snapshot from a single vantage point can't corroborate anything about endpoints we already pin locally - our pins are the ground truth for what our clients see, and your snapshot can neither confirm nor refute them. The exercise only had value where you were the claimant and we held independent measurements (the public npm servers). For our own endpoints there's nothing a third party can add.

    What would make your data genuinely interesting later:

    1. Sign each capture - Ed25519 per snapshot, key pinned publicly. That's a weekend of work, not a research problem, and it's the difference between "public and unsigned" and evidence.
    2. Probe from 2-3 vantage points minimum. "Changed for everyone" is the only signal a remote index can add over local pinning; one vantage point can never separate change from per-client serving.
    3. Put the listings-vs-measured split in the landing headline, not only in /api/stats. "47,544 projects indexed" reads as 47,544 monitored servers at a glance - that's the number people will quote back at you.
      When 1 and 2 exist, an advisory-only recon view over public signed snapshots is worth a conversation - still never a trust root.

    Something in the other direction, since your index is HTTPS-only: rugsnare publishes a daily drift feed of the official modelcontextprotocol npm servers (stdio - the half your probes can't see): contract hashes per tool, per version, plus change events - drift-feed/ in this repo. If you're building an index, that's free, reproducible ground truth for the stdio side. Use it however you like; attribution appreciated, not required.

    And noted on the no-claims point - agreed and closed.

  4. Paraphern commented on Oct 20, 2026

    @Paraphern
    Owner

    Thank you - the server-side angle is the right complement: we see the client half (what a pinned version actually serves at install time, per version), you see the population half (what all shipped versions look like across 47,500+ servers).

    Two questions from the corpus side, in case a join is worth building:

    1. What exactly does a Dominion drift baseline capture per server - tool contracts (name / description / inputSchema), or a broader manifest?
    2. Is the index consumable programmatically? We keep a slug -> MCP Registry id mapping for the servers we pin (registry-mapping.json), and joining it with a population-level index would line our per-version pins up with drift stats at scale.

    If the capture format ever needs an interchange spec, PIN-SPEC exists for exactly that and is free to reuse.

  5. vdineshk commented on Oct 20, 2026

    @vdineshk
    Author

    Thanks - and agreed on the split: install-time pins vs population-level history are two halves of the same question.

    1. A drift baseline captures the tool contracts: each tool's name, description and inputSchema, compared field by field, with the capture hash taken over the canonical tools array (sorted keys, compact JSON). It does not currently capture the broader server manifest (instructions, capabilities, serverInfo).

    2. Programmatic today: GET /api/drift?server=<url> for a stateless check; POST /api/drift/baseline publishes a public manifest; stored captures are Ed25519-signed and verify against /.well-known/capture-jwks.json; full surface in /openapi.json. One honest calibration on the numbers: 47,558 is indexed projects, not drift-measured endpoints. Endpoints previously checked: 9; projects with runtime measurements: 312. The population-level drift corpus is early, so any join starts thin on our side and would thicken as monitors run.

    Thanks for PIN-SPEC - reading it, and the registry-mapping is useful. On the join itself I want to think about what the exchange should look like before committing either way; I'll come back to you on that here.

  6. vdineshk commented on Oct 21, 2026

    @vdineshk
    Author

    Coming back on the join - yes to an exploratory exchange, scoped tight: public surfaces only, no private data, no money either way. If it ever grows past that, that is a separate conversation.

    What that looks like concretely:

    • On your side the pieces are already public: the registry-mapping and pin corpus. Useful as-is.
    • On our side, the public surface is the signed capture format and the free baseline publish. One caveat I keep by policy: I don't publish third-party manifests without the operator's consent, so I won't seed public baselines for servers in your mapping myself. But POST /api/drift/baseline is free and open - for any server where you have the operator's ok (or your own), a publish gives you a signed, timestamped public capture to pin against, verifiable against the JWKS without trusting us.
    • Where I think the real overlap is: format interoperability. Your pins say what a client saw at install time; our captures say what the population served at read time. If PIN-SPEC and the signed-capture shape can reference the same canonical tools-array hash (sorted keys, compact JSON, SHA-256), joins become trivial later without either of us building an API for the other. Happy to compare formats and note where they already match and where they don't.

    That's the exploratory scope. If it works, it works in the open.

  7. Paraphern commented on Oct 21, 2026

    @Paraphern
    Owner

    The scope works for us as stated: public surfaces, no private data, no money, in the open.

    Agreed that format interoperability is the real overlap, and your framing is exactly right: our pins say what a client saw at install time, your captures say what the population served at read time - the join is only as good as the hashes being comparable.

    On the technical fit: our canonicalization profile is sorted keys (recursive), compact serialization, and numbers per RFC 8785 §3.2.2.3; strings keep JSON-minimum escaping, and undefined and null are distinct. For an interop hash we would deliberately use your field set - {name, description, inputSchema} per tool, sorted by name, SHA-256 over the compact array - as a surfaceHash in PIN-SPEC, optional and additive, computed independently of our internal per-tool hashes (which cover one more field, icons, for our own gating; that difference is exactly comparison-document material, not a blocker). Two things decide byte-compatibility, and neither is guessable from prose: your exact number and string escaping rules, and your array ordering - our spec treats array order as significant, so sorting by name for the interop hash is a deliberate, stated deviation on our side.

    Concrete first artifact, since we can be our own operator: rugsnare ships its own MCP server. We pin a released version, we publish a signed baseline for that endpoint through POST /api/drift/baseline, and the thread gets the first public join pair - our pin and your capture of the same surface, verifiable without trusting either of us.

    On process: PIN-SPEC changes go through our versioned spec, so I will bring surfaceHash there rather than promise the field before it is written down. The comparison you suggested - where the formats already match and where they do not - is the right first document, and the corpus side of it is mine to write.

  8. vdineshk commented on Oct 22, 2026

    @vdineshk
    Author

    Byte-compat is verifiable today, no prose needed: every published baseline exposes the canonical tools array and its manifest_hash over the free GET /api/drift?server=<url>, so the exact rules are checkable by recompute against any public capture. I just did that exercise against a live baseline (26 tools) and the match is exact, so here is the profile, verified rather than described:

    • Per-tool field set: {name, description, inputSchema} - nothing else.
    • Object keys sorted recursively at every depth.
    • Compact serialization: no insignificant whitespace.
    • Strings: JSON-minimum escaping, non-ASCII emitted as raw UTF-8 (not \uXXXX).
    • Tools array: sorted by name. So on this point there is no deviation needed on your side - our canonical array is already name-sorted.
    • SHA-256 hex over the UTF-8 bytes of that array.

    One honest edge: the current public corpus has no non-integer numbers in any captured schema, so float serialization is unexercised - the recompute recipe above is the arbiter if that ever matters, and it runs against any public baseline without trusting us.

    On the join pair: yes - pin a released rugsnare version, publish through POST /api/drift/baseline whenever you're ready, and the thread gets its first public pair. Once your baseline exists it sits in our monitored set, so a future release recompare happens automatically.

    Comparison document: corpus side is yours; format questions land here and I'll answer them in the open.

  9. Paraphern commented on Oct 24, 2026

    @Paraphern
    Owner

    The pair is up, and the two halves agree byte-for-byte — both artifacts below are public:

    • Endpoint: https://rugsnare-mcp.rugsnare.workers.dev/mcp — the rugsnare 1.5.0 MCP server (the same two read-only tools, the same 2025-06-18 handshake) hosted as a Cloudflare Worker: public HTTPS, no auth, per the prober's constraints.
    • Our half: pinned with rugsnare itself against the endpoint. surfaceHash (PIN-SPEC §3.2 — {name, description, inputSchema} per tool, name-sorted, compact UTF-8, SHA-256) = a7f60d0b5cc214dada5b2c1ec51ff080e0b0828694a0104733b2cc1d5967fb2e. The pin file ships in-repo at audits/drift-watch/pins/.
    • Your half: baseline published via POST /api/drift/baseline — signed capture of the same endpoint, manifest_hash = a7f60d0b5cc214dada5b2c1ec51ff080e0b0828694a0104733b2cc1d5967fb2e, 2 tools, captured 2026-10-23T18:36:33Z, publicly readable at GET /api/drift?server=…, verifiable against the Ed25519 JWKS at /.well-known/capture-jwks.json.

    Two independent implementations from two vantage points produced one identical digest — byte-compat demonstrated rather than described. One context note: we checked the public ledger before publishing (the only census-LIVE endpoint in our corpus, mcp.context7.com, wasn't in it), so this pair appears to be the ledger's first public entry.

    Per your standing behavior the endpoint now sits in the monitored set, which makes the join live going forward: the next rugsnare release that changes this surface should appear as a re-capture your clock sees and a diff finding ours does. The comparison document (corpus side — where the formats already match and where they don't; the one honest divergence left is float serialization, unexercised until a schema carries a non-integer number) is next into the repo.

  10. vdineshk commented on Oct 24, 2026

    @vdineshk
    Author

    Confirmed from our side, independently: pulled the public baseline for the endpoint just now and recomputed SHA-256 over the canonical tools array (sorted keys, compact UTF-8, name-sorted) - digest matches both published artifacts byte-for-byte. Two implementations, two vantage points, one hash. That is the join working as designed.

    The endpoint is in our monitored set as of now, so the next surface-changing release gets a re-capture on our clock and a diff finding on yours. First public pair on the ledger - noted, and welcome.

    On the float edge: agreed it stays unexercised until a real schema carries a non-integer number; the recompute recipe remains the arbiter. Looking forward to the comparison document.

  11. kimjooyoon commented on Oct 27, 2026

    @kimjooyoon
    Contributor

    One extra fixture for the recursive property-order edge: keep the tool name ASCII, and put U+E000 and U+10000 inside inputSchema.properties:

    {"name":"lookup","inputSchema":{"type":"object","properties":{"\uE000":{"type":"string"},"\uD800\uDC00":{"type":"integer"}}}}
    

    RFC8785 §3.2.3 puts U+10000 first by UTF-16 units. Our pinned Go encoding/json profile puts U+E000 first. This exercises object-property ordering even when every tool-array name is ASCII.

    I maintain the Gooo saved-definition comparison experiment and published four synthetic input pairs, exact preimages/hashes and a pinned reproduction command. Expected states belong to our explicitly named gooo/mcp-tool-definition-json/v1 profile: raw key reorder compares the same; 1e0 versus 1.0 remains different; annotation removal is reported separately. The JCS file is one manually specified string-only reference; these runs use the existing Gooo-generated Go library and compare saved rows only.

    Revision2 supplies object inputSchema for every tool, including the annotation control. All four relation states were re-executed; earlier minimal projection controls remain in the Gist history.

    Would the nested Unicode-property case be useful in your interoperability vectors? The inputs are BSD-3-Clause and can be reused independently of Gooo.

    Prepared with Codex; no live endpoint was contacted and no integration or external adoption is claimed.

  12. Paraphern commented on Oct 27, 2026

    @Paraphern
    Owner

    Yes - useful, and taken. We ran your inline pair through our implementation before answering: our property-key order comes out U+10000 first, then U+E000 (our key sort is UTF-16 code-unit, i.e. RFC 8785 §3.2.3), with surfaceHash = 7776cc742e8165bbe0f7e6ed610f76dfcf6ea347f4d0bea21afd9ad4a555ca03 on that single-tool set - agreeing with the JCS-expected order and diverging from your Go profile's U+E000-first, which is precisely the class the comparison doc exists to name. Nice design touch: keeping tool names ASCII isolates the property-key axis, so our documented tools-array deviation (code-point sort) stays out of the picture entirely.

    The four pairs land as an interop test-vector section in the comparison doc (BSD-3 accepted, credited - committed just now); our doc's sort-unit row gets split explicitly into tool-array order (deliberate deviation, code-point) and property-key order (JCS, UTF-16) - your case is the reason that split needs to exist.

  13. kimjooyoon commented on Oct 27, 2026

    @kimjooyoon
    Contributor

    Thanks for executing it and adding the property-key/tool-array distinction. I independently reproduced your 7776cc... result with the unchanged product/src/hash.js at e0a12ea3.

    One correction to my input history: after your reply I added inputSchema.type: "object" to the corpus and inline example. Your hash is correct for the earlier minimal schema; the revised input gives a3760c... under the same implementation. Both full inputs, hashes and the four-pair observations are retained here. The property ordering is unchanged.

    A possible next vector is an ASCII property name that looks like an array index:

    {"name":"lookup","inputSchema":{"type":"object","properties":{"10":{"type":"string"},"2":{"type":"integer"}}}}

    At that commit, JSON.stringify(stable(...)) emits "2" before "10", whereas JCS §3.2.3 requires lexicographic property order ("10", "2"). The observation file supplies both exact preimages and digests; an alphabetic-key control matches. Would you prefer to document this as a serialization-profile deviation, or treat it as a future versioned correction? Existing pin compatibility makes that distinction useful.

    Prepared with Codex; these are synthetic hash-function checks, without a live endpoint or a full Rugsnare detector run.

  14. Paraphern commented on Oct 29, 2026

    @Paraphern
    Owner

    Confirmed on our side, and thank you for the sharpest vector yet - this one is not a profile deviation, it's a bug against our stated profile, and the mechanism is worth recording:

    stable() sorts keys lexicographically (['10','2'].sort() does yield ["10","2"]), but it then materializes the result into a plain JS object - and ECMAScript requires integer-like string keys to enumerate in ascending numeric order regardless of insertion order. JSON.stringify therefore emits "2" before "10", silently defeating the sort for exactly this key class. Your fixture reproduces it: our surfaceHash on the index-shaped pair is a40da8451aa76046b5e5f15761ed06a1ae4aaf5df50174ab7fa46813c22ae4a5, computed over numeric order - JCS §3.2.3 wants lexicographic ("10" before "2").

    Answering your question directly: a versioned correction, not a documented deviation - PIN-SPEC claims lexicographic key order, so shipping "numeric for integer-like keys" as documented behavior would misstate our own spec. The fix shape is a canonical serializer that emits keys from the sorted list directly instead of materializing an intermediate object (a plain JS object cannot carry this order for this key class - that's the whole trap). We swept the published corpus ahead of the fix: zero of 87 pin snapshots contain integer-like property names in their schemas, so the correction changes no digest anywhere in the corpus - it only protects future schemas from silently hashing in the wrong order. Your fixture lands as a regression test with credit, alongside the four-pair set; we also reproduced your corrected input (a3760c19825c8b60… at the same commit) - the observation file is the reference set for both shapes.

  15. Paraphern commented on Nov 7, 2026

    @Paraphern
    Owner

    The JCS-true canonicalization fix from your integer-like property-key vector is now on npm: [email protected] (published today, on-chain pinned at 0x7aa9ed77). Your fixture — with the correct lexicographic ordering of "10" before "2" — is documented in PIN-SPEC §3.2 as a golden test vector alongside the simple tool, so independent implementations can byte-check against both. The regression test asserts the reference digest 79552f1d... in the suite.

    Three contributions now: the interop vectors, the Go data-loss fix (#15), and this — thank you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions