From 5e182c5b34fc90abcb4340fd0d888ba22a59c28a Mon Sep 17 00:00:00 2001
From: Todd Schiller
Date: Thu, 4 Jun 2026 11:39:32 -0400
Subject: [PATCH 1/2] Show roach-motel and webdriver-probe detections in the
popup
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Both rules currently land their findings only as sr-only landmarks —
visible to agents but invisible to sighted users. Add a "Detected on this
page" section to the browser-action popup and tint the toolbar badge
amber on tabs with detections so the human user sees what was flagged.
Rules emit a typed `rule-detection` message after stamping the landmark;
the existing landmark-already-present short-circuits act as per-document
dedupe latches. The background keeps a per-tab record alongside
`tabCounts`, cleared on navigation, tab close, and when the producing
rule is toggled off mid-session.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
extension/src/background.ts | 142 ++++++++++++++++--
extension/src/lib/detection-messages.ts | 48 ++++++
extension/src/popup.html | 65 ++++++++
extension/src/popup/DetectionsSection.tsx | 77 ++++++++++
extension/src/popup/Popup.tsx | 2 +
extension/src/popup/use-tab-detections.ts | 60 ++++++++
.../__tests__/roach-motel-annotate.test.ts | 38 +++++
.../webdriver-probe-annotate.test.ts | 53 +++++++
extension/src/rules/roach-motel-annotate.ts | 35 ++++-
.../src/rules/webdriver-probe-annotate.ts | 16 ++
10 files changed, 521 insertions(+), 15 deletions(-)
create mode 100644 extension/src/lib/detection-messages.ts
create mode 100644 extension/src/popup/DetectionsSection.tsx
create mode 100644 extension/src/popup/use-tab-detections.ts
diff --git a/extension/src/background.ts b/extension/src/background.ts
index b9e4937..784f82b 100644
--- a/extension/src/background.ts
+++ b/extension/src/background.ts
@@ -1,16 +1,44 @@
// Copyright (c) 2026 PixieBrix, Inc.
// Licensed under PolyForm Shield 1.0.0 — see LICENSE.
+import type {
+ DetectionKind,
+ DetectionPayload,
+ GetTabDetectionsRequest,
+ GetTabDetectionsResponse,
+ RuleDetectionMessage,
+} from "./lib/detection-messages";
import { subscribeEnforcementEnabled } from "./lib/enforcement";
import { startClassifyPortListener } from "./lib/llm-background";
+import { ruleStatesStorage } from "./lib/storage";
import { startWebdriverProbeRegistration } from "./lib/webdriver-probe-registration";
// Per-tab, per-frame placeholder counts. Each content script reports its own
// frame's tally; the badge shows the sum across frames for that tab.
const tabCounts = new Map>();
+// Per-tab record of rule detections surfaced to the popup. One entry per
+// kind per tab — both contributing rules are topFrameOnly and self-dedupe
+// per document, so a single payload per kind is the natural shape. Held in
+// memory, cleared on top-level navigation and tab close, same posture as
+// `tabCounts`. A service-worker restart drops it; the popup briefly shows
+// "Nothing flagged" on a page that did have detections until the next
+// re-apply. Promote to chrome.storage.session if that becomes a problem.
+const tabDetections = new Map>();
+
+// Maps each detection kind to the rule id that produces it. Used to clear
+// stale entries when a user toggles the rule off mid-session.
+const DETECTION_KIND_TO_RULE_ID = {
+ "roach-motel": "roach-motel-annotate",
+ "webdriver-probe": "webdriver-probe-annotate",
+} as const satisfies Record;
+
// Pleasant blue — clearly an extension affordance, not a warning/error.
-const BADGE_COLOR = "#2563eb";
+const BADGE_COLOR_DEFAULT = "#2563eb";
+// Amber — tab has a roach-motel / webdriver-probe detection worth seeing
+// in the popup. Matches the .enforcement--off palette in popup.html so the
+// "something to look at" signal is visually consistent across surfaces.
+const BADGE_COLOR_DETECTION = "#f59e0b";
function totalForTab(tabId: number): number {
const frames = tabCounts.get(tabId);
@@ -34,17 +62,25 @@ function formatBadge(total: number): string {
return String(total);
}
-function setBadge(tabId: number, total: number): void {
- const text = formatBadge(total);
+function hasDetections(tabId: number): boolean {
+ return (tabDetections.get(tabId)?.size ?? 0) > 0;
+}
+
+function refreshBadge(tabId: number): void {
+ const placeholderText = formatBadge(totalForTab(tabId));
+ const detection = hasDetections(tabId);
+ // Detection without a placeholder count gets a single "!" so the badge
+ // still shows up. Otherwise keep the existing count text — the color
+ // change alone signals "open the popup."
+ const text = placeholderText || (detection ? "!" : "");
chrome.action.setBadgeText({ tabId, text }).catch(() => {
// noop
});
if (text) {
- chrome.action
- .setBadgeBackgroundColor({ tabId, color: BADGE_COLOR })
- .catch(() => {
- // noop
- });
+ const color = detection ? BADGE_COLOR_DETECTION : BADGE_COLOR_DEFAULT;
+ chrome.action.setBadgeBackgroundColor({ tabId, color }).catch(() => {
+ // noop
+ });
}
}
@@ -62,16 +98,39 @@ function recordFrameCount(tabId: number, frameId: number, count: number): void {
} else {
frames.set(frameId, count);
}
- setBadge(tabId, totalForTab(tabId));
+ refreshBadge(tabId);
+}
+
+function recordDetection(tabId: number, payload: DetectionPayload): void {
+ let entry = tabDetections.get(tabId);
+ if (!entry) {
+ entry = new Map();
+ tabDetections.set(tabId, entry);
+ }
+ entry.set(payload.kind, payload);
+ refreshBadge(tabId);
}
function clearTab(tabId: number): void {
tabCounts.delete(tabId);
- setBadge(tabId, 0);
+ tabDetections.delete(tabId);
+ refreshBadge(tabId);
+}
+
+function clearDetectionsOfKind(kind: DetectionKind): void {
+ for (const [tabId, entry] of tabDetections) {
+ if (entry.delete(kind)) {
+ if (entry.size === 0) {
+ tabDetections.delete(tabId);
+ }
+ refreshBadge(tabId);
+ }
+ }
}
chrome.tabs.onRemoved.addListener((tabId) => {
tabCounts.delete(tabId);
+ tabDetections.delete(tabId);
});
// On a top-level navigation, drop stale per-frame counts so the new document
@@ -90,11 +149,49 @@ subscribeEnforcementEnabled((enabled) => {
if (enabled) {
return;
}
- for (const tabId of tabCounts.keys()) {
- clearTab(tabId);
+ // Snapshot the union of tabs we're tracking before clearing — the badge
+ // for each needs to refresh, and we don't want to iterate a map we're
+ // mutating.
+ const affected = new Set([
+ ...tabCounts.keys(),
+ ...tabDetections.keys(),
+ ]);
+ tabCounts.clear();
+ tabDetections.clear();
+ for (const tabId of affected) {
+ refreshBadge(tabId);
}
});
+// When a user disables one of the detection-producing rules mid-session,
+// drop the now-stale entries from every tab so the popup matches the
+// current rule selection. Detections for the other (still-enabled) rule
+// stay put. Seed `previousRuleStates` from storage before subscribing —
+// `subscribe` only fires on changes, never with the current value, so
+// without a seed the first off-transition would compare against `null`
+// and skip the clear. We can't use top-level await here (the bundler
+// emits IIFE for the service worker), so chain `.get().then(...)`.
+let previousRuleStates: Record | null = null;
+ruleStatesStorage.subscribe((next) => {
+ const previous = previousRuleStates;
+ previousRuleStates = { ...next };
+ if (previous === null) {
+ return;
+ }
+ for (const [kind, ruleId] of Object.entries(DETECTION_KIND_TO_RULE_ID) as [
+ DetectionKind,
+ string,
+ ][]) {
+ if (previous[ruleId] === true && next[ruleId] === false) {
+ clearDetectionsOfKind(kind);
+ }
+ }
+});
+// eslint-disable-next-line unicorn/prefer-top-level-await -- IIFE bundle, no TLA
+void ruleStatesStorage.get().then((initial) => {
+ previousRuleStates ??= { ...initial };
+});
+
chrome.runtime.onMessage.addListener(
(rawMessage: unknown, sender, sendResponse) => {
if (!rawMessage || typeof rawMessage !== "object") {
@@ -124,6 +221,27 @@ chrome.runtime.onMessage.addListener(
return undefined;
}
+ if (message.type === "rule-detection") {
+ const tabId = sender.tab?.id;
+ if (typeof tabId === "number") {
+ recordDetection(tabId, (message as RuleDetectionMessage).payload);
+ }
+ return undefined;
+ }
+
+ if (message.type === "get-tab-detections") {
+ const request = message as unknown as GetTabDetectionsRequest;
+ const entry =
+ typeof request.tabId === "number"
+ ? tabDetections.get(request.tabId)
+ : undefined;
+ const response: GetTabDetectionsResponse = {
+ detections: entry ? [...entry.values()] : [],
+ };
+ sendResponse(response);
+ return undefined;
+ }
+
return undefined;
},
);
diff --git a/extension/src/lib/detection-messages.ts b/extension/src/lib/detection-messages.ts
new file mode 100644
index 0000000..993364d
--- /dev/null
+++ b/extension/src/lib/detection-messages.ts
@@ -0,0 +1,48 @@
+// Copyright (c) 2026 PixieBrix, Inc.
+// Licensed under PolyForm Shield 1.0.0 — see LICENSE.
+
+// Shared payload + message types for surfacing rule detections to the
+// popup. Rules that hide their findings in the a11y tree (sr-only
+// landmarks) emit a `rule-detection` message at the same moment they
+// stamp the landmark; the background keeps a per-tab record so the popup
+// can render a human-visible "Detected on this page" list. Imported by
+// the rule modules (content world), the background service worker, and
+// the popup — keep this file dependency-free so it stays trivially
+// bundleable into each of those three entry points.
+
+import type { RoachMotelDifficulty } from "../rules/site-data.generated";
+
+export type DetectionKind = "roach-motel" | "webdriver-probe";
+
+export interface RoachMotelDetectionPayload {
+ kind: "roach-motel";
+ host: string;
+ url: string;
+ difficulty: RoachMotelDifficulty;
+ cancellationUrl: string | null;
+ source: "curated" | "justdeleteme";
+}
+
+export interface WebdriverProbeDetectionPayload {
+ kind: "webdriver-probe";
+ host: string;
+ url: string;
+}
+
+export type DetectionPayload =
+ | RoachMotelDetectionPayload
+ | WebdriverProbeDetectionPayload;
+
+export interface RuleDetectionMessage {
+ type: "rule-detection";
+ payload: DetectionPayload;
+}
+
+export interface GetTabDetectionsRequest {
+ type: "get-tab-detections";
+ tabId: number;
+}
+
+export interface GetTabDetectionsResponse {
+ detections: DetectionPayload[];
+}
diff --git a/extension/src/popup.html b/extension/src/popup.html
index e0bf478..5f68e8d 100644
--- a/extension/src/popup.html
+++ b/extension/src/popup.html
@@ -110,6 +110,71 @@
outline: 2px solid #2563eb;
outline-offset: 2px;
}
+ .detections {
+ margin: 0 0 12px;
+ }
+ .detections__heading {
+ margin: 0 0 6px;
+ padding-bottom: 4px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #555;
+ border-bottom: 1px solid #e4e4e7;
+ }
+ .detections__empty {
+ margin: 0;
+ font-size: 11px;
+ color: #888;
+ }
+ .detections__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+ .detection {
+ padding: 8px 10px;
+ border: 1px solid #fde68a;
+ background: #fffbeb;
+ border-radius: 6px;
+ font-size: 12px;
+ }
+ .detection strong {
+ display: block;
+ font-weight: 600;
+ color: #92400e;
+ }
+ .detection__host {
+ margin: 2px 0 0;
+ font-size: 11px;
+ color: #555;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ }
+ .detection__detail {
+ margin: 4px 0 0;
+ font-size: 11px;
+ color: #666;
+ line-height: 1.3;
+ }
+ .detection__cancel {
+ display: inline-block;
+ margin: 4px 0 0;
+ font-size: 11px;
+ color: #2563eb;
+ text-decoration: none;
+ }
+ .detection__cancel:hover {
+ text-decoration: underline;
+ }
+ .detection__source {
+ margin: 4px 0 0;
+ font-size: 10px;
+ color: #888;
+ }
.rule-groups {
display: flex;
flex-direction: column;
diff --git a/extension/src/popup/DetectionsSection.tsx b/extension/src/popup/DetectionsSection.tsx
new file mode 100644
index 0000000..8ba6d82
--- /dev/null
+++ b/extension/src/popup/DetectionsSection.tsx
@@ -0,0 +1,77 @@
+// Copyright (c) 2026 PixieBrix, Inc.
+// Licensed under PolyForm Shield 1.0.0 — see LICENSE.
+
+import type { DetectionPayload } from "../lib/detection-messages";
+import { useTabDetections } from "./use-tab-detections";
+
+const DIFFICULTY_LABEL: Record<"hard" | "very-hard" | "impossible", string> = {
+ hard: "hard",
+ "very-hard": "very hard",
+ impossible: "effectively impossible",
+};
+
+const SOURCE_LABEL: Record<"curated" | "justdeleteme", string> = {
+ curated: "FTC enforcement / consumer-press list",
+ justdeleteme: "JustDeleteMe directory",
+};
+
+export function DetectionsSection() {
+ const detections = useTabDetections();
+ // Hold off on rendering until the runtime fetch resolves — otherwise
+ // the empty state flashes for one frame on tabs that do have
+ // detections.
+ if (detections === null) {
+ return null;
+ }
+ return (
+
+ Detected on this page
+ {detections.length === 0 ? (
+ Nothing flagged on this page.
+ ) : (
+
+ {detections.map((detection) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function DetectionItem({ detection }: { detection: DetectionPayload }) {
+ if (detection.kind === "roach-motel") {
+ return (
+
+ Hard to cancel
+ {detection.host}
+
+ Cancellation is {DIFFICULTY_LABEL[detection.difficulty]} on this site.
+
+ {detection.cancellationUrl !== null && (
+
+ How to cancel
+
+ )}
+
+ Source: {SOURCE_LABEL[detection.source]}
+
+
+ );
+ }
+ return (
+
+ navigator.webdriver read
+ {detection.host}
+
+ This site can distinguish AI-agent traffic from human traffic and may
+ serve different content to agents.
+
+
+ );
+}
diff --git a/extension/src/popup/Popup.tsx b/extension/src/popup/Popup.tsx
index 03005ed..78f94d2 100644
--- a/extension/src/popup/Popup.tsx
+++ b/extension/src/popup/Popup.tsx
@@ -8,6 +8,7 @@ import { optionsButtonStorage } from "../lib/options-button-toggle";
import { RuleList } from "../lib/RuleList";
import { ruleStatesStorage } from "../lib/storage";
import { useChromeStorageValue } from "../lib/use-chrome-storage-value";
+import { DetectionsSection } from "./DetectionsSection";
export function Popup() {
const states = useChromeStorageValue(ruleStatesStorage);
@@ -60,6 +61,7 @@ export function Popup() {
)}
+
(null);
+ useEffect(() => {
+ let cancelled = false;
+ void fetchDetections()
+ .then((next) => {
+ if (!cancelled) {
+ setDetections(next);
+ }
+ })
+ .catch(() => {
+ // Service worker may be asleep / restarting — surface the empty
+ // state rather than hanging on `null`.
+ if (!cancelled) {
+ setDetections([]);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+ return detections;
+}
+
+async function fetchDetections(): Promise {
+ const [tab] = await chrome.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ if (typeof tab?.id !== "number") {
+ return [];
+ }
+ const request: GetTabDetectionsRequest = {
+ type: "get-tab-detections",
+ tabId: tab.id,
+ };
+ // @types/chrome infers the response type from `sendMessage`'s second
+ // generic. Defend with optional chaining for the SW-restart edge where
+ // the response could come back undefined.
+ const response = await chrome.runtime.sendMessage<
+ GetTabDetectionsRequest,
+ GetTabDetectionsResponse | undefined
+ >(request);
+ return response?.detections ?? [];
+}
diff --git a/extension/src/rules/__tests__/roach-motel-annotate.test.ts b/extension/src/rules/__tests__/roach-motel-annotate.test.ts
index a91a700..ef01c28 100644
--- a/extension/src/rules/__tests__/roach-motel-annotate.test.ts
+++ b/extension/src/rules/__tests__/roach-motel-annotate.test.ts
@@ -7,13 +7,23 @@ import { findWarning, roachMotelAnnotateRule } from "../roach-motel-annotate";
const LANDMARK_SELECTOR = 'section[data-abs-rule="roach-motel-annotate"]';
+// Rule's `apply` sends a `rule-detection` runtime message after landing
+// the landmark. `chrome.runtime.sendMessage` isn't present in jsdom, so
+// stub it here. Tests that assert call shape read from this mock.
+const sendMessageMock = jest.fn().mockResolvedValue(undefined);
+
beforeEach(() => {
document.body.innerHTML = "";
+ sendMessageMock.mockClear();
+ (globalThis as unknown as { chrome: unknown }).chrome = {
+ runtime: { sendMessage: sendMessageMock },
+ };
});
afterEach(() => {
roachMotelAnnotateRule.teardown();
hiddenTextStripRule.teardown();
+ delete (globalThis as unknown as { chrome?: unknown }).chrome;
});
describe("findWarning", () => {
@@ -110,6 +120,32 @@ describe("roachMotelAnnotateRule.apply (on nytimes.com/subscription/all-access)"
expect(document.querySelectorAll(LANDMARK_SELECTOR)).toHaveLength(1);
});
+ it("emits a rule-detection runtime message with the matched payload", () => {
+ roachMotelAnnotateRule.apply(document.body);
+
+ expect(sendMessageMock).toHaveBeenCalledTimes(1);
+ expect(sendMessageMock).toHaveBeenCalledWith({
+ type: "rule-detection",
+ payload: {
+ kind: "roach-motel",
+ host: "www.nytimes.com",
+ url: "https://www.nytimes.com/subscription/all-access",
+ difficulty: "hard",
+ cancellationUrl:
+ "https://help.nytimes.com/hc/en-us/articles/115014679508",
+ source: "curated",
+ },
+ });
+ });
+
+ it("does not re-emit the runtime message on repeated apply", () => {
+ roachMotelAnnotateRule.apply(document.body);
+ roachMotelAnnotateRule.apply(document.body);
+ roachMotelAnnotateRule.apply(document.body);
+
+ expect(sendMessageMock).toHaveBeenCalledTimes(1);
+ });
+
it("teardown removes the landmark", () => {
roachMotelAnnotateRule.apply(document.body);
expect(document.querySelector(LANDMARK_SELECTOR)).not.toBeNull();
@@ -160,6 +196,7 @@ describe("JustDeleteMe fallback", () => {
expect(warning?.notes ?? "").toContain(
"Source: JustDeleteMe (justdelete.me)",
);
+ expect(warning?.source).toBe("justdeleteme");
});
it("does not fire on a JDM host when path is not signup-y", () => {
@@ -185,6 +222,7 @@ describe("JustDeleteMe fallback", () => {
expect(warning).not.toBeNull();
expect(warning?.notes ?? "").toContain("FTC enforcement");
expect(warning?.notes ?? "").not.toContain("JustDeleteMe");
+ expect(warning?.source).toBe("curated");
});
it("returns null when neither curated nor JDM matches", () => {
diff --git a/extension/src/rules/__tests__/webdriver-probe-annotate.test.ts b/extension/src/rules/__tests__/webdriver-probe-annotate.test.ts
index 5e073dc..755348b 100644
--- a/extension/src/rules/__tests__/webdriver-probe-annotate.test.ts
+++ b/extension/src/rules/__tests__/webdriver-probe-annotate.test.ts
@@ -17,8 +17,17 @@ function dispatchProbe(): void {
const PROBE_FLAG = "__abs_webdriver_probe_installed";
+// The rule sends a runtime message on first landmark stamp; stub
+// `chrome.runtime.sendMessage` so apply() doesn't throw and so the
+// "emits to background" tests below can assert on it.
+const sendMessageMock = jest.fn().mockResolvedValue(undefined);
+
beforeEach(() => {
document.body.innerHTML = "";
+ sendMessageMock.mockClear();
+ (globalThis as unknown as { chrome: unknown }).chrome = {
+ runtime: { sendMessage: sendMessageMock },
+ };
// jest-environment-jsdom DOES execute inline