Skip to content
Merged
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
3 changes: 3 additions & 0 deletions collector/privacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions docs/PRIVACY_AND_DATA.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ 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 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.

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:
Expand Down
2 changes: 2 additions & 0 deletions gateway/plugin_redaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions server/ai_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions server/capture_exclusions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions server/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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


Expand Down
3 changes: 2 additions & 1 deletion server/evidence_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion server/privacy_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
50 changes: 50 additions & 0 deletions shared/core/private_browsing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""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)) or _BROWSER.search(str(title)))
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 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.
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",
})
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()}
3 changes: 2 additions & 1 deletion shared/core/sensitive_identifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions shared/evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
76 changes: 76 additions & 0 deletions tests/test_evaluation_privacy_followups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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, [], [])


@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)


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)
Loading