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.
- The extension never stores browsing history. There is no visit log, no ordered list of pages, no session replay, no "recently visited" cache.
- 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.
- 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.
- 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 zerofetchcalls. 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. - 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. - Permissions are requested at the narrowest scope that works, and only once the code that uses them exists. A permission is added to
manifest.jsonin 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, each with 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 tab garden 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.
The remaining three 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 Tab Garden 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. |
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 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:
{"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.
Tab Garden
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. | You delete the follow-up. |
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, bothfetch(chrome.runtime.getURL(path))insrc/background/overlay.ts. That form reads a file out of the extension's own bundle and cannot reach the network: the argument is achrome-extension://URL. Any hit whose argument is notchrome.runtime.getURL(...)would be a real finding.grep -rn "storage.sync" src/returns nothing.grep -rn "chrome.history" src/returns nothing.public/manifest.jsonlists 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.
This page is generated from PRIVACY.md in the Sporekeeper source at build time and ships inside the extension. It is the same document the build check compares against the code, so it cannot describe behaviour the code does not have.