# Privacy

Sporekeeper is built so that the interesting question ("did you clock in?") can be
answered without the extension ever learning where you browse.

This document describes what the code does today, at the phase currently committed.
It is updated in the same commit as any change that affects it.

## Hard guarantees

These are absolute. Zero exceptions.

1. **The extension never stores browsing history.** There is no visit log, no
   ordered list of pages, no session replay, no "recently visited" cache.
2. **The extension never stores, logs, or transmits URLs, page titles, or tab
   contents for any site that is not on your explicit flagged list.** Non flagged
   pages are compared against your patterns and then discarded. Nothing about them
   is written anywhere.
3. **For flagged sites, the extension stores only a boolean match result and
   activity timestamps.** It never stores the full URL, the path, the query
   string, the fragment, or the page title. "You were active on a work site at
   14:03" is the entire record. Which work site is not recorded.
4. **All data stays in `chrome.storage.local`.** There is no server, no account,
   no sync, no analytics, no telemetry, no crash reporting, and no external
   network request of any kind. The extension makes zero `fetch` calls. The
   local bridge described below does not change this: it is two processes on
   your machine talking over a pipe, with no socket and no port.
5. **All assets are local.** The pixel art is generated from a matrix checked into
   this repo (see `tools/generate-icons.mjs`) and written into the extension
   bundle at build time. No image, font, script, or stylesheet is hotlinked from
   an external origin.
6. **Permissions are requested at the narrowest scope that works, and only once
   the code that uses them exists.** A permission is added to `manifest.json` in
   the same commit as its first real use, never speculatively.

## The one thing that can leave this machine

Everything above is local. A connected spreadsheet is the single exception, and
it is built so that the exception cannot widen quietly.

**It is off.** `sheetSync.enabled` is false in a fresh install and in every
settings object written before it existed. `sheetSyncAllowed()` is the only
function that may open it, every path that would make a request goes through
that one function, and pausing everything closes it: a kill switch that leaves
one pipe open is not a kill switch.

**Switching it on is not connecting.** It makes connecting possible. No request
is made until you sign in and choose a file.

**What would be sent, in full.** `SHEET_COLUMNS` in `src/lib/sheet-sync.ts`:

    What, Site, Status, Saved, Last moved, Link

Six columns. A test asserts a row never exceeds that header and never carries
the internal id. Sending anything more means editing that list, which means
editing this paragraph.

**Where.** The one file you picked, in the account you signed into. There is no
Sporekeeper server, so there is nowhere else for it to go.

**Never sent.** Your setups, your daily limits, your open tabs, your saved
sessions, your activity totals, or any page you did not save as a follow-up.

## The Privacy panel, in the extension itself

Everything in this document is also a screen. The Privacy category on the setup
page has three panels, and they exist because a promise you have to find a
document to read is not much of a promise.

**What is running.** One switch per thing, and no switch that turns on several
at once. Pause everything is the kill switch: nothing counted, no page looked
at, no card shown, and what you have saved stays saved.

**Enhanced context.** Five capabilities Sporekeeper could have if it were allowed to read more,
three of them now built. Each carries four facts: why it would want this, what
it would be able to read, what would be stored, and how long that would be
kept. Every one is off until you turn it on.

**One of the five is now built: open tab titles and addresses**
(`enhancedContext.tabMetadata`), and its switch is real. Off, the Spore Forest
groups by hostname exactly as it always has. On, sorting tabs onto shelves may
read the title and the address of each open tab, compare them against the rules
on the folders you made, and put matching tabs into a Chrome tab group.

What that does and does not mean, exactly. It reads every open tab, not only
the one in front, which is more than anything else in Sporekeeper looks at and
is the reason it is its own switch. It reads only when you press a button;
there is no timer and no background sort. What it reads is used to make one
decision and then dropped: `src/lib/shelves.ts` is a pure module that is handed
tabs and returns placements, and it has no way to write anything. What survives
the decision is the folders themselves, which are names and rules, and the
Chrome tab groups you can see on your own tab strip.

**A second one is now built: page text, on sites you approve one at a time**
(`enhancedContext.approvedPageContext`). It is the most invasive thing
Sporekeeper can do, so it is fenced on four sides.

It needs both a list and a grant. A page is read only when its host is on the
list you built in the privacy panel **and** Chrome has granted that origin.
That list is deliberately not the same as the sites flagged for counting work
time: reusing those would mean flagging a site as work silently made its
content readable, which is an escalation nobody agreed to. Removing a site from
the list also drops the Chrome grant, so the capability cannot outlive the
consent.

It reads only while you are sorting. There is no timer, no background pass, and
no content script that sits on a page waiting. The text is pulled by injection
at the moment you press Sort, capped at 20,000 characters in the page before it
crosses back, and used to answer one question: do this folder's words appear
here. Then it is gone. Nothing derived from any page is written to storage, and
guarantees 1 to 4 above are unaffected by it.

It is allowed to file a tab and it is not allowed to unfile one. If the page
backs up where the rules were sending a tab, the plan says so. If it does not,
the plan marks that placement as unsupported and still puts it where your rules
said, because a folder that quietly overrules the rules you wrote is worse than
one that is occasionally wrong out loud. Filing a tab from page text alone
needs two of the folder's words rather than one, since at that point the page is
the only evidence there is.

**A third one is now built: browsing history, to suggest folders**
(`enhancedContext.browserHistory`). This one needs reading against guarantee 1
above, carefully, because at a glance they look like they disagree.

Guarantee 1 says the extension never **stores** browsing history, and it still
does not. There is no visit log, no ordered list of pages, and no cache of
anywhere you have been. What this capability adds is a **read**, and only a
read: `chrome.history.search` is called at the moment you press Suggest, the
rows go into a pure function, and what comes back is a list of folder names and
the rules that would fill them. The rows are never written anywhere and are
gone when the call returns. If you accept a suggestion, what is stored is a
folder called Lever with the rule `jobs.lever.co` on it, which is a sentence
you wrote down, not a record of your visits.

Why it exists. What is open right now is a snapshot, and a snapshot misses the
shape of how somebody works: the site visited every morning is not open in the
afternoon, so the folder that would be most useful is the one your tab list can
never suggest.

How much it reads. Ninety days and at most a thousand rows. Further back
describes somebody who is no longer you, and the sites worth a folder are the
ones visited constantly, which are in the first thousand rows of any window.

It needs a Chrome permission as well as the switch, and the permission is
**optional** in the manifest rather than required. A fresh install has never
asked for it and could not use it: turning the switch on is the moment Chrome
asks, the switch stays off if you refuse, and turning it off hands the
permission back so the capability cannot outlive the consent.

What it will not do. It never proposes a folder for something an existing
folder already covers, it never creates a folder on its own, and it never
opens, closes, groups or touches a tab. A suggestion is a name and a rule with
a Make it button beside it, and nothing happens until you press it.

The remaining two are still **shown as unbuilt and cannot be switched on**,
which is the honest state for them: `mergeFeatures` stores the flags, and no
code reads them. A toggle that flips and changes nothing teaches people the
panel is decorative, and this is the one panel that cannot afford to be. When
one is built, its switch becomes real in the same commit, as this one did.

**Delete what is kept.** Six buttons: limit history, saved sessions,
follow-ups, folders, the sites you approved for reading, and everything else.
Each needs two presses, because none of them has an undo. They are wired to
`clearFocusHistory`, `clearTabSessions`, `clearFollowups`, `clearShelves`,
`clearReadableSites` and `clearAllPersonalData`, and they delete locally and
immediately. Nothing was sent anywhere, so there is nowhere else to delete from.

Two of them are worth spelling out.

Forgetting an approved site hands its Chrome grant back at the same moment,
and so does deleting everything. Clearing only the list would leave Chrome
still granting access to that site: the record of having agreed gone, and the
access still there. Reading a page requires both, so both go together, and
that is what `clearReadableSites` is for rather than a plain delete of the key.

Deleting folders deletes the rules and nothing else. The Chrome tab groups
those rules made are on your own tab strip and stay exactly as they are;
nothing in this project closes, ungroups, or moves a tab you did not ask it to.

The last button says "everything else" because it does not delete your setups.
Those are what the rest of the page is for, and a button that quietly took them
as well would be a trap. Each setup has its own delete, on the setup.

## Permissions currently requested

The manifest requests eight permissions and one optional host permission.

| Permission | Why it is needed | What it does not allow |
| --- | --- | --- |
| `storage` | Read and write `chrome.storage.local`, which holds the state machine state, your flagged site patterns, your companion choice, and activity timestamps. | It grants no access to page content, tabs, URLs, or history. It is local only: this extension does not use `chrome.storage.sync`, so nothing leaves the machine. |
| `alarms` | MV3 service workers are evicted when idle, so every timer longer than a few seconds has to be an alarm. One repeating alarm drives the `TICK` that expires snoozes and notices absence. | Alarms carry no data about you and cannot read anything. |
| `idle` | `chrome.idle` reports only `active`, `idle` or `locked` for the machine as a whole. It is what lets the extension tell working from a tab left open at lunch. | It carries no information about what you are doing, which application is in front, or what is on screen. |
| `tabs` | Needed to read a tab URL so it can be tested against the patterns you flagged. This is the widest permission in the project and the one that matters most. | The mitigation is architectural rather than promised: the URL is matched in memory and collapses to a job id or null before anything is written. No URL, page title, or tab id is ever stored, and none is ever sent anywhere. |
| `tabGroups` | Lets the Spore Forest put tabs into named, coloured Chrome tab groups, which is the only way an extension can make a group at all. Added in the same commit as the code that uses it. | It cannot read what a tab contains, and it does nothing on its own: every call is behind a button. A group is a label on tabs you already have open. |
| `history` (optional) | Lets the Spore Forest suggest folders for the places you keep going back to, which is the one thing your open tabs can never show. Optional, so a fresh install has never asked for it. | Read only, and only when you press Suggest. Ninety days, a thousand rows, into a pure function that returns folder names. Nothing read is stored, and turning the switch off hands the permission back. |
| `declarativeNetRequest` | Tiger parent mode. When a daily limit with blocking on is spent, a rule is added that stops requests to that domain, and it is removed when the day rolls over or you switch the toggle off. | It cannot read what you browse. Chrome matches the rules itself and never tells the extension what it matched, which is why blocking is done this way rather than by watching tabs. No rule exists for a domain you did not write a limit for. |
| `nativeMessaging` | Lets the extension talk to the local bridge, and only to a host whose own manifest names this exact extension id. | It grants the extension no filesystem access. Everything it learns is what the bridge chose to send, which is four fields per event. |
| `scripting` | Injects the reminder overlay into the one tab that triggered it, at the moment it triggers, and never otherwise. | It is gated by the host permission below, so it cannot reach a site you have not granted. There is no declared content script, so nothing runs on any page you have not been asked about. |
| `optional_host_permissions` (`*://*/*`) | Declares that a host permission **may** be asked for. Nothing is granted by declaring it. The extension requests one origin at a time, by pressing a button, in Chrome's own dialog. | Optional permissions start off. Until you grant a specific site, the extension cannot inject into it, and revoking it in `chrome://extensions` stops the overlay on that site immediately. Detection keeps working without it: the counter climbs, the reminder simply cannot be drawn. |

No host permission is granted by default and no content script is declared. The
extension still cannot read the content of any web page: `tabs` exposes a tab
URL, not its DOM, and `scripting` writes the overlay into a page without reading
anything out of it.

The companion art is still not in `web_accessible_resources`. Listing it would
make it fetchable by any page that knows the extension id, which is a way for a
site to detect that you run this extension. The art is read inside the service
worker and passed into the injected function as a data URL instead.

One resource is listed: the page you land on when tiger parent mode stops a
site. A redirect target has to be, or the redirect cannot happen, and the
alternative is Chrome's own "site can't be reached", which is indistinguishable
from broken internet. It is declared with `use_dynamic_url: true`, so its URL is
not a fixed string a site can probe for. That is a smaller exposure than the
whole art directory and it buys the one screen that explains what is happening.

## Permissions planned, and the privacy reasoning behind each

These are not in the manifest yet. They are listed so the plan is auditable in
advance, and each will be added only when its phase lands.

| Permission | Phase | Why the narrowest form is being used |
| --- | --- | --- |
| _(none outstanding)_ | | The Phase 4 rows that used to sit here have moved up into the table above. |

If a future phase needs a permission not in this table, it gets added here with a
justification in the same commit, or it does not ship.

## Two signal sources that need extra scrutiny

Beyond links, two other ways of detecting work have been requested. Neither is
implemented yet. Both are recorded here before any code is written, because both
touch data that the guarantees above would otherwise forbid.

### Mail from specific people (Outlook on the web)

The request: name a few people, and mail from them counts as work.

What this requires that links do not: reading the sender field out of the page.
That is page content, which guarantee 2 forbids for non flagged sites. Outlook
would be on your flagged list, so guarantee 2 permits it, and guarantee 3 then
binds what may be kept.

The rules this feature follows. All of it is built:
`src/lib/outlook.ts` holds the parsing and `src/content/outlook-reader.ts` the
DOM walk, with 35 tests between them.

- **The sender never leaves the tab.** This is the strongest property here and
  it is structural rather than a promise. The injected script is handed your
  watched names, does the comparison inside the page, and returns **an index
  into that list, or -1**. The worker therefore learns "the second name you
  wrote matched"; it cannot learn who sent anything, because there is no shape
  of return value that could carry it. There is a test asserting the return is
  always a number, whoever the mail is from. The name and the address never
  reach the worker, never reach storage, and never leave the script's own
  function scope. Injected scripts run in an isolated world, so the page cannot
  read your list either.
- **Only the sender is looked at.** Not the subject, not the body, not the
  recipient list, not attachments, not any other message in the mailbox.
- **Anything that does not look like a person is discarded.** A candidate longer
  than 120 characters is body text that leaked in, not a name, so it is dropped
  before any comparison happens. There are tests for this.
- **The names you typed are stored**, because you typed them and the extension
  has to compare against something. That is your input, not observed data.
- **Mail from anyone not on your list produces no record of any kind.** Not a
  count, not a hash, not a "someone else" marker.
- Outlook on the web only, inside a tab, and only after you grant host
  permission for that Outlook domain. The Outlook desktop application is outside
  the browser and no Chrome extension can see it.

Matching is done on **semantics, not CSS class names**: a `mailto:` href, an
email shaped string, an `aria-label`. A selector like `.ms-Persona-primaryText`
is one redesign away from breaking, and a matcher that breaks quietly is worse
than one that never shipped. When no strategy hits, the result is nothing, never
a guess.

Reading a sender name out of your mail is a real escalation over matching a URL.
It is listed here so the tradeoff is explicit rather than buried.

`tools/outlook-probe.js` is a one time diagnostic you paste into the Outlook
console to find out which strategy works on your mailbox. It makes no network
call, and it redacts addresses by default so its output is safe to share.

### Claude Code sessions, via the local bridge

The request: opening a Claude Code session in a particular folder counts as work
for the job that folder belongs to. This is the primary use case for the project,
so it is built rather than deferred.

A Chrome extension cannot see local processes, terminals, editor windows, or the
filesystem. No API exposes any of it. So the session reports itself, through the
three files in `bridge/`. See [bridge/README.md](bridge/README.md) for the
mechanics.

This adds a component that runs outside the browser, which is a real change to
the trust boundary and is why it gets its own section here.

**What crosses the boundary.** One line of JSON per event. Four fields, and this
is the entire vocabulary:

```json
{"v":1,"at":1754575200000,"kind":"activity","folder":"/Users/abby/code/day-job"}
```

**What is deliberately absent.** Claude Code hands a hook considerably more than
this. The `UserPromptSubmit` payload contains your prompt text, and every payload
carries a transcript path and a session id. The hook reads `cwd` and nothing
else. The wire format has no field that could carry the rest, so it cannot leak
by accident. There is a test (`bridge-events.test.ts`, "drops any extra field a
writer tries to smuggle through") asserting that even a future writer adding a
`prompt` field would have it stripped at the parse boundary.

**What the helper can reach.** One file, `~/.sporekeeper/events.jsonl`, which it
reads and deletes. That is the whole of its access to your machine. It does not
enumerate processes, read your code, or watch the filesystem. The directory is
created mode 0700 and the file mode 0600, so it is readable only by you.

**Still zero network.** The helper talks to Chrome over stdin and stdout, a pipe
between two processes on your machine. There is no socket, no port, and no
network call. Guarantee 4 holds unchanged.

**It is not a daemon.** Chrome launches the helper, it answers one question, and
it exits. It runs for roughly fifty milliseconds per poll and holds no state
between runs.

**Folders you did not list produce nothing.** The folder is compared against your
job list and dropped if it does not match, before anything reaches storage.
Working on a personal project is invisible to this extension.

**It is removable.** `npm run bridge:uninstall` deletes the Chrome registration
and the launcher. Deleting `~/.sporekeeper` removes the event file. Removing the
hook lines from `~/.claude/settings.json` stops anything being written in the
first place. Any one of those three, on its own, ends the data flow.

### Daily limits (Focus Limits)

A limit is a domain you typed and a number of minutes. What the extension does
with it is read the host of the tab in front, test it against your list, and
throw it away.

**What is read.** The URL of the active tab, in one function, when the machine
is not idle. It is reduced to a host immediately.

**What is compared.** The host, against the domains in your own rules. A rule
for `instagram.com` also covers `www.` and `m.` subdomains of it, which is why
the host is compared rather than the full URL.

**What is stored.** For a host that matched: the domain you already typed, plus
a total of active milliseconds for today, plus a date key. That is the whole
record. For a host that did not match: nothing at all, and there is no way to
tell from storage that you visited it. There is no visit log, no timestamp per
visit, no title, no path, no query string, no count of visits.

A stored total says "you spent 43 minutes on instagram.com today". It cannot say
which pages, in what order, or how many separate sittings.

**How long.** 90 days, `RETENTION.focusUsageDays`. Totals older than that are
dropped by `prune()` on the next sample that banks time. In practice only one
day per domain is ever present, because a total from a previous day is discarded
rather than added to, which is also how the daily reset works.

**Deleting it.** `clearFocusHistory()` removes every total and every piece of
transient state. `clearAllPersonalData()` removes the rules as well.

**Turning it off.** `features.focusLimits.enabled`, or the global
`monitoringPaused`. Either one means no host is read at all, and the open
stretch is closed rather than banked later as one unbroken sitting.

### Spore Forest

Tidying tabs means looking at tabs, so the guarantee here cannot be that it does
not look. It is that what it reads it does not keep.

**When it looks.** On a button press, and once a minute for a count.

The count is the new part, and the ordering of it is the design rather than an
optimisation. Once a minute the worker asks how many tabs are open. That is a
number. Only if the number is 35 or more does anything look at hosts, and only
then to work out whether there is a grouping worth offering. Under 35 tabs, no
host is examined at all, ever, on that path.

Everything else in `src/background/tab-garden.ts` still happens on a button
press: grouping, gathering duplicates, saving a session, restoring one.

**What it reads.** The tabs in the current window: address, title, pinned,
active, and when each was last used. It computes groups, duplicates and stale
tabs from that and returns counts and titles. The addresses are gone when the
function returns.

**What it stores.** Nothing, on every path except one.

**The exception, and it is a real one.** "Save this window" writes down the
address and title of every ordinary web page in the window. That is what a saved
session is; a session that forgot the addresses could not bring anything back.
It happens because you pressed a button that says save, it is listed in the rail
where you can see exactly what was kept, and deleting the session deletes the
addresses with it. `clearTabSessions()` deletes all of them at once.

**It never closes a tab.** Not a duplicate, not a stale one, not one you asked
it to. Duplicates are gathered into a group called Duplicates, next to each
other, and closing them is your click. A tab you did not mean to lose cannot be
recovered by the extension that lost it, and being right nine hundred times does
not pay for being wrong once. This is checkable rather than promised:

    grep -rn "tabs\.remove(" src/     returns nothing

**What else it never does.** It does not save a session on its own. It does not
rearrange tabs on its own: `autoApplyGroups` is off by default and nothing reads
it yet. The pure module also refuses to propose the tab you are looking at or a
pinned tab, with tests, rather than relying on care at the call site.

### Follow-ups

A follow-up is a page you saved on purpose. It holds a title and an address,
because a reminder about a job listing that cannot reopen the listing is not a
reminder.

**How one is created.** You press a button. That is the only way. Nothing
recognises a job listing, infers one, or watches for one. The capability that
would is `enhancedContext.approvedPageContext`, it is off by default, and no
code reads it.

**What is read, and when.** Once a minute the address of the tab in front is
compared against the addresses already on your own list. That comparison is the
only reason this is allowed to read one. A page you never saved matches nothing,
is written nowhere, and leaves no trace that you visited it. The fragment is
ignored, so scrolling to a section of a listing is still that listing.

**What is stored.** The follow-up you created: title, address, host, status,
when you saved it, when the status last moved, and a time you named if you
picked one. Kept until you delete it. `clearFollowups()` removes all of them.

**The time in the card is not trusted.** The picker computes the instant in the
page, because that is where the picker is, and any page can post a message to an
extension. `snoozeUntil` refuses anything that is not a finite number, in the
future, and inside a year, so a page cannot silence a follow-up until 9999.

## What is stored, concretely

Every key in `chrome.storage.local`, all of it on this machine, none of it
synced. `tools/check-privacy-doc.mjs` fails the build if a key exists in the
code and is not named here, so this list cannot quietly fall behind the software
it describes.

| Key | What it holds | Kept until |
| --- | --- | --- |
| `meta` | Storage schema version, and whether setup has been completed once. | Uninstall. |
| `settings` | Your thresholds, your work day boundary, and the two switches for the overlay and the bridge. | Uninstall. |
| `prefs` | The companion and scene you chose, and the clock in link you typed. | Uninstall. |
| `jobs` | Your setups: the name you gave each, the sites, people and folders you typed, and the companion you picked. All of it typed by you. | You delete the setup. |
| `jobStates` | Per setup: the state machine phase, today's work day key, milliseconds counted toward the next question, milliseconds worked today, when activity was last seen, whether a question is pending, and how many prompts have been shown today. | Reset daily; cleared on uninstall. |
| `focus` | The one open stretch: which setup is being credited and since when. A setup id and a timestamp, never a URL. | Overwritten constantly; removed when nothing is being counted. |
| `promptAttempt` | The result of the last attempt to draw a reminder: a setup id, an outcome such as shown or not-permitted, and a timestamp. It exists so a failure to inject is visible instead of silent. | Overwritten by the next attempt. |
| `bridgeAttempt` | The result of the last local bridge poll: an outcome such as no-host or credited, how many lines the bridge handed over, how many setups got time from them, and a timestamp. Counts only. Never a folder path, and never anything the bridge sent. It exists so a bridge that is not working is visible instead of silent. | Overwritten by the next poll, at most once a minute. |
| `features` | The feature switches and the Enhanced Context consent flags, all of the latter off. | Uninstall. |
| `focusRules` | Your daily limits: the domain you typed, the minutes you chose, warning and snooze settings, whether blocking is on, and the companion for it. | You delete the limit. |
| `focusUsage` | Per limited domain, one record: the domain you typed, a date, and total active milliseconds for that day. No visit log, no times of day, no count of visits. | 90 days, pruned automatically. |
| `focusStates` | Per limit: whether today's card has fired, whether it was snoozed and until when, and whether you dismissed it for the day. | Reset daily. |
| `focusStretch` | Which limited domain is in front and since when. One record, overwritten each sample, removed when you look elsewhere. | Not a history of anything. |
| `tidyState` | Whether the tab clutter card has been offered, snoozed or dismissed today. | Reset daily. |
| `tabSessions` | Sessions you saved: a name, a date, and the address, title and pinned state of each ordinary web page in the window. | You delete the session. |
| `followups` | Pages you saved: title, address, host, status, when you saved it, when its status last moved, its companion, and a reminder time if you set one. Plus a job title, a company and a pay range on any follow-up added by a scan you ran, read from a page you already had open. | You delete the follow-up. |
| `sheetColumns` | The headers of your follow-up spreadsheet and which field fills each one. The shape of the file, not its contents: no follow-up data is in here. | You reset the columns, or clear everything. |
| `shelves` | The folders you sort tabs onto: a name, a colour, the hosts and words that mean that folder, and whether you wrote it or accepted it from a proposal. Rules only. No tab, title or address of anything you had open is kept here. | You delete the folder. |
| `readableSites` | The hosts you approved for reading page text, one at a time. Hostnames and nothing else: no page, no path, no text, and nothing derived from anything that was read. | You remove the site, which also drops its Chrome grant. |

Two of these hold addresses the extension observed rather than ones you typed:
`tabSessions` and `followups`. Both exist only because you pressed a button that
says save, both are listed in the interface where you can see exactly what was
kept, and deleting either deletes the addresses with it.

Nothing here is ever sent anywhere. There is no Sporekeeper server. The only
capability that could send anything is the spreadsheet connector described
above, which is off, unbuilt, and would send the six columns named there.

## How to verify these claims yourself

- `grep -rn "XMLHttpRequest\|WebSocket\|sendBeacon" src/` returns nothing.
- `grep -rn "fetch(" src/` returns exactly two hits, both
  `fetch(chrome.runtime.getURL(path))` in `src/background/overlay.ts`. That form
  reads a file out of the extension's own bundle and cannot reach the network:
  the argument is a `chrome-extension://` URL. Any hit whose argument is not
  `chrome.runtime.getURL(...)` would be a real finding.
- `grep -rn "storage.sync" src/` returns nothing.
- `grep -rn "chrome.history" src/` returns nothing.
- `public/manifest.json` lists every permission the extension holds.
- The extension has no build step that pulls remote assets, so `dist/` contains
  only files produced from this repo.

## Uninstalling

Removing the extension from `chrome://extensions` deletes its
`chrome.storage.local` data along with it. There is nothing left behind anywhere
else, because there is nowhere else.
