diff --git a/docs/superpowers/specs/2026-07-29-extension-options-design.md b/docs/superpowers/specs/2026-07-29-extension-options-design.md index 6b6a45a..d9e8c64 100644 --- a/docs/superpowers/specs/2026-07-29-extension-options-design.md +++ b/docs/superpowers/specs/2026-07-29-extension-options-design.md @@ -10,14 +10,15 @@ Add an options panel to the extension, reachable from `about:addons` → the extension's detail card → the **Options** tab (Windows) / **Preferences** tab (macOS, Linux). The panel configures two new entry points that this change also introduces: a context-menu item on selected text, and a keyboard shortcut that -opens a fresh ChatGPT chat. +does the same for the current selection, falling back to a fresh empty chat when +nothing is selected. The existing `gpt ` address-bar search provider is **not** modified and does not read the settings. See [Why the search provider is untouched](#why-the-search-provider-is-untouched). -New permissions: `storage` and `menus`. Both are in Firefox's no-prompt class, -so the install dialog gains no new lines and `data_collection_permissions: -["none"]` remains truthful. +New permissions: `storage`, `menus`, and `activeTab`. All three are in Firefox's +no-prompt class, so the install dialog gains no new lines and +`data_collection_permissions: ["none"]` remains truthful. ## Goals @@ -30,14 +31,20 @@ so the install dialog gains no new lines and `data_collection_permissions: ## Non-goals -- Making the `gpt ` search provider configurable. Out of scope by - decision; it requires either host permissions on `chatgpt.com` or a - navigation-interception mechanism, both of which conflict with goals 3 and 4. +- Making the `gpt ` search provider configurable. The declarative + `search_url` is fixed at install time, and the three routes around that were + each rejected: host permissions plus navigation interception (conflicts with + goals 3 and 4), a bundled extension page as `search_url` (Firefox refuses to + install), and `browser.omnibox` (works, but moves the transmitter of search + terms from Firefox to extension code — see Risk 6). `gpt ` keeps + behaving exactly as it does today. +- Auto-submitting the prompt on the user's behalf. Rejected on security grounds + after dedicated adversarial testing; see + [Appendix B](#appendix-b-why-auto-submit-was-rejected). +- Moving the `gpt` keyword to `browser.omnibox`. Deliberately declined to + minimise AMO data-collection exposure, not for lack of a working mechanism. - A toolbar button. - Named/multiple prompt templates with add-remove-reorder UI. One template only. -- Reading the page selection from the keyboard shortcut. That requires - `activeTab` + `tabs.executeScript`; deliberately excluded (see - [Rejected alternatives](#rejected-alternatives)). - Migrating to Manifest V3. - Chrome or other-browser packaging (`AGENTS.md` browser scope). - User-facing error notifications. The `notifications` permission *does* @@ -77,11 +84,23 @@ The one parameter with quasi-official standing is `q`, because OpenAI's own "ChatGPT search" browser extension uses `https://chatgpt.com/?q=`. Build on it; treat everything else as best-effort. -**`q` prefills but does not submit.** Auto-submit was real historically but has -been prefill-only since approximately November 2025, after OpenAI gated it on -the `Sec-Fetch-Site` header as a prompt-injection mitigation. No copy in the -options panel, README, or AMO listing may say the query is *searched*, *sent*, -or *asked* — only that ChatGPT opens **with the query filled in**. +**`q` prefills but does not submit — confirmed for this extension.** Auto-submit +was real historically but has been prefill-only since approximately November +2025, after OpenAI gated it on the `Sec-Fetch-Site` header as a prompt-injection +mitigation. + +Verified on 2026-07-29 in the maintainer's own logged-in Firefox profile, on +this extension's actual navigation path: the prompt lands in the composer and +waits. The user presses Enter twice — once to leave the address bar, once to +send. The hypothesis that address-bar navigation might still be favoured +(`Sec-Fetch-Site: none` being the most-trusted value) **does not hold in +practice**. + +No copy in the options panel, README, or AMO listing may say the query is +*searched*, *sent*, or *asked* — only that ChatGPT opens **with the query filled +in**. The two-keypress behaviour is an upstream ChatGPT change, not a defect in +this extension, and is documented as such rather than worked around; see +[Rejected alternatives](#rejected-alternatives). ## Architecture @@ -134,7 +153,7 @@ only `mergeSettings()` is unit-tested. ### Manifest additions ```json -"permissions": ["storage", "menus"], +"permissions": ["storage", "menus", "activeTab"], "background": { "scripts": ["background/background.js"], "type": "module", @@ -146,9 +165,9 @@ only `mergeSettings()` is unit-tested. "browser_style": false }, "commands": { - "new-chat": { + "ask-chatgpt": { "suggested_key": { "default": "Alt+Shift+G" }, - "description": "Open a new ChatGPT chat" + "description": "Ask ChatGPT about the selected text" } } ``` @@ -302,8 +321,25 @@ both. ## Data flow -1. **Trigger.** `browser.menus.onClicked` supplies `info.selectionText`, or - `browser.commands.onCommand` supplies no query. +1. **Trigger.** Two entry points, both producing a query string: + - `browser.menus.onClicked` supplies `info.selectionText` directly. No + injection, no `activeTab` involved. + - `browser.commands.onCommand` reads the selection itself: resolve the active + tab with `tabs.query({active: true, currentWindow: true})`, then + `tabs.executeScript` returning `window.getSelection().toString()`. Read + **only** `tab.id` from the query result — reading `tab.url` or `tab.title` + would require the `tabs` permission, which *does* carry an install warning. + + Pass **both** `allFrames: true` (a selection inside an iframe is invisible + to the top frame) and `matchAboutBlank: true` (needed for `about:blank` + frames). Have the injected snippet report which frame it came from, and + take the first non-empty result. + + **Null-check every element of the result array.** `executeScript` does not + only throw — on parent-process `about:` pages such as `about:addons` and + `about:preferences` it resolves *silently* to `[null]`, so + `results[0].text` is a TypeError waiting to happen. If nothing usable comes + back the query is empty and a blank chat opens. 2. `settings = await load()` — `load()` already pipes the raw `storage.sync.get()` result through `mergeSettings()`, so callers always receive a complete, validated settings object and never need to merge again. @@ -327,6 +363,7 @@ Every path resolves to something useful. No path shows an error to the user. | Case | Behaviour | |---|---| | Selection empty or whitespace | Omit `q` entirely; open a blank chat with the other settings applied. | +| `executeScript` fails on a restricted page (`about:`, `addons.mozilla.org`, `accounts.firefox.com`, `view-source:`, the PDF viewer) | Failure has **three** measured shapes, all of which must be handled: a thrown `Missing host permission for the tab`, a thrown variant naming frames, and a **silent resolution to `[null]`** on parent-process `about:` pages. Catch the throws, null-check the array, and treat all three as an empty selection → open a blank chat. The shortcut must never appear broken just because the user pressed it on a Firefox page. | | Prompt exceeds the URL budget | Truncate per step 3 above. Never sever a percent-escape or split a surrogate pair. | | `storage.sync` rejects (quota, or a sanitized backend error) | `try`/`catch` → fall back to `DEFAULT_SETTINGS`. Never block opening ChatGPT. Do not branch on error type: Firefox sanitizes all non-quota backend errors to `"An unexpected error occurred"`. | | Fresh or signed-out profile returns `{}` | `mergeSettings` layers stored values over `DEFAULT_SETTINGS`, so `{}` yields defaults. | @@ -378,8 +415,18 @@ Add: - The page is also usable standalone via `browser.runtime.openOptionsPage()` - Settings persist across a browser restart - The context-menu item appears only when text is selected, and is absent otherwise -- `Alt+Shift+G` opens a new chat, appears under `about:addons` → gear → Manage - Extension Shortcuts, and can be rebound +- `Alt+Shift+G` with text selected opens ChatGPT with that text, matching the + context menu; with nothing selected it opens a blank chat; on a restricted + page (`about:config`, an AMO page) it opens a blank chat rather than failing + visibly. It appears under `about:addons` → gear → Manage Extension Shortcuts + and can be rebound +- **The shortcut must be verified by actually pressing it.** + `commands.getAll()` reports a `suggested_key` as registered even when Firefox + has silently overridden it — measured: `Ctrl+Shift+Y` (Downloads on Linux) + showed as registered and never fired. A green `getAll()` is not evidence the + shortcut works. +- A selection inside an iframe is picked up by the shortcut, not just top-level + page text - Each of the three `openIn` values behaves correctly - **A packaged build's install prompt gains no new lines.** This cannot be checked with `npm run dev` — install prompts are suppressed for temporary @@ -469,6 +516,21 @@ does not imply CI green. Sync with Add-ons enabled, synced end-to-end encrypted through their own Mozilla account. Do **not** write "never leaves your device" unconditionally. Nothing reaches Zavage Software, so the zero-data-collection claim stands. + + Revisit the bare sentence "This extension collects zero user data." It is true + in the plain-English sense and `data_collection_permissions: ["none"]` is + retained, but AMO's definition of transmission is broader (Policy 6, 6.2.2.1), + and the context menu does put selected text into a chatgpt.com URL. Prefer + wording that is specific about what happens — the extension sends the text you + chose to the ChatGPT URL you asked it to open, stores your settings in your own + profile, and reports nothing to Zavage Software — over a blanket claim a + reviewer could read differently. See Risk 6. +- **README** — document the two-keypress behaviour plainly as an upstream + ChatGPT limitation: ChatGPT stopped auto-submitting prompts passed by URL, so + the query arrives typed into the composer and the user presses Enter to send. + State that this is outside the extension's control and that no API key, + server, or intermediary is involved. Do not describe it as a bug or promise a + fix. - **AGENTS.md** — update Project Structure for the new directories, Build/Test commands for `test:unit`, and the Testing Guidelines manual checklist. - **CHANGELOG.md** — entry for the release. @@ -480,12 +542,16 @@ does not imply CI green. |---|---| | Rewrite the `gpt` keyword's navigation to honour settings | Needs host access to `chatgpt.com` plus `webRequest`/`tabs`; adds install-prompt lines. | | Point `search_url` at a bundled extension page that redirects | Firefox requires an http(s) search URL; also a visible redirect flash. Not pursued. | -| Keyboard shortcut reads the page selection | Requires `activeTab` + `tabs.executeScript`. No lighter route exists — a declared `` content script is strictly worse. Excluded by decision; revisitable as its own change. | +| ~~Keyboard shortcut reads the page selection~~ | **No longer rejected — now in scope.** Initially dropped believing `activeTab` carried a permission cost. Verification showed `activeTab` is in Firefox's `OptionalPermissionNoPrompt` class and adds no install warning, and reading a selection on an explicit gesture to build the requested URL transmits nothing, so `data_collection_permissions: ["none"]` holds. The lighter-looking alternatives are all worse: a declared `` content script is a real host permission with a warning. | | `background.page` HTML shim for ES modules | Works, but unnecessary once `background.type: "module"` was confirmed for MV2. | | `background.scripts` without `type` | Classic scripts; an `import` statement fails with a compile-time `SyntaxError` and silently kills the script. | | Curated model dropdown | Slug lists rot within a quarter and every stale entry fails silently to the account default. | | Migrate to MV3 now | Orthogonal, larger diff. Mozilla has announced no MV2 sunset and promises ≥12 months' notice. | | Toast/notification on failure | The `notifications` permission adds an install warning. All failure paths degrade silently instead. | +| Content script on `chatgpt.com` that presses Enter for the user | **Rejected on security grounds after dedicated adversarial testing — see [Appendix B](#appendix-b-why-auto-submit-was-rejected). Not a future path; the minimum bar is recorded there so it cannot be revived cheaply.** Three independent attack lenses returned "not viable". The decisive result is that the design authenticates the wrong thing: proving the extension started the navigation is easy, but auto-submit needs to prove the *user authored the prompt*, and a hostile page can smuggle ~16 KB of invisible text into `info.selectionText`. **Decision: document the two-keypress behaviour as an upstream ChatGPT limitation instead.** | +| Opt-in auto-submit via `optional_permissions` | The permission machinery works — verified, `permissions.request()` succeeds from the inline `about:addons` panel and the Firefox install dialog is provably unchanged. It was the *safety* that failed, not the plumbing. Also: an optional host permission is visible on the AMO listing page before install, so it was never free. | +| `browser.omnibox` to make `gpt ` honour the settings | **Technically viable and verified working** — needs no permissions, reuses the same `gpt` keyword, lints clean. Rejected for policy exposure: it moves the transmitter of search terms from Firefox to extension code, which is the specific change that puts `data_collection_permissions: ["none"]` at risk (AMO 6.2.2.1 classes search terms as personal data). Declining it keeps Firefox as the transmitter. Revisit only after AMO answers the declaration question. | +| `search_url` pointing at a bundled extension page | Impossible, not merely discouraged. The Firefox schema and addons-linter both constrain `search_url` to `https://` or `http://localhost`, and Firefox 153.0.1 **refuses to install the extension entirely** ("Extension is invalid") for `moz-extension://`, a relative path, or a root-relative path. `search_url_post_params` only changes the method; `search_form` is scheme-restricted and unsupported on Firefox. | ## Risks @@ -504,6 +570,26 @@ does not imply CI green. 4. **MV2's long-term future is not guaranteed.** The strongest Mozilla commitment is dated March 2024 and nothing newer restates it. Mitigated by keeping the code MV3-portable. +5. ~~The shortcut's selection-reading depends on a `commands` invocation + conferring `activeTab`.~~ **Resolved, positive.** Firefox's + `ext-commands.js` calls `tabManager.addActiveTabPermission(tabTracker.activeTab)` + before firing the event, and a real synthesized keypress in Firefox 153.0.1 + read page text with `activeTab` as the only host-ish permission. No longer a + risk. +6. **`data_collection_permissions: ["none"]` carries residual policy risk for + the selection paths.** AMO Policy 6 defines transmission as data handled + outside the add-on or local browser, and 6.2.2.1 names search terms as + personal data. Once extension code reads page text and puts it in a + chatgpt.com URL, a reviewer could argue `websiteContent` applies. The + mitigating reading is 6.2.2.2's implicit consent — a user-initiated action + through a clearly labelled control — but 6.2.2.2(5) is explicitly + discretionary and no published Mozilla guidance resolves it. This design + deliberately minimises exposure by **not** moving the `gpt` keyword to + `browser.omnibox`, leaving Firefox as the transmitter for the address-bar + path. If AMO ever requires declaring `searchTerms`, the install prompt gains + "The developer says this extension collects: search terms" — which would + conflict with this design's no-new-install-lines goal. Worth asking AMO + before submission, not at submission. ## Appendix A: Verification findings @@ -580,7 +666,7 @@ by running commands against copies under `/tmp`; the repo itself was unmodified. | Parameter | Rating | Basis | |---|---|---| | `q` prefills | **Solid** | Dated 2026 sources; maintained tooling; OpenAI's own extension uses it; a live probe returned HTTP 200 with all parameters preserved. | -| `q` auto-submits | **Refuted** | Prefill-only in every dated report since 2025-11-11. Tenable TRA-2025-22 documents OpenAI gating auto-submit on `Sec-Fetch-Site`. | +| `q` auto-submits | **Refuted, and confirmed by first-hand test** | Prefill-only in every dated report since 2025-11-11. Tenable TRA-2025-22 documents OpenAI gating auto-submit on `Sec-Fetch-Site`. Reproduced 2026-07-29 in the maintainer's logged-in Firefox via this extension's own search-provider path. | | `model` honoured alongside `q` | **Uncertain** | An unresolved 2024-12-28 report says `q` forces GPT-4o regardless; counter-evidence exists from 2024–2025; no 2026 source positively confirms. Maintained tooling grades it experimental. | | Valid `model` slugs | **Uncertain** | No authoritative list; sightings rot (`gpt-4`, `o1-preview`, `gpt-4o`, `gpt-5-thinking`, `auto`, `instant`, `thinking`). | | `temporary-chat=true` | **Uncertain** | Community-confirmed originally; multiple reports of it not applying or reverting after the first message; OpenAI reworking the feature. | @@ -594,11 +680,129 @@ by running commands against copies under `/tmp`; the repo itself was unmodified. ### Unresolved -- Whether Firefox search-provider navigations land in the favoured - `Sec-Fetch-Site: none` bucket for auto-submit. Inference from the Fetch - Metadata spec, contradicted by a dated report of a default-search string - losing auto-submit. Settled only by the manual parameter reality check. +- ~~Whether Firefox search-provider navigations land in the favoured + `Sec-Fetch-Site: none` bucket for auto-submit.~~ **Resolved 2026-07-29, + negative.** Tested in the maintainer's logged-in Firefox on this extension's + own path: the prompt only prefills. Being in the most-trusted navigation + bucket does not restore auto-submit. - Whether Firefox enforces Chrome-style `storage.sync` write-rate quotas. The constants exist in the schema but no rate-limiting logic appears in the Rust backend. Made moot by debouncing writes. - Whether `Alt+Shift+G` collides with OS-level or other-add-on shortcuts. +- Whether AMO accepts `required: ["none"]` once extension code reads page text + (Risk 6). Ask AMO; 6.2.2.2(5) is discretionary and unresolved in published + guidance. + +### Round two (2026-07-30) + +A second verification pass, again with adversarial challenge. Browser claims +tested by driving real Firefox 153.0.1 under Xvfb via Marionette — real +keypresses, real address-bar input, real permission doorhangers — not headless +approximations. + +- **A `commands` keyboard shortcut confers `activeTab`.** Firefox's + `ext-commands.js` calls `tabManager.addActiveTabPermission(tabTracker.activeTab)` + before firing `onCommand`. A synthesized real keypress read page text with + `permissions: ["activeTab", "menus"]` and nothing else; the identical call + from a no-gesture background path threw `Missing host permission for the tab`. +- **`executeScript` fails in three shapes**, one silent: two distinct throws plus + a resolution to `[null]` on parent-process `about:` pages. +- **`suggested_key` loses silently to Firefox's built-ins** while + `commands.getAll()` still reports it registered. `Ctrl+Shift+Y` never fired. +- **`activeTab` adds no install-prompt line** — re-confirmed by running + Firefox's own `ExtensionData.formatPermissionStrings` inside 153.0.1: + `["activeTab", "storage", "menus"]` yields `msgs: []`, byte-identical to + requesting nothing. +- **`search_url` cannot be an extension page.** Schema and addons-linter + constrain it to `https://` or `http://localhost`; Firefox 153.0.1 refuses to + install ("Extension is invalid") for `moz-extension://`, relative, and + root-relative paths. +- **`browser.omnibox` works with zero permissions** and can reuse the `gpt` + keyword alongside the registered engine, linting clean. Declined for policy + exposure, not capability — see [Rejected alternatives](#rejected-alternatives). +- **`permissions.request()` works from the inline `about:addons` options page.** + Bug 1382953 was fixed in Firefox 61. The grant does not reload the inline + page. It must be the **first statement** in the click handler: awaiting a real + API call first loses the gesture and rejects with `permissions.request may + only be called from a user input handler`, and the gesture does not survive + `runtime.sendMessage` delegation to the background. +- **An optional permission cannot reach the Firefox install dialog** — the + install prompt's info object has no top-level `optionalPermissions`, the only + key the string formatter reads for that section. Verified with real XPI + installs plus an order-reversed control. +- **But the AMO listing page does change.** With an optional host permission the + listing gains "Optional permissions: Access your data for chatgpt.com", + *before* install. Confirmed against a live MV2 control (SponsorBlock). Note + publishing the already-committed `data_collection_permissions` will surface + that card by itself, independent of anything here. + +## Appendix B: Why auto-submit was rejected + +Recorded in full so the feature cannot be revived without re-clearing the same +bar. Three adversarial lenses — prompt injection, permission integrity, AMO +policy — independently returned "not viable". + +**The design authenticated the wrong invariant.** "Did the user initiate this +navigation?" is provable. "Did the user *author this text*?" is what auto-submit +needs. For the two entry points this spec builds, the text comes from the page +DOM — so extension-initiated provenance is not merely insufficient, it is +inverted: the paths the extension can prove it started are exactly the paths +where a hostile page supplies the payload. + +**Selection smuggling (confirmed empirically).** A hostile page can place ~16 KB +of invisible instructions into `menus.onClicked`'s `info.selectionText` while the +user sees only innocuous bait text. The user's click is genuine; the prompt is +the attacker's. The only thing standing between an attacker-chosen prompt and +the user's logged-in ChatGPT account is that the text sits visibly in the +composer until a human presses Enter. **Auto-submit deletes that checkpoint.** +This is also why the context menu and shortcut remain safe *as long as they only +prefill*. + +**`webNavigation.transitionType` is not a security boundary.** The signal looks +right — a `gpt` search emits `transitionType: "generated"` with +`transitionQualifiers: ["from_address_bar"]`, and content cannot forge those +values directly. But the marker is stashed per-tab and consumed only when a +document *commits*, so a urlbar navigation that never commits (HTTP 204, a +download, an external-protocol handler, a cancelled load) leaves it armed and +**the next navigation to commit inherits it, whoever started it.** Demonstrated: +a page navigating itself via its own inline timer was reported as `typed` + +`["from_address_bar"]` **10.47 seconds** after the urlbar action, against a +control of `link` + `[]`. The intended 5-second freshness guard is dead code — +`data.time - Date.now() > RECENT_DATA_THRESHOLD` is always false (inverted +subtraction, and a microsecond-scaled constant compared against a millisecond +delta) — so the stale marker never expires and an attacker need win no race: it +arms a timer on blur and waits. Separately, `webNavigation` needs no host +permission to see full URLs, so granting it would expose the user's entire +real-time browsing history for a gate that does not hold. + +**Same-tab substitution defeats the pending-record scheme (confirmed).** Even +restricted to omnibox-typed text, `omnibox`'s default disposition is the current +tab. The outgoing hostile document receives `beforeunload` — a deterministic +signal that the extension's navigation has begun, not a race — arms +`setTimeout(…, 0)`, and sets `location.href` to `chatgpt.com/?q=EVIL`. Measured +beacon sequence: `ext-navigate-start[q=HONEST]` → `beforeunload fired` → +`timer0: set location OK` → `chat-loaded[q=EVIL-TIMER]`. The extension believes +it navigated the tab to the user's prompt; the document actually there is the +attacker's, and the pending record is still armed. (Opener-based substitution is +dead in Firefox 153 — chatgpt.com sends `COOP: same-origin`, and cross-origin +location writes are denied regardless.) + +**What was *not* the problem.** The plumbing all works: `permissions.request()` +from the inline panel, the unchanged Firefox install dialog, optional +data-collection permissions in Firefox 153. The rejection is about safety, and +secondarily about AMO policy — not about feasibility. + +**Minimum bar if ever revisited.** Auto-submit may only ever apply to text the +extension can attest the user typed into browser chrome (an `omnibox` +`onInputEntered` value — a page can neither write the omnibox, fire the event, +nor observe it). Never to `info.selectionText`. It must open a **new** tab, never +navigate the current one. The pending map must stay in memory only — never +persisted, because tab ids restart low at browser start, creating a replay +hazard. Authorization must be re-validated synchronously at the instant of +submit against the live `location.search`, never latched, since chatgpt.com is a +client-routed SPA and the content script survives same-document navigation. +Single attempt, ~1.5–2 s timeout, `allFrames: false` with an explicit top-frame +assertion, abandon permanently on any navigation event, never retry, never a +fallback chain. And the AMO declaration question (Risk 6) must be answered +first, because it decides whether the feature is compatible with a clean install +prompt at all.