Governance for agent instructions: audit, score, and pin MCP servers, Agent Skills, and prompts. Deterministic; findings carry a reference and a fix.
4
stars
10
commits
Python
primary language
Aug 25, 2026
updated
·)))Governance for agent instructions.
Tool descriptions and skill files are injected into a model's context on every request. They decide which tool gets called, with what arguments, whether the client asks before something is destroyed, and whether a skill fires at all. They are production configuration — and almost nobody reviews them, versions them, or notices when they change.
sounding audits them, scores them with a formula you can check, writes the
corrections, and pins them so you find out when they drift.
Three artifact types:
| MCP servers | permission, blast radius, injection surface, transport, secrets |
| Agent Skills | triggering, progressive disclosure, dangerous commands, portability |
| Prompts | output contract, failure behaviour, interpolation safety, contradictions |
They are the same problem. A skill and a tool description are both text handed to a model, living in a repo, versioned, drifting — so they share one engine and one report.
·))) filekit
HIGH MCP003 tool:delete_file
Description instructs the model rather than describing the tool
(matched: 'Ignore previous'). This text enters the context window verbatim.
ref MCP security guidance — scan before installing, pin tool descriptions
fix Describe behaviour only. A tool description is API documentation,
not a prompt.
score 0/100 100 - [7xhigh(15) + 6xmedium(7)] = 0
found 7 high · 6 medium · 0 low
Every finding carries a reference. Not "this looks risky" — the spec section or guidance that says why. A finding without a source is an opinion, and this tool does not ship opinions.
Every finding carries a fix, or a question. When the correction is unambiguous, it says what to change. When it depends on intent the tool cannot infer, it asks — with options, capped at three per run:
Three questions would let me write the fixes:
1. `run` looks like it runs commands. How is the command constrained?
allowlist of fixed commands / sandboxed / arbitrary — no constraint / not sure / skip
And then it writes them. Answers become edits to your descriptor, shown as a diff before anything is touched:
- "url": "http://files.example.com/mcp",
+ "url": "https://files.example.com/mcp",
- "FILEKIT_API_KEY": "sk-live-9d2f8a1c4b7e0392"
+ "FILEKIT_API_KEY": "${FILEKIT_API_KEY}"
- "readOnlyHint": true
+ "readOnlyHint": false
Anything generated that still needs a human is marked TODO in the output. The
tool does not write prose and present it as finished — and in a SKILL.md it
rewrites the frontmatter only. Body text is yours.
The score shows its formula. 100 - [7xhigh(15) + 6xmedium(7)] = 0. A number
you cannot audit is a vibe.
No model is involved. Every rule is deterministic. Same input, same output, every time — which is what a linter has to be.
pip install sounding
From a clone — which is also the only way to run it before the first release lands on PyPI:
git clone https://github.com/alinotfoundbtw/sounding
cd sounding
pip install -e .
Python 3.10 or newer. No dependencies.
sounding audit server.json # MCP: a descriptor or client config
sounding audit ./my-skill # Skill: a directory or SKILL.md
sounding audit ./prompt.txt # Prompt: a .txt or .md without frontmatter
sounding audit server.json --format md # a report you can hand to someone
sounding audit server.json --interactive # answer the open questions
sounding fix server.json # dry run — show the diff
sounding fix server.json --write # apply it (writes a .bak)
sounding fix server.json --answers a.json --write
sounding pin server.json # write sounding.lock.json
sounding diff server.json # detect drift since the pin
sounding audit . # scan a whole project
sounding audit . --format sarif # GitHub code scanning
sounding audit . --write-baseline # adopt on an existing repo
sounding eval ./skills # do these skills trigger on the right tasks?
sounding rules # every rule set and the scoring formula
sounding serve # run as an MCP server (see MCP-SETUP.md)
sounding selfaudit # audit this tool's own MCP manifest
Accepts three input shapes, because these are what people actually have:
tools/list response — {"tools": [...]}{"name":..., "transport":..., "tools":[...]}{"mcpServers": {...}}$ sounding audit .
·))) 11 artifact(s)
ARTIFACT KIND SCORE FINDINGS
examples/messy-server.json mcp 0 7h 6m 0l
examples/prompts/messy.txt prompt 0 8h 4m 1l
examples/skills/messy-skill skill 0 6h 3m 2l
examples/prompts/image-thin.txt prompt 49 2h 3m 0l
examples/clean-server.json mcp 100 clean
examples/notes-server-v2.json mcp 100 clean
examples/prompts/clean.txt prompt 100 clean
examples/prompts/image-good.txt prompt 100 clean
examples/skills/block-scalar-skill skill 100 clean
examples/skills/clean-skill skill 100 clean
examples/skills/defensive-skill skill 100 clean
mean score 68/100
config: .sounding.json
Skills and MCP descriptors are found by shape, not filename — a SKILL.md, or
JSON carrying an mcpServers object or a tools array. Prompts are opt-in via
promptGlobs, because every repo is full of .txt files that are not prompts
and guessing would bury the real findings.
- run: pip install sounding
- run: sounding audit . --fail-on high
Or into GitHub's Security tab:
- run: sounding audit . --format sarif > sounding.sarif
continue-on-error: true
- uses: github/codeql-action/upload-sarif@v3
with: { sarif_file: sounding.sarif }
Adopting on an existing project without four hundred findings on day one:
sounding audit . --write-baseline # record what is already there
sounding audit . --baseline # from now on, only what is new
Baselined artifacts show as 13 baselined, never as clean. They are a
backlog, not a pass, and the output refuses to pretend otherwise.
Full options in CONFIG.md.
Exit codes: 0 clean · 1 medium findings, or a command that declined to run ·
2 high findings, or drift in a tool contract. A refusal never borrows 2, so
a failed diff in CI always means the contract actually changed.
A server earns trust, then quietly changes what its tools claim to do. The description is what the model reads, so changing it changes behaviour even when the code is untouched.
examples/notes-server-v2.json is the same server one release later, with
notes_search now claiming to return note bodies too:
$ sounding pin examples/clean-server.json
·))) pinned 1 server(s), 2 tool(s) -> sounding.lock.json
$ sounding diff examples/notes-server-v2.json
·))) drift detected:
~ notes: version 1.2.0 -> 1.3.0
! notes.notes_search contract changed (8c54f6b4d84b91a7 -> 5d08119d9a3adf5a)
Commit the lockfile. Review the diff like you would any other change to production config.
$ sounding fix examples/messy-server.json --interactive
What values are valid for `path`?
1) a fixed set (enum)
2) matches a pattern
3) any value within a sandboxed root
4) genuinely unconstrained
> 3
Answers can also come from a file — examples/answers.json answers four of the
five questions and leaves one open on purpose:
$ sounding fix examples/messy-server.json --answers examples/answers.json
[...]
·))) 8 change(s):
tools[ping].description = 'Call an external service to ping. TODO: name the service, the data sent to it, and the failure behaviour.' (TODO: finish this)
description scaffold TODO
tools[delete_file].annotations.readOnlyHint = False
annotation contradicted the description
env.FILEKIT_API_KEY = '${FILEKIT_API_KEY}'
literal credential replaced with a reference — rotate the old value
url = 'https://files.example.com/mcp'
transport upgraded to https
tools[delete_file].inputSchema.properties.path.pattern = '^(?!/)(?!.*\\.\\.).+$'
relative paths only, no traversal
tools[read_file].inputSchema.properties.path.pattern = '^(?!/)(?!.*\\.\\.).+$'
relative paths only, no traversal
tools[run].inputSchema.properties.cmd.enum = ['TODO_VALUE_1', 'TODO_VALUE_2'] (TODO: finish this)
replace with the real allowed values TODO
version = '0.1.0' (TODO: finish this)
starting version so consumers can pin TODO
3 of these are scaffolds marked TODO. They are placeholders, not finished work — edit them before shipping.
Still unresolved:
shell:run: `run` looks like it runs commands. How is the command constrained?
Dry run. Re-run with --write to apply.
Three rules hold here:
--write is explicit, and it leaves a .bak.TODO inside the
text itself, so it cannot ship unnoticed.sounding rules lists them with the scoring weights.
MCP (12) — description quality, permission and blast radius, transport, secrets, drift.
Skills (13) — frontmatter validity, name alignment, trigger quality, progressive disclosure, broken reference files, host-override language, dangerous commands, embedded secrets, machine-specific paths.
Prompts (11 generic + profiles) — missing output format, undefined failure behaviour, undelimited interpolation, contradictions, vague quantifiers, negation-heavy phrasing, repetition, embedded secrets, bloat.
Generic rules are true of every prompt, which is why they are shallow. Profiles add the dimensions that actually decide quality for one kind of prompt: an image prompt is checked for subject, medium, lighting, composition, colour and mood; an extraction prompt for its schema and absent-value rule; an agent prompt for its tool boundary, stopping condition, and untrusted-content handling. See PROFILES.md.
Prompt rules are deterministic only. Whether a prompt works can be found only by running it, and this tool runs nothing — so it checks the structural class of problem instead, which is where most prompt failures actually live.
The adapter is chosen from the path; --type overrides it.
Severity weights: high 15 · medium 7 · low 3. Score is 100 - sum(weights),
floored at zero.
Static rules cannot tell you whether a skill fires on the tasks it is meant to handle. That is a retrieval question, and retrieval is measurable without a model.
$ sounding eval examples/skills --cases examples/cases.json
·))) eval — 4 skill(s) under examples/skills
Measures trigger selection only: whether the right skill would be
chosen, not whether its instructions work. Lexical proxy, not a model.
routing 5/6 decisive and correct (83%)
WRONG 'book me a flight to Berlin'
expected None, chose block-scalar-skill
No description collisions.
The one failure is the interesting kind: a task no skill should handle gets claimed by one anyway.
Collision analysis needs no cases at all — point it at a skills directory and it reports which descriptions will fight over the same tasks. That is the most common reason skills misfire once more than a handful are installed.
What it measures: trigger selection. What it does not: whether the instructions work once the skill fires — that needs a model, and this tool runs nothing. The scorer is BM25 lexical retrieval, which will disagree with a real model at the margins. It is a smoke test, not an oracle, and every report says so.
--scaffold cases.json writes a starting case file. Every task in it is a TODO
on purpose: a case derived from a description only proves the description
matches itself.
playground/ is a single page that runs these rules in the browser via Pyodide — the same wheel the CLI installs, so the web version cannot silently disagree with CI.
Nothing is uploaded. MCP configs carry API keys; a security tool that shipped them somewhere would be a contradiction.
sounding selfaudit runs the MCP rule set against this server's own tool
manifest. The test suite asserts it scores 100, so the manifest cannot drift out
of compliance without CI failing.
Two rules in this repo were corrected because the self-audit caught them firing wrongly — a description mentioning "system prompt" as its subject was being read as an injection attempt. A linter that only ever runs on other people's work never finds those.
This is static analysis of a declared tool contract. Nothing is executed, connected to, or scanned. It tells you what a server claims about itself.
A server that passes cleanly can still be malicious at runtime — the contract and the implementation are different things, and no static tool can bridge that. What this catches is the large class of problems that are visible in the declaration and that nobody is currently looking at.
SKILL.md): same engine, second adapterpin/diff for skills and prompts; an eval layer beyond lexicalOne engine, three adapters. Existing prompt tooling manages prompts inside an application; nothing covers the agent-configuration layer, which is where skills and tool contracts live.
Rules must be deterministic and carry a reference. A rule that fires on
well-written servers is a bug — false positives are how a linter loses its
audience, so tests/ guards the clean fixture as hard as the messy one.
Every rule and fixture in this repo was written by the same person, so of course they agreed. The real test was running it against 35 professionally-written skills by other authors.
The long version, including the rule that flagged security guidance because it quoted an attack string, is in WRITEUP.md.
The first run was damning: 39 findings, a false-positive rate near 46%, and one skill scored 13/100. Four distinct defects:
| Defect | Scale | Cause |
|---|---|---|
| YAML block scalars unparsed | 11 of 35 | description: | read as broken syntax, cascading into every other rule |
| Trigger detection too narrow | 16 of 35 | Required one blessed phrasing; real descriptions say "when building new UI" or list situations |
| Defensive prose read as attack | 1 of 35 | A skill quoting "ignore previous instructions" to warn about it was flagged for containing it |
| Length threshold below reality | 5 of 35 | 2,500 words; the corpus median is 916 and its p90 is 2,937 |
After: 7 findings, 28 of 35 clean, mean score 99. The remainder are genuine and all low severity.
The third defect is the one worth naming. A rule that fires on security guidance because it describes an attack inverts its own purpose — the careful author gets the finding and the careless one does not. No amount of self-review found it; running the tool on someone else's careful work did.
Every one of the four is now a fixture in examples/ and a test in
tests/, including a test asserting the trigger rule still fires on
genuinely vague descriptions — because tuning until nothing fires is the same
failure wearing a different mask.
Every rule is fuzzed against malformed input in the test suite: non-dict tools, null names, integer descriptions, strings where objects belong, unterminated regex bait, control characters, 20k-character fields.
Six crashes were real before those tests existed. A linter that dies on a
malformed file is useless precisely when it is most needed — malformed files are
the ones worth checking — so structural problems are reported as MCP000
findings rather than raised as exceptions.
MIT.
·))) alinotfound
10 commits
Python
71.1%
HTML
28.9%
Governance for agent instructions: audit, score, and pin MCP servers, Agent Skills, and prompts. Deterministic; findings carry a reference and a fix.
4
stars
10
commits
Python
primary language
Aug 25, 2026
updated
·)))Governance for agent instructions.
Tool descriptions and skill files are injected into a model's context on every request. They decide which tool gets called, with what arguments, whether the client asks before something is destroyed, and whether a skill fires at all. They are production configuration — and almost nobody reviews them, versions them, or notices when they change.
sounding audits them, scores them with a formula you can check, writes the
corrections, and pins them so you find out when they drift.
Three artifact types:
| MCP servers | permission, blast radius, injection surface, transport, secrets |
| Agent Skills | triggering, progressive disclosure, dangerous commands, portability |
| Prompts | output contract, failure behaviour, interpolation safety, contradictions |
They are the same problem. A skill and a tool description are both text handed to a model, living in a repo, versioned, drifting — so they share one engine and one report.
·))) filekit
HIGH MCP003 tool:delete_file
Description instructs the model rather than describing the tool
(matched: 'Ignore previous'). This text enters the context window verbatim.
ref MCP security guidance — scan before installing, pin tool descriptions
fix Describe behaviour only. A tool description is API documentation,
not a prompt.
score 0/100 100 - [7xhigh(15) + 6xmedium(7)] = 0
found 7 high · 6 medium · 0 low
Every finding carries a reference. Not "this looks risky" — the spec section or guidance that says why. A finding without a source is an opinion, and this tool does not ship opinions.
Every finding carries a fix, or a question. When the correction is unambiguous, it says what to change. When it depends on intent the tool cannot infer, it asks — with options, capped at three per run:
Three questions would let me write the fixes:
1. `run` looks like it runs commands. How is the command constrained?
allowlist of fixed commands / sandboxed / arbitrary — no constraint / not sure / skip
And then it writes them. Answers become edits to your descriptor, shown as a diff before anything is touched:
- "url": "http://files.example.com/mcp",
+ "url": "https://files.example.com/mcp",
- "FILEKIT_API_KEY": "sk-live-9d2f8a1c4b7e0392"
+ "FILEKIT_API_KEY": "${FILEKIT_API_KEY}"
- "readOnlyHint": true
+ "readOnlyHint": false
Anything generated that still needs a human is marked TODO in the output. The
tool does not write prose and present it as finished — and in a SKILL.md it
rewrites the frontmatter only. Body text is yours.
The score shows its formula. 100 - [7xhigh(15) + 6xmedium(7)] = 0. A number
you cannot audit is a vibe.
No model is involved. Every rule is deterministic. Same input, same output, every time — which is what a linter has to be.
pip install sounding
From a clone — which is also the only way to run it before the first release lands on PyPI:
git clone https://github.com/alinotfoundbtw/sounding
cd sounding
pip install -e .
Python 3.10 or newer. No dependencies.
sounding audit server.json # MCP: a descriptor or client config
sounding audit ./my-skill # Skill: a directory or SKILL.md
sounding audit ./prompt.txt # Prompt: a .txt or .md without frontmatter
sounding audit server.json --format md # a report you can hand to someone
sounding audit server.json --interactive # answer the open questions
sounding fix server.json # dry run — show the diff
sounding fix server.json --write # apply it (writes a .bak)
sounding fix server.json --answers a.json --write
sounding pin server.json # write sounding.lock.json
sounding diff server.json # detect drift since the pin
sounding audit . # scan a whole project
sounding audit . --format sarif # GitHub code scanning
sounding audit . --write-baseline # adopt on an existing repo
sounding eval ./skills # do these skills trigger on the right tasks?
sounding rules # every rule set and the scoring formula
sounding serve # run as an MCP server (see MCP-SETUP.md)
sounding selfaudit # audit this tool's own MCP manifest
Accepts three input shapes, because these are what people actually have:
tools/list response — {"tools": [...]}{"name":..., "transport":..., "tools":[...]}{"mcpServers": {...}}$ sounding audit .
·))) 11 artifact(s)
ARTIFACT KIND SCORE FINDINGS
examples/messy-server.json mcp 0 7h 6m 0l
examples/prompts/messy.txt prompt 0 8h 4m 1l
examples/skills/messy-skill skill 0 6h 3m 2l
examples/prompts/image-thin.txt prompt 49 2h 3m 0l
examples/clean-server.json mcp 100 clean
examples/notes-server-v2.json mcp 100 clean
examples/prompts/clean.txt prompt 100 clean
examples/prompts/image-good.txt prompt 100 clean
examples/skills/block-scalar-skill skill 100 clean
examples/skills/clean-skill skill 100 clean
examples/skills/defensive-skill skill 100 clean
mean score 68/100
config: .sounding.json
Skills and MCP descriptors are found by shape, not filename — a SKILL.md, or
JSON carrying an mcpServers object or a tools array. Prompts are opt-in via
promptGlobs, because every repo is full of .txt files that are not prompts
and guessing would bury the real findings.
- run: pip install sounding
- run: sounding audit . --fail-on high
Or into GitHub's Security tab:
- run: sounding audit . --format sarif > sounding.sarif
continue-on-error: true
- uses: github/codeql-action/upload-sarif@v3
with: { sarif_file: sounding.sarif }
Adopting on an existing project without four hundred findings on day one:
sounding audit . --write-baseline # record what is already there
sounding audit . --baseline # from now on, only what is new
Baselined artifacts show as 13 baselined, never as clean. They are a
backlog, not a pass, and the output refuses to pretend otherwise.
Full options in CONFIG.md.
Exit codes: 0 clean · 1 medium findings, or a command that declined to run ·
2 high findings, or drift in a tool contract. A refusal never borrows 2, so
a failed diff in CI always means the contract actually changed.
A server earns trust, then quietly changes what its tools claim to do. The description is what the model reads, so changing it changes behaviour even when the code is untouched.
examples/notes-server-v2.json is the same server one release later, with
notes_search now claiming to return note bodies too:
$ sounding pin examples/clean-server.json
·))) pinned 1 server(s), 2 tool(s) -> sounding.lock.json
$ sounding diff examples/notes-server-v2.json
·))) drift detected:
~ notes: version 1.2.0 -> 1.3.0
! notes.notes_search contract changed (8c54f6b4d84b91a7 -> 5d08119d9a3adf5a)
Commit the lockfile. Review the diff like you would any other change to production config.
$ sounding fix examples/messy-server.json --interactive
What values are valid for `path`?
1) a fixed set (enum)
2) matches a pattern
3) any value within a sandboxed root
4) genuinely unconstrained
> 3
Answers can also come from a file — examples/answers.json answers four of the
five questions and leaves one open on purpose:
$ sounding fix examples/messy-server.json --answers examples/answers.json
[...]
·))) 8 change(s):
tools[ping].description = 'Call an external service to ping. TODO: name the service, the data sent to it, and the failure behaviour.' (TODO: finish this)
description scaffold TODO
tools[delete_file].annotations.readOnlyHint = False
annotation contradicted the description
env.FILEKIT_API_KEY = '${FILEKIT_API_KEY}'
literal credential replaced with a reference — rotate the old value
url = 'https://files.example.com/mcp'
transport upgraded to https
tools[delete_file].inputSchema.properties.path.pattern = '^(?!/)(?!.*\\.\\.).+$'
relative paths only, no traversal
tools[read_file].inputSchema.properties.path.pattern = '^(?!/)(?!.*\\.\\.).+$'
relative paths only, no traversal
tools[run].inputSchema.properties.cmd.enum = ['TODO_VALUE_1', 'TODO_VALUE_2'] (TODO: finish this)
replace with the real allowed values TODO
version = '0.1.0' (TODO: finish this)
starting version so consumers can pin TODO
3 of these are scaffolds marked TODO. They are placeholders, not finished work — edit them before shipping.
Still unresolved:
shell:run: `run` looks like it runs commands. How is the command constrained?
Dry run. Re-run with --write to apply.
Three rules hold here:
--write is explicit, and it leaves a .bak.TODO inside the
text itself, so it cannot ship unnoticed.sounding rules lists them with the scoring weights.
MCP (12) — description quality, permission and blast radius, transport, secrets, drift.
Skills (13) — frontmatter validity, name alignment, trigger quality, progressive disclosure, broken reference files, host-override language, dangerous commands, embedded secrets, machine-specific paths.
Prompts (11 generic + profiles) — missing output format, undefined failure behaviour, undelimited interpolation, contradictions, vague quantifiers, negation-heavy phrasing, repetition, embedded secrets, bloat.
Generic rules are true of every prompt, which is why they are shallow. Profiles add the dimensions that actually decide quality for one kind of prompt: an image prompt is checked for subject, medium, lighting, composition, colour and mood; an extraction prompt for its schema and absent-value rule; an agent prompt for its tool boundary, stopping condition, and untrusted-content handling. See PROFILES.md.
Prompt rules are deterministic only. Whether a prompt works can be found only by running it, and this tool runs nothing — so it checks the structural class of problem instead, which is where most prompt failures actually live.
The adapter is chosen from the path; --type overrides it.
Severity weights: high 15 · medium 7 · low 3. Score is 100 - sum(weights),
floored at zero.
Static rules cannot tell you whether a skill fires on the tasks it is meant to handle. That is a retrieval question, and retrieval is measurable without a model.
$ sounding eval examples/skills --cases examples/cases.json
·))) eval — 4 skill(s) under examples/skills
Measures trigger selection only: whether the right skill would be
chosen, not whether its instructions work. Lexical proxy, not a model.
routing 5/6 decisive and correct (83%)
WRONG 'book me a flight to Berlin'
expected None, chose block-scalar-skill
No description collisions.
The one failure is the interesting kind: a task no skill should handle gets claimed by one anyway.
Collision analysis needs no cases at all — point it at a skills directory and it reports which descriptions will fight over the same tasks. That is the most common reason skills misfire once more than a handful are installed.
What it measures: trigger selection. What it does not: whether the instructions work once the skill fires — that needs a model, and this tool runs nothing. The scorer is BM25 lexical retrieval, which will disagree with a real model at the margins. It is a smoke test, not an oracle, and every report says so.
--scaffold cases.json writes a starting case file. Every task in it is a TODO
on purpose: a case derived from a description only proves the description
matches itself.
playground/ is a single page that runs these rules in the browser via Pyodide — the same wheel the CLI installs, so the web version cannot silently disagree with CI.
Nothing is uploaded. MCP configs carry API keys; a security tool that shipped them somewhere would be a contradiction.
sounding selfaudit runs the MCP rule set against this server's own tool
manifest. The test suite asserts it scores 100, so the manifest cannot drift out
of compliance without CI failing.
Two rules in this repo were corrected because the self-audit caught them firing wrongly — a description mentioning "system prompt" as its subject was being read as an injection attempt. A linter that only ever runs on other people's work never finds those.
This is static analysis of a declared tool contract. Nothing is executed, connected to, or scanned. It tells you what a server claims about itself.
A server that passes cleanly can still be malicious at runtime — the contract and the implementation are different things, and no static tool can bridge that. What this catches is the large class of problems that are visible in the declaration and that nobody is currently looking at.
SKILL.md): same engine, second adapterpin/diff for skills and prompts; an eval layer beyond lexicalOne engine, three adapters. Existing prompt tooling manages prompts inside an application; nothing covers the agent-configuration layer, which is where skills and tool contracts live.
Rules must be deterministic and carry a reference. A rule that fires on
well-written servers is a bug — false positives are how a linter loses its
audience, so tests/ guards the clean fixture as hard as the messy one.
Every rule and fixture in this repo was written by the same person, so of course they agreed. The real test was running it against 35 professionally-written skills by other authors.
The long version, including the rule that flagged security guidance because it quoted an attack string, is in WRITEUP.md.
The first run was damning: 39 findings, a false-positive rate near 46%, and one skill scored 13/100. Four distinct defects:
| Defect | Scale | Cause |
|---|---|---|
| YAML block scalars unparsed | 11 of 35 | description: | read as broken syntax, cascading into every other rule |
| Trigger detection too narrow | 16 of 35 | Required one blessed phrasing; real descriptions say "when building new UI" or list situations |
| Defensive prose read as attack | 1 of 35 | A skill quoting "ignore previous instructions" to warn about it was flagged for containing it |
| Length threshold below reality | 5 of 35 | 2,500 words; the corpus median is 916 and its p90 is 2,937 |
After: 7 findings, 28 of 35 clean, mean score 99. The remainder are genuine and all low severity.
The third defect is the one worth naming. A rule that fires on security guidance because it describes an attack inverts its own purpose — the careful author gets the finding and the careless one does not. No amount of self-review found it; running the tool on someone else's careful work did.
Every one of the four is now a fixture in examples/ and a test in
tests/, including a test asserting the trigger rule still fires on
genuinely vague descriptions — because tuning until nothing fires is the same
failure wearing a different mask.
Every rule is fuzzed against malformed input in the test suite: non-dict tools, null names, integer descriptions, strings where objects belong, unterminated regex bait, control characters, 20k-character fields.
Six crashes were real before those tests existed. A linter that dies on a
malformed file is useless precisely when it is most needed — malformed files are
the ones worth checking — so structural problems are reported as MCP000
findings rather than raised as exceptions.
MIT.
·))) alinotfound
10 commits
Python
71.1%
HTML
28.9%