An MV3 extension for Chromium browsers that warns you when third-party trackers read what you type into form fields — before you hit Submit.
Not on the Chrome Web Store yet, so it is loaded unpacked — about a minute:
git clone https://github.com/flyhighbarney/wiretype.git(or download a release and unzip it)- Open
chrome://extensions— oredge://,brave://,opera://,vivaldi://— and turn on Developer mode - Load unpacked → select the
extension/folder, the one containingmanifest.json - Pin the toolbar icon, so the badge count is visible
INSTALL.md covers every browser, the difference between the
development and release builds, why Firefox and Safari cannot work, and how to
confirm the install is live — worth reading, because on most pages Wiretype is
correctly silent and silence looks identical to a broken install.
Complete and running. Not yet submitted to the Chrome Web Store — see
PUBLISHING.md for what is outstanding.
| Step | Status |
|---|---|
| 1. Scaffold and win the injection race | verified — both fixtures PASS via the loaded extension |
| 2. Sensor core, attribution, perf budget | verified — <0.1 ms/keystroke, 25 stack captures per 1122 reads |
| 3. Tracker classification, Phase A scan | verified — classification and page scan confirmed in Brave |
| 4. Warning UI overlay | complete — rings, tooltip, banner, launcher, panel |
| 5. Extension shell and ship prep | complete — 157 automated checks, 48.8 KB zip |
Automated coverage: 157 checks across smoke-summary.js (verdict logic and
forbidden vocabulary), smoke-trackers.js (matching and list hygiene),
smoke-realworld.js (the list against production vendor URLs),
check-invariant.js (privacy invariant), and build.js (manifest and
packaging). Every suite is negative-tested — each check has been confirmed to
fail when the thing it guards is broken.
A page that replaces the value getter after we install does not just evade
us — it makes us report "nothing detected", which reads as safety. That is a
worse outcome than not shipping.
Two mechanisms, both triggered on focusin, which is the last moment before
typing and rare enough to stay off the keystroke path:
verifyHooks()compares each instrumented prototype's current getter against the one we installed and re-wraps if it changed, chaining through whatever the page put there. Recovers from prototype replacement.probe(el)reads a field and checks whether our getter actually ran. This catches what a descriptor comparison cannot: an ownvalueproperty defined on the element itself, shadowing the prototype. The value is discarded inside the function under the same invariant asreport().
When a bypass cannot be repaired it is reported, not hidden. The verdict
turns amber, the number becomes !, and the panel says the result cannot be
trusted. smoke-summary.js asserts that tampering outranks a clean result and
never outranks an observed read — what we already witnessed still happened.
Ranked by what I expect it to turn up. Nothing below is covered by the suites.
- Live sites.
smoke-realworld.jsnow pins the list against 55 production script URLs, which closes the false-negative gap for vendors we know about. It cannot tell us about a vendor absent from the list entirely, nor confirm that a real page's pixel reads a real field. Load a page running Meta Pixel or Hotjar and check the vendor is named rather than the site's own bundle. contenteditable. Not hooked at all. Rich-text editors are a real coverage gap, not a bug — worth stating as a known limitation.- Password-manager autofill. Writes values without keystrokes; may trigger reads and could produce either false positives or missed detections.
- Screen reader. The
aria-liveannouncements have never been heard.
iframe.html, spa.html and csp.html now exist for the three areas that
previously had no coverage, but have not been run yet.
Bugs found by reading the code after the UI was already verified: page chrome
rendering per-frame, and an unreachable stale-ring condition
(!el && !detail.rect, where rect is always set). Both fixed; neither has a
regression test yet.
INSTALL.md |
Loading it unpacked in every browser, and confirming it is live. |
PUBLISHING.md |
What must be true before this ships. Read this before submitting. |
SUBMISSION.md |
Every Chrome Web Store dashboard field, with the exact value to paste. |
STORE.md |
Listing copy, single-purpose statement, permission justifications. |
PRIVACY.md |
Privacy policy. Needs hosting at a public URL for the store. |
SECURITY.md |
How to report a vulnerability, and what counts as one here. |
CHANGELOG.md |
Release notes. |
LICENSE |
MIT — a default, not yet a considered choice. |
extension/
manifest.json MV3. Two content scripts: MAIN (sensor) + ISOLATED (UI).
trackers.js Curated list: 56 vendors, 40 flagged highRisk.
sensor.js MAIN world, document_start. Hooks .value getters,
attributes reads by stack trace, scans page scripts.
Field contents never leave this file.
content.js ISOLATED world. Renders warnings. Never sees values.
background.js Service worker. Per-tab findings, badge, panel state.
sidepanel.html/.js Browser side panel. Same data, same verdict logic.
summarize.js The verdict: tone, the one big number, its unit, and the
sentence. Single source of truth for what we claim.
Requireable in Node, so the copy is unit-tested.
explanations.js Plain-English vendor copy, and the graded advice.
icons.js Nine inline SVG paths, shared by both panels.
icons/ Generated by tools/make-icons.js.
fixtures/
race.html Sensor installs before the first page script; a
cross-origin script reads a plain input.
react.html React-controlled input, React bundled FIRST-PARTY.
Asserts which origin was blamed, not just that we fired.
perf.html 30 controlled inputs, 200 synthetic keystrokes.
phase-a.html Late-injected tracker, no reads. Page scan only.
ui.html Overlay checklist: placement, overlap, edges, a11y.
tamper.html Three real bypasses: pristine getter from an iframe,
delete-and-redefine, and an own property shadowing the
prototype. The sensor must recover or say so.
iframe.html Cross-origin iframe with its own form and tracker. Rings
must appear in-frame; chrome must not.
spa.html pushState routing. Rings must not outlive their fields;
findings must survive the route change.
csp.html Strict policy, no unsafe-inline. Proves a page cannot
opt out of being watched, and that shadow styles survive.
thirdparty/
tracker.js Conversion-pixel behaviour: scrapes on load/click/blur.
replay.js Session-replay behaviour: reads on every keystroke.
inert.js Present but never reads. Phase A must still warn.
tools/
make-icons.js Generates the PNGs from code, no image dependency.
check-invariant.js Statically enforces "values never leave sensor.js".
smoke-summary.js Unit-tests the verdict logic, including the vocabulary it
must never emit: blocked, prevented, protected, safe.
smoke-trackers.js Hostname matching and list hygiene. Pins the rule that
decides who gets publicly accused.
smoke-realworld.js Matches the list against 55 production vendor script
URLs. Found 7 false negatives on first run.
build.js dist/ + zip. Runs all suites; refuses to ship on failure.
- Field rings and tooltips — on the fields themselves, while you type.
- Amber banner — bottom left, when a known vendor is merely loaded.
- Floating launcher — bottom right, like Grammarly's. Red with a count when a read was observed, amber when vendors are only present. Click to open the in-page panel: per-vendor explanation, which fields it read, and what to do.
- Badge — the same count on the toolbar icon.
- Native side panel — the same detail via
chrome.sidePanel, for browsers that implement it (Chrome 114+).
The in-page panel is the primary surface, not the native one. Brave and some
other Chromium browsers do not expose chrome.sidePanel, so an injected panel
is the only version that works everywhere — and it can sit beside the form the
warning is actually about. The native panel is kept as a bonus where available.
Both panels render from the same data and share explanations.js.
The design target is that a user understands the situation without reading a sentence. Prose is the fallback, not the interface.
- One number, large. The verdict block leads with a single figure — how many of your fields were read — in 34px mono. That number and the two words beside it are the whole message.
- Three colour bands. Red means we watched a read happen. Amber means a known form-reader is loaded but nothing has been read. Blue-grey means nothing was found. The band appears on the verdict, the card borders, the badge, and the launcher, so severity is consistent wherever the eye lands.
- Category glyphs. An eye watches you type, a target measures you, an envelope harvests contacts. Learned once, then recognised without reading the vendor name.
- Field pills. Which fields were touched, as mono chips. Scannable as data rather than parsed as a list.
- Two-word badges.
DATA READorNO READ. - Prose collapsed. The explanation and the advice sit behind a native
<details>. Three paragraphs per vendor is unscannable, and on a page with four vendors it buries the two facts that matter: who, and which fields.
Colour is never the only signal — every band is paired with a text badge, a
glyph, and an aria-label, so the hierarchy survives colour blindness and
screen readers. opacity is deliberately not used for de-emphasis: it drags
body text under the 4.5:1 contrast floor. The loaded tier recedes via an
outlined badge and a dimmer border instead.
The first live test found almost nothing, on pages that certainly run trackers. Four causes, all fixed:
new FormData(form)was invisible. It serialises every field inside the browser without ever invokingHTMLInputElement.prototype.value, so the getter hook saw nothing. It is the idiomatic way to read a form in modern code, and submit-time harvesting is far more common in the wild than per-keystroke reads. Now wrapped with a Proxy on the constructor.- Reads of empty fields were discarded. The gate was "is there anything to steal", which threw away the most useful signal available: a script that reads a field while it is empty will read it once it is not. Trackers overwhelmingly read on load and on submit, so casual browsing hits the empty read and never the filled one. Now its own tier — see below.
- A site's own CDN subdomain counted as third-party. Attribution compared
origins, so
static.example.comreading a form onexample.comwas reported as a third party. On themarkup.org we confidently blamedmrkp-static-production.themarkup.org, which is their own bundle. Now compared by registrable domain. - Seven vendors shipped from hosts not on the list, two of them wrong
rather than missing —
ct.pinterest.comandmixpanel.comlooked like coverage but would never have matched a real page. Found bysmoke-realworld.js.
One cause is not a bug and worth being honest about: since the 2022 Leaky Forms research, several session-replay vendors changed their defaults to mask inputs, and Meta's Automatic Advanced Matching is no longer on by default. The behaviour is real and still widespread, but it is less universal than it was, and a tool that only ever says "nothing found" will be uninstalled regardless of whether it is right. Hence tier two.
Kept deliberately distinct, because conflating them is how a privacy tool becomes noise:
- Present (amber, weakest) — the page loads a vendor known to read form fields. Shown before you type. Presence is not proof, so it never claims a read. Verdict: "1 tracker can read".
- Reads your fields (amber, solid outline) — we watched a specific script read a specific field, and the field was empty at the time. Nothing was taken, but the read is proof it will be. This is the tier that fires on real pages, and the one that delivers the actual promise: a warning about this box, before you type in it. Verdict: "1 tracker reads your fields".
- Data read (red) — a script read a field that had content in it. Verdict: "3 fields read".
A field can be upgraded from tier two to tier three mid-session — the outline goes from amber to red the moment a read catches content — but never downgraded. What we witnessed happened.
Two origins are needed so the "tracker" is genuinely cross-origin. The fixtures
use localhost for the third party and 127.0.0.1 for the page — different
hostnames, and localhost matches a DEBUG-only entry in the tracker list so
vendor naming can be tested without calling a real tracker.
cd "Z:/business solutions/wiretype" && python -m http.server 8010 --bind 127.0.0.1cd "Z:/business solutions/wiretype/fixtures/thirdparty" && python -m http.server 8011Load the extension: chrome://extensions → Developer mode → Load unpacked →
select extension/. Then open the fixtures under
http://127.0.0.1:8010/fixtures/.
Each page prints its own PASS/FAIL. Reload the extension (⟳) after any change
to extension/ — Chrome serves a cached copy otherwise, and a stale build
silently produces stale results.
perf.html deliberately does not self-load the sensor, so it can measure a
true extension-disabled baseline.
The first build blamed the nearest non-self stack frame. React's
_valueTracker defines a value getter on the element instance, so a tracker's
read arrives as [our getter] -> [React's getter] -> [tracker]. Taking the
nearest frame credits React — and when React is bundled first-party, as real
sites do, the read then looks first-party and is dropped entirely. Silent
blindness on React apps, no error, no signal.
Attribution now collects every origin on the stack and prefers a known
high-risk tracker, then any known tracker, then the outermost third party.
Error.stackTraceLimit is raised to 30 for our capture only and restored
immediately; V8's default of 10 can be exhausted by framework internals.
The React fixture asserts which origin was blamed, so this cannot regress into a green PASS again.
We cannot know the calling origin without a stack trace, so a script reading a field on every keystroke was costing a full capture per read — every one of them a duplicate we then discarded. Each element is now sampled on its first 8 reads, then every 64th. The periodic probe still catches a pixel that starts reading later in the session.
Measured against a session-replay scraper: reads rose by 242, captures by 4.
requestAnimationFrame does not fire in a background tab, which would have left
the budget permanently exhausted and the sensor blind there.
A conversion pixel scrapes fields around submit-adjacent events; session-replay
scripts read on every keystroke. The fixtures model both (tracker.js and
replay.js) because a harness that only models one will pass while the other
goes undetected — which happened once already.
Field contents never leave the getter in sensor.js. The bridge carries a field
id, vendor, and geometry — never a value.
This is enforced, not merely documented:
node tools/check-invariant.jsIt allowlists the bridge payload keys, rejects any value reaching a network,
storage, logging, or messaging sink, confirms the UI layers never touch
.value, and requires the side panel to build DOM without innerHTML (it
renders vendor names and field labels that came from page markup).
Comments and string bodies are stripped before scanning, so the checks cannot match their own documentation — nor be argued away by it.
build.js runs it and refuses to package on failure. The checks are themselves
negative-tested by mutating a copy of the source via WT_EXT_DIR and
confirming each one fails.
What the panel does receive: vendor names, field labels (page-authored text such as "Email address"), and field types. Labels are the site author's markup, never anything the user typed.
node tools/build.jsFlips DEBUG off, verifies the manifest, runs the invariant checks, and writes
dist/ plus a versioned zip. It aborts rather than shipping a debug build,
which would log every field read to the console of every page.
Listing copy is in STORE.md; the mandatory privacy policy is in PRIVACY.md.