Declared versus actual behaviour

Check S16 · agent surface family · severity HIGH · automatic · one of the 16 skill and plugin checks

Maps to OWASP LLM03 OWASP LLM06 ATLAS AML.T0110.001 The gap between a tool's description and its implementation: a script or a hook that makes a request, reads a secret store or installs something the description never names. A gap is a reason to read the code, not proof of intent.

The question this check asks: does this skill or plugin do something it never mentions? Its description and its allowed-tools line are what it says it is for. Its scripts and its hook commands are what it does. S16 puts the two side by side and writes a row where the second goes further than the first: a network request, a read of a secret store, or a step that survives a reboot, in a package whose own words name no such purpose.

Why it matters

Every other check on this roster reads one side. S6 and S7 read what the author declared. S9 reads the text the agent is given, S10 the scripts, S13 the hooks. A skill described as “formats markdown tables” whose script posts to a host nobody named passes each of them: the request is not next to a credential read, so S10's exfiltration shape is silent, and nothing in the frontmatter is wrong. What is wrong is the distance between the sentence and the script, and no single-sided check can see a distance.

The incident record has that shape in it. Snyk's write-up of 11 February 2026 describes SkillGuard, a skill published as a scanner for other skills, which carried a reverse shell and credential exfiltration. The description said one thing and the package did another. S9 and S10 are the checks that match what such a package contains; S16 is the check for the mismatch itself, which is why it grades highest when it sits beside one of their findings. The malicious skills page has the record with its sources.

How RepoGates scores it

What counts as said. For a script, the frontmatter of the skill that owns it: its name, description and compatibility line, and its allowed-tools. For a hook, and for a script that belongs to no one skill, what the plugin as a whole declares: the manifest's and the marketplace entry's descriptions and keywords, the repository's description, and every skill's frontmatter. A purpose is declared in one of three ways. By a word: each behaviour has a published list of word stems (ninety-two for the network, among them api, fetch, download, deploy and sync), matched at the start of a word. By a tool: WebFetch, WebSearch, Bash(curl …), Bash(gh …) or an MCP tool on the allowed-tools line declares the network. Or by naming the host: “Manage Linear issues” declares a call to api.linear.app. A wide Bash or Bash(*) grant is a surface, not a purpose, and declares nothing.

What counts as done. In a script's code, with comment lines blanked as S10 reads it: a network request (a call shape such as curl, requests.post, fetch( or Invoke-WebRequest, never a URL that nothing requests; an import of Node's http, https, net or tls module under whatever name it is given, unless the file is plainly a server, where only a client call through that import counts); a read of a secret store (an SSH key, a cloud credentials file, the agent's own configuration directory; a .env file alone is not one); a step that survives a reboot (a crontab entry, a LaunchAgent, a systemd unit, an append to a shell profile). In a hook: a command that makes a request, an http hook whose address is not plainly this machine, or an interpreter handed an inline program.

The grade. A gap standing alone is INFO at 0 points on an established listing — ninety days or older — and MEDIUM at 4 points on a newer one. It is HIGH at 10 points only beside another S9 or S10 finding in the same skill, or for a hook, beside another finding of the plugin; the S10 row that reports the same evidence does not count as another. It is never CRITICAL, so it never makes the verdict BLOCK. Alone, a gap is allowed or warned about under every profile RepoGates ships. The HIGH, which exists only beside another finding, warns under the default profile and blocks under the strict organisation profile, as any HIGH does there. The rows are skill-undeclared:network, skill-undeclared:credential and skill-undeclared:persistence, each with the script's path, and skill-hook-undeclared:network and skill-hook-undeclared:inline-code, each with the hook's event. At most three scripts are listed per behaviour, the most severe first. A finding reads “script makes a network request (to example.net), and neither the description nor allowed-tools names a network purpose”.

Measured on 10 October 2026 through the same code as /v1/skill/score, on the three lists this roster is measured on: 187 skills.sh keys, all 310 official-marketplace plugins and the first 100 community plugins — 597 keys. S16 wrote ten rows on nine keys, every one a note: eight for a script that makes a request its skill's description does not name (five distinct scripts, because the same skill is listed more than once), and two for one plugin's hooks that run an inline program. No row was a credential read or a persistence step, none was MEDIUM or HIGH, and no verdict changed: the three lists read 151 PASS / 10 REVIEW / 26 BLOCK, 282 / 28 / 0 and 94 / 5 / 1 with the check and without it, and every BLOCK among them is S1's. Three things about that result are stated with it. Twenty-six of the 597 listings have a source repository under ninety days old and none of them carried a gap, so the MEDIUM grade was not exercised by the measurement; it is pinned by the roster's tests. This tier read 3,178 files on those keys and left 3,541 unread under the twelve-file cap, scripts last, so a quiet S16 is a statement about what was read; on eleven community plugins whose source was not visible the check is listed as not checked. And the lists were run twice that day: the first run wrote twelve rows, and two of them were a skill's own local server, read as a request because it imports Node's http module. The rule was corrected and the numbers here are the second run's. On these lists S16 found no listing the rest of the roster had missed; what it adds today is the note beside the script, and the rule that turns that note into a HIGH when the script also matches a campaign shape.

False positives we know about

A description that is shorter than the skill. The brainstorming skill of a widely installed plugin describes itself as something to use before creative work, and ships a browser helper that opens a WebSocket to the skill's own local companion. Nothing is hidden and nothing is wrong; the description is a sentence about when to use the skill, not an inventory of its files. That is the commonest row this check writes, and it is why a gap alone is a note. The word lists are wide on purpose — a missing word costs an honest skill a row, an extra word only costs this check a note — and where a word is missing the fix is a word in the published list, not a change to the skill.

A request to this machine. A script that calls localhost to talk to a development server is a network request to this check, and so is a hook command that does. The exemption was considered and refused for commands: a shell line can make a second request to an address held in a variable, and a loopback address beside it would have hidden that one. A plugin that runs a local server says so in its description, and the word server declares it.

A hook that runs a one-line program. Many plugins format a file or print a reminder with python3 -c or node -e from a hook. That is a row only when nothing the plugin declares says it automates, checks, formats, logs or guards anything — thirty-nine stems — and then a note on an established listing.

A new listing. On a repository under ninety days old every gap is MEDIUM, which makes the verdict REVIEW. That is the roster's rule for a declared surface everywhere, and it is the cost of grading by age: an honest skill published last week and a dishonest one look the same from here.

What this check does not cover

What the code means. This is a comparison of two texts by fixed rules. It does not understand what a script is for, cannot tell a telemetry call from a theft, and does not follow a request into the function that builds its address. A purpose stated in the body. Only the frontmatter and the plugin's own descriptions count as said; a paragraph in the SKILL.md that explains the upload is not read as a declaration. Files this tier did not read. Twelve content reads per key: a script that was not fetched has no row, the report says how many files went unread, and a partial scan is never a pass on them. What a hook's script does. A hook that launches a file from the plugin's own bin/ is read as a launch; the file is a script like any other if it was among the twelve. Behaviour other than the three. Writing outside the project, deleting files and spawning processes are not compared here. Every way to write a request. The call shapes are a list. In a Node file that also runs a server, a module handed on to another function, a thirteenth imported name, or a .listen( that is only text in a string is read as no request; and an author who adds one word to the description has declared the purpose. This check is a note beside the code, not a barrier in front of it. ClawHub (@owner/slug) is read from the listing's own record and its published package — the package streamed under a 512 KB cap and read for at most twelve files at 64 KB each. The declarations come from the SKILL.md in that record and the scripts from the package: a package this tier could not read leaves this check listed as not checked, never passed.

Scope, the same on every page of this roster: RepoGates does not see git clone, package managers or curl — outside Claude Code with the RepoGates plugin, whose hook refuses a clone or install that names a blocked repository on the command line, before it runs. The hook sees Bash tool calls in that one client and nothing else: npx skills add owner/repo and claude plugin install name@marketplace are shell lines it judges; /plugin install is a slash command the hook never sees; a skill copied by hand into ~/.claude/skills is never seen at all. This tier reads the listing record, the tree once, and at most twelve files of 64 KB each — SKILL.md files first, then a plugin's agents and commands, then the scripts beside each skill; more than fits is a partial scan, stated, and never a pass on what was not read. The deep scan reads the whole package. A PASS means nothing matched on what was read, never that the skill was run.

Check it yourself

Read the two sides the way the check does. On a checkout of the skill, print what it says, then list what its scripts reach for:

sed -n '/^---$/,/^---$/p' SKILL.md
grep -rnE 'curl|wget|requests\.|urlopen|fetch\(|Invoke-WebRequest' scripts/
grep -rnE '\.ssh|\.aws/credentials|\.netrc|crontab|LaunchAgents|systemctl' scripts/

For a plugin, read hooks/hooks.json the same way: each command and each url is something that runs without being asked. Anything the second and third commands print that the first did not prepare you for is the gap. Whether it matters is a judgement about the author, which is what the provenance checks at the top of the roster are for.

Questions

Why is a gap never a block on its own? Because most gaps are short descriptions, not concealment. A description is a sentence written for a person choosing a skill, and an honest author leaves out what seems obvious. A check that blocked on the distance between that sentence and the code would block ordinary skills every day and teach readers to ignore it. So a gap alone is a note on an established listing and a MEDIUM on one under ninety days old, and it reaches HIGH only beside another S9 or S10 finding in the same skill — the case where the script already matched a shape the 2026 campaigns used, and the description said nothing of it.

What counts as declaring a purpose? A word, a tool or a host name. Each behaviour has a published list of word stems matched at the start of a word in the skill's name, description and compatibility line. An allowed-tools entry such as WebFetch, WebSearch, Bash(curl …) or an MCP tool declares the network. And a description that names the service declares a call to that service's host: “Manage Linear issues” covers api.linear.app. A wide Bash grant declares nothing, because it is a permission and not a purpose.

Does this read what the code means? No. It compares two texts by fixed rules: call shapes in the code against word lists in the description. It cannot tell a telemetry ping from a theft, does not follow a request into the function that builds its address, and reads only the files this tier read — at most twelve, of 64 KB each. It is the cheapest honest version of the question, and the deep scan reads the rest of the package.

Related checks

S7 Declared requirements — the declarations themselves, written as notes · S10 Scripts and executables — what the scripts do, graded on its own · S13 Hooks and monitors — the hooks' matchers and launch lines · S9 Instruction shapes — the finding that turns a gap from a note into a HIGH.

Add RepoGates to Chrome Pricing

Numbers on this page: the 10 October 2026 measurement of 187 skills.sh keys, all 310 official-marketplace plugins and the first 100 community-marketplace plugins, run through the same code as /v1/skill/score; the incident record behind the malicious skills page (Snyk, 11 February 2026); the word lists in the product's policy file, as counted on 10 October 2026; weights from the product's scoring table.