Grant Anderson

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.

Date
Stack
  • Browser Extension
  • JavaScript
  • Manifest V3
  • AI Security
  • Prompt Injection
A long column of document lines under a green sky, with a heavy frame drawn around only the upper portion. One line below the frame is outlined in dashed red. To the right, three bars step upward in green, ochre and red.

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.

The uncomfortable part is who that exposes. People who have an agent read a page for them are, by and large, people who didn’t fancy reading it themselves. That is the entire proposition. So the one person placed to notice a paragraph of instructions addressed to a machine is exactly the person who isn’t looking.

How it decides

Two signals, and it wants both. 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 actually determines whether the thing is usable. A detector that fires on ordinary pages gets switched off within a day, and then it protects nobody.

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, not 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, because a fixed pattern list is a fixed pattern list.

Worth being plain about what a clean result means: nothing matched. That isn’t the same claim as “this page is safe”, and it shouldn’t be read as one.