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