Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,18 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4

- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: +security-and-quality

- name: Autobuild
uses: github/codeql-action/autobuild@v2
uses: github/codeql-action/autobuild@v3

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,39 @@ In order for this watcher to be available in the UI, you'll need to have a Away
To log current window title the terminal needs access to macOS accessibility API.
This can be enabled in `System Preferences > Security & Privacy > Accessibility`, then add the Terminal to this list. If this is not enabled the watcher can only log current application, and not window title.

## Privacy Filter

You can configure rules to **drop** or **redact** sensitive window events before they are sent to aw-server. This is a client-side pre-filter: matching events never leave the machine at all (on the Python heartbeat path; see the macOS note below).

Add `[[aw-watcher-window.privacy_filter]]` entries to your config file
(`~/.config/activitywatch/aw-watcher-window/aw-watcher-window.toml`):

```toml
# Drop events from private-browsing windows entirely
[[aw-watcher-window.privacy_filter]]
pattern = "(?i)private browsing|incognito"
action = "drop"

# Redact window titles that contain sensitive account information
[[aw-watcher-window.privacy_filter]]
pattern = "(?i)bank|my account|password"
action = "redact"
replacement = "REDACTED" # optional; defaults to "excluded"

# Drop events from a specific app by matching its name
[[aw-watcher-window.privacy_filter]]
pattern = "(?i)signal|whatsapp"
field = "app" # optional; defaults to "title"
action = "drop"
```

Rule fields:
- `pattern` — Python `re` regex (case-insensitive flag supported via `(?i)`)
- `field` — window-event field to match against (`"title"` by default; use `"app"` to match on application name)
- `action` — `"drop"` (discard the event) or `"redact"` (replace the field value)
- `replacement` — string to use when redacting; defaults to `"excluded"`

Rules are applied in order. A `"drop"` rule exits immediately — subsequent rules are not evaluated for that event.

> **macOS note**: The default `swift` strategy sends heartbeats from a separate binary and cannot apply these rules. If `privacy_filter` is configured, the watcher **refuses to start** under `--strategy swift` so titles are not leaked. Use `--strategy jxa` or `--strategy applescript` on macOS.

7 changes: 7 additions & 0 deletions aw_watcher_window/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import argparse
from collections.abc import Mapping

import tomlkit

Expand Down Expand Up @@ -94,4 +95,10 @@ def parse_args():
parsed_args = parser.parse_args()
parsed_args.research_category_map = dict(config.get("research_category_map", {}))
parsed_args.research_app_category_map = dict(config.get("research_app_category_map", {}))
privacy_filter_cfg = config.get("privacy_filter", [])
# A single [aw-watcher-window.privacy_filter] table is a common TOML
# mistake; list(mapping) would yield the keys and silently drop the rule.
if isinstance(privacy_filter_cfg, Mapping):
privacy_filter_cfg = [privacy_filter_cfg]
parsed_args.privacy_filter_rules = list(privacy_filter_cfg or [])
return parsed_args
43 changes: 34 additions & 9 deletions aw_watcher_window/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .config import parse_args
from .exceptions import FatalError
from .lib import get_current_window
from .privacy_filter import apply_privacy_filters, compile_privacy_rules
from .research_filter import transform as research_transform
from .macos_cli import build_swift_command
from .macos_permissions import background_ensure_permissions
Expand Down Expand Up @@ -95,7 +96,18 @@ def main():
if args.research_enabled
else None
)
privacy_filter_rules = compile_privacy_rules(
getattr(args, "privacy_filter_rules", [])
)
if sys.platform == "darwin" and args.strategy == "swift":
Comment thread
TimeToBuildBob marked this conversation as resolved.
Comment thread
TimeToBuildBob marked this conversation as resolved.
if privacy_filter_rules:
logger.error(
"privacy_filter is configured, but the macOS swift strategy "
"sends heartbeats from a separate binary and cannot apply "
"those rules. Refusing to start so window titles are not "
"leaked. Use --strategy jxa or --strategy applescript."
)
sys.exit(1)
logger.info("Using swift strategy, calling out to swift binary")
binpath = os.path.join(
os.path.dirname(os.path.realpath(__file__)), "aw-watcher-window-macos"
Expand Down Expand Up @@ -135,6 +147,7 @@ def main():
],
research_category_map=research_category_map,
research_app_category_map=research_app_category_map,
privacy_filter_rules=privacy_filter_rules,
)


Expand All @@ -147,6 +160,7 @@ def heartbeat_loop(
exclude_titles=[],
research_category_map=None,
research_app_category_map=None,
privacy_filter_rules=None,
):
while True:
if os.getppid() == 1:
Expand Down Expand Up @@ -186,17 +200,21 @@ def heartbeat_loop(
exclude_titles=exclude_titles,
research_category_map=research_category_map,
research_app_category_map=research_app_category_map,
privacy_filter_rules=privacy_filter_rules,
)

now = datetime.now(timezone.utc)
current_window_event = Event(timestamp=now, data=current_window)

client.heartbeat(
bucket_id,
current_window_event,
pulsetime=compute_pulsetime(poll_time),
queued=True,
)
if current_window is None:
logger.debug("Event dropped by privacy filter, skipping heartbeat")
else:
now = datetime.now(timezone.utc)
current_window_event = Event(timestamp=now, data=current_window)

client.heartbeat(
bucket_id,
current_window_event,
pulsetime=compute_pulsetime(poll_time),
queued=True,
)

sleep(poll_time)

Expand All @@ -207,7 +225,14 @@ def transform_window(
exclude_titles=None,
research_category_map=None,
research_app_category_map=None,
privacy_filter_rules=None,
):
# Privacy filter runs first — sensitive events never reach other transforms
if privacy_filter_rules:
current_window = apply_privacy_filters(current_window, privacy_filter_rules)
Comment thread
TimeToBuildBob marked this conversation as resolved.
if current_window is None:
return None

if research_category_map is not None:
return research_transform(
current_window,
Expand Down
133 changes: 133 additions & 0 deletions aw_watcher_window/privacy_filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""
Watcher-side privacy filter: drop or redact sensitive events before sending.

Mirrors the server-side ``privacy_filters`` engine in aw-server-rust so that
sensitive data never leaves the machine in the first place.

Configure rules in ``~/.config/activitywatch/aw-watcher-window/aw-watcher-window.toml``:

.. code-block:: toml

# Drop events whose title matches a private-browsing pattern
[[aw-watcher-window.privacy_filter]]
pattern = "(?i)private browsing|incognito"
action = "drop"

# Redact window titles containing a banking domain
[[aw-watcher-window.privacy_filter]]
pattern = "(?i)bank\\.example\\.com"
action = "redact"
replacement = "REDACTED" # optional; defaults to "excluded"

Rule fields:
pattern -- Python ``re`` regex applied to the target field value.
field -- Window-event field to match against (default: ``"title"``).
action -- ``"drop"`` (discard the event) or ``"redact"`` (replace the value).
replacement -- Replacement string for ``"redact"`` action (default: ``"excluded"``).

macOS note: the default ``swift`` strategy cannot apply these rules (it
sends heartbeats from a separate binary). The watcher refuses to start
under ``--strategy swift`` when any rule compiled, so titles are not
leaked. Use ``--strategy jxa`` or ``--strategy applescript`` on macOS.
"""

import logging
import re
from typing import Optional

logger = logging.getLogger(__name__)

_VALID_ACTIONS = frozenset({"drop", "redact"})


def compile_privacy_rules(raw_rules: list) -> list:
"""Validate and compile regex patterns in privacy filter rules.

Returns a list of compiled rule dicts ready for :func:`apply_privacy_filters`.
Invalid rules (bad regex or unknown action) are skipped with an error log.
"""
compiled = []
for raw in raw_rules:
if not isinstance(raw, dict):
logger.error("privacy_filter rule is not a table: %r — skipped", raw)
continue

pattern_str = raw.get("pattern", "")
if not pattern_str:
logger.error("privacy_filter rule missing 'pattern' — skipped: %r", raw)
continue

try:
pattern = re.compile(pattern_str)
except (re.error, TypeError) as exc:
logger.error(
"privacy_filter: invalid regex %r — %s — rule skipped", pattern_str, exc
)
continue

action = raw.get("action", "redact")
if action not in _VALID_ACTIONS:
logger.error(
"privacy_filter: unknown action %r (expected 'drop' or 'redact') — rule skipped",
action,
)
continue

field = raw.get("field", "title")
if not isinstance(field, str) or not field:
logger.error(
"privacy_filter: 'field' must be a non-empty string — rule skipped: %r",
raw,
)
continue

replacement = raw.get("replacement", "excluded")
if not isinstance(replacement, str):
logger.error(
"privacy_filter: 'replacement' must be a string — rule skipped: %r",
raw,
)
continue

compiled.append(
{
"pattern": pattern,
"field": field,
"action": action,
"replacement": replacement,
}
)
return compiled


def apply_privacy_filters(window: dict, rules: list) -> Optional[dict]:
"""Apply compiled privacy filter rules to a window event.

Returns ``None`` when the event should be dropped entirely, otherwise a
(possibly modified) copy of the window dict. Never mutates the input.
"""
if not rules:
return window

result = dict(window)
for rule in rules:
field = rule["field"]
value = result.get(field)
if not isinstance(value, str):
continue
if rule["pattern"].search(value):
if rule["action"] == "drop":
logger.debug(
"privacy_filter: dropping event (field=%r matched %r)",
field,
rule["pattern"].pattern,
)
return None
else: # redact
logger.debug(
"privacy_filter: redacting field=%r matched %r",
field,
rule["pattern"].pattern,
)
result[field] = rule["replacement"]
return result
53 changes: 53 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,59 @@ def test_parse_args_defaults_research_off_without_config(tmp_path, monkeypatch):
assert args.research_app_category_map == {}


def test_parse_args_wraps_single_privacy_filter_table(monkeypatch):
"""A single [privacy_filter] table must not be list()'d into its keys."""
monkeypatch.setattr(
config_module,
"load_config",
lambda: {
"exclude_title": False,
"exclude_titles": [],
"poll_time": 1.0,
"strategy_macos": "swift",
"privacy_filter": {
"pattern": "(?i)bank",
"action": "redact",
"replacement": "REDACTED",
},
},
)
monkeypatch.setattr(sys, "argv", ["aw-watcher-window"])

args = config_module.parse_args()

assert args.privacy_filter_rules == [
{
"pattern": "(?i)bank",
"action": "redact",
"replacement": "REDACTED",
}
]


def test_parse_args_keeps_privacy_filter_array(monkeypatch):
monkeypatch.setattr(
config_module,
"load_config",
lambda: {
"exclude_title": False,
"exclude_titles": [],
"poll_time": 1.0,
"strategy_macos": "swift",
"privacy_filter": [
{"pattern": "incognito", "action": "drop"},
{"pattern": "bank", "action": "redact"},
],
},
)
monkeypatch.setattr(sys, "argv", ["aw-watcher-window"])

args = config_module.parse_args()

assert len(args.privacy_filter_rules) == 2
assert args.privacy_filter_rules[0]["action"] == "drop"


def test_parse_args_attaches_research_category_map(monkeypatch):
monkeypatch.setattr(
config_module,
Expand Down
Loading
Loading