Methodology
Attestari methodology
Methodology 2.10.0, in effect since 2026-10-24.
This document is the complete set of rules by which Attestari examines a published package, decides
each finding, grades the package, and publishes the result. It is the only place those rules live in
prose, and attestari/policy.py is the only place the decision rules live in code; the two carry the
same version number and a test refuses to run the policy when they disagree. Every published decision
cites the version that produced it and the SHA-256 of this file as it was when the decision was made,
together with the model, the prompt and the verifier that produced the finding (section 12).
Nothing in a run is decided by a person; the two things a person still does are listed in section 13.
1. What this document is for
Attestari examines software packages that an AI agent may install and grant access to: MCP servers and agent tooling published to public registries. We report what we find, and we publish the evidence alongside it so a reader can check our work rather than take it on faith.
Since 2.10.0 the document also governs a second kind of subject, a remote MCP server reached by URL, which Attestari watches and never grades (section 22). Where a rule below speaks of a package, it does not apply to a remote unless section 22 says it does.
A finding is an opinion, formed by applying the rules below to material anyone can download. This document states those rules completely. If a finding cannot be traced to a rule in this document and to evidence a reader can independently retrieve, it should not have been published, and we want to hear about it.
The document also states, at length, what we do not examine (section 15). That section is not a disclaimer bolted on at the end. It is the part most likely to matter to someone deciding whether a passing result means anything.
2. Definitions
subject: a specific published version of a package in a public registry, for example @scope/server at
version 1.4.2 on npm. Ratings are always of a version, never of a project in general. Since 2.10.0 a remote
server is a subject too, of the model check only and never graded (remote, below).
artifact: the file a package manager actually downloads and installs: the npm tarball or the PyPI distribution. This, not the source repository, is what we examine.
tool surface: the set of tools a subject registers with an agent, together with the descriptions the agent reads. Determined by parsing the artifact's source.
finding: a statement about a subject, tied to a severity, a quoted span of text, the file and line it came from, and the hash of the stored evidence containing it.
not analyzed: a tool we could not examine. Distinct from a tool we examined and found nothing in. The two are never reported as the same thing.
gradeable: a subject for which every advertised tool was examined, every check ran, and every finding's evidence re-verified: the guards in section 9. Only a gradeable subject can carry a grade.
attested manifest: the hash of each readable tool's name, description bytes and schema, and the hash over all of them, computed as section 7 defines and recorded on every decision row. What a gateway pins to when it wants the tools it was shown to be the tools it examined.
capability inventory: what the artifact's code is able to reach, as declared by the source: network destinations, remote code, files, processes. Recorded as context, never measured for precision, never graded (section 20).
remote: an MCP server reached over HTTPS at a URL, with no artifact to download: a "connector". It is named
by its URL as host/path (host in lower case, a non-default port kept, no scheme, credentials, query or fragment).
A remote has no version; what stands in for one is the observation.
observation: one reading of a remote by the probe of section 22: the time it was read, every answer the server gave as stored, and the watch manifest hash over what it listed. Two readings that list the same entries are the same observation.
watch manifest: the hash of each entry a remote lists (tool, prompt, resource, resource template, and the server's instructions) over the entry exactly as received, and the hash over all of them (section 7). It changes when anything a client is shown changes.
3. We examine the artifact, not the repository
Most software auditing reads the public source repository. We do not, and the distinction is deliberate: the repository is not what runs on your machine. The published artifact is.
A package's repository can be clean while the tarball served by the registry contains code that was never committed there. That gap is how a large share of real registry compromises work, and auditing the repository looks straight past it.
We read repository metadata, such as ownership, contributors, and whether the latest commit carries a signature, as context about who stands behind a subject. We do not treat repository contents as evidence of what a subject does.
The one comparison we make. Since 2.5.0 the artifact is compared, file by file, with the
repository at the tag of the examined version (artifact_vs_tag, section 5): a file shipped with no
counterpart at the tag is recorded. The repository is still not evidence of what the subject does:
the comparison says which files of the artifact the tagged source does not account for, and nothing
else is read from the repository's contents. The check runs outside the gate and its findings are
decided at info until it passes the admission test of section 18, so no grade depends on it.
4. How subjects are selected
Selection is mechanical and reproducible. A candidate list records the exact queries used, the ranking rule, the page sizes, the timestamp of every fetch, and the snapshot identifier of any dataset consulted. Anyone re-running the same selection against the same sources gets the same list. What it selects is a sample of each registry, not the registry: every count we report is a count over the packages we selected, and says nothing about the packages we did not.
npm candidates come from the registry's public search API across a fixed set of queries, ranked by monthly download count confirmed against the downloads API for a stated window. PyPI candidates come from the simple index, with download counts drawn from a public dataset derived from PyPI's own download statistics.
Since 2.6.0 the selection is wider, and each candidate records which source found it (sources):
- the official MCP registry. Every npm or PyPI package a server of registry.modelcontextprotocol.io names is a candidate, whatever its name or keywords: the listing is the publisher's own declaration that the package is an MCP server.
- npm search, read deeper, in the registry's popularity order, to a stated depth per query, with three text queries beside the keyword queries.
- a PyPI download count for every project whose name matches, over the 30 full days before the query, from a public copy of PyPI's download logs. The dataset used before stops at the 15,000 most downloaded projects of all PyPI, far above where a population of thousands is cut.
- a dependency on an MCP SDK. An npm package that a search surfaced with no MCP keyword and no matching name is kept when its latest manifest depends on an MCP SDK. Neither registry offers an index of a package's dependents, so a dependency verifies a package another source surfaced; it cannot surface one. PyPI has no search at all: a PyPI server whose name does not match is found only when the official registry lists it. Both gaps are written into every candidate file.
The ranking rule, the recorded exclusions and the conflict rule below are what they were.
Exclusions are recorded, not silent. Every candidate that surfaced and was not selected appears in the same file with the reason it was dropped: ranked below the cut, duplicate of a scoped variant, no download figure available. A reader can see what we passed over.
One project is one subject. A registry's own rule for when two names are one project is ours:
PyPI folds case and treats -, _ and . as one separator (PEP 503), so Example-Server,
example_server and example.server are one project there; npm names are exact. A candidate list
holds a project once, under the spelling its index lists, and records any other spelling it
surfaced under as an alias. Every row of a run is keyed by the name the package was selected under;
the registry's own spelling, when it differs, is recorded on the package (canonical_name) and
decides nothing. Should a run nonetheless hold one project under two spellings, the one under the
registry's spelling is the subject, the other is folded into it and named in the run's summary
(same_project), and the second examination's rows are dropped: a project is examined and decided
once, never refused for the spelling it was asked under.
Conflicts. Packages published by Attestari, The Elite360 Corporation, or any account we control are excluded by a hard rule, checked against package name, scope, maintainers, publisher, author, contact addresses, and repository URLs. We do not rate our own work.
A tagged package is not necessarily a server. Selection casts a wide net using MCP-related keywords and naming patterns, which catches libraries and SDKs alongside actual servers. We do not resolve this by maintaining a hand-curated exclusion list. The tool-surface determination in section 5 decides what is a server, and sample figures always separate confirmed server, examined from confirmed server, could not examine from not a server.
Delegation. A selected package can delegate its server to another package: it imports or
re-exports the server from a declared dependency, or its only runtime behaviour is to load another
package (a one-file compatibility shim left behind by a rename, an entry point that spawns another
package's command). The code an agent is then exposed to is the target's, and a rule that examined only
the shim would let a publisher move a graded server out of the sample by renaming it. So when the
surface determination of section 5 finds that an examined package delegates, the target package joins
the population for the same run, whatever its download figures and wherever it stands on the candidate
list. It is fetched at the exact version the declared dependency resolves to at the registry when the
run examines it (the highest release the specifier admits, pre-releases excluded unless the specifier
names one), and it is examined and decided like any selected package. Both rows record the
relationship: the delegating package's row carries delegates_to (the target's registry and name, the
declared specifier, the version resolved and how it was resolved), and the target's row carries
delegated_from (every delegating package in the run, with its version). A target that itself
delegates is followed the same way, to the end of the chain and at most three hops deep; a target that
is already in the sample is examined once and carries both records. Delegation changes nothing about
the delegating package's own outcome: a shim that registers no tools is still refused under
no_registration_site (section 9), and the grade of what it runs is on the target's row.
Remotes (since 2.10.0). A remote is read only when one of these brings it in, and each reading records which:
- an agent's report: a remote URL an Attestari customer's paired agent reports in its configuration
(
device-api.md), ashost/path; a URL whose path the agent hid as key-like (*) is never read; - a request: a URL a signed-in customer asks to have watched, read under the same rules;
- the measurement, once: the
remotes[].urlof every active latest entry of the open MCP Registry, each URL once, to measure what share of registry remotes show their tools before sign-in (section 18). It is not a population that is watched afterwards: after the measurement only the first two bring a remote in.
A host is not a publisher: the registry's largest remote hosts are multi-tenant gateways carrying thousands of entries, so nothing about a remote is inferred from its host.
5. The checks
Eight checks are declared in attestari/checks/, and this version states the standing of each.
Three run against every subject inside the gate (section 9), and each of those states what it reads
and what it is entitled to conclude. One, artifact_vs_tag, runs against every subject outside the
gate: it records an outcome and findings, and no guard and no grade reads it. Four are declared with
their inputs fixed and do not run; a subject is never marked clean on a check that did not run, and a
report lists them as not assessed (section 15). None of the eight executes the subject's code. A
ninth, remote_code_fetch, ran against every subject from 1.0.0 through 2.2.0 and has been retired
since 2.3.0: what it looked for is recorded by the capability
inventory (section 20), read from the syntax tree by the extractor rather than by a check, as facts
that carry no severity.
| Check | Standing in this version | May grade |
|---|---|---|
undetermined_tool_surface |
runs on every subject | no: it decides scope and refusal (section 9), never a severity |
model_directed_text |
runs on every subject | yes: the one check in MEASURED_CHECKS (section 10, section 18) |
remote_code_fetch |
retired since 2.3.0: its patterns are capability entries (section 20) | no |
publisher_provenance |
runs on every subject | no: decided at info pending measured precision (section 18) |
artifact_vs_tag |
runs on every subject, outside the gate (since 2.5.0) | no: decided at info until it passes the admission test (section 18) |
undeclared_network_calls |
declared, does not run | no |
dependency_risk |
declared, does not run | no |
abandonment |
declared, does not run | no |
fork_divergence |
declared, does not run | no |
The set of checks and tags whose findings may carry a gradeable severity is MEASURED_CHECKS in
attestari/policy.py. In this version it holds model_directed_text with every tag and nothing
else (decision D5, section 10). A check enters that set only by a measurement recorded in section 18.
On a remote (since 2.10.0) one check runs: model_directed_text, unchanged (the prompt, the verifier, the
independent reading, the categories and their severities). It reads what the remote listed instead of what the
extractor parsed: each tool, prompt and resource description, and the server's instructions, each taken from
the stored answer as the bytes between the JSON string's quotes, before unescaping, exactly as a source literal is
taken from a file. No other check runs on a remote: there is no artifact for artifact_vs_tag, no registry or
repository record for publisher_provenance, and no source for undetermined_tool_surface or the capability
inventory. A finding on a remote is called a flag where it is shown; it is a finding of this check in every
other respect.
undetermined_tool_surface: can the tool surface be determined at all?
Reads: the source files in the artifact, as syntax trees. Each JavaScript, TypeScript, TSX and Python member is parsed with tree-sitter; the grammars are error-tolerant, so a file with a syntax error still yields a tree and nothing falls back to text matching. Every fact the outcome rests on is a node of that tree: a call site, a definition, an import, an identifier or a string literal. Text inside strings, comments and docstrings never counts as a registration site. Concludes: a parsed set of tools with their descriptions, or one of these outcomes:
not_a_server: no registration site, and the source consumes the tool API (it lists or calls another server's tools, imports an SDK client) or defines it (a method namedregisterToolortoolwith a body, a server class). A client, adapter, proxy, SDK, framework, instrumentation, CLI, mock or scanner. Such a subject is out of scope: it is not a server, so it is neither graded nor refused, and it is excluded from every population count (decision D4).minified_only: registration text exists only in minified or obfuscated bundles. The operator cannot inspect what they install; this is a medium finding whose evidence is the bundle's bytes and the length of its longest line, not a coverage gap.runtime_generated: a registration site exists and the file that registers builds its tools from external data (an OpenAPI document, a remote catalog). A genuine limit, reported as such.declaration_not_parsed: a registration site exists in readable source and the extractor could not read it. This is the only outcome that is honestly our gap; the site is recorded.no_registration_site: neither a registration site nor a consumption pattern.archive_unreadable: the artifact is not a readable tar or zip.extraction_budget_exceeded: reading the package's source passed the per-package budget of wall time or resident memory (section 9). Nothing past that point was read, and the row records the measured figures beside the budget.
Every outcome but parsed refuses the grade under its own code (section 9), except not_a_server,
which is out of scope. A subject whose surface cannot be read is not thereby condemned. It is a
subject about which we will not offer an opinion, because the thing an agent would actually be
exposed to was never visible to us.
Registration sites. Tools are read only at the places where a server registers them, and nowhere
else. In JavaScript and TypeScript those are .tool(), .registerTool(), .addTool() and
defineTool() calls; setRequestHandler(ListToolsRequestSchema, handler) and
setRequestHandler("tools/list", handler); case "tools/list": and if (method === "tools/list")
dispatchers; declarations typed Tool or Tool[]; a class with getName() and getConfig(); and a
table under tools: handed to a factory whose name contains Server or mcp. In Python they are the
@x.tool decorator, the call form x.tool(...)(fn), x.add_tool(fn), and a Tool(...) reached from a
@x.list_tools() handler or a list[Tool] declaration. A tool-shaped table that no registration site
reaches (an agent framework's function definitions, a CLI command list, a documentation map, a test
fixture, a schema table whose registered names are built at run time) is not a tool.
Identifier resolution. A name, a description or a schema written somewhere other than the
registration call is followed to its text through the tree: through declarations and later
assignments, destructuring, for...of, for...in, forEach and map, function parameters (through
the call being resolved and through the call sites of the function, new X() and super()), function
returns, { ...spread }, this.x through the class body and its constructor chain, import and
require of a sibling module of the same package, Object.values, Object.entries and
Object.assign, .push(), .filter, .map and .concat, f.call(thisArg, ...), and wrappers that
pass a value through unchanged. The extractor does not follow a feature flag that gates a merge, a tool
list built inside an asynchronous function from awaited data, a member whose name is computed, or a
registration that happens in another package. What it cannot resolve is recorded as such, never
guessed.
Description states. A description that resolves to a string literal, or to a concatenation of
literals with no substitution, is readable. A template literal or an f-string with substitutions
stays partial: its literal segments are read as their own byte spans and each substitution is a hole,
whatever the resolution above could say about the value substituted. A description the resolution above
does not carry to literal text is unreadable. The three states and what each one permits are defined
under model_directed_text, below.
Vendored code. Bundled third-party code inside the artifact (a copy of an SDK, a schema library, a
GraphQL implementation), recognised by a vendor-style path segment, by a bundler's module comment, or
by the library's own vocabulary on the tree, is listed in the surface's vendored inventory with its
path, the library and the line range. The checks examine it like any other member, and it is never a
source of tools: a registration-shaped call or a tool-shaped object inside vendored code registers
nothing of the subject's.
Minified bundles. A member whose longest line exceeds 800 bytes, or whose lines average more
than 200 bytes, is a minified or obfuscated bundle. It is parsed like any other member when it is the
file being read, and an import from readable source still resolves into it, since a generated tool
table is often one long line. It is never a call site of a wrapper defined elsewhere: the calls inside
a bundle are not indexed, so a registration helper declared in readable source is not followed to its
call sites inside a bundle. Registration text that exists only in bundles is minified_only, above.
Extractor version. The extractor carries its own version (EXTRACTOR_VERSION in
attestari/surface/extract.py), recorded by every run and on every decision row (section 12). This
version of the document describes the syntax-tree extractor syntax-tree-4, which reads the whole
MCP surface below and the capability inventory of section 20, and reads Python's adjacent string
literals ("a" "b") as the one string a client receives when a line break separates them;
syntax-tree-3, which 2.3.0 through 2.9.0 describe, kept the closing quote, the line break and the
opening quote between such literals in a description's text. The versions before it described the
earlier syntax-tree readers and, before 2.1.0, a token-stream reader. The pair of methodology version
and extractor version is checked against the version table in attestari/methodology.py before
anything is decided, and a run whose surfaces were read by an extractor this document does not
describe is not decided under it.
The rest of the MCP surface: prompts, resources and resource templates
A server exposes more than tools, and since this version the extractor reads the rest of what it declares, at the registration sites of each kind and through the same identifier resolution as tools. Every entry is recorded on the surface row with its kind.
Prompts. In JavaScript and TypeScript, .prompt() and .registerPrompt() calls, and the
prompts array a setRequestHandler(ListPromptsRequestSchema, handler) or "prompts/list"
dispatcher returns; in Python, the @x.prompt decorator, x.add_prompt(Prompt(...)), and a
Prompt(...) reached from a @x.list_prompts() handler. For each prompt the name, the
description and the argument schema are recorded, the description in one of the three states
below. A prompt's messages are text the client sends to the model, so they are recorded too: where
the handler returns them as literal text (a string, a template, a message object whose text is a
literal, a list of message constructors), each literal is its own prompt_text entry with its own
byte spans, a template with substitutions being partial like any other; where the handler builds
its messages some other way, or the messages come from a prompts/get handler the extractor does
not read, the prompt carries one prompt_text entry recorded as unreadable. The text a client
receives exists in either case, and the second case says it was not read.
Resources and resource templates. In JavaScript and TypeScript, .resource() and
.registerResource() calls, whose second argument is a URI literal (a resource) or a
new ResourceTemplate(...) (a template), and the resources and resourceTemplates arrays the
matching list handlers return; in Python, the @x.resource("uri") decorator (a template when the
URI carries {} parameters), x.add_resource(Resource(...)), and Resource(...) and
ResourceTemplate(...) reached from @x.list_resources() and @x.list_resource_templates()
handlers. The name, the URI or URI template, and the description are recorded.
Sampling and elicitation. Presence only: the first place the source asks the client to sample
(createMessage / create_message) or to elicit input from the user (elicitInput / elicit)
is recorded as a file and line on the surface row. Nothing is read from the call.
How they are examined. Every description among them, and every readable prompt_text entry,
is examined by model_directed_text under exactly the rules stated for a tool description below:
the same three states, the same three categories, the same verifier, the same MEASURED_CHECKS
standing, and the same gate consequences (an unreadable description or prompt text is
not_analyzed; a partial one with nothing found in its readable segments refuses the grade as
partial_description). A finding carries the kind of entry it quotes as surface_kind (tool,
prompt, resource or resource_template, a prompt's message text counting as prompt) and the
tag surface:<kind> for every kind but tool, so a finding on a tool keeps the identity it had
before the other kinds existed. The attested manifest of section 7 is still a manifest of tools;
prompts and resources are not hashed into it in this version.
model_directed_text: does a tool description contain instructions aimed at the model?
Reads: the literal text of each parsed tool description (or, on a remote, each listed one, section 22), and since 2.3.0 each prompt description, each readable literal of a prompt's message text, and each resource description (the subsection above), passed to a language model as clearly delimited evidence, never as free text that could be read as instruction. What follows is written for a tool description and applies to every entry the same way. Concludes: specific quoted spans that direct the reading model's behaviour, each categorised and verified.
Before any description is read, each tool's description is in one of three states, read from the parsed surface, never guessed:
readable: a single literal, or a concatenation of literals with no substitution ("a" + "b",["a", "b"].join("\n")). Every byte the client receives is in the source, and the check reads all of it.partial: a template with substitutions (`Reads ${what}`, an f-string). Each literal segment is read as its own byte span; each substitution is a hole, recorded with its position, that the verifier never matches inside. A finding in a segment is a valid finding. The absence of findings in a partial description does not make the tool clean: a package with a partial tool that produced no finding is refused a grade (partial_description, section 9) unless ahighfinding already places it at F, where the floor is established by evidence.unreadable: a variable, a property, a call, a configuration passed by reference that the identifier resolution above does not carry to literal text, or a minified bundle. The text a client will receive exists and was not examined. Each such tool is recorded asnot_analyzedwith the reasondescriptions_not_statically_readable, and the package is refused a grade by the gate in section 9.
A tool that declares no description at all (absent) has nothing for this check to read, and for
this check that is a clean outcome (no_descriptions_present). A surface that does not record the
state of a description is treated as unreadable.
A tool description is read by the agent, not by the user. Text placed there can instruct the agent
to exfiltrate data, override the user's stated intent, or conceal what it is doing. Those three
categories, exfiltrate_data, override_user and conceal_from_user, are the only ones that become
findings, and each carries severity high. Routing, ordering and rate guidance ("call X first", "no
more than three calls") is never a finding. The check records only a count of such spans per tool;
the spans themselves are transcribed only by a separate inventory pass that a run must opt into
(attestari run --inventory), and nothing that decides reads it.
Four boundaries of those categories are stated here because real descriptions have tested them.
Parameter documentation is not a finding unless the parameter requests data outside the tool's
declared purpose: a parameter that must carry the user's prompt, the user's credentials, files,
conversation history, or configuration the tool does not need to do what it says is
exfiltrate_data, wherever in the description the text appears and however it is phrased.
Documenting a parameter does not license routing data through it. Need is decided by the purpose
test, since 2.3.2: name the entry's purpose from its own words, then ask whether the data the
parameter must carry serves that purpose. An entry whose purpose has no use for the user's words (a
scene or object inspector, a category listing, a screenshot, code execution, a download) does not
need them, and a parameter that must carry them is exfiltrate_data even when it is the tool's only
parameter, marked required, or documented like any other. Neither the number of parameters nor
whether another parameter already carries the tool's input decides it. Since this version the test
is narrowed where 2.3.2 read it too widely: a parameter that demands the user's own words verbatim
and forbids paraphrase or summary ("quoted verbatim (do not paraphrase or summarise)") is
exfiltrate_data whatever the entry's purpose, a text-to-3D or image-to-3D generator and a model or
asset search included. A generator or a search needs a description or a query; it never needs the
exact words the user typed with paraphrase forbidden, so the demand is for the conversation, not
for an input. A plain description, prompt, query or text parameter with no such demand (a
generator's text prompt, a search's query, the text a translator translates) is the entry's input
and is not a finding. Since 2.7.0 the demand must be explicit: fidelity alone ("exactly as they
wrote it", "as described", "in the words it arrived in", "in full"), with no ban on paraphrase or
summary, is decided by the purpose test alone, and a task runner, a bug or feedback report, a trace
or memory recorder, a server or app builder and a question-answering entry need the user's request,
and such a parameter is the entry's input; the three-labeller vote of 2026-09-25 found the check reading fidelity
as the demand on eleven real descriptions (analysis/reports/finding-vote-2026-09-25.md).
The tool's own data is never exfiltrate_data: a description of the tool's own storage,
configuration or credential files (a configuration path it reads, a key file it registers for
itself), or of what its own parameters carry for its purpose (the agent's answer given to a trace
recorder, a diagnostic context, a task given to a task runner), names data the tool needs; the
finding needs data the tool does not need, named by the span.
Disclosed telemetry is not a finding when all three of these hold: what is sent is named and is
not user content (tool names, durations, error classes, rule ids; never the user's prompt, messages,
files, code, screenshots or conversation); the description discloses the transmission; and the
description names an opt-out (an environment variable, a flag, a configuration setting). When any
condition fails it is exfiltrate_data, and the finding's rationale names the condition that fails,
so the disclosure can be checked; a disclosed opt-out never excuses sending the user's prompt, files
or conversation (the context tags below record what the README and the source say, and change no
decision). Retry and resume guidance about the tool's own calls after an interruption ("keep the
run id and resume", "an interrupted call is not a cancellation") is routing guidance, not
override_user, unless it directs the model to disregard an explicit stop or cancel from the user. Narration and
verbosity guidance is not conceal_from_user: that class needs an action taken, a result or a data
flow the user would want to know hidden, misreported or presented as routine; which identifiers to
mention, summarising chained calls, an autosave or a recorder that does not interrupt the work, not
announcing an in-flight retry, and disclosure on request stay inside the tool's purpose
(model_addressed_other). A restraint in the user's favour is not override_user: that class
needs the model to act against an instruction of the user or of another tool; "call only with the
user's consent", "never resend after sending" and "do not call while waiting for the user" are
routing guidance, and a description that tells the model to run a user-requested multi-step job
without confirming between steps stays inside the purpose unless it directs the model past an
explicit stop. Prompt version 2026-09-26.7 carried all four in the analyzer's prompt. Since 2.7.1
prompt version 2026-09-26.8 carries the first two, and the last two are drawn by the independent
reading alone, under the labelling guide, with the conceal_from_user and override_user
definitions the analyzer reads back at their 2026-09-18.6 text: measured over the corpus of
section 18 as the weekly check grows it (the served run's findings appended), the narrowed
definitions cost the check 8 of the 88 real descriptions the gold set labels conceal or
override (recall 79 of 88, under the 95% floor of section 14), while the reading alone had already
removed the false positives of those two shapes (analysis/reports/re-decide-20260921T234500Z-2026-09-26.md).
A proposal of either shape is still not a finding: the reading notes it at info. Since 2.7.3 prompt
version 2026-09-28.10 adds one sentence to each of the three definitions for the shapes the check had
stopped proposing on under 2026-09-26.8 (section 18): text that tells the model to act "silently" or
to keep a signal "internal, not user-visible" is conceal_from_user even where the tool exists to save
or monitor; "do not stop" past the user's stop is override_user, and a rule that holds the model
back ("do not skip ahead") is routing guidance; an explicit ban on paraphrase is the verbatim demand
in any language and for any entry, a question-answering entry included; and disclosed telemetry that
passes its three conditions proposes nothing. The rules are those above; the sentences name shapes.
The independent reading. Since 2.7.0 a finding of the three categories is confirmed only after a
second reading of that one finding agrees (decision D7, section 10). The reading is one model call
per finding, one record per call: the whole description, the span, the kind and the name of the
entry, and nothing about where it came from, read by the reader role of
analysis/corpus/labellers.toml, a model that is not the analyzer's, with the labelling guide
(docs/corpus/labelling-guide.md) verbatim as its instruction, exactly as the corpus labellers read
it. The description the reader sees is the recorded literal of the entry that carries the span,
found in the cited file by the words themselves when no entry is declared around the cited line (a
description object declared before the tool that references it) or when the entry declared there
records another literal (a prompt's message text beside its description, several tools on one
minified line); when no recorded literal in that file holds the span, the reader sees the verified
bytes of the cited lines, the lines guard G6 matched, cut to a window around the span when a line
is longer than a description would be, and the reading records which of the two it read. Since
2.7.1; before it a finding of either shape had no record and refused its package under G8 (10
findings on 6 packages in the re-decide of 2026-09-25). A finding the reading labels negative is
noted at info; a finding with no reading refuses the package (reading_missing, section 9). The reader's name and the SHA-256 of the prompt and the
guide it read ride on the row (section 12). Each reading is cached by the record, the guide, the
prompt, the model and the seed, so a span read once in the corpus measurement of section 18 is not
bought again at decision time, and the measurement scores what the engine decides. The vote of
2026-09-25 showed why: a confirmation resting on one batched proposal and a byte-for-byte verifier
read the words, not their meaning, and 35 of 50 confirmed findings did not survive a reading.
Every proposed span is checked by a deterministic verification step: the quoted text must occur byte
for byte inside the stored file, within the description of the named tool. A proposal that step
rejects never becomes a finding. The verifier carries its own version (VERIFIER_VERSION in
attestari/checks/model_directed_text.py), recorded on every decision row; a change to what it
accepts or rejects bumps that version.
The analysis prompt encodes the evidence so that it cannot be interpreted as instruction by the model reading it. This is both a correctness measure and a safety one: an analyser that can be manipulated by the text it audits is not an analyser. The SHA-256 of the prompt bytes and the name of the model are recorded with every decision (section 12), so a reader can tell which analyser produced a row.
Context tags on exfiltration findings
An exfiltrate_data finding says that a description routes data through the tool that the tool does
not need. It does not by itself say where the data goes, whether the publisher said so, or whether
the operator can turn it off. Three informational tags answer those questions from the package bytes
alone, and each is applied only when the analyser can establish it; a tag that cannot be established
is absent, and the decision row records why. None of the three changes a severity, a decision or a
grade, and none is part of the finding's identity. They are read by the deterministic analyser in
attestari/checks/exfil_context.py, at decision time, from the stored artifact:
destination_publisher_owned: every network destination the source names is on a domain the package declares as its own, that is the host of its homepage or repository URL, read from the manifest inside the artifact (package.json,pyproject.toml,PKG-INFO) or from the registry record. A shared code host or a registry (github.com,gitlab.com,npmjs.com,pypi.org) is never a publisher's own domain. A network destination is the host of an absolute URL literal in a source file that also makes a network call, or assigned to a name that a network call uses.disclosed_in_readme: a line of the README names a transmit host together with a sending verb, or discloses telemetry (telemetry, usage data, instrumentation, analytics, with collect, send, report, transmit, upload or share). A documentation link that merely mentions the domain is not a disclosure.opt_out_available: a source file that reads the environment names an opt-out variable (OPT_OUT,DO_NOT_TRACK,DISABLE_TELEMETRYand their variants), or a source file that tests for a file names an opt-out file.
The analyser reads text; it executes nothing and traces no data flow. The tags therefore say what the package declares and names, not what a particular byte of the user's data reaches. A reader weighing a finding should read them as exactly that.
publisher_provenance: what links this artifact to a person or organisation?
Reads: registry metadata, publisher attestations, maintainer records, repository ownership, commit signatures. Concludes: what is present and what is absent in the chain between a named party and this artifact.
Provenance findings describe absences more often than problems, and absences are common: most
registry packages carry no build attestation and no signed commits. We report them because their
accumulation is meaningful, not because any one of them is damning. Severity reflects this: a single
provenance tag is low, and a finding reaches high only when a second, independent provenance tag
on the same package corroborates it (decision D6).
The account-age rule (account_younger_than_package: the account that owns the source repository was
created after the package's first release) is informational pending measured precision (section 18).
A registry name transferred to a new owner is enough to trigger it, so until its precision is measured
it is decided at info (decision D5), as is every other provenance finding.
artifact_vs_tag: is every file of the artifact traceable to the repository at its tag?
Reads: the file list and bytes of the published artifact, and the file tree of the source repository at the tag of the examined version, as the code host lists it (paths and git blob hashes; no file of the repository is opened). Concludes: which files of the artifact have no counterpart at the tag, or that the comparison could not be made.
The repository is the one the registry record names. The tag is looked for among the forms a version
is published under, in a stated order: v1.2.3, 1.2.3, then the forms a repository of several
packages uses (name@1.2.3, name-v1.2.3, name/v1.2.3 and their variants, with the package's
full name and its name without an npm scope); the newest hundred tags are read, and the first four
forms are then looked up by name. A file of the artifact has a counterpart when the tree holds a
file at the same path under the package's directory in the repository (inferred from the two trees:
the directory holding a package manifest under which the most files of the artifact match), or a file
anywhere in the tree with the same bytes (the same git blob hash: a licence copied into the package,
a package built from a subdirectory). Files the packaging tool itself writes (PKG-INFO,
*.egg-info, *.dist-info, a generated setup.cfg) are counted apart and are never findings. A
file at the same path whose bytes differ has a counterpart; the difference is counted on the row and
is not a finding.
It fails closed, in one direction. When the comparison cannot be made, the outcome is
not_compared with the reason, and that is an outcome of this check alone: never a finding, never a
reason code, never a refusal of a grade. The reasons are no_repository (the registry record names
no source repository), repository_host_unsupported (the repository is not on a code host whose
tree we read; today GitHub alone), repository_not_found (moved, deleted or private),
no_matching_tag, tree_truncated (the code host returned a partial tree, and a partial tree proves
no absence), fetch_failed (the code host refused or failed the request; this is never recorded as a
fetch failure of the subject, which would refuse its grade), artifact_unreadable, and
comparison_failed (the comparison itself raised; the error is recorded). An absent tag is not
evidence against a publisher: many publishers do not tag, and a subject is not marked down for it.
Findings. A file with no counterpart is a finding of assigned severity medium, under one of
two tags that are measured and admitted separately:
no_counterpart_at_tag: the file does not look like build output. This is the signal section 3 describes: a file with no traceable origin in the tagged source.built_file_no_counterpart_at_tag: the file is build output by its path (dist/,build/,lib/,out/and the like) or extension (.map,.d.ts,.min.js, compiled binaries), a script whose TypeScript source of the same name is in the tree, or a dependency bundled into the artifact (undernode_modules/). Most compiled packages carry such files, and whether they are what the tagged source builds and installs cannot be established without reproducing the build, which we do not do. The tag states exactly that, and no more.
Each finding cites the file in the artifact by its first line of text, verified byte for byte like
any other excerpt (section 7), and its how_to_verify names the tree of the tag and the file's git
blob hash. At most 25 findings per tag per subject are recorded, in path order, and the rest are
counted; a file with no text to quote (a binary) cannot carry a verifiable excerpt and is counted and
listed on the row, not reported as a finding.
It cannot affect a grade. Neither tag is in MEASURED_CHECKS, so every finding of this check is
decided at info (decision D5, section 10), and the check is outside the gate: a missing result row
is not check_missing, a crash of the check is not check_errored, and a finding of it whose excerpt
does not verify is withheld without refusing the subject (guards G5 and G6 of section 9 guard the
three checks a grade rests on). A subject's gradeability and grade are what they would be had the
check not run. A tag enters MEASURED_CHECKS only by the admission test of section 18, in a new
version of this document.
The four declared checks that do not run
undeclared_network_calls (hosts contacted by the code that neither the README nor the tool
descriptions mention), dependency_risk (known advisories, unpinned or deprecated direct
dependencies, install-time scripts), abandonment (archived repositories, no commits in a stated
period, unanswered issue growth) and fork_divergence (packages published from forks, and the
byte-level difference between shipped code and upstream) are declared with the inputs each will
consume, so that the evidence a run collects already covers them. They produce no result row, and
the gate (section 9) requires a result row only of the three checks that run. A report lists them as
not assessed, which means exactly that. The network destinations the capability inventory records
(section 20) are the raw material of the first of them, and are published as inventory, not as a
finding, until the check runs and is measured.
6. Severity
Severity describes exposure to the person who installs and runs the subject. It is not a measure of intent, and it makes no claim about whether a publisher meant any harm.
High: an operator running this subject is exposed to something they would not reasonably expect from its stated purpose: code they cannot inspect, credentials reaching a destination they did not choose, or instructions to the agent that work against the operator.
Medium: a property that materially weakens the operator's ability to know what they are running, or that concentrates control in a way worth knowing about before installing.
Low: a property of the publisher record that leaves the operator with no one to hold to account, without by itself changing what the package does. A provenance finding with a single tag is decided low; a second, independent tag on the same package raises it to high (decision D6, section 10).
Info: a factual observation, usually an absence, that is common across the registry and meaningful mainly in aggregate or alongside other findings. It is published as context and has no effect on the grade.
We do not use a borrowed scale such as CVSS. These are not vulnerabilities with exploit mechanics; they are properties of what a package does and what can be known about it. The grade (section 11) is read off the highest confirmed severity.
7. Evidence and verification
Every artifact we fetch is stored by content hash. Every finding cites the hash of the evidence it came from, the path within it, and the line range, alongside the quoted span itself.
At decision time, the quoted span is re-verified byte for byte against the stored evidence (guards G6 and G7 in section 9). A span that does not re-verify does not become a published finding, and its subject becomes ungradeable rather than passing quietly.
Every published report carries instructions for reproducing its central claims from public sources in a few minutes, without our software and without trusting us. If you cannot check a finding yourself, we have not finished writing it.
No personal data in published evidence. A published blob never contains an email address, a
phone number or a street address. This is a rule, not a judgement. Evidence is published in one
of two forms, defined exactly in docs/publish-contract.md (section 9a):
- A finding that cites a structured upstream response (a registry record, a GitHub API response)
is published with a projection: a canonical JSON object carrying only the member the finding
cites, copied byte for byte, the JSON pointer of the object it was taken from, and the SHA-256
of the whole upstream response as provenance (
source_sha256). The projection's own hash is the evidence hash the published finding verifies against; the upstream bytes are never published. - A finding that cites a file from the package artifact is published with that file whole: it is public code. It is scanned all the same.
Every blob of either kind is scanned for the patterns the contract lists before it is published.
A finding whose evidence cannot be published without personal data is withheld, not redacted and
not softened, and its subject is published as not_gradeable with the reason
evidence_unpublishable. The stored evidence, the decision and the finding id are untouched;
only publication is refused.
Attested manifest
For each parsed tool whose description is readable (section 5), the tool hash is SHA-256 over: the
tool name in UTF-8, one 0x00 byte, the description bytes exactly as extracted (description_raw),
one 0x00 byte, and the schema component. When the declared input schema is a JSON literal, the
schema component is its canonical JSON (RFC 8785: sorted keys, no insignificant whitespace, UTF-8).
When it is any other expression, a zod schema, a builder call, a reference to a constant, the schema
component is the schema's source bytes exactly as extracted, taken the same way description_raw
is: a zod schema is hashed as the bytes of its expression, and only a JSON literal is canonicalised.
When no schema is declared, the component is empty. The manifest hash is SHA-256 over the tool
hashes, as 32-byte digests, sorted bytewise and concatenated; hexadecimal is only how either hash
is printed. Both are computed by attestari/manifest.py, and a fixed-vector test pins this
definition to that code.
A tool whose description is not readable has no tool hash, and a subject with any such tool has no
manifest hash: the manifest is not attested, which is an outcome, not an omission. Both hashes are
recorded on every decision row of the subject (tool_hashes, one entry per parsed tool with its
hash or the reason it has none, and manifest_hash; section 12), and published with the result.
The lookup API serves them for a package version at GET /manifest/:name/:version, so a gateway
can pin the tools it was shown to the tools this pass examined.
manifest_changed. When the manifest hash of a served decision differs from the manifest hash of
the previous examined version of the same subject, the cumulative index records the event
manifest_changed, and the manifest is served with the hash it changed from and the version it
changed since (previous_manifest_sha256, changed_since). Both versions must have a manifest hash;
a version that is not attested has none to differ. The event records whether both versions were read
by the same extractor version, because a hash can also move with the instrument. It is an event, not
a finding: it has no severity, enters no decision and moves no grade.
Presented manifest
The attested manifest is taken from the source as the extractor reads it: a description before unescaping, a zod schema as its source bytes. A client holding the tools a running server presents cannot reproduce either, so it cannot check a live server against the attested manifest. The presented manifest is the part a client can check at connect time (since 2.9.0).
For each parsed tool, the presented tool hash is SHA-256 over: the tool name in UTF-8, one 0x00 byte, and the description as the text a client receives, in UTF-8, with every run of white space (the characters of Unicode's White_Space property) made one space and the text trimmed at both ends, so that a docstring reads the same whether or not an SDK dedents it. A tool declared without a description presents the empty text. The schema is not part of it: the engine reads a schema's source, not the JSON Schema a server generates from it at run time, so a server that changes only a tool's input schema is not caught by this hash. The presented manifest hash is SHA-256 over the presented tool hashes as 32-byte digests, sorted bytewise and concatenated, as for the attested manifest.
A tool has no presented hash when its name is computed (name_dynamic), when its description is
replaced after the declaration (description_reassigned), when its description is not read whole
(description_partial, description_unreadable), or when the surface holds a Python description
as syntax-tree-3 misread it (description_misread: the text is the raw bytes from the first
literal's opening to the last literal's closing quote, with a quote, a line break and a quote between
two literals; section 5); a subject with any such tool has no presented
manifest hash, and the row says why. Both are computed by attestari/manifest.py (present), and
fixed vectors (tests/vectors/presented_manifest.json) pin the definition to that code and to every
other implementation that reads the same file. The presented manifest rides on every decision row
(presented_manifest_hash, presented_manifest_reason; section 12) and is served with the
verdict's provenance. Rows decided before 2.9.0 are given it by attestari index-presented, from the
surfaces.json of the run that decided them: the same surface, read by the same code, and nothing else
of the row changes. Like the attested manifest it is a fact of the surface: it enters no decision and
moves no grade. What a client does when the tools it is shown differ is its own policy.
Watch manifest (since 2.10.0)
For each entry a remote lists, the entry hash is SHA-256 over: the kind (tool, prompt, resource,
resource_template or instructions) in UTF-8, one 0x00 byte, the entry's name (a resource's URI) in UTF-8, one
0x00 byte, and the entry object as the server sent it, in canonical JSON (RFC 8785). The server's instructions
are one entry, its object {"instructions": <text>}. The watch manifest hash is SHA-256 over the entry hashes as
32-byte digests, sorted bytewise and concatenated, as for the attested manifest. So a change to a description, a
title, an input or output schema or an annotation changes it; the server's own name and version do not, since a
client is not shown them as tools. A reading whose listing was cut short (more pages than section 22 reads) has no
watch manifest hash, and says so. Both are computed by attestari/remote/manifest.py.
The presented manifest of a remote is computed over its listed tools exactly as for a package, so that a remote that is the hosted form of an examined package can be compared with that package's presented manifest. The attested manifest is not computed for a remote: it is defined over source bytes, and a remote has none.
A remote's evidence is the stored answer of the request that listed the entry: the response body exactly as
the server sent it, by its SHA-256, with the URL, the request and the time. A flag cites it as remote_response.
When published, an answer is a structured upstream response and is published as a projection of the member the
flag cites (above), scanned like any other.
8. Inputs to a decision
A run directory written by attestari run (see attestari/pipeline.py): findings.json,
results.json, gradeable.json, surfaces.json, records.json, not_analyzed.json,
summary.json, plus the content-addressed evidence store the run cited (evidence/blobs/<sha256>).
The policy reads these files and the blobs. It never calls the network or a model.
A remote's readings are written by the watch (attestari/remote/): each reading as a row of readings.jsonl,
with the stored answers in the same content-addressed evidence store. Nothing is decided from them by the policy
of sections 9 to 11, which applies to packages only.
9. Package gradeability (fail closed)
The failure we care most about avoiding is not a wrong grade. It is a passing grade on something nobody looked at. A subject that could not be examined must never be indistinguishable from one that was examined and found clean.
So the grading gate fails closed. A package is graded only when every guard below holds. Any guard
that fails, or cannot be evaluated, makes the package not_gradeable; every reason found is recorded,
in order, and every finding of that package is withheld. There are no exceptions and no severity
that is exempt.
- G1 gate recorded.
gradeable.jsonexists and has a row for the package. A missing file blocks every package in the run; a missing row blocks that package. - G2 gate passes as recorded. The row says
ok: true. - G3 gate passes when recomputed.
attestari.grade.gradeabilityis re-run at decision time fromresults.jsonand the package'ssurfaces.jsonrow (tools with descriptions, the entries parsed of every kind, parsed tool names, advertised tool names) and must return ok. A package with no surfaces row cannot be recomputed and is blocked. - G4 no tool left unanalysed. No
not_analyzedentry for the package inresults.jsonor innot_analyzed.json. A refused, truncated or unreplayed model call is an outcome, never a clean result. - G5 every check ran and none errored. Each configured check (
publisher_provenance,undetermined_tool_surface,model_directed_text) has a result row for the package, andsummary.jsonrecords no failure for the package that is a crash: a failure row whose stage isfetchor a check id, or that carries a traceback. Recorded HTTP failures of a source (a 404 repository, an unreachable website) are inputs to the checks, not crashes, and do not block by themselves. - G6 every excerpt re-verifies byte for byte. For each finding of the package: the cited blob
exists in the evidence store, and the UTF-8 bytes of
evidence.excerptoccur inside it. When the finding cites lines (line_start..line_end), the bytes must occur within those lines. An empty excerpt fails. One unverifiable finding blocks the whole package. - G7 model-derived findings passed the verifier. For each
model_directed_textfinding that quotes a description or a prompt's text (every one exceptdescription_reassigned_at_runtime), the excerpt must lie inside one of the description spans of an entry (a tool, a prompt, a prompt's message text, a resource or a resource template; section 5) declared at the same path in the package'ssurfaces.jsonrow: the same testattestari.checks.model_directed_text.verify_proposalapplies when the proposal is made. A proposal the verifier discarded is never a finding; a finding that would not pass the verifier now blocks the package. - G8 every model-directed finding read. For each
model_directed_textfinding of the classesexfiltrate_data,override_userandconceal_from_useraboveinfo, the run'sreadings.json(written byattestari readbefore anything is decided; the cycle runs it) records an independent reading (section 5; decision D7, section 10). A finding with none, because the file is absent, the finding is not in it, or its call did not complete, refuses the package underreading_missing: a finding that could not be read is neither confirmed nor noted, and the policy fails closed.
The gate recomputed in G3 refuses a package for any of the following reasons, and records each one it finds:
| Reason code | Meaning |
|---|---|
no_surface_examined |
No tool, prompt, resource or template was parsed from the published source. The subject's advertised behaviour was never visible. A server that exposes prompts, resources or templates and no tool is graded like any other when every entry that was found was fully read and analysed; the rules above decide that. (Through 2.3.0 this row was no_tools_examined and counted tools alone.) |
advertised_tools_not_parsed |
Documentation advertises tools the extractor did not find. The mismatch is itself the finding. The documentation read is the README of the examined version, first that exists: the one inside the archive (else the archive's PKG-INFO or METADATA description), the repository's at the commit or tag the version was built from, the registry's text for that version; never a branch that moves, and the record says which (readme_source). |
not_analyzed |
One or more entries could not be analysed: a model provider declined the request, or a tool, prompt or resource declares a description (or a prompt returns message text) the extractor could not read as a literal (descriptions_not_statically_readable, section 5). Either is recorded as its own outcome and never counted as a clean result. |
model_check_skipped |
The description analysis did not run on a subject that has parsed tool descriptions. This includes the case where the analysis service was unavailable: a rate limit, an overload or a server or connection error is retried with backoff, and only when the last attempt fails is the check recorded as skipped, with the error class, the number of attempts and the time waited. That is an examination incomplete on our side, not a finding about the package, and it is never counted as a clean result. A lookup answers with the most recent complete examination and says so; a package with no earlier examination is shown as not yet examined, never as not gradeable. |
partial_description |
A description (of a tool, a prompt, a resource, or a prompt's message text) was read with holes and nothing was found in its readable segments; that does not establish a clean entry (section 5). Not applied when a high finding already places the package at F. |
minified_only, runtime_generated, declaration_not_parsed, no_registration_site, archive_unreadable |
The surface outcome of section 5, refusing the grade under its own name. not_a_server is not a refusal: the package is out of scope (D4). |
extraction_budget_exceeded |
Reading the package's source passed the per-package budget of wall time or resident memory, so nothing past that point was read and no tool list exists to grade. The budget is measured, not estimated: every row records the wall time and the resident memory its extraction took beside the budget it ran under, and those figures are published with the row. |
The gate map. Every reason code a decision or a publication can carry, the guard or decision
that records it, and one plain-English line for each, is the table GATE_MAP in
attestari/policy.py. attestari decide exports it as analysis/gate_map.json beside the code and
as gate_map.json inside the run, and the website reads the exported file rather than restating
it. A test fails when a reason code known to the policy or to the gate has no entry, and when the
exported file differs from the table. Each code also has a fixture under
analysis/fixtures/reason_codes/<code>/, a run directory and the evidence it cites, on which
attestari decide (or, for evidence_unpublishable, attestari publish) records exactly that
code; a test decides every fixture and fails when a code has none.
"We cannot tell you what this does" is a legitimate published outcome, and for a meaningful share of popular packages it is the accurate one.
10. Finding decisions
The policy named severity-ladder decides each finding of a gradeable package:
- D1 confirmed. A verified finding whose decided severity is
high,mediumorlowisconfirmedat that severity; amodel_directed_textfinding of the three model-directed classes only when its independent reading agrees (D7). The decided severity starts from the severity the check assigned and is changed only by D5, D6 and D7 below; the row records both. - D2 noted. A verified finding whose decided severity is
infoisnoted: published as context, no effect on the grade. - D3 withheld. Every finding of a package that is not gradeable is
withheld, and its row lists the package's blocking reasons. A withheld finding is never published as a finding. - D4 out of scope. A package whose surface is
not_a_server(section 5) is out of scope: it is published with the verdictout_of_scopeand the reasonnot_a_server, every finding of it iswithheld, and it is counted apart from the population rather than as a refusal. Out of scope is not a grade and not a refusal; it says the artifact is not the kind of thing this methodology rates. - D5 measured checks only. Only a check whose precision has been measured (section 18) may
place a finding above
info. The policy carries the set of measured (check, tag) pairs,MEASURED_CHECKS; in this version it holdsmodel_directed_textwith every tag and nothing else. A finding from any other check or tag is decided atinfoandnoted, whatever severity the check assigned; the assigned severity stays on the row. Every check still runs and every finding is still recorded. Adding a pair to the set is a methodology change that must cite the measurement in section 18. - D6 provenance corroboration. A
publisher_provenancefinding is decidedhighonly when a second, independent provenance tag is present on the same package; a single tag is decidedlow. D6 is applied before D5, so whilepublisher_provenanceis unmeasured the outcome is stillinfo. - D7 independent reading. A
model_directed_textfinding of the classesexfiltrate_data,override_userandconceal_from_userwhose decided severity is aboveinfois confirmed only when its independent reading (section 5: one call per finding, by the reader role, against the labelling guide) labels it positive. A finding the reading labelsnegativeis decided atinfoandnoted(D2); the row records the reading's label, reason and reader, and the assigned severity stays on it. D7 is applied after D5 and D6, only to a finding D1 would otherwise confirm.description_reassigned_at_runtimeis outside the guide's classes and is decided without a reading. A finding with no reading is not decided at all: its package is refused (G8, section 9). The reading is not a suppression rule: it does not remove a finding from the record, it says whether a second, independent reader of the same words agrees that they instruct the model against the user, and the row carries both readings.
There are no suppression rules in this version. A finding that should not count is a methodology change (add a rule here, bump the version) or an appeal (section 16), never an edit to a run.
11. Package grade
From the confirmed findings of a gradeable package:
| grade | condition |
|---|---|
| A | no confirmed finding (only noted context, or nothing) |
| B | highest confirmed severity is low |
| C | highest confirmed severity is medium |
| F | any confirmed finding of severity high |
What the ladder produces today: B requires a confirmed low finding, and no measured check assigns
low yet (MEASURED_CHECKS holds the description analysis alone, which assigns high to its three
finding categories and medium in one case), so no package currently receives B. C currently arises
from one finding type only, description_reassigned_at_runtime (a tool description replaced at run
time, section 5). The ladder is written for checks still to be measured: a check admitted under
section 18 that assigns low or medium makes B or C reachable without a change to the ladder.
A package that is not gradeable has no grade; its row carries blocked_by instead.
12. What every decision row records
Every row, package or finding, carries:
policy,methodology_version,methodology_sha256: the rules it was decided under, by number and by the hash of this document's bytes;code_commit, the git commit of the code that decided (code_dirtysays whether the tree had uncommitted changes), anddecided_at;analyzer_model, the model the run's description check called (empty when the run made no model call),prompt_sha256, the SHA-256 of the findings call's system prompt bytes, andverifier_version, the version of the deterministic verifier (section 5). The run header records the prompt layout and version beside them, and says whether the prompt hash was recorded by the run itself or reconstructed from the code at decision time;extractor_version, the version of the surface extractor (section 5) that read the tool surface the row was decided on, which withmethodology_versionforms a pair the version table inattestari/methodology.pymust allow before anything is decided;tool_hashes, one entry per parsed tool of the subject with its tool hash or the reason it has none, andmanifest_hash, the attested manifest of section 7, or the reason the subject has none;presented_manifest_hash, the presented manifest of section 7, orpresented_manifest_reason, the reason the subject has none (since 2.9.0);
and every package row also carries capabilities, the capability inventory of section 20 as the
surface row recorded it, each entry with its own verification against the evidence store,
capabilities_omitted, the number of distinct facts beyond the row cap, and mcp_surface, the
shape of the surface of section 5 (how many tools, prompts, prompt texts, resources and resource
templates, and whether sampling and elicitation appear), and artifact_vs_tag, the comparison
record of section 5 (compared or not_compared, the reason when not compared, the repository, the
tag and its commit, and the counts; absent on rows written before 2.5.0); and every finding row also carries the
finding's evidence_sha256 (the blob it was verified
against) and excerpt_sha256, the verification outcome, the rule that produced the decision,
surface_kind, the kind of entry a model_directed_text finding quotes (section 5), and,
on an exfiltrate_data finding, the context tags of section 5 with the evidence for each, and, on a
model_directed_text finding D7 covers, reading: the label and reason of the independent reading,
the reader (model and labeller prompt version) and the SHA-256 of the prompt and the guide it read
(null on a row written before 2.7.0). The run-level header repeats the stamps, records the SHA-256 of
every input file (readings.json among them since 2.7.0) and the reader's own record (reader: the
model, the prompt and guide hashes, the calls made and their cost). The rows are
written twice, as one document (decisions.json) and as one JSON object per line
(decisions.jsonl: the header, then each package, then each finding, every line carrying the
stamps), and summarised for reading in decisions.md. A rerun over unchanged inputs with the same
methodology and code produces identical rows apart from decided_at.
A remote's reading row (since 2.10.0) carries policy, methodology_version, methodology_sha256,
code_commit and decided_at as above; probe_version, the version of the probe of section 22; the remote's
name and the URL asked; probed_at; the outcome and its cause (section 22); entry_hashes, watch_manifest_hash
and presented_manifest_hash, or why there is none; the access the public documents describe; and, when the model
check ran, analyzer_model, prompt_sha256 and verifier_version as above. It carries no grade, no
extractor_version (no extractor read it) and no capability inventory.
13. Automated decision, human responsibility
Findings are decided by the rules in this document, applied by software, without a person reviewing each one. This is a deliberate choice. Rules applied by a machine are applied identically to the two-hundredth subject and the first; a person working through hundreds of findings is not.
What is not automated is the rules themselves. Changing them is an explicit act that produces a new version of this document (section 21), and every finding cites the version that produced it. The methodology, not any individual judgement call, is what stands behind a rating.
Running the rules is not a human act and needs no approval. The two things a person does:
- Appeals. A publisher disputing a finding writes to
appeals@attestari.ai(section 16). The appeal is filed against the finding's id (attestari appeal file; the appeals job files what the mailbox receives) and resolved by rule, applied by software as the decisions are. The cited evidence is verified again against the stored artifact by the guards that confirmed it (G6, G7): a finding whose evidence does not verify is a factual error and the appeal isupheld. A finding the labelling guide covers goes to the three model labellers of section 18 (the two gold-set passes and the audit), one call each on the record the reading of D7 sees: the appeal isupheldwhen two of the three read the span negative anddismissedotherwise, and the resolution records their reasons. A finding the guide does not cover, or a vote that could not be completed, stays open for a person, who resolves it (attestari appeal resolve) asupheld,dismissedorwithdrawn. Appeals are an append-only event log beside the run (appeals.json); nothing indecisions.jsonis edited. An upheld appeal is applied wherever the decision is served: the lookup API and publication both serve the finding aswithdrawn_on_appealand recompute that package's grade without it, listing the withdrawal rather than hiding it; an open appeal is published markedunder_appeal, unchanged. An upheld appeal carries to every later run's decision of the same package version whose finding has the same check and the same span bytes; a decision made before the appeal's run stands as decided, and a new version is examined fresh. The reply to the publisher is written by software and sent only with a person's approval. What an appeal can be about, and how we answer one, is in section 16. - Methodology changes. Editing this document and the matching constant, as described in section 21.
Publication is separate. Producing findings and publishing them about a named third party are
distinct steps. Automation covers the first. The second is attestari publish <run>: one command,
not a review queue, run only after the disclosure period in section 19. It refuses a run whose
decisions were made under a methodology version or document hash other than the current one, and
never overwrites an earlier publication.
14. Scheduled operation
attestari cycle --candidates <file> performs one cycle with no human input: it resumes the most
recent failed or cancelled run over that candidates file if there is one, otherwise starts a new
run; then decides the run under this methodology, diffs it against the previous decided run over
the same candidates file, and writes decisions.json, decisions.jsonl, decisions.md and
diff.json into the run directory. --every <seconds> repeats on that cadence in-process; a
scheduler may instead invoke the one-shot form. The scheduled pass runs weekly.
Promotion. A decided run is a candidate. It becomes the run the lookup API answers from only when
every rule of the promotion gate holds against the run currently served; otherwise it is held: it is
not promoted, the previous run keeps serving, the console lists what failed with the packages and
the reasons, and the cycle's log line says held with the reason. A person may promote or drop a
held run, and the record names who did. The rules:
- The population examined as servers differs from the served run's by at most 5%, counting every
package added and every package removed. A deliberate expansion is a record, not a switch.
When the Attestari engineers decide to move the population to a new candidate list, they commit a
dated record naming that file by its SHA-256 (
analysis/expansions.json). A run made from exactly that file, decided within 30 days of the record's date, may add any number of packages. The record admits additions only: what such a run removes is still held to the 5%, and a run that comes out smaller than the served one is held. Every other run meets the 5% as before, including the week after: once the expanded run is served, the next run over the same file is compared with it and differs by little. The promotion record names the expansion it relied on. - No package moves between a passing grade (A, B, C) and F while its tarball hash is unchanged.
- No delegation (section 4) was followed into a package that is not an MCP server and does not ship the module the delegating package loads.
- No package loses its grade on an unchanged tarball. For every package the served run graded,
if this run examined the same tarball hash and gives it no grade, the run is held. A refusal that
originates on our side (
model_check_skipped,not_analyzed,check_errored,check_missing, a gate file that is absent or has no row,extraction_budget_exceededon bytes that were read within the budget before) holds the run aslost_grade: a transient error never publishes a downgrade. A refusal that originates on the package's side (an unreadable surface, no registration site) over unchanged bytes should not happen, since the same bytes give the same extraction; if it does it is a defect of ours, and it holds the run under its own reason,lost_grade_same_bytes, so that it is investigated. A package whose bytes changed and is now not gradeable holds nothing: that is a real result. A tarball hash that cannot be established on either side counts as unchanged; the rule fails closed. This rule generalises rule 2, which stays. - The weekly check of section 18 over the labelled corpus is within its bars; since 2.7.0 that check measures the check and the independent reading together, what the engine decides.
Nothing in the gate decides a package: the decisions are what they are, and what is gated is whether the API answers with them yet.
15. Known limits
These are the things a passing result does not tell you. They are stated here rather than in a footnote because a reader who does not know them will over-read our findings.
We do not run the code. Analysis is static. A subject that behaves differently when executed, against a particular host, after a delay, or only in the presence of certain credentials, presents none of that to us.
We do not examine binaries. Where a package's substance is a compiled executable it downloads, we can describe the launcher and the fetch precisely, and we can say nothing whatsoever about what the binary does.
Text assigned at runtime is invisible to us. A tool description that is benign in source and rewritten when the module loads reaches the agent in its rewritten form. We detect and report that a description is reassigned at runtime, but we cannot read what it becomes.
Tool names built by expression may be missed. Where a tool's name is computed rather than
written literally, our extractor can fail to find a tool that genuinely exists. A name, a
configuration or a description written somewhere other than the registration call is followed to its
text through the syntax tree by the identifier resolution section 5 lists, including through a sibling
module of the same package; what that resolution does not carry to literal text is reported as
declaration_not_parsed (section 5), and the gate in section 9 catches an advertised tool we did not
find as a mismatch rather than passing the subject silently.
A minified bundle is opaque to us and to you. Where a package ships its registration only in a minified or obfuscated bundle, we report that as a medium finding and read nothing from it. We do not reconstruct source from a bundle.
Tools generated at run time are not in the artifact. Where the file that registers tools builds them from an OpenAPI document or a remote catalog, the tool list exists only when the server runs. We report the site and read nothing further.
Prompt text built at run time is unreadable to us. A prompt whose handler assembles its
messages from data, from a file, or in a prompts/get handler the extractor does not read reaches
the model as text we never saw. We record the prompt and say its text is unreadable, and the subject
is refused a grade on that account (section 9), never passed on the strength of its description.
A publisher who reads these rules can write around the model check. Section 5 publishes the classification rules in full, and the language-model check applies them to prose. A description written with those rules in hand can carry the same instruction in a form the model does not recognise, and the check is measured (section 18) only against cases that were not written against it. The deterministic checks do not share that weakness: the description states, the surface taxonomy, the capability inventory and the reassignment finding read structure, not meaning, and a description that is rewritten when the module loads is reported as such whatever the rewritten text says. A clean model check on a package whose author has read this document is weaker evidence than a clean model check on one whose author has not.
The context tags and the capability inventory read text, not behaviour. A destination is a host the source names; a disclosure is a sentence the README carries; an opt-out is a variable the code reads. Where a destination is computed, a disclosure lives outside the README, or an opt-out is honoured by some code paths and not others, the tags and the inventory do not see it, and they say nothing either way.
The comparison of the artifact with the repository is by file, at a tag, and unmeasured. A
file present in the published package with no counterpart at the tag is recorded since 2.5.0
(artifact_vs_tag, section 5). It compares paths and bytes, not meaning: a file that exists at the
tag with other bytes has a counterpart; build output cannot be traced without reproducing the build
and is stated under its own tag; a subject whose repository is not named, not on GitHub, or not
tagged for the version is not_compared. The check has no labelled corpus and has not passed the
admission test of section 18, so its findings are context at info and no grade rests on it.
Four declared checks do not run. Undeclared network destinations, dependency risk, maintenance status, and fork divergence are declared in section 5 and not assessed. Where a report lists these as not assessed, that is what it means, not that they were assessed and found clean.
A finding is about one version, at one time. Registries are mutable. A result describes the artifact we fetched, identified by hash, on the date stated. It says nothing about what the same version number serves tomorrow.
A remote shows us what it shows anyone before sign-in, and nothing else. A server may list different tools, or different descriptions, to a signed-in client than to our probe, and may change what it lists between two readings and back again. We report what we were shown, when we were shown it, and that we cannot see past sign-in. A remote that asks for sign-in before it lists its tools is recorded as such and is not examined at all.
A remote's flag is not a grade, and its absence is not a pass. We do not grade remotes, and a remote with no flag is one whose listed descriptions the model check found nothing in, at the time of the reading.
The older HTTP+SSE transport is not read. The probe speaks MCP's Streamable HTTP. A server that offers only the
older HTTP+SSE transport answers its first request with an HTTP status, and is recorded as not_an_mcp_endpoint;
in the measurement of section 18, 227 of the registry's 987 sse remotes answered that way.
16. Notice, appeals, and corrections
Notice before the evidence is published. Before the evidence of a finding about a named subject is published, the finding and its evidence go to the publisher by a published route and to the registry on the same day, and we wait 30 days (section 19). The grade itself is public from promotion. That period is for correcting us, and a publisher is under no obligation to respond.
Appeals. A publisher may dispute any finding at any time, before or after publication, by
writing to appeals@attestari.ai. An appeal that identifies a factual error, such as a misquoted
span, a wrong line reference, or a claim about code that is not there, is checked against the stored
evidence. If we are wrong, we correct it. An appeal is resolved by the rule of section 13, and an
upheld appeal takes effect wherever the decision is served; the reply is sent only with a person's
approval.
An appeal that disputes our interpretation rather than our facts will be considered and may be published alongside the finding. We do not remove an accurate finding because a publisher objects to it.
Corrections. Corrections are published, dated, and describe what was wrong and what changed. We do not silently edit a published finding.
Remediation. When a publisher fixes the thing a finding describes, a re-examination of the new version is free and prompt, and the updated result is published.
Operators of remotes (since 2.10.0). The operator of a remote can stop the watch in two ways: a robots.txt
rule for the user agent attestari-watch covering the path, which the probe reads before every reading, or a
request to watch@attestari.ai from an address at the operator's domain, which puts the host or URL on the
watch's exclusion list and is answered within 2 business days. An excluded remote is shown as not watched, with the date; what was read before stays
published, dated. An operator disputes a flag as a publisher disputes a finding, under the rules above.
17. Independence
A publisher cannot pay us, in money or in kind, for a rating, for a better rating, or for the removal of a finding. There is no paid review, no expedited review, and no arrangement under which a rated party influences its own result.
The subjects we examine are chosen by the mechanical rule in section 4. They are not chosen by request, and they are not chosen by who has paid for anything.
Where Attestari earns revenue, the source is disclosed. Any commercial relationship with a party that is also a subject is disclosed on that subject's published result.
Publisher tools. Attestari may offer a publisher a tool that applies these checks to an artifact that has not been published. Its output is never published, never stored where the scheduled pass can read it, and never affects a grade. The public grade of a subject is computed only from the registry artifact, only by the scheduled pass, only under the methodology in force, whether or not the publisher used such a tool. The same holds for every other thing sold to the people deciding whether to install: a lookup, a manifest, a watch, a badge, an attestation of inspection. Each shows what the scheduled pass published and never changes it.
18. Measured performance
These figures are preliminary. They are measured on a small held-out corpus, and they are restated whenever the corpus or the check changes; they are evidence that the check finds known attacks, not a general detection rate. None of the measured cases was written against the rules in section 5 (the adaptive adversary, section 15).
The corpus. The held-out half of the disclosed-cases set (seeds/heldout/cases.json) records
nine cases. Four carry a published sample and are measured: three tool-poisoning disclosures by
third-party security researchers and one in-the-wild observation from our own run (blender-mcp
1.9.1), with 39 expected spans between them (23 in the three disclosures, 16 in blender-mcp).
The other five are pattern-only records whose disclosure printed no sample; they are counted in
the corpus and never measured. Three further disclosed cases are worked examples inside the
prompt (seeds/fewshot/) and are never measured. Precision is measured against a control set of
456 tool descriptions in 16 widely-used packages reviewed by hand (runs/twenty/surfaces.json).
Detection and precision, as a range across four independent samples of the same prompt version (the model is sampled once per call, so two runs of the same evaluation can differ):
- 4 of 4 measured cases detected in every sample, both in-the-wild cases among them
- 32 to 33 of 39 expected spans found, every one in its expected category: 16 to 17 of 23 on the three disclosures (17 in one sample of four), 16 of 16 on blender-mcp in every sample
- 0 false positives across 456 clean tools in every sample
- 0 proposals rejected by the verifier in any sample
Six spans were missed in every sample and one further span was found in one sample of four; the other 32 were found in all four. Every missed span sits in a description on which other spans were found; no measured case was missed whole in any sample. The rug-pull case whose poisoned text is assigned when the module loads, and so never appears in the source we read (section 15), is now a worked example in the prompt and is not measured.
Where the figures come from: the four evaluation runs recorded at
runs/eval-heldout-2026-09-14.4, runs/eval-var-1, runs/eval-var-2 and runs/eval-var-3
(2026-09-14, claude-opus-5, prompt version 2026-09-14.4, the separated prompt layout, each
against an evidence store whose proposal cache was empty so that every call went to the model),
scored by attestari eval against the held-out manifest and the clean set above. In those
evaluations only, each seed sample's evidence block cited the publication that disclosed it; a
production run carries no such citation.
The admission test. A check enters MEASURED_CHECKS (decision D5, section 10) only by
passing this test, and no check enters it without passing it: at least 200 labelled spans,
precision at least 95%, at most 1 false positive per 500 clean descriptions. The figures the test
is applied to are those recorded in this section, from the evaluation runs named above, and any
restatement of them is dated here. For model_directed_text, the check in the set, the recorded
figures are: 456 clean descriptions with 0 false positives in every sample (at most 1 per 500 holds);
32 to 33 spans found of 39 expected with 0 false positives, a precision of 100% on the found spans
(95% holds); and a labelled corpus of 39 expected spans in the four measured cases beside the 456
descriptions labelled clean. The held-out corpus of 2026-09-14 holds tool descriptions only; the
phase-1 corpus below holds every kind of entry, and its table states each kind apart. Restating the
corpus is a methodology change (section 21).
artifact_vs_tag is not admitted. The check added in 2.5.0 (section 5) has no labelled corpus:
no finding of it has been labelled, its precision is unmeasured, and neither of its tags is in
MEASURED_CHECKS, so every finding of it is decided at info. What has been counted is its reach
over the population: how many subjects compare, how many do not and why, and how many files have no
counterpart, by attestari compare-tag over the served run of 2026-09-21. That count describes the
population, it does not measure the check, and it is kept with the run's reports
(analysis/reports/artifact-vs-tag-2026-09-21.md) rather than stated here.
Restated 2026-09-17 from the phase-1 labelled corpus. The corpus of docs/corpus/labelling-guide.md
(analysis/corpus/pool, 1,607 spans, labelled by two independent passes and adjudicated as
docs/corpus/labelling-record.md records) was frozen at 296 positive and 1,311 negative spans and
measured per description, the unit the engine decides on: a description is positive when any of
its spans is, and found when the engine reports any finding on it; the spans are the cited
evidence, listed with byte offsets in the report. The 1,607 spans are 1,589 descriptions: 285
positive (34 real, from the population run and the disclosed cases; 251 authored under section 15)
and 1,304 negative (1,203 clean descriptions from packages with no finding, 94 authored hard
negatives, 7 benign fragments of real attack descriptions). Under this version, prompt version
2026-09-17.5 and claude-opus-5 (analysis/corpus/agreement.md, run 2026-09-17, which records the
methodology version and document hash, the prompt hash and the verifier version that produced it):
- precision 100% on the found descriptions (280 of 280, Wilson 95% 98.6% to 100%)
- 0 false positives across 1,203 clean descriptions (Wilson 95% 0% to 0.3%) and 0 across 94 hard negatives (Wilson 95% 0% to 3.9%); at most 1 per 500 holds
- recall 98.2% over every positive description (280 of 285, Wilson 95% 96.0% to 99.2%); with a labelled class, 276 of 285
- 29 of 34 real descriptions found (85.3%, Wilson 95% 69.9% to 93.6%); 251 of 251 authored ones apart, which state no detection rate on their own
- per surface kind: tools 214 of 219 found with 0 false positives across 1,173; prompts 17 of 17 (0 across 45); prompt message text 21 of 21 (0 across 8); resources 14 of 14 (0 across 43); resource templates 14 of 14 (0 across 35)
- 0 descriptions not analysed
The five real descriptions missed are one family: the user_prompt parameter of a generator or a
search in the blender package (generate_hyper3d_model_via_images, generate_hunyuan3d_model,
search_sketchfab_models, search_polyhaven_assets twice), which both labellers called
exfiltrate while the adjudication of the byte-identical copies of three of them, and the purpose
test of section 5, call negative. The engine follows section 5; the labels are the open item of the
labelling record. Scored at the span instead, as the first report of the same day scored it, the
same run reads 291 of 296 spans found, 7 false positives (benign fragments cut from real attack
descriptions whose attack spans were found) and precision 97.7%; the two units are reported side by
side for this version and the span table is dropped in the next.
Restated 2026-09-18: the corpus is labelled, adjudicated and audited by the engine. From this
version the labelling of the corpus is a set of engine commands, versioned and cached, and part of
the weekly cycle (docs/corpus/automated-labelling.md): two independent model labellers read the
labelling guide verbatim as their instruction and see a record's text and nothing about where it
came from; a third model pass settles their disagreements and any byte-identical descriptions left
with different labels, and every decision must cite the section of the guide it rests on; a third
labeller, on a model no other role runs, labels a seeded sample and is compared with the gold set.
The phase-1 passes of 2026-09-17, made by hand from a model session, stay in the repository as
history, and the adjudications they made still decide their records, the twelve reversed below
excepted. The roles, their models and
seeds are analysis/corpus/labellers.toml; every call is logged with its raw response;
docs/corpus/labelling-record.md records the passes, their cost, and how the new labellers compare
with the phase-1 gold set (the new gold set agrees with it on 1,599 of 1,607 spans; eight span
labels moved and no description changed side).
Restated 2026-09-18 under 2.4.0: the adjudication of the generator and search family is reversed.
The twelve user_prompt records of the blender generators and searches (six tools, the population
and the disclosed copy of each) were adjudicated negative in phase 1 on the reading that a
generator or a search consumes the user's words. The Attestari engineers reversed that on 2026-09-18
(docs/corpus/phase1-reversal.md): the span demands the user's words verbatim and forbids
paraphrase, a generator or a search needs a description and never the exact words with paraphrase
forbidden, so the demand is for the conversation and the records are exfiltrate. Both labellers
and the third labeller had read the guide that way; the phase-1 adjudicator had not. The reversal is
a second entry for each record in analysis/corpus/adjudicated.json (adjudicator
attestari-engineers@2026-09-18); the first entries stay in the file and the later entry decides. The
labelling guide says so in one sentence under the exfiltrate test (guide version 2026-09-18.1),
section 5 of this document and prompt version 2026-09-18.6 carry the narrowed purpose test, and the
held-out manifest expects all sixteen blender spans again. Both labellers and the third labeller
labelled the whole pool again under the new guide: no label of the family moved, and eight labels
moved elsewhere (one of A's, six of B's and one of the third labeller's; seven of them between two
positive classes), which the third pass adjudicated where the two labellers then differed.
Restated 2026-09-26 under 2.7.0: the measurement scores the decision, and the corpus holds the
vote's false positives and real-world descriptions outside Latin script. The three-labeller vote
of 2026-09-25 (docs/disclosure/finding-vote-2026-09-25.md; analysis/reports/finding-vote-2026-09-25.md)
disputed 35 of the 50 confirmed model_directed_text findings of the served run: the corpus had 15
descriptions in Korean, Chinese or Japanese out of 1,607, and 9 of the 35 were in those languages.
The 35 joined the pool on 2026-09-25 as labelled real-world examples, and on 2026-09-26 attestari corpus append --scripts non-latin added 222 records from the served run of 2026-09-21: every
model_directed_text finding on a description written mostly outside Latin script (10) and a seeded
sample of such descriptions from packages with no finding (220, at most two tools per package, from
1,220 available in 1,670 packages: CJK 867, Hangul 284, Hiragana 61, Cyrillic 8), labelled by A and
B and adjudicated like every other record. From this version the measurement applies the independent
reading of section 5 to every proposal the check makes on the frozen set, exactly as decide does: a
description counts as found only by a proposal the reading labels positive, and a description with a
proposal the reader could not read is excluded from every rate, as decide refuses its package. The
figures of the check alone are kept beside them, in a sentence of their own, so the reader's share of
the precision can be seen; the site shows both. In the measurement of 2026-09-26 the model refused one batch of twenty descriptions outright (stop
reason refusal, on every retry): those 20 descriptions (14 clean, 4 authored, 1 from the
population, 1 hard negatives) are not analysed and are excluded from every rate, as a refused call
is at decision time (not_analyzed, section 9); the rest of the corpus is measured in full.
Restated 2026-09-26 under 2.7.1: the corpus holds the served run's findings, and two boundaries
return to the reading. The weekly check of section 14 does not measure the corpus this document
described: the cycle's corpus step first appends the served run's model_directed_text findings to
its working copy and labels them, and the rule is held on that larger set. On 2026-09-25 that set
measured recall on real descriptions at 79 of 88 under prompt version 2026-09-26.7, under the 95%
floor, while the repository's corpus measured 41 of 42: the eight descriptions the check no longer
proposed on are real findings of the served run the gold set labels conceal or override, the two
shapes the narrowed definitions of 2026-09-26.7 had told the analyzer not to report
(analysis/reports/re-decide-20260921T234500Z-2026-09-26.md). The same append was made against the
repository's pool (85 records, 69 of them the served run's findings with their spans, labelled by A
and B and adjudicated like every other record; docs/corpus/labelling-record.md), so the pool is
1,949 records and this section states the set the rule is measured on. Prompt version 2026-09-26.8
keeps the explicit verbatim demand and the tool's own data and reads conceal_from_user and
override_user at their 2026-09-18.6 text (section 5); the independent reading, not the analyzer's
prompt, draws the narration and restraint boundaries. The figures below are measured under it, with
the reading applied as before. The restored text recovers four of the eight: the check proposes on
86 of 90 real descriptions, and the engine decides 84 of 90, still under the 95% floor the weekly
check holds a run to, so the floor is not met by this version and the gate holds a run measured
this way until it is. Of the six, two are proposals the reading labels negative against the gold
set (a tool that excludes sensitive records and "leaves no trace" of them, which the reading reads
as the tool's own behaviour; "fix it yourself with rollback_file", which it reads as routing);
four the check does not propose on under either text, all four among the eight the report of the
re-decide lists. None of the six is a false positive; every false positive the earlier
versions had removed stays removed (0 on clean descriptions, 0 on hard negatives after the reading).
Restated 2026-09-27 under 2.7.2: the two gold labels the reading disputed were put to the
adjudicator again. The two records of the paragraph above where the independent reading and the
gold set disagreed were put to the corpus adjudicator by name (attestari corpus adjudicate --again,
one call, blind to everything but the description, the span, the two labellers' labels and reasons,
and the guide; a record the two labellers agreed on goes as a review case, and the adjudicator's
prompt says their agreement is not evidence). It decided each citing the guide, and the later entry
decides the label as the corpus rule says: the tool that excludes sensitive records and "leaves no
trace" of them is negative ("describes tool behaviour, not an instruction to the model to hide
something from the user; section 3 and section 2 conceal test"), reversing the conceal of
2026-09-26; "fix it yourself with rollback_file, do not wait for the human" stays override
("directs the model to take a corrective action without the user's decision, per section 2"), so on
that record the reading, not the gold set, is the one that differs. The frozen set loses one real
positive description: the engine decides 84 of 89 (94.4%), still under the 95% floor by one
description, and the gate keeps holding a run measured this way. The five that remain are the four
descriptions the check does not propose on and the write_file reading; nothing about the prompt's
conceal_from_user and override_user text changes them.
Restated 2026-09-28 under 2.7.3: the prompt names the three shapes the check had stopped proposing
on. The four real descriptions of the served run the check no longer proposed on under prompt
version 2026-09-26.8 were four findings the check itself had made under 2026-09-18.6 in the run, on
three shapes the definitions name without an example and the benign worked examples of 2026-09-26.7
sit close to: an instruction to save "silently" or to treat a signal as "internal, not user-visible"
(conceal_from_user), "do not stop" past the user's stop (override_user), and an explicit ban on
paraphrase written in Japanese on a question-answering entry, which 2026-09-26.8's sentence that such
an entry "needs the request" was read as licence for. Prompt version 2026-09-28.10 adds one sentence to
each definition naming the shape, one to the "do not stop" sentence saying that a rule which holds
the model back is routing guidance, and one to the telemetry rule saying that a passing test
proposes nothing (section 5); the rules and the worked examples are unchanged. Measured over the
same corpus: the check proposes on 88 of 89 real descriptions and the engine decides 87 of 89
(97.8%), above the floor; the two it misses are the Japanese explicit ban, which the check still
does not propose on, and write_file, whose reading the gold set stands against. The check alone
now proposes on 4 clean descriptions and 2 hard negatives it did not before, and the reading removes
every one of them (0 and 0 after it); five population descriptions the gold set labels negative
are found and read positive (three of them under 2.7.2 as well), and they are what the precision
figure counts, not clean or hard-negative false positives. The held-out evaluation under
2026-09-28.10: 4 of 4 cases, 32 of 39 spans, 16 of 16 on blender-mcp, 0 false positives across the
456 clean descriptions. Promotion under this version is the gate's as before; the promotion of the served
run of 2026-09-21 on 2026-09-28 was made under 2.7.2 by explicit decision over the held corpus rule,
and its record says so.
The statements below are generated, not written: they are the sentences attestari corpus agreement writes into analysis/corpus/agreement.md from its numbers, a test fails when this
section does not carry them as generated, and the site renders the same sentences from
content/measured/agreement.json, refusing to build when that file was produced under another
version of this document. Who labelled the corpus:
What the engine decides over it, per description, under this version, prompt version 2026-09-28.10, claude-opus-5 and the independent reading by model:claude-opus-4-8@labeller-2026-09-18.1 (393 proposals read, 19 read negative):
and the check alone, before the reading:
The model can answer differently on different occasions; these figures are from the stored answers of measurement run 20261024T233315Z, kept so that anyone can replay them exactly.
The frozen set is 1,949 spans (376 positive, 1,573 negative) in 1,907 descriptions (337 positive,
1,551 negative), the pool of 2.4.0 with the 35 findings the vote disputed, the 222 real-world
descriptions outside Latin script added under 2.7.0 and the served run's 85 records added under
2.7.1. The real positive descriptions are 89, of which the engine decides 87; 19 description(s)
went unanalysed (one batch of twenty the model refused on every retry in this measurement; under
2.9.0 two such batches went unanalysed, 38 descriptions, and one of them was answered this time;
which of their descriptions are analysed varies between runs of the measurement, and the counts of
clean, synthetic and hard-negative descriptions vary with it). The third labeller differs from the gold set on one record of its sample (a
prompt's instruction to refuse a late unlock, which it reads as restraint in the user's favour and
both labellers read as override); the report lists it. The span-level table of 2.3.2 is dropped,
as that version said it would be: the description is the unit the engine decides on, and the spans
remain in the report as the cited evidence.
The weekly check. The cycle that decides the population runs this measurement again before a run is promoted to the one the lookup API answers from: the pool grows by what the last promoted run found and by any newly disclosed case (records are only ever added), the two labellers label what is new, the third pass adjudicates, and the check is run over the corpus as it then stands, every proposal it makes read by the reader (D7), so the figures are those of the decision and not of the proposal. A run is held, and the console says why, when the check reports more than 1 false positive per 500 clean descriptions, or more than that rate over the authored hard negatives, or finds fewer than 95% of the real positive descriptions, or when any of those steps could not be completed. The cost of the step is recorded with the cycle.
The shortfall. model_directed_text was admitted on 39 attack spans and 456 clean descriptions,
against a published bar of 200 labelled spans and 1,000 clean descriptions. The phase-1 corpus
reached that bar on 2026-09-17 (296 labelled attack spans, 1,203 clean descriptions) and the
figures above pass the test on it. Its labels are two independent model passes with adjudication;
no person has labelled it yet. Until a human pass confirms the labels, the "measured" label carries
this note on every page that shows it.
The population pass. The scheduled pass over the 200 most-downloaded candidates of both
registries (runs/20260914T234604Z, candidates file top200.json, decided 2026-09-15 under
prompt version 2026-09-14.4 with claude-opus-5) records, over the 200 packages selected:
- 21 out of scope (
not_a_server), leaving a population of 179 confirmed or presumed servers - 61 gradeable: 59 graded A and 2 graded F, both on
exfiltrate_datafindings of the description check (blender-mcp 1.9.1, sixteen findings; @shopify/dev-mcp 1.15.2, one finding) - 118 not gradeable, every one under
not_analyzedrecorded by the gate (G2) and confirmed when recomputed (G3); the gate's own codes over the population:no_registration_site46,advertised_tools_not_parsed41,not_analyzed22,declaration_not_parsed16,partial_description11,minified_only6,runtime_generated2,archive_unreadable1 - 553 findings recorded: 17 confirmed, 181 noted at
info(53 of them decided atinfoby D5, 34 frompublisher_provenanceand 19 fromremote_code_fetch, the latter now the capability inventory), 355 withheld with their packages
These counts are counts over the selected sample (section 4), not over either registry. A grade of A in that pass means the one measured check found nothing in every readable description of the subject; it does not speak to the four declared checks that do not run, or to anything in section 15. The same run, decided again under this version, produces the same grades: the changes of this version add what a row records and change nothing a row decides.
Remotes in the open MCP Registry (since 2.10.0)
The list. On 2026-10-23, from 18:05 to 18:18 UTC, the registry's active latest entries were read (490 pages):
31,648 distinct remote URLs. 333 of them carry a template variable (https://{tenant}.example.com/mcp); they name
no single server and were not read.
The reading. From 18:23 to 22:04 UTC the probe of section 22 read each of the other 31,315 URLs once, on 23,234
hosts, with no model pass (no description was examined, so this measures what can be read, not what it says). No
reading failed on our side. The readings were taken by probe 1.0.0 and are counted here under the outcomes of
probe 1.1.0, from the stored readings, without asking any server again (attestari remote recount): asked to slow
down and payment required are outcomes of their own.
| Outcome | URLs | Share | Hosts |
|---|---|---|---|
read: the tools listed before sign-in |
15,133 | 48.3 % | 10,403 |
sign_in_required |
7,181 | 22.9 % | 6,807 |
unreachable |
3,979 | 12.7 % | 3,794 |
rate_limited: asked us to slow down |
1,922 | 6.1 % | 194 |
opted_out |
1,195 | 3.8 % | 1,133 |
not_an_mcp_endpoint |
1,064 | 3.4 % | 910 |
paid_endpoint: 402 Payment Required |
408 | 1.3 % | 126 |
not_probed |
433 | 1.4 % | 208 |
- Read: every listing ended within the probe's 20 pages. Together they list 247,712 tools (a median of 7 a
server, at most 3,765), 7,931 prompts and 59,590 resources; 9,381 servers send
instructions. 15,125 have a presented manifest (the other 8 list no tool). - Sign-in required: 5,884 name an authorization server in their protected resource metadata, and 4,903 name the scopes a person would be asked to grant.
- Unreachable, by cause: the connection refused 2,125; a server error 566; the name does not resolve 557; sent us to another address 361; a certificate not valid for the address 194; no answer within 30 seconds 176.
- Rate limited: asked twice to slow down, by probe 1.0.0, which then gave up. 1,672 of the 1,922 are one host, which asked even at one request a second. Probe 1.1.0 slows such a host to one request every 10 seconds, then every minute, and asks again before it gives up (section 22).
- Not an MCP endpoint, by cause: an HTTP status that is not an MCP answer 979 (404: 725; 405: 118; 400: 88; 410: 41; others: 7), a JSON-RPC error 43, an answer that is not JSON-RPC 39, an answer that does not decode 2, an answer over 4 MB 1.
- Paid endpoint: 408 answered 402 Payment Required, on 126 hosts; 340 of them are
sseremotes. - Opted out: all 1,195 by
robots.txt, on 1,130 hosts whose file was read, and every one of those files is a rule for every robot (User-agent: *); none namesattestari-watch. None was by request. - Not probed: a part of the address that looks like a key 228, a query 203, a private address 2.
By transport: of 30,328 streamable-http remotes, 15,081 were read (49.7 %); of 987 sse remotes, 52 were read
(5.3 %), 340 asked for payment and 227 answered with an HTTP status that is not an MCP answer, most of them, it
seems, because the probe does not yet speak the older HTTP+SSE transport (section 15). The registry's largest hosts
are multi-tenant: one host carries 2,364 of the URLs (2,363 read), another 1,048 (1,035 read), and a third 1,894
(187 read, 1,672 rate limited).
So about half of the registry's remotes show their tools to anyone before sign-in, and about one in five asks for sign-in first. These are counts over the registry's remotes on one day; they say nothing about remotes that are not in the registry, nor about what a server lists after sign-in.
19. Disclosure
Disclosure runs on a fixed clock, the same for every subject, and the clock is not ours to shorten or extend for anyone. The grade is the grade: it is public from promotion. What waits is the evidence.
At promotion, the grade. When a run is promoted (section 14), the grade of each decision and its public summary are published: the package and version, the grade, the number of confirmed findings, the highest severity, the class names of the findings, and the methodology version and document hash. The summary names no file, no line, no span and no claim.
Day 0, registry and publisher. For a finding of severity high or medium, the finding with its
evidence and the methodology version go to the registry's security contact and to the publisher, by
the routes recorded in docs/disclosure/subjects.json: a published route, never a person's address,
and the registry alone where the publisher has none. Day 0 is the date these notices are sent, and it
is recorded with the routes. A finding a registry can act on before its evidence is public is worth
more to the operators exposed to it than evidence published first.
Day 30, the evidence. The evidence (the spans, files, lines and claims of the findings, and the
capability inventory of section 20) is published thirty days after day 0, by the command in section
13, in the form of docs/publish-contract.md. It is published whether or not the publisher or the
registry has replied, with any open appeal marked as such (section 16) and any correction the notice
period produced already applied. A fixed version is examined and published on the same clock as any
other version.
Nobody gets the evidence early. Nothing goes to anyone before day 0, and no one other than the
registry and the publisher receives the evidence of a high or medium finding before day 30,
paying customers included. The full report of such a package withholds its evidence from every
caller, keyed or not, until its day 30, and says the date on which it becomes available; with no day
0 recorded, the evidence is withheld.
Noted findings (severity info, decision D2), out-of-scope packages and refused packages are
published on the same day-30 clock without a day-0 notice: they state an absence, a scope or a
refusal, not a risk that a registry can act on.
Remotes (since 2.10.0). A flag on a remote is not held on the clock above: the text it quotes is what the
remote serves to any client before sign-in, so holding it back keeps from nobody what the remote itself shows
them, while a remote can change what it serves at any moment, so a flag is worth most to the people using it on
the day it is found. A flag is shown nowhere, to a customer or on the public page, until the independent reading of
section 5 has confirmed it, exactly as for a package's finding (decision D7); a flag the reading labels negative
is noted at info and is not shown as a flag. Once confirmed, the flag is published at once: a caller with a key
reads it with its span, the request and the stored answer; the answer without a key, and the public page, say that
the remote is flagged and in which classes, without the span. Its operator is told by a published route: the
host's security.txt (RFC 9116), or else the security policy of the repository the remote's registry entry
names; each notice is sent only once the Attestari engineers have approved it, and where there is no route the
reading records that none was found. A correction or a cleared flag is published as section 16 says.
20. Capability inventory
A grade says whether the description check found an instruction to the agent that works against the
operator. It does not say what the package's code can reach. The capability inventory does, as far
as text can: for each examined subject it records what the artifact's source can reach, as facts
read from the syntax tree by the extractor (attestari/surface/capabilities.py), with no model
involved, no severity, and no effect on any decision or grade. It is never graded.
What is recorded. One entry per distinct fact, a fact being a kind, a tag and a target, on the
package's surface row and, from there, on its decision row (capabilities, section 12):
network: a call that opens a connection (fetch,http/https,axios,got,ky,undici,WebSocket,net/tls;requests,httpx,urllib,aiohttp,websockets,socketand the like). The target is the host of the URL the call is given when that URL is a literal, or a template or f-string whose literal prefix carries the host; the literal itself when it is not a URL;dynamicwhen there is no literal to read.filesystem: a read or a write (read/write) throughnode:fsandfs/promises, Deno and Bun file calls,open(),pathlib,os,shutil,tempfile,aiofiles. The target is the path literal, followed throughpath.join,Path(...) / ...andos.path.joinwhen every part is a literal, elsedynamic.process: a process spawned (spawn: an executable with arguments) or a shell command run (exec: a command line handed to a shell), throughchild_process,execa,subprocess,os.system,os.exec*,asynciosubprocesses. The target is the command literal ordynamic.environment: a variable read (process.env.X,Deno.env.get,os.environ,os.getenv). The target is the variable name, ordynamicwhen the name is computed or the whole environment is read.credentials: an environment variable whose name matches a secret pattern (env: key, token, secret, password, credential, private key and their variants), or a path literal that names a known credential file (file:.ssh,.aws,.netrc,.npmrc,.pypirc,.git-credentials,.kube,.docker,.env,credentials.jsonand the like). The entry records that the source names it.remote_code: code obtained or built at run time:dynamic_evaluation(eval,new Function,vm.*,exec),remote_import(animport()orrequire()of an http(s) URL, apip installfrom a URL),dynamic_import(an import whose specifier is computed), and the install scripts ofpackage.jsonthat download (install_script_download) or download and run something (install_script_download_execute): the one kind read from a manifest rather than from a tree.
Every entry cites its file, its line, the SHA-256 of that file and the exact bytes of the call, a
span like every other span this document records: it is verified byte for byte against the stored
evidence when the row is decided (the file is recovered from the stored artifact and checked
against its SHA-256; the inventory does not copy its files into the evidence store, the artifact
being there already), and the verification outcome is on the entry. Within one file, a
fact seen more than once is one entry carrying a count. A minified bundle is read like any other
member and its entries say so (bundle); vendored regions are skipped. A subject whose extraction
stopped at the budget (section 9) has no inventory: nothing read in part is presented as an
inventory of the whole.
What is not recorded. Any inference. The retired remote_code_fetch check (1.0.0 through
2.2.0) concluded that a package downloads and runs something by tying a network call, a file write
and a spawn together through identifiers; this version records the network call, the write and the
spawn each as a fact and leaves the conclusion to the reader. The inventory reads names as written:
a client bound under another name, a wrapper around a library call, a computed member, a call
reached through a dependency, are not resolved and are not recorded, and the absence of an entry is
never a statement that the package cannot do the thing. Nothing here is a finding, nothing here is
decided, and no entry moves a grade: the declared checks of section 5 that would turn a fact into a
finding (undeclared_network_calls first among them) enter MEASURED_CHECKS only by the admission
test of section 18, and this inventory is their raw material until then.
21. Versioning
This document carries a version: the methodology_version: line at the top, MAJOR.MINOR.PATCH.
Every published finding cites the version in force when it was produced and the SHA-256 of the
document. Superseded versions remain available at their own permanent addresses, so a finding
published in 2026 can still be read against the rules that made it in 2026.
A change to what a check examines, what a severity means, or when we refuse to grade is a version change. Correcting a typo is not. A change to what a row records, or to how and when a result is disclosed, is a version change too, because a reader of an older row must be able to tell what it does not carry.
Changing any rule is a deliberate act: edit this document, bump methodology_version, bump
METHODOLOGY_VERSION in attestari/methodology.py, add a line to the Versions section, copy the
document to a new snapshot under the site's content/methodology/, and commit. A published snapshot
is never edited.
Versions
1.0.0 (2026-09-11): first automated policy. Guards G1-G7, decisions D1-D3, grade ladder A/B/C/F, appeals and publication as the two human acts.
1.1.0 (2026-09-11): the public methodology around the unchanged rules: what the document is for, definitions, artifact not repository, subject selection, the four checks, severity definitions, evidence and verification, known limits, notice, appeals and corrections, independence, measured performance, versioning. Guards, decisions and the grade ladder are unchanged from 1.0.0.
1.2.0 (2026-09-11): two corrections. Evidence: no personal data in published evidence; structured responses are published as projections of the cited member with the upstream hash as provenance, files ship whole and scanned, and a finding that cannot be evidenced without personal data is withheld with its subject refused (
evidence_unpublishable). Model check: "no literal tool descriptions" is split intono_descriptions_present(clean) anddescriptions_not_statically_readable(not analysed, refuses the grade throughnot_analyzed), read from the parsed surface and failing closed when the surface does not say.1.3.0 (2026-09-11): the surface taxonomy and three description states. A surface that did not parse is
not_a_server(out of scope, decision D4),minified_only(a medium finding),runtime_generated,declaration_not_parsed,no_registration_siteorarchive_unreadable, each refusing the grade under its own code except the first. A description isreadable,partial(literal segments read, substitutions are holes; findings valid, absence not clean:partial_description) orunreadable. The extractor resolves names, configurations and descriptions written as constants declared in the same file, reads class-basedgetName()/getConfig()declarations, acceptsschemaandargsas schema keys, and reads Python call-form registrationx.tool(name=...)(fn).1.4.0 (2026-09-14): unmeasured checks decide at
info. Decision D5: only checks whose precision has been measured (section 18) may grade; the setMEASURED_CHECKSholdsmodel_directed_textalone, so everyremote_code_fetchandpublisher_provenancefinding is recorded and published as context atinfo. Decision D6: a provenance finding ishighonly with a second, independent provenance tag on the same package, otherwiselow. Section 5 marksremote_code_fetchand the account-age rule as informational pending measured precision. Section 7 gains "Attested manifest": a tool hash per readable tool and a manifest hash per subject, defined inattestari/manifest.pyand pinned by a fixed-vector test. Section 17 gains "Publisher tools": a publisher's own use of these checks never affects a grade. Section 6 no longer namesanonymous_publisheras the only low finding (D6 decides any single-tag provenance finding low); section 5 records routing guidance as a count per tool, the inventory being a separate opt-in pass.1.5.0 (2026-09-14): two boundaries of the model check stated in section 5. Parameter documentation is not a finding unless the parameter requests data outside the tool's declared purpose: a parameter that must carry the user's prompt, credentials, files, history or configuration the tool does not need is
exfiltrate_datawherever in the description it appears. Guidance about retrying or resuming the tool's own calls after an interruption is routing guidance, notoverride_user, unless it directs the model to disregard an explicit user stop or cancel. Prompt version 2026-09-14.4 carries both. Section 4 speaks of a sample, not a population. Section 15 names the adaptive adversary: a publisher who reads the published rules can write around the model check; the deterministic checks and the reassignment finding do not share that weakness. Section 18 is marked preliminary, states the held-out corpus size, and is restated from the evaluation run under 2026-09-14.4. Guards, decisions and the grade ladder are unchanged.2.0.0 (2026-09-15): Section 5 lists the eight declared checks with the standing of each, names
MEASURED_CHECKS, gives the verifier a version, and adds the three context tags onexfiltrate_datafindings (destination_publisher_owned,disclosed_in_readme,opt_out_available), informational and read from the package bytes at decision time. Section 7 states that a zod schema is hashed as its source bytes and that both manifest hashes ride on every decision row and are served per package version. Section 9 adds the gate map (every reason code, its gate, one line, exported asgate_map.json) and a fixture per reason code. Section 12 addsanalyzer_model,prompt_sha256,verifier_version,tool_hashesandmanifest_hashto every row, anddecisions.jsonlbesidedecisions.json. Section 17 extends the publisher-tools rule to everything sold to installers. Section 18 adds the population pass of 2026-09-14 beside the held-out figures. New section 19, Disclosure: registry and publisher on day 0, publication on day 30, on one clock for everyone. New section 20, Capability inventory: theremote_code_fetchfindings and the named network destinations are inventory, declared and not measured, in place of the check's earlier severity standing. Versioning moves to section 21. Guards G1-G7, decisions D1-D6,MEASURED_CHECKS, the prompt and the grade ladder are unchanged, and the population pass of 2026-09-14 decides to the same grades under this version.2.10.0 (2026-10-24): the version in force, a minor version: remote MCP servers, watched, not graded. A remote server reached by URL becomes a subject of the model check only (sections 1, 2, 5): it is read from outside by a probe that never signs in and never calls a tool (section 22), its listed descriptions go through
model_directed_textunchanged, and a finding on it is a flag, never a grade; no gradeability guard applies. Section 4 states which remotes are read; section 7 adds the watch manifest and the presented manifest of a remote; section 12 the reading row; section 15 the limits of reading from outside; section 16 the operator's opt-out; section 18 the measurement of the registry's remotes; section 19 publishes a remote's flag once the independent reading confirms it (its span to a caller with a key) and tells its operator by a published route. Nothing about a package changes: guards G1-G7, decisions D1-D6,MEASURED_CHECKS, the prompt, the verifier, the extractorsyntax-tree-4and the grade ladder are unchanged from 2.9.1.2.9.1 (2026-10-07): a patch of 2.9.0: the extractor reads Python's adjacent string literals across a line break as one string. Checked against live servers, the presented manifest of 2.9.0 matched what a running server presents for an npm server with zod schemas and for a Python server with one literal per description, and did not match for a Python server whose descriptions are adjacent literals on separate lines:
syntax-tree-3evaluated the literals' source outside the parentheses they stand in, where a line break ends the expression, and kept the raw bytes, quotes and line break included, as the description's text. Extractorsyntax-tree-4evaluates them between parentheses (section 5);description_rawand the attested manifest are unchanged. A tool whose surface still holds the misread text has no presented hash (description_misread, section 7): in the served rows that is 3,161 tools of 168 packages, which keep their decisions and grades and are examined again withsyntax-tree-4at their next examination. What the model check of section 5 read of those descriptions included the quotes and the line break; the bytes it may quote are verified against the file as before. Section 18 is restated from the measurement under this version: the real descriptions are as under 2.9.0 (87 of 89), and one of the two batches the model refused under 2.9.0 was answered this time. Guards, decisions, the prompt, the verifier and the grade ladder are unchanged, and no grade changes.2.9.0 (2026-10-07): a minor version: the presented manifest. Section 7 adds a second manifest beside the attested one, computable from what a running server presents: per tool, the name and the description as a client receives it with its white space collapsed, and no schema. A client (the Attestari app's Protect) checks a live server's tools against it at connect time, because the attested manifest is taken from source and cannot be reproduced from a live tool list. Section 12 adds
presented_manifest_hashandpresented_manifest_reasonto every row; rows decided before 2.9.0 are given them from their own run's surfaces.json. The cumulative index is schema 8. Guards G1-G8, decisions D1-D7, the prompt, the verifier, the extractor (syntax-tree-3) and the grade ladder are unchanged from 2.8.0, and no grade changes.2.8.0 (2026-09-29): a minor version: appeals are resolved by rule and an upheld appeal is applied wherever the decision is served. Section 13 states the rule: the cited evidence is verified again against the stored artifact (a factual error upholds), a finding the labelling guide covers goes to the three model labellers of section 18 one call each (two of three negative upholds, otherwise dismissed with the reasons), and what the rule does not cover stays open for a person; the lookup API and publication both serve an upheld appeal's finding as
withdrawn_on_appealand recompute the grade without it; an upheld appeal carries to later runs for the same package version and the same span bytes, and a new version is examined fresh; the reply to the publisher is sent only with a person's approval. Section 16 says so in its appeals paragraph. Guards G1-G8, decisions D1-D7, the prompt, the verifier, the extractor (syntax-tree-3) and the grade ladder are unchanged from 2.7.3, and no served row changes.2.7.3 (2026-09-28): a patch of 2.7.2: the analyzer's prompt names the three shapes it had stopped proposing on, and section 18 is restated. Prompt version 2026-09-28.10 adds one sentence to each of the three definitions of section 5 (act "silently" or keep a signal "internal, not user-visible" is
conceal_from_user; "do not stop" past the user's stop isoverride_user, a rule that holds the model back is routing guidance; an explicit ban on paraphrase is the verbatim demand in any language and for any entry) and one to the telemetry rule (a passing test proposes nothing), the rules and the worked examples unchanged. Measured over the corpus of section 18 with the reading applied: 87 of 89 real descriptions (97.8%), 0 false positives on clean descriptions and hard negatives, the held-out cases 4 of 4 and blender-mcp 16 of 16. The run of 2026-09-21 was promoted on 2026-09-28 under 2.7.2 by explicit decision over the heldcorpusrule, recorded on its promotion record; this version changes no served row. Guards, decisions and the grade ladder are unchanged.2.7.2 (2026-09-27): a patch of 2.7.1: two gold labels of the corpus the independent reading disputed were put to the adjudicator again, and section 18 is restated. The corpus adjudicator decided each by name, blind to everything but the record, the two labellers' labels and reasons, and the guide (a record the two labellers agreed on goes to the adjudicator as a
reviewcase, whose prompt says their agreement is not evidence; adjudicator prompt 2026-09-27.3), and the later entry decides the label as the corpus rule says: oneconcealreversed tonegative, oneoverrideconfirmed. Recall on real descriptions with the reading applied is 84 of 89 (94.4%), one description under the 95% floor; the gate keeps holding a run measured this way. Rules, guards, decisions, the prompt and the grade ladder are unchanged.2.7.1 (2026-09-26): a patch of 2.7.0: the analyzer proposes under the wider
conceal_from_userandoverride_userdefinitions again, and the reading can always be made. Section 5 keeps the four boundaries and moves two of them from the analyzer's prompt to the independent reading: prompt version 2026-09-26.8 carries the explicit verbatim demand and the tool's own data, and readsconceal_from_userandoverride_userat their 2026-09-18.6 text, because the narrowed definitions of 2026-09-26.7, measured over the corpus as the weekly check grows it, cost the check 8 of 88 real descriptions the gold set labelsconcealoroverride(recall 79 of 88, under the 95% floor of section 14) that the reading alone had not; two of the five authored benign shapes leave the worked examples with them. Section 5 also states what the reader sees when the span is not inside the literal of the entry declared around the cited line: the literal of the entry in the cited file that holds the words, else the verified bytes of the cited lines, so that a finding the verifier accepted can always be read; under 2.7.0 such a finding refused its package under G8. The corpus of section 18 holds the served run'smodel_directed_textfindings as the weekly check appends them (85 records, 1,949 in all), so the section states the set the gate's rule is measured on, and section 18 is restated under prompt version 2026-09-26.8. Guards, decisions, the grade ladder and the row are unchanged.2.7.0 (2026-09-26): a minor version: a finding is confirmed only after an independent reading agrees. Section 10 gains decision D7: a
model_directed_textfinding of the three model-directed classes is confirmed only when a second reading of that one finding, one call by a model that is not the analyzer's against the labelling guide, labels it positive; a finding readnegativeis noted atinfo, and the row records both readings. Section 9 gains guard G8 and the reason codereading_missing: a finding with no reading refuses its package, fail closed. Section 5 states four boundaries in place of three: the verbatim demand must forbid paraphrase explicitly, the tool's own data is neverexfiltrate_data, narration and verbosity guidance is notconceal_from_user, and a restraint in the user's favour is notoverride_user; prompt version 2026-09-26.7 carries all four, with five authored benign shapes as worked examples, and section 5 states the reading. Section 12 records the reading on the row and the reader in the header. Section 14's rule 5 and section 18's weekly check measure the check and the reading together. Section 18 is restated on the grown corpus (the 35 disputed findings of the vote and 222 real-world descriptions outside Latin script) with the decision's figures and the check's own beside them. The grades of the served run are re-decided under this version before any of them is served, sent or published again (docs/engine-precision-2026-09-26.md). Prompt tightening under the same rules stays an engine change; the reading is a rule.2.6.3 (2026-09-25): a patch version: the grade is the grade. Section 19 is rewritten: a decided grade and its public summary (grade, confirmed-finding count, highest severity, class names, methodology version) are published at promotion; the evidence of a
highormediumfinding goes to the registry and the publisher on day 0, by the routes recorded indocs/disclosure/subjects.json, and is published on day 30; no one else receives it before then, paying customers included, and the full report withholds it from every caller until its day 30. Before this version section 19 said the decision was published on day 30, while the lookup API already served the grade from promotion. Section 16's notice paragraph says the same, and no longer sends a notice to a contact address from a registry record. No check, severity, guard, decision, prompt, verifier, extractor or gate rule changes, section 18 is unchanged, and every grade decided under 2.6.2 is the grade under this version.2.6.2 (2026-09-24): a patch version: the README is read at the version examined, and section 11 says what the ladder produces today. Section 9's
advertised_tools_not_parsedrow states the source of the documentation it compares: the README inside the archive, else the repository's at the commit or tag the version was built from, else the registry's text for that version, never the repository's default branch, with the source recorded on the record (readme_source). Before this version the engine read the default branch, so an unchanged artifact could lose its grade because the repository was edited: the seed run of 2026-09-21 held npmfirecrawl-mcp3.24.0, same tarball, on a tool the default branch had added that day. Section 11 gains a paragraph: no measured check assignslow, so no package currently receives B, and C arises fromdescription_reassigned_at_runtimealone. No check, severity, guard, decision, prompt, verifier, extractor or gate rule changes, and section 18 is unchanged.2.6.1 (2026-09-23): a patch version: one project is one subject. Section 4 states the registry's identity rule (PEP 503 on PyPI, exact names on npm): a candidate list holds a project once under its index spelling with other spellings as aliases, every row of a run is keyed by the name the package was selected under with the registry's spelling recorded as
canonical_name, and a run that holds one project under two spellings decides it once, under the registry's spelling, with the other named in the run's summary (same_project). Before this version the engine keyed a PyPI package's rows by the registry's spelling, so a package selected under another spelling was refused undercheck_missingfor want of its own rows; the seed run of 2026-09-23 met six such packages. No check, severity, guard, decision, prompt, verifier, extractor or gate rule changes.2.6.0 (2026-09-21): a minor version: the selection is wider and the gate can admit a deliberate expansion. Section 4 adds three sources to the selection (the official MCP registry, a PyPI download count for every matching project name from a public copy of PyPI's download logs, and a dependency on an MCP SDK for npm packages a search surfaced without a keyword or a matching name), reads npm search deeper, records the source of every candidate, and states the two gaps that remain (no index of dependents on either registry; no search on PyPI). Section 14's first rule gains the expansion record: a dated record naming a candidates file by its hash lets a run made from exactly that file add any number of packages within 30 days, additions only; the 5% stands for every other run. A run may be made a candidate by request: the rules are evaluated and recorded and nothing is promoted. The engine paces its requests to the code host on the allowance the host reports, so that a request refused for the allowance is waited out and not recorded as a fetch failure, which would refuse a grade (section 9 is unchanged: a fetch failure still refuses). No check, severity, guard, decision, prompt (2026-09-18.6), verifier, extractor (
syntax-tree-3),MEASURED_CHECKSor grade ladder changes.2.5.0 (2026-09-21): a minor version: a check is added, outside the gate. Section 5 gains
artifact_vs_tag: the published artifact is compared file by file with the repository at the tag of the examined version, and a file with no counterpart at the tag is a finding of assigned severitymediumunder one of two tags (no_counterpart_at_tag,built_file_no_counterpart_at_tag). It fails closed in one direction: no repository, a repository on another code host, no matching tag, a partial tree or a refused request isnot_compared, an outcome of the check alone and never a finding, a reason code or a refusal. The check runs outside the gate: guards G5 and G6 do not read it, neither tag is inMEASURED_CHECKS, and every finding of it is decided atinfo(D5) until a tag passes the admission test of section 18, so no subject's gradeability or grade differs from what it would be without the check. Section 3 no longer says the comparison is not made; section 12 records the comparison on every package row (artifact_vs_tag); section 15 states what the comparison does not see; section 18 states that the check is not admitted. Section 7 names themanifest_changedevent of the cumulative index, served with the manifest asprevious_manifest_sha256andchanged_since: an event, not a finding, with no effect on a grade. No reason code is added and section 9 is unchanged. No severity, guard, decision, prompt (2026-09-18.6), verifier, extractor (syntax-tree-3),MEASURED_CHECKSor grade ladder changes.2.4.1 (2026-09-20): a patch of 2.4.0. Section 14 states the promotion gate, which earlier versions ran without stating it outside section 18, and the gate gains a rule: no package loses its grade on an unchanged tarball. A run in which a package the served run graded has the same tarball hash and no grade is held, the previous run keeps serving, and the console lists the packages and the reasons; a refusal originating on our side holds as
lost_grade, one originating on the package's side over unchanged bytes aslost_grade_same_bytes, and a package whose bytes changed is a real result and holds nothing. The rule generalises the grade-flip rule, which stays. The Attestari engineers' decision of 2026-09-19 (docs/transient-errors.md) follows run20260919T155239Z, promoted while four packages went from A to not gradeable because the analysis service answered overloaded; the engine was right not to count an errored check as clean, and the gate was wrong to promote. Section 9's row formodel_check_skippedsays that a check skipped after the retries of a transient error ran out is an examination incomplete on our side, that a lookup answers with the most recent complete examination, and that a package with none is shown as not yet examined. Section 18 is unchanged. No check, severity, guard, decision, prompt (2026-09-18.6), verifier, extractor (syntax-tree-3) or grade ladder changes.2.4.0 (2026-09-18): a minor version: a rule reading changes. Section 5 narrows the purpose test of 2.3.2: a parameter that demands the user's own words verbatim and forbids paraphrase or summary is
exfiltrate_datawhatever the entry's purpose, a generator and a search included, because such an entry needs a description and the demand is for the conversation; a plain description or query parameter with no verbatim demand is not a finding. The scene inspector rule and the three conditions of disclosed telemetry stand as in 2.3.2. Prompt version 2026-09-18.6 carries the narrowed test; every proposal cached under an earlier version is discarded. The Attestari engineers' decision of 2026-09-18 (docs/corpus/phase1-reversal.md) reverses the phase-1 adjudication of the twelve blender generator and search records: they areexfiltrateby a later entry inadjudicated.json, the earlier entries kept; the labelling guide carries the sentence (guide version 2026-09-18.1) and the held-out manifest expects sixteen blender spans again. Section 18 is restated from the rerun: 41 of 41 real descriptions found, precision 292 of 292, 0 false positives across 1,203 clean descriptions and 94 hard negatives. The 95% floor on recall over real descriptions stays in the weekly check. No severity, guard, decision, reason code, verifier, extractor (syntax-tree-3) or grade ladder changes; the blender package's F rested on ten expected findings and rests on sixteen.2.3.3 (2026-09-18): a patch of 2.3.2. Section 18 is restated: the corpus is labelled by two independent model labellers, adjudicated by a third model pass that must cite the labelling guide, and audited by a third labeller, all as engine commands inside the weekly cycle (
docs/corpus/automated-labelling.md); the statement of who labelled the corpus and the statement of what the check did over it are generated fromanalysis/corpus/agreement.mdand carried here verbatim, and the site renders the same sentences. The gold set made this way agrees with the phase-1 gold set on 1,599 of 1,607 spans, and no description changes side: 29 of 29 real descriptions found, precision 280 of 280, 0 false positives across 1,203 clean descriptions and 94 hard negatives. The section states the weekly check that holds a run from promotion (more than 1 false positive per 500 clean descriptions or hard negatives, or under 95% of the real positive descriptions found). The span-level table of 2.3.2 is dropped, as announced. No check, severity, guard, decision, reason code, prompt (2026-09-17.5), verifier, extractor (syntax-tree-3) or grade ladder changes, and no decision moves.2.3.2 (2026-09-17): a patch of 2.3.1. Section 5 states the test behind the parameter boundary of 1.5.0 (the purpose test: name the entry's purpose from its own words, then ask whether the parameter's data serves it; a generator or a search consumes the user's words, a scene inspector does not, and the number of parameters decides nothing) and the three conditions under which disclosed telemetry is not a finding (what is sent is named and is not user content, it is disclosed, an opt-out is named; a failing condition is named in the rationale). Prompt version 2026-09-17.5 carries both; every proposal cached under an earlier version is discarded. Section 18 is restated from the phase-1 labelled corpus, measured per description with the spans as cited evidence (
analysis/corpus/agreement.md,docs/corpus/labelling-record.md), dated, and the check records the methodology version and document hash that produced its figures. No severity, guard, decision, verifier, extractor (syntax-tree-3) or grade ladder changes; the blender package's F rests on the eleven tools that have no use for the user's words and does not move.2.3.1 (2026-09-17): a patch of 2.3.0. Section 9's reason code
no_tools_examinedis replaced byno_surface_examined: a package is refused for an empty surface only when no tool, prompt, resource or resource template was parsed, so a server that exposes prompts or resources and no tool is graded like any other when every entry found was fully read and analysed (thenot_analyzed,partial_descriptionandmodel_check_skippedrules decide that, as they have since 2.3.0). Section 18 states the shortfall of the measured check against the admission bar in plain words, and the "measured" label carries that note wherever it is shown. The gate map, its fixture and the tests follow. No check, severity, guard, decision, prompt, verifier, extractor (syntax-tree-3) or grade ladder changes; the served run, decided again under this version, is reported with every changed package inanalysis/reports/methodology-2-3-1-2026-09-17.md.2.3.0 (2026-09-17): Section 20 becomes real: the capability inventory is a deterministic, no-model reading of the syntax tree recording, per subject, what the code can reach (network hosts, file system reads and writes, processes spawned, environment variables read, credentials named, code obtained or built at run time), as facts with file:line evidence verified byte for byte, never a severity and never a grade effect;
remote_code_fetchis retired as a check, its patterns being capability entries, and section 5 lists three checks that run. Section 5 gains the rest of the MCP surface: prompts and the literal text of their messages, resources and resource templates, read at their registration sites by extractorsyntax-tree-3, their descriptions examined bymodel_directed_textunder the same rules and the sameMEASURED_CHECKSstanding as tool descriptions, with the kind of entry on the finding (surface_kind); sampling and elicitation are recorded by presence. Section 9'snot_analyzedandpartial_descriptioncover every kind, and G7 checks an excerpt against every kind's spans. Section 12 addscapabilities,capabilities_omitted,mcp_surfaceandsurface_kind. Section 18 states the admission test toMEASURED_CHECKSverbatim and where the numbers of the one check in the set are recorded. Section 15 names prompt text built at run time. Guards G1-G7, decisions D1-D6,MEASURED_CHECKS, the prompt, the verifier and the grade ladder are unchanged from 2.2.0.2.2.0 (2026-09-16): Section 4 gains the delegation rule: a package that delegates its server to another package (imports or re-exports the server from a declared dependency, or whose only runtime behaviour is to load another package) brings the target into the population for the same run, at the version the declared dependency resolves to, regardless of download data or list position, and both rows record the relationship (
delegates_to,delegated_from, with the exact version resolved). Section 5's list of outcomes namesextraction_budget_exceeded, which section 9 has carried since 2.1.1, and states the minified call-site rule the extractor has applied sincesyntax-tree-2: a bundle is never a call site of a wrapper defined elsewhere, while an import still resolves into it. The extractor version stayssyntax-tree-2, paired with this version in the version table. No other rule, severity, gate orMEASURED_CHECKSchanges: guards G1-G7, decisions D1-D6, the prompt, the verifier and the grade ladder are unchanged from 2.1.1.2.1.1 (2026-09-16): a patch of 2.1.0. Section 9's reason-code table gains
extraction_budget_exceeded: reading a package's source past a per-package budget of wall time or resident memory refuses the grade under that code, and the budget is measured and published with every row. Section 15's sentence about names resolved from "a constant declared in the same file" now describes the syntax-tree resolution section 5 describes. The extractor version paired with this document issyntax-tree-2: no syntax tree is kept across files, bundled third-party code is never indexed or resolved through, and extraction runs under the measured budget. No other rule, severity, gate orMEASURED_CHECKSchanges: guards G1-G7, decisions D1-D6, the prompt, the verifier and the grade ladder are unchanged from 2.1.0.2.1.0 (2026-09-16): Section 5 describes the syntax-tree surface extractor in place of the token-stream reader: source is parsed with tree-sitter, tools are read only at the MCP registration sites the section lists, names, descriptions and schemas are followed through the identifier resolution the section lists (including sibling modules of the same package), a template literal stays
partial, and bundled third-party code is inventoried asvendoredand is never a source of tools. The extractor carries its own version, recorded by every run and, per section 12, on every decision row; the pair of methodology version and extractor version is checked against the version table before anything is decided. No rule, severity, gate orMEASURED_CHECKSchanges: guards G1-G7, decisions D1-D6, the prompt, the verifier and the grade ladder are unchanged from 2.0.0.
22. Remote servers: watched, not graded
A remote MCP server ships no artifact: what runs is on someone else's machine, and what a client sees is whatever the server lists when it is asked. The guards of section 9 cannot be met for it (there is no file to examine), so a remote is never graded. It is watched: read from outside, kept, and compared, and its descriptions are examined by the model check. Everywhere it is shown it is labelled "Watched, not graded".
The subject. A remote is named by its URL (section 2) and examined per observation. Its flags carry the watch manifest hash as their subject's version, so a flag keeps its identity until the remote changes what it lists.
The probe. A reading asks, in order: the host's robots.txt; the protected resource metadata for the path
(RFC 9728) and the metadata of the authorization server it names (RFC 8414); MCP initialize and
notifications/initialized; tools/list, following at most 20 pages; and prompts/list and resources/list when
the server declares those capabilities. Nothing else. It never signs in, never registers a client, never calls a
tool, never follows a redirect, and sends no credential or cookie. Every request names the probe in its
User-Agent (attestari-watch/<version> (+https://attestari.ai/watch)) and in clientInfo, and comes from the one
outgoing address the conduct page publishes (136.117.233.38). It reads one URL at most once a day and one request a second per host
at most. A host that answers 429 is asked again more slowly: alone, after its Retry-After (up to a minute),
once; in a measurement, the host is slowed to one request every 10 seconds, then every minute (or its Retry-After
if longer), stays that slow for the rest of the run, and starts at that pace in the next one. It waits 10 seconds to connect and 30 for an answer and reads at
most 4 MB of one. It asks only an https address whose host resolves to public addresses only, connects to the
address it checked, and never asks an address with a query, credentials or a path segment an agent hid as
key-like.
Outcomes. A reading ends in exactly one: read (every listing read whole, or cut short at the page limit,
which the reading records); sign_in_required (the server answered 401 or 403; the access its public documents
describe is recorded); rate_limited (still asking to slow down at the slowest pace above); paid_endpoint (402
Payment Required); unreachable (no answer in time, a name that does not resolve, a certificate not valid for the
address, a refused connection, a redirect, or a server error); not_an_mcp_endpoint (an answer, but not an MCP
server's); not_probed (an address the rules above never ask); opted_out (the operator's
robots.txt or request, section 16). None of them is a grade, and only read is examined.
What is examined. model_directed_text over each listed tool, prompt and resource description and the
server's instructions, as section 5 says, with the evidence of section 7 and the independent reading of section 5:
a flag is shown only once that reading confirms it, and then at once (section 19). Nothing else.
Changes. Each reading is compared with the previous one of the same remote: an entry added or removed; a
description, schema, annotation or title changed; the instructions changed; the scopes or the authorization server
of the public documents changed; the outcome changed. Each change is recorded with its before and after. A change
is an event, like manifest_changed: it carries no severity and moves nothing.
Limits. Section 15.