From 1bca65a309b558fbc21ce7cb5dc82d19c02eea39 Mon Sep 17 00:00:00 2001 From: Koyar Afrasyab Date: Tue, 20 Oct 2026 21:37:25 +0000 Subject: [PATCH 1/3] Exclude localized private browser windows at capture and retained-evidence reads --- collector/privacy.py | 3 ++ docs/PRIVACY_AND_DATA.md | 18 ++++++++ gateway/plugin_redaction.py | 2 + server/ai_context.py | 2 + server/capture_exclusions.py | 4 ++ server/db.py | 6 ++- server/privacy_pipeline.py | 5 ++- shared/core/private_browsing.py | 41 ++++++++++++++++++ shared/core/sensitive_identifiers.py | 3 +- shared/evidence.py | 2 + tests/test_evaluation_privacy_followups.py | 50 ++++++++++++++++++++++ 11 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 shared/core/private_browsing.py create mode 100644 tests/test_evaluation_privacy_followups.py diff --git a/collector/privacy.py b/collector/privacy.py index 3503d0ab4..8b4dd1d44 100644 --- a/collector/privacy.py +++ b/collector/privacy.py @@ -5,6 +5,9 @@ def should_exclude(app: str, title: str, excluded_apps: Iterable[str], title_patterns: Iterable[str]) -> bool: + from shared.core.private_browsing import private_browser_window + if private_browser_window(app, title): + return True app_lower = (app or "").lower() if any(x.lower() in app_lower for x in excluded_apps if x): return True diff --git a/docs/PRIVACY_AND_DATA.md b/docs/PRIVACY_AND_DATA.md index 366e9a99f..102ed0f9f 100644 --- a/docs/PRIVACY_AND_DATA.md +++ b/docs/PRIVACY_AND_DATA.md @@ -6,6 +6,24 @@ This document describes the current v0.114 behavior. It is a technical descripti ## Local-first boundary +### Explicit private-window markers + +Browser chrome markers such as Chrome's `(Incognito)` / Swedish and German +`(Inkognito)` and Edge's `InPrivate` are excluded independently of older saved +keyword lists. Detection requires a browser application and an explicit marker; +ordinary titles about private equity or a brand named Inkognito retain context. +This is a marker-based safeguard, not a claim that every private window on every +platform exposes a detectable title marker. + +The same boundary protects retained evidence on local database reads, AI +responses (including Full), hosted projections and exports. It preserves opaque +event/session IDs, timestamps and durations, and removes titles, targets, +resource/file references, clipboard linkage and screenshot paths. It does not +delete or rewrite historical database rows, change access grants, or run a +startup re-scan. Use the existing activity deletion controls when stored history +itself must be removed. New capture and ingest apply the exclusion before those +details can be stored. + The current local server binds to loopback (`127.0.0.1`). Captured event data is stored on the local machine unless the user deliberately exports it or connects OpenWorkGraph to another system. Loopback binding alone is not treated as authentication. v0.48 adds installation-local capability controls around the local interfaces: diff --git a/gateway/plugin_redaction.py b/gateway/plugin_redaction.py index 5529ac358..4dc6a1380 100644 --- a/gateway/plugin_redaction.py +++ b/gateway/plugin_redaction.py @@ -362,6 +362,8 @@ def visit(self, value: Any, *, detail: str, key: str = "") -> Any: def project(value: dict[str, Any], *, secret: str, principal: str, detail: str = "compact", owner_names: Iterable[str] = ()) -> dict[str, Any]: + from shared.core.private_browsing import suppress_private_browsing + value = suppress_private_browsing(value) if detail not in {"compact", "rich"}: raise ValueError("detail must be compact or rich") safe = PublicRedactor(secret=secret, principal=principal, owner_names=owner_names).visit(value, detail=detail) diff --git a/server/ai_context.py b/server/ai_context.py index 0e77750b0..b9fd8ff3f 100644 --- a/server/ai_context.py +++ b/server/ai_context.py @@ -388,6 +388,8 @@ def redact_for_ai(value: Any) -> tuple[Any, str]: # deliberately applies in Full mode: a full-detail toggle is not consent # to redisclose personal messaging excluded from capture. value = suppress_personal_chat_evidence(value) + from shared.core.private_browsing import suppress_private_browsing + value = suppress_private_browsing(value) detail = effective_detail() if detail["detail_level"] == DETAIL_FULL: return _annotate_representation(value, DETAIL_FULL), DETAIL_FULL diff --git a/server/capture_exclusions.py b/server/capture_exclusions.py index bdfe55a54..26b66cdcf 100644 --- a/server/capture_exclusions.py +++ b/server/capture_exclusions.py @@ -102,6 +102,10 @@ def apply_to_desktop_event(event: dict[str, Any], config: dict[str, Any]) -> dic allowlists and privacy path). Already-excluded events are returned as is. """ meta = event.get("metadata") if isinstance(event.get("metadata"), dict) else {} + from shared.core.private_browsing import suppress_private_browsing + protected = suppress_private_browsing(event) + if protected.get("metadata", {}).get("excluded_by") == "private_browser_window": + return protected source = str(event.get("source") or meta.get("source") or "desktop").lower() if source in {"agent", "browser_extension"} or str(event.get("event_type") or "").startswith("browser_"): return event diff --git a/server/db.py b/server/db.py index 44d8f35ec..beb0fa977 100644 --- a/server/db.py +++ b/server/db.py @@ -157,7 +157,8 @@ def _row_to_event(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]: d.pop("id", None) if "metadata_json" in d: d["metadata"] = _decoded_metadata(d.pop("metadata_json")) - return d + from shared.core.private_browsing import suppress_private_browsing + return suppress_private_browsing(d) def _identity_value(e: dict[str, Any], key: str, default: str = "") -> str: @@ -633,7 +634,8 @@ def rows(query: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]: d = dict(row) if "metadata_json" in d: d["metadata"] = _decoded_metadata(d.pop("metadata_json")) - result.append(d) + from shared.core.private_browsing import suppress_private_browsing + result.append(suppress_private_browsing(d)) return result diff --git a/server/privacy_pipeline.py b/server/privacy_pipeline.py index 4354bf299..63abd5f81 100644 --- a/server/privacy_pipeline.py +++ b/server/privacy_pipeline.py @@ -151,10 +151,13 @@ def reset_persistent_identities() -> bool: def redact_for_display_now(value: Any) -> Any: """Apply the presentation pipeline unconditionally.""" - return _redactor(value) + from shared.core.private_browsing import suppress_private_browsing + return _redactor(suppress_private_browsing(value)) def redact_for_display(value: Any) -> Any: + from shared.core.private_browsing import suppress_private_browsing + value = suppress_private_browsing(value) # AI-context requests (MCP) are redacted once, for the whole response, by # server.ai_context according to the effective detail level. Redacting here # too would make "Full" impossible and double-process every string. diff --git a/shared/core/private_browsing.py b/shared/core/private_browsing.py new file mode 100644 index 000000000..2c4b76610 --- /dev/null +++ b/shared/core/private_browsing.py @@ -0,0 +1,41 @@ +"""Never expose identifiable evidence from explicitly marked private windows. + +Use browser chrome markers, not a generic occurrence of 'private' in work text. +Also applies on reads so pre-upgrade rows need no blocking startup migration. +""" +from __future__ import annotations + +import re +from typing import Any + +_BROWSER = re.compile(r"chrome|chromium|safari|firefox|microsoft edge|brave|vivaldi|opera", re.I) +_MARKER = re.compile( + r"(?:\(\s*(?:incognito|inkognito|inprivate)\s*\)|" + r"[-–—]\s*(?:inprivate|private browsing|privat surfning))\s*$", re.I, +) + + +def private_browser_window(app: str = "", title: str = "") -> bool: + return bool(_BROWSER.search(str(app)) and _MARKER.search(str(title))) + + +def suppress_private_browsing(value: Any) -> Any: + if isinstance(value, list): + return [suppress_private_browsing(item) for item in value] + if not isinstance(value, dict): + return value + title = value.get("window_title") or value.get("page_title") or value.get("context_title") or "" + app = value.get("app") or value.get("container_app") or value.get("app_or_surface") or "" + if private_browser_window(str(app), str(title)): + # An allowlist prevents new target/file/clipboard/screenshot fields from + # accidentally bypassing the boundary. Keep structural event linkage. + safe = {key: value[key] for key in ( + "event_id", "observed_at", "event_type", "source", "session_id", + "schema_version", "organization_id", "actor_id", "device_id", "sensor_id", + "duration_seconds", "started_at", "ended_at", "context_id", + ) if key in value and not isinstance(value[key], (dict, list))} + safe.update(app="Excluded", window_title="", metadata={ + "excluded": True, "excluded_by": "private_browser_window", + }) + return safe + return {key: suppress_private_browsing(child) for key, child in value.items()} diff --git a/shared/core/sensitive_identifiers.py b/shared/core/sensitive_identifiers.py index 481400722..fb6504135 100644 --- a/shared/core/sensitive_identifiers.py +++ b/shared/core/sensitive_identifiers.py @@ -531,7 +531,8 @@ def conn_repl(match: re.Match[str]) -> str: def sanitize_event_identifiers(event: dict[str, Any]) -> dict[str, Any]: """Return a deep copy safe for persistence while preserving event structure.""" - e = copy.deepcopy(event) + from .private_browsing import suppress_private_browsing + e = copy.deepcopy(suppress_private_browsing(event)) def walk(value: Any, *, key: str = "") -> Any: if isinstance(value, dict): diff --git a/shared/evidence.py b/shared/evidence.py index 68f1d2672..c07434225 100644 --- a/shared/evidence.py +++ b/shared/evidence.py @@ -38,6 +38,8 @@ def rich_evidence_row(row: dict[str, Any], *, include_identity: bool = False) -> only; consumers should inspect ``metadata`` whenever they need the complete captured context. Local screenshot paths are intentionally never exposed. """ + from shared.core.private_browsing import suppress_private_browsing + row = suppress_private_browsing(row) meta = _metadata(row) page = meta.get("page") if isinstance(meta.get("page"), dict) else {} target = meta.get("target") if isinstance(meta.get("target"), dict) else {} diff --git a/tests/test_evaluation_privacy_followups.py b/tests/test_evaluation_privacy_followups.py new file mode 100644 index 000000000..2a6ad39cf --- /dev/null +++ b/tests/test_evaluation_privacy_followups.py @@ -0,0 +1,50 @@ +from copy import deepcopy + +import pytest + +from collector.privacy import should_exclude +from server.capture_exclusions import apply_to_desktop_event +from shared.core.private_browsing import private_browser_window, suppress_private_browsing +from shared.core.sensitive_identifiers import sanitize_event_identifiers +from shared.evidence import rich_evidence_row +from gateway.plugin_redaction import project + + +@pytest.mark.parametrize('marker', ['Inkognito', 'Incognito', 'inkognito', 'InPrivate']) +@pytest.mark.parametrize('event_type', ['focus_span', 'screen_click', 'clipboard_copy', 'clipboard_paste']) +def test_private_window_drops_all_content_before_capture_and_on_legacy_reads(marker, event_type): + event = {'event_id': 'opaque-event', 'session_id': 'opaque-session', + 'observed_at': '2026-10-20T10:00:00Z', 'duration_seconds': 12, + 'app': 'Google Chrome', 'window_title': f'SECRET_TITLE - Google Chrome ({marker})', + 'source': 'desktop', 'event_type': event_type, + 'screenshot_path': '/private/SECRET_FILE.png', + 'metadata': {'target': {'label': 'SECRET_TARGET'}, + 'page': {'hostname': 'secret.invalid'}, + 'file_reference': {'file_ref': 'SECRET_FILE'}, + 'clipboard_transfer_id': 'SECRET_CLIPBOARD'}} + original = deepcopy(event) + assert should_exclude(event['app'], event['window_title'], [], []) + for safe in [apply_to_desktop_event(event, {}), sanitize_event_identifiers(event), + rich_evidence_row(event), suppress_private_browsing({'rows': [event]}), + project({'rows': [event]}, secret='test', principal='test', detail='rich')]: + assert 'SECRET' not in str(safe) + assert 'secret.invalid' not in str(safe) + assert 'opaque-event' in str(safe) + assert event == original + safe = suppress_private_browsing(event) + assert safe['duration_seconds'] == 12 + assert safe['session_id'] == 'opaque-session' + + +@pytest.mark.parametrize('app,title', [ + ('Google Chrome', 'Inkognito brand pricing - Google Search'), + ('Google Chrome', 'Private equity report - Google Chrome'), + ('Preview', 'Research (Inkognito)'), + ('Google Chrome', 'GitHub pull request #42'), +]) +def test_regular_work_context_and_references_are_preserved(app, title): + event = {'app': app, 'window_title': title, + 'metadata': {'resource_reference': {'resource_ref': 'owg:r:opaque-work'}}} + assert not private_browser_window(app, title) + assert suppress_private_browsing(event) == event + assert not should_exclude(app, title, [], []) From b83f30eb735a8af5d1a2d4a5e5e3343a7ef1d4dd Mon Sep 17 00:00:00 2001 From: Koyar Afrasyab Date: Tue, 20 Oct 2026 21:40:06 +0000 Subject: [PATCH 2/3] Cover retained derived context aliases in private-window protection --- docs/PRIVACY_AND_DATA.md | 3 ++- shared/core/private_browsing.py | 15 ++++++++++++--- tests/test_evaluation_privacy_followups.py | 13 +++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/PRIVACY_AND_DATA.md b/docs/PRIVACY_AND_DATA.md index 102ed0f9f..e0baefee4 100644 --- a/docs/PRIVACY_AND_DATA.md +++ b/docs/PRIVACY_AND_DATA.md @@ -10,7 +10,8 @@ This document describes the current v0.114 behavior. It is a technical descripti Browser chrome markers such as Chrome's `(Incognito)` / Swedish and German `(Inkognito)` and Edge's `InPrivate` are excluded independently of older saved -keyword lists. Detection requires a browser application and an explicit marker; +keyword lists. Detection requires an explicit marker and a browser application +or browser name in the title (for legacy normalized context rows); ordinary titles about private equity or a brand named Inkognito retain context. This is a marker-based safeguard, not a claim that every private window on every platform exposes a detectable title marker. diff --git a/shared/core/private_browsing.py b/shared/core/private_browsing.py index 2c4b76610..a1d470b30 100644 --- a/shared/core/private_browsing.py +++ b/shared/core/private_browsing.py @@ -16,7 +16,8 @@ def private_browser_window(app: str = "", title: str = "") -> bool: - return bool(_BROWSER.search(str(app)) and _MARKER.search(str(title))) + return bool((_BROWSER.search(str(app)) or _BROWSER.search(str(title))) + and _MARKER.search(str(title))) def suppress_private_browsing(value: Any) -> Any: @@ -24,8 +25,10 @@ def suppress_private_browsing(value: Any) -> Any: return [suppress_private_browsing(item) for item in value] if not isinstance(value, dict): return value - title = value.get("window_title") or value.get("page_title") or value.get("context_title") or "" - app = value.get("app") or value.get("container_app") or value.get("app_or_surface") or "" + title = (value.get("window_title") or value.get("page_title") or value.get("context_title") + or value.get("resource_title") or value.get("display_hint") or "") + app = (value.get("app") or value.get("container_app") or value.get("app_or_surface") + or value.get("surface") or value.get("work_surface") or "") if private_browser_window(str(app), str(title)): # An allowlist prevents new target/file/clipboard/screenshot fields from # accidentally bypassing the boundary. Keep structural event linkage. @@ -37,5 +40,11 @@ def suppress_private_browsing(value: Any) -> Any: safe.update(app="Excluded", window_title="", metadata={ "excluded": True, "excluded_by": "private_browser_window", }) + for field in ("surface", "work_surface", "container_app", "app_or_surface"): + if field in value: + safe[field] = "Excluded" + for field in ("resource_title", "page_title", "context_title", "display_hint"): + if field in value: + safe[field] = "" return safe return {key: suppress_private_browsing(child) for key, child in value.items()} diff --git a/tests/test_evaluation_privacy_followups.py b/tests/test_evaluation_privacy_followups.py index 2a6ad39cf..79f1d5542 100644 --- a/tests/test_evaluation_privacy_followups.py +++ b/tests/test_evaluation_privacy_followups.py @@ -48,3 +48,16 @@ def test_regular_work_context_and_references_are_preserved(app, title): assert not private_browser_window(app, title) assert suppress_private_browsing(event) == event assert not should_exclude(app, title, [], []) + + +@pytest.mark.parametrize('title_field', ['resource_title', 'context_title', 'page_title']) +def test_legacy_derived_context_keeps_schema_without_private_content(title_field): + event = {'event_id': 'context-1', 'surface': 'Gmail', + title_field: 'SECRET_TITLE - Google Chrome (Inkognito)', + 'target_label': 'SECRET_TARGET', 'context_text': 'SECRET_BODY', + 'resource_locator': 'https://secret.invalid/record'} + safe = suppress_private_browsing(event) + assert safe['surface'] == 'Excluded' + assert safe[title_field] == '' + assert safe['event_id'] == 'context-1' + assert 'SECRET_' not in str(safe) and 'secret.invalid' not in str(safe) From 3a3a063323b422e329f38b3f8bdc111e8ea78427 Mon Sep 17 00:00:00 2001 From: Koyar Afrasyab Date: Tue, 20 Oct 2026 21:43:51 +0000 Subject: [PATCH 3/3] Guard retained private-window context in Activity rendering --- server/evidence_query.py | 3 ++- tests/test_evaluation_privacy_followups.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/server/evidence_query.py b/server/evidence_query.py index 72ac4457b..f6f2fb5f9 100644 --- a/server/evidence_query.py +++ b/server/evidence_query.py @@ -154,7 +154,8 @@ def _row_item(row: Any, cache: dict[str, str] | None = None) -> dict[str, Any]: redacted AI context. URL paths and raw target labels are not sent: the action is a canonical semantic verb (or the structural event type). """ - value = dict(row) + from shared.core.private_browsing import suppress_private_browsing + value = suppress_private_browsing(dict(row)) try: metadata = json.loads(value.get("metadata_json") or "{}") except Exception: diff --git a/tests/test_evaluation_privacy_followups.py b/tests/test_evaluation_privacy_followups.py index 79f1d5542..350866684 100644 --- a/tests/test_evaluation_privacy_followups.py +++ b/tests/test_evaluation_privacy_followups.py @@ -61,3 +61,16 @@ def test_legacy_derived_context_keeps_schema_without_private_content(title_field assert safe[title_field] == '' assert safe['event_id'] == 'context-1' assert 'SECRET_' not in str(safe) and 'secret.invalid' not in str(safe) + + +def test_dashboard_activity_hides_legacy_private_context_before_title_formatting(): + from server.evidence_query import _row_item + row = {'event_id': 'legacy-context', 'observed_at': '2026-10-20T10:00:00Z', + 'surface': 'Gmail', 'resource_title': 'SECRET_TITLE - Google Chrome (Inkognito)', + 'target_label': 'SECRET_TARGET', 'action': 'click', 'metadata_json': '{}'} + safe = _row_item(row) + assert safe['surface'] == 'Excluded' + assert safe['page'] == 'Excluded' + assert safe['resource_title'] == '' + assert safe['event_id'] == 'legacy-context' + assert 'SECRET_' not in str(safe)