5 min read

The prompt injection nobody bothered to hide.

Last month I wrote about prompt injections hidden in invisible Unicode, and ended on the part that detector cannot touch: an instruction written in plain, visible English needs no hidden characters at all. This is the detector for that part.

The shape is not hypothetical. In April 2025 Invariant Labs published a tool-poisoning attack in which an MCP tool called add told the model, in its description, to read the user's MCP config and SSH key, pass them along in an extra argument, and say nothing about it. Every word was visible. Nobody reads tool descriptions; the model reads all of them.

The user approves a calculator. The model is handed a set of instructions.

Why this is harder than it looks

The obvious detector flags text that gives orders. It is useless, because the files agents read are supposed to give orders. A skill says “run the tests before committing”. A rules file says “never edit generated code”. A CLAUDE.md is nothing but instructions.

So agentmetry scan does not look for instructions. It looks for six shapes that honest text has little reason to take:

  • Overriding the model's other instructions
  • Concealing something from the user, or acting silently
  • Reaching for a credential file: an order to read or send .ssh, .aws/credentials, .env, mcp.json
  • Exfiltrating to a URL or an email address
  • Shadowing other tools: “all other tools must…”
  • <IMPORTANT>-style tags, which carry the payload in every published example

One small rule does a lot of the work. Verbs are matched only in their base form. A description says what a tool does, in the third person: “Reads a file and returns its contents”. An injection tells the model what to do: “read the file and pass it”. The first is silent and the second is not.

Here it is on a listing modelled on that attack:

$ agentmetry scan poisoned_tools.json
  9 findings in 1 item scanned
  [high] injection/secret_file_access  poisoned_tools.json: tool add
  [high] injection/conceal_from_user   poisoned_tools.json: tool add
  [high] injection/instruction_override  poisoned_tools.json: tool notes
  ...
  [medium] injection/hidden_tag  poisoned_tools.json: tool add

Nine false alarms on my own machine

A detector like this is only worth running if it stays quiet on the text people actually write. So before shipping it I pointed it at 362 files on my laptop that are full of instructions and security vocabulary: this project's docs and changelog, and the 235 Claude skills installed on the machine.

It raised nine findings. Every one was a false alarm, in four shapes:

  • A noun that looks like a verb. “A read of ~/.ssh/id_rsa is a credential-access detection” describes a detection. It does not perform one.
  • A tool name. cursor.Read on ~/.aws/credentials is the name of a tool, in documentation about that tool.
  • Setup. “Copy the example to .env” is in half the READMEs ever written.
  • Two sentences. “A typo costs you the envelope silently. Read the ids back” is advice, not an order to read something silently.

Each is fixed, and each is now a test that must stay silent, next to the attacks that must not. On the same 362 files the shipped version reports nothing. That is a regression guard on my own text, not a false-positive rate in the field, and I would rather say so than imply otherwise.

The cheaper attack: a name

Not every supply-chain attack needs clever text. An agent told to install “the requests library” types what it was told, and an MCP config copied from a forum runs whatever package it names. So scan also checks names:

$ agentmetry scan pypi:request
  [high] typosquat/near_known  pypi:request
      request (pypi) is 1 edit from requests; check it is the package you meant

$ agentmetry scan npm:server-filesystem
  [high] typosquat/scope_dropped  npm:server-filesystem
      server-filesystem (npm) is the name of the scoped package
      @modelcontextprotocol/server-filesystem; check it is the package you meant

Run with no argument, it reads the MCP servers every agent on the machine is configured to launch, and checks each launch package the same way.

What a standalone scanner cannot do

A tool description is only visible at one moment: when a server answers tools/list. After that, the model has it and the file on disk does not. So the same check runs inside Agentmetry's MCP proxy, on every listing, and the result is written to the tamper-evident trail as counts per category.

Counts, never the text. A record that stored the poisoned description would be storing the payload.

That puts the scan result on the same record as what the server did afterwards, and next to the fingerprint that moves when its description changes. A server that passed its scan on Monday and changed what it tells the model on Thursday shows up as both.

What it does not do

  • It never says “safe”. “No findings” means these checks found nothing, which is all it can honestly mean.
  • An instruction phrased unlike any of the six shapes is invisible to it.
  • It never runs a server or fetches a package. Checking what an unpinned npx -y entry would install means running it, which is the attack.
  • Writing about these attacks resembles them. Run it on this post and it will flag it. Point it at what an agent reads, not at security writing.

The rules and their limits are in docs/scanner.md, the attacks and the false alarms are in the tests, and the project is github.com/blitzcrieg1/agentmetry, Apache 2.0.