Sneppard Sniffer
A Chrome extension that flags prompt injection aimed at AI browsing agents, by looking for instruction-like text crossed with evidence someone took pains to hide it.

A Manifest V3 Chrome extension that reads a page the way an AI assistant reads it, meaning all of it, and flags text that seems to be addressing the assistant rather than you.
The gap it lives in
An AI browsing agent ingests the whole DOM. You read the part that gets painted. Those are not the same document, and prompt injection lives in the difference: text put on a page for the agent and positioned so that you’ll never come across it.
There is an awkward wrinkle in who that leaves exposed. If you have asked an agent to read a page for you, you probably were not planning to read it yourself, which is rather the point of asking. So the person best placed to notice a paragraph of instructions aimed at a machine is usually the one who is not looking.
How it decides
It looks for two signals and wants both before it says anything. Instruction-like language on its own is unremarkable, since plenty of ordinary pages discuss talking to models. Hidden text on its own is even less remarkable, since the web is full of it. Together they are worth a second look.
The first signal is a set of 27 weighted patterns, each worth 5 to 10
points. They cover the usual repertoire: “ignore previous instructions”,
persona hijacking (“you are now [role]”), system prompt overrides, obfuscation
markers such as DAN and fake chat tokens like <|im_start|>, credential
exfiltration attempts, negation-based manipulation (“do not reveal”), and
conditional injection (“when asked … say …”).
The second is evidence the text was concealed, worth +3 points per vector.
| Vector | What it looks for |
|---|---|
| Hidden CSS | display:none, visibility:hidden, opacity under 0.05, font-size under 2px, colour matched to the background, clip:rect(0,0,0,0), clip-path:inset(100%), zero-dimension boxes |
| Off-screen positioning | position:absolute or fixed with left/top beyond −500px, or text-indent at −1000px or less |
| Attributes | alt, aria-label, title, placeholder, data-tooltip |
| HTML comments | Comment bodies containing injection keywords |
| Meta tags | Meta content containing injection keywords |
Those combine into a single number:
finding score = sum of matched pattern weights + 3 per concealment flag
The page score is the sum of all findings, which lands it in one of four bands.
| Score | Level | Badge |
|---|---|---|
| 0 | safe | none |
| 1–5 | low | amber ! |
| 6–14 | medium | orange !! |
| 15+ | high | red !!! |
One detail is worth spelling out, because it’s the sort of thing that quietly
breaks a detector. Checking the element that holds the text isn’t enough. If a
wrapper three levels up carries display:none, the child’s own computed style
still reports whatever it declared. The child isn’t being painted, but nothing
in its own style says as much. So the scanner walks up to ten ancestors instead
of trusting the node in front of it.
Not crying wolf
This is the part that decides whether the thing is usable at all. A detector that fires on ordinary pages gets switched off within a day, and after that it is not protecting anyone.
Two guards do most of that work:
- Hidden elements alone never produce a finding. Dropdown menus, modal shells, off-screen navigation, screen-reader-only labels. Hidden text is ubiquitous and almost all of it is innocent. Concealment only starts counting once there is something instruction-shaped inside it.
- Visible text needs a high-weight pattern (≥9) to register at all. Prose about AI says “act as” and “do not reveal” constantly, and a page that says those things in the open isn’t hiding anything.
Both were checked against a pair of pages at opposite ends:
| Page | Result |
|---|---|
Adversarial fixture (test/injection-fixture.html) |
High risk, score 198, all 8 planted injections detected |
| Benign control page | Safe, score 0 |
Legitimate pages carrying “act as” or “do not reveal” in visible content also scored zero, which is the outcome the second guard exists to produce.
Working inside MV3
Manifest V3 shaped a fair amount of the code. No eval, no inline scripts, so
everything is a real file under the extension’s own CSP. Everything async,
built on await.
The service worker is the constraint that matters most: it isn’t somewhere you can keep things. It’s torn down when idle, so anything that needs to outlive a single scan has to sit in storage instead of in a variable, and the worker has to be written as though it just woke up, because it usually has.
The other one is rendering. Reading computed style is what makes this detector
work, and doing it in a tight loop over a large DOM is exactly how you make a
page stutter. Style reads are deferred and batched through
requestAnimationFrame so the scan doesn’t fight the browser for the main
thread. Dynamically injected content is picked up by a MutationObserver,
debounced at 1200ms, so a page that keeps rewriting itself doesn’t trigger a
rescan on every keystroke.
What it isn’t
It’s a heuristic detector rather than a shield. It can’t block anything; it looks, and then it tells you. It can’t see inside cross-origin iframes or shadow DOM either. Novel phrasing walks straight past it and will keep doing so, which is the standing limitation of any fixed pattern list.
Worth being plain about what a clean result means: nothing matched. That is not quite the same claim as “this page is safe”, and it is worth not reading it as one.
What I’d change
The honest answer is the validation. Two pages is not a test set. An adversarial fixture I wrote myself and one benign control tell me the detector fires when I plant something and stays quiet when I do not, which is the least I could ask of it. They tell me nothing about the false-positive rate on ordinary pages, and that number is the one that decides whether anybody keeps the extension installed. A few hundred pages pulled at random would be a better use of an afternoon than another ten patterns.
The weights have the same problem. Five to ten points per pattern, three per concealment vector, bands at 6 and 15. Every one of those is a number I chose because it felt about right, and none of them has been fitted to anything.
If I rebuilt it I would also change the emphasis. The pattern list is the
brittle half and it is where most of the code went. Concealment detection is
the durable half, since hiding text takes roughly the same handful of tricks
regardless of what the text says, and walking the ancestors to catch an
inherited display:none generalises in a way that a phrase list never will.
I would start from the concealment side and treat the phrases as the hint
rather than the evidence.