Skip to content

feat: add Research Edition privacy filter for window titles#130

Open
TimeToBuildBob wants to merge 12 commits into
ActivityWatch:masterfrom
TimeToBuildBob:feat/research-edition-filter
Open

feat: add Research Edition privacy filter for window titles#130
TimeToBuildBob wants to merge 12 commits into
ActivityWatch:masterfrom
TimeToBuildBob:feat/research-edition-filter

Conversation

@TimeToBuildBob

Copy link
Copy Markdown
Contributor

Summary

Adds an opt-in Research Edition mode that classifies browser window titles into study categories and drops non-browser titles entirely — replicating the approach from the ERC research fork without the overhead of maintaining a separate fork, code-signing pipeline, or notarization setup.

This is the watcher-side half of the ActivityWatch Research Edition for the Lund/IIIEE study. The CI build variant (which bakes in research_enabled = true at package build time) is a separate PR to the main activitywatch bundle repo.

What changed

  • aw_watcher_window/research_filter.py (new) — pure-Python, no external deps:
    • is_browser(app) — case-insensitive lookup against a known browser app list
    • classify_title(title, category_map) — first-match substring classifier; returns "excluded" when nothing matches
    • transform(window, category_map) — applies the full ERC transform: browser titles → category, non-browser titles → dropped entirely (only app recorded)
  • aw_watcher_window/config.py — adds research_enabled = false default and [aw-watcher-window.research_category_map] TOML table; exposes --research CLI flag
  • aw_watcher_window/main.pyheartbeat_loop applies research_transform() after the existing exclude_title/exclude_titles step when Research Edition is enabled
  • tests/test_research_filter.py (new) — 19 tests covering is_browser, classify_title, and transform (input immutability, url/incognito field preservation, macOS JXA extra fields, edge cases)

Behaviour at a glance

# Normal user — zero behaviour change (research_enabled = false by default)

# Researcher config (~/.config/activitywatch/aw-watcher-window/aw-watcher-window.toml):
# [aw-watcher-window]
# research_enabled = true
#
# [aw-watcher-window.research_category_map]
# youtube = "Entertainment"
# facebook = "Social Media"
# gmail = "Email"

# Browser event: {"app": "Chrome", "title": "YouTube - Google Chrome"}
# → {"app": "Chrome", "title": "Entertainment"}

# Browser event (unmatched): {"app": "Firefox", "title": "Hacker News"}
# → {"app": "Firefox", "title": "excluded"}

# Non-browser event: {"app": "iTerm2", "title": "~/projects/foo"}
# → {"app": "iTerm2"}   (title dropped)

macOS note

The swift strategy (the default on macOS) delegates to the compiled Swift helper and bypasses this Python transform. Use --strategy jxa or --strategy applescript on macOS if Research Edition support is needed there.

Test plan

  • python3 -m unittest tests/test_research_filter.py — all 19 tests pass
  • Normal run with no config change: behaviour unchanged
  • Run with research_enabled = true and a category map: browser titles classified, non-browser titles dropped
  • Run on macOS with --strategy jxa + --research: titles transformed correctly

Adds an opt-in transform that classifies browser window titles into
study categories and drops non-browser titles entirely, replicating the
ERC fork approach without maintaining a separate fork.

- New module `research_filter.py`: `is_browser()`, `classify_title()`,
  `transform()` — all pure functions, no dependencies beyond stdlib
- Config: `research_enabled = false` (default off) + TOML table
  `[aw-watcher-window.research_category_map]` for the researcher's map
- CLI: `--research` flag mirrors the config option
- main.py: `heartbeat_loop` applies the transform after existing
  `exclude_title`/`exclude_titles` steps
- 19 tests covering is_browser, classify_title, and transform (including
  input-immutability, url/incognito preservation, macOS JXA extra fields)

macOS note: the `swift` strategy delegates to the compiled Swift helper
and bypasses this Python transform. Use `--strategy jxa` or
`--strategy applescript` on macOS for Research Edition support.

Part of ActivityWatch Research Edition for the Lund/IIIEE study.
@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds an opt-in Research Edition privacy filter that classifies browser window titles into configurable study categories and drops non-browser app titles entirely. The previous blocking concerns — silent macOS Swift bypass, double load_config() call, empty-category-map silent bypass, and non-browser URL leakage — have all been resolved in this iteration.

  • research_filter.py (new): pure-Python transform that classifies browser titles by substring match (URL-first, title-fallback) and drops non-browser titles; strips URLs from both paths and preserves incognito metadata.
  • macos.swift: Swift helper updated with matching research logic, --research/--research-category argument parsing, and NetworkMessage.title made String? to represent dropped titles.
  • config.py / main.py: adds research_enabled config knob and --research/--no-research CLI flags; transform_window helper cleanly separates research transform from legacy exclude logic.

Confidence Score: 5/5

Safe to merge; all critical path changes are covered by tests and the previous blocking concerns have been resolved.

The Swift binary now correctly handles research mode end-to-end via its own --research/--research-category argument parsing, the Python transform uses is None (not falsiness) so an empty category map correctly applies the filter, non-browser URLs are stripped in both paths, and config loading was consolidated into parse_args. The remaining findings are cosmetic: one Swift log line renders the optional wrapper literally, and the two browser-app lists are duplicated across Swift and Python with no sync guard.

aw_watcher_window/macos.swift — one error-log interpolation of the now-optional data.title will print Optional(...). Both browser-app lists (BROWSER_APPS in Python and researchBrowserApps in Swift) need to be updated together when new browsers are added.

Important Files Changed

Filename Overview
aw_watcher_window/research_filter.py New pure-Python privacy transform; correctly uses is None guard (not falsiness), never mutates input, strips URLs from both browser and non-browser paths, and preserves incognito metadata.
aw_watcher_window/macos.swift Swift helper correctly implements research mode with matching browser-app set and URL-first classification; title changed to String? — one pre-existing debug log line now renders the optional wrapper literally.
aw_watcher_window/main.py Clean integration: research_category_map set to None when disabled, passed to both Swift subprocess and Python heartbeat loop; transform_window correctly routes to research transform first.
aw_watcher_window/config.py Adds research_enabled default and research_category_map TOML table; category map attached to parsed_args in parse_args(), eliminating any second config load.
aw_watcher_window/macos_cli.py Correctly appends --research and --research-category pattern category pairs only when research_category_map is not None; empty dict still passes --research.
tests/test_research_filter.py Comprehensive: covers is_browser case-insensitivity, URL-first classification priority, transform immutability, URL stripping, Linux WM class names, and edge cases.
tests/test_main.py New tests verify research map is passed to the Swift subprocess command and that research transform takes precedence over exclude_title/exclude_titles.
tests/test_config.py New tests confirm research_category_map is attached to args and that --no-research correctly overrides a config-enabled setting.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Window event captured] --> B{research_enabled?}
    B -- No --> C{exclude_title or\nexclude_titles match?}
    C -- Yes --> D[title = 'excluded']
    C -- No --> E[Send event as-is]
    D --> E

    B -- Yes --> F{macOS + swift strategy?}
    F -- Yes --> G[Pass --research + --research-category\nflags to Swift binary]
    G --> H{isResearchBrowser?}
    H -- Yes --> I[classifyResearch\nURL-first then title]
    I --> J{Pattern match?}
    J -- Yes --> K[title = category name]
    J -- No --> L[title = 'excluded']
    K --> M[Send event: app + title, url stripped]
    L --> M
    H -- No --> N[Send event: app only\ntitle + url dropped]

    F -- No --> O[Python heartbeat_loop\nresearch_transform called]
    O --> P{is_browser app?}
    P -- Yes --> Q[classify_title\nURL-first then title]
    Q --> R{Pattern match?}
    R -- Yes --> S[title = category name]
    R -- No --> T[title = 'excluded']
    S --> U[Send event: app + title, url stripped]
    T --> U
    P -- No --> V[Send event: app only\ntitle + url dropped]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[Window event captured] --> B{research_enabled?}
    B -- No --> C{exclude_title or\nexclude_titles match?}
    C -- Yes --> D[title = 'excluded']
    C -- No --> E[Send event as-is]
    D --> E

    B -- Yes --> F{macOS + swift strategy?}
    F -- Yes --> G[Pass --research + --research-category\nflags to Swift binary]
    G --> H{isResearchBrowser?}
    H -- Yes --> I[classifyResearch\nURL-first then title]
    I --> J{Pattern match?}
    J -- Yes --> K[title = category name]
    J -- No --> L[title = 'excluded']
    K --> M[Send event: app + title, url stripped]
    L --> M
    H -- No --> N[Send event: app only\ntitle + url dropped]

    F -- No --> O[Python heartbeat_loop\nresearch_transform called]
    O --> P{is_browser app?}
    P -- Yes --> Q[classify_title\nURL-first then title]
    Q --> R{Pattern match?}
    R -- Yes --> S[title = category name]
    R -- No --> T[title = 'excluded']
    S --> U[Send event: app + title, url stripped]
    T --> U
    P -- No --> V[Send event: app only\ntitle + url dropped]
Loading

Reviews (8): Last reviewed commit: "fix(research): prioritize research trans..." | Re-trigger Greptile

Comment thread aw_watcher_window/main.py Outdated
Comment thread aw_watcher_window/research_filter.py Outdated
dict | None union syntax requires Python 3.10+; replace with
Optional[dict] from typing to fix typecheck + runtime errors on py3.9.
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Pushed ef488e8 to fix the CI failures.

Root cause: dict | None union syntax (PEP 604) requires Python 3.10+, but CI runs Python 3.9. The transform() function in research_filter.py used this syntax, causing both a mypy typecheck error and a TypeError at import time.

Fix: Replace dict | None with Optional[dict] from typing — compatible with Python 3.9+.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment thread tests/test_main.py Fixed
Comment thread aw_watcher_window/main.py Outdated
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Privacy Fix: Strip URLs from Browser Events

I've identified and fixed the privacy gap identified by Greptile's P1 finding: browser URLs were being leaked to the ActivityWatch server despite the privacy filter being active.

The Issue

On macOS JXA (the required strategy for Research Edition support), browser events carry full URLs that can expose search queries, authentication tokens, and other sensitive path components. The previous implementation used {**window, "title": category} which preserved the url field via dict spread.

The Fix

if is_browser(app):
    # Browser: replace title with a study category
    title = window.get("title", "")
    category = classify_title(title, category_map)
    # Don't spread window dict — URLs must not be exposed for privacy
    result = {"app": app, "title": category}
    # Preserve incognito flag if present (metadata, not a privacy concern)
    if "incognito" in window:
        result["incognito"] = window["incognito"]
    return result

Verification

Tested locally:

  • Input: {'app': 'chrome', 'title': 'Google Search', 'url': 'https://google.com/search?q=secret', 'incognito': True}
  • Output: {'app': 'chrome', 'title': 'searching', 'incognito': True}
  • ✅ URL is NOT leaked
  • ✅ Incognito flag is preserved as useful metadata
  • ✅ Title is correctly classified

The fix maintains the privacy guarantee of Research Edition mode on macOS JXA by ensuring raw browser URLs are never uploaded to the ActivityWatch server, while still preserving the incognito flag as non-sensitive metadata.

Commit: c75c6b3 (local, ready to push when auth is configured)

On macOS JXA, browser events carry full URLs that can expose search queries,
authentication tokens, and other sensitive path components. The previous
implementation used `{**window, 'title': category}` which preserved the url
field via dict spread, leaking URLs to the ActivityWatch server despite the
privacy filter being active.

Changed to explicitly construct the result dict, stripping the URL while
preserving the incognito flag as non-sensitive metadata. Non-browser apps
(e.g. Finder) continue to preserve the url field since file:// URLs are not
a privacy concern.

Updated test to verify URLs are NOT included in browser results.

Fixes Greptile ActivityWatch#130 P1 privacy finding.
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Privacy Fix Applied: URLs Stripped from Browser Events

I've pushed commit 6e6f3c1 that addresses the P1 privacy finding from Greptile review.

The Issue

The original implementation used {**window, "title": category} which preserved the entire window dict via spread operator. On macOS JXA (the required strategy for Research Edition), this leaked full browser URLs to the ActivityWatch server — exposing search queries, authentication tokens, and other sensitive path components.

The Fix

Changed to explicitly construct the result dict:

  • Browsers: Only include app and title (classified category)
  • Preserved: incognito flag (non-sensitive metadata)
  • Stripped: url field (privacy-critical for browsers)
  • Non-browsers: Continue to preserve url (e.g., file:// URLs are not a privacy concern)

Test Update

Updated the test test_browser_preserves_url_and_incognitotest_browser_strips_url_but_preserves_incognito to verify URLs are NOT included in browser results.

CI is running now — tests should pass with this fix.

@ErikBjare

Copy link
Copy Markdown
Member

@greptileai review

Comment thread aw_watcher_window/research_filter.py Outdated
Non-browser apps on macOS JXA can expose sensitive URLs via file:// and
mailbox:// schemes (e.g., file:///Users/jdoe/sensitive-folder/ or
mailbox://user@corp.com/inbox/). The privacy filter must strip these just as
it strips browser URLs to prevent leaking sensitive path components to the
ActivityWatch server.

Changes:
- Remove 'url' field from non-browser preserved fields
- Keep 'incognito' flag preserved (non-sensitive metadata)
- Update test to verify URL stripping for non-browser apps

Fixes Greptile P1 security finding.
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

P1 Security Fix Applied: Non-browser URLs Stripped

I've pushed commit 97f32b26 addressing the P1 security finding from Greptile's latest review.

The Issue

The privacy filter was correctly stripping URLs from browser events (search queries, auth tokens), but Greptile caught that non-browser apps on macOS JXA can also expose sensitive URLs:

  • file:///Users/jdoe/sensitive-folder/ (file manager)
  • mailbox://user@corp.com/inbox/msg-id (Mail app)

These were being preserved and would leak to the ActivityWatch server.

The Fix

Changed the non-browser branch to drop the url field entirely (matching the browser privacy policy):

result = {"app": app}
# Preserve incognito flag if present (metadata, not a privacy concern)
if "incognito" in window:
    result["incognito"] = window["incognito"]
# Note: URL is NOT preserved for non-browser apps

Test Coverage

Updated test_non_browser_drops_title_and_url to verify URLs are correctly stripped for non-browser apps.

All Greptile findings now appear addressed:

  • ✅ Non-browser URL stripping (P1) — fixed
  • ✅ Empty category_map handling (P1) — fixed + tested
  • ✅ safari.exe removal (P2) — fixed
  • ✅ load_config() call efficiency (P2) — fixed

Ready for final review verification.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment thread aw_watcher_window/research_filter.py Outdated
@ErikBjare

Copy link
Copy Markdown
Member

@TimeToBuildBob See #130 (comment)

…able

The Swift strategy can capture browser URLs via accessibility APIs, giving
more reliable classification than page titles (which change mid-load or show
generic text like "New Tab"). Classify by URL first, fall back to title.

Both Python transform() and Swift applyResearchFilter() now implement this:
- classify_title() accepts optional url= param, tries URL patterns first
- Swift classifyResearch() takes url parameter, checks it before title

URL is still stripped from output in both implementations for privacy.
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants