From 1bc163f5bc1a8422f9ad659061e1fdd8cfeb077b Mon Sep 17 00:00:00 2001 From: chopratejas Date: Tue, 26 May 2026 13:23:51 -0700 Subject: [PATCH] fix(ccr): scope proactive expansion by workspace (cross-project leak) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the cross-project context leak Jocelyn reported 2026-05-26: working on a Ruby/Rails project (daphni-rails), an unrelated Python file (an Ollama inference provider from project `tamag0`) was being injected into context as "Proactive Context Expansion - relevant to your query". Two completely different projects, two different languages, two different working directories — but the same proxy process was serving both, and the in-memory ContextTracker had no workspace identity to filter on. Root cause ---------- `self.ccr_context_tracker` is one instance per proxy process. Every session, every project, every user shared the same `_contexts` dict. `track_compression()` stored sample content with no provenance key; `analyze_query()` ran lexical keyword overlap across the full dict without filtering. Within the 5-minute age window, surface-level token matches ("provider", "session", "oauth", generic code/test structure) scored above the 0.3 relevance threshold, recommendations came back, and execute_expansions() injected the full original content into a foreign session. Refuted: this is NOT a race condition (joce's hypothesis). It reproduces single-threaded, one-request-at-a-time. Plain shared mutable state. Fix --- Add a required `workspace_key` to the tracker API and filter on it inside `analyze_query`: 1. `CompressedContext` gets a `workspace_key: str` field. 2. `track_compression(..., workspace_key=...)` is now keyword-only, no default — fail-loud on missing. 3. `analyze_query(..., workspace_key=...)` is also keyword-only; an empty workspace_key short-circuits to `[]` (fail-closed per `feedback_no_silent_fallbacks`). 4. The loop at `analyze_query` skips any entry whose workspace_key differs from the request's. In the Anthropic proxy handler: 5. New `_resolve_ccr_workspace(request, body)` static helper uses the memory subsystem's `ProjectResolver` so CCR and memory agree on project identity. Tier order: x-headroom-project-id → x-headroom-cwd → CLI override → cwd: line in system prompt. 6. Both track and analyze sites gate on `ccr_workspace_key` being non-empty — turning off proactive expansion entirely when project identity can't be resolved is the safest default (it's an optimization, not correctness). 7. `format_expansions_for_context(expansions, workspace_label=...)` was already wired (GH #462 Fix C); the call site now passes the label so the injected block declares its provenance, symmetric with the memory injection header. Affected population ------------------- - Default mode (no `--cache`): bug fixed. - Cache mode: was never affected — proactive expansion short- circuits in cache mode to preserve prefix stability. Tests ----- - 6 new workspace-scoping tests in `test_ccr_context_tracker.py`: same-workspace match still works, cross-workspace silently filtered, empty workspace_key fail-closes, two workspaces each see only their own, workspace_label propagates to formatter, LRU cross-workspace doesn't leak even with full tracker. - 6 new `_resolve_ccr_workspace` resolver tests in `test_proxy_handler_helpers.py`: explicit project-id wins, cwd header → key+label, two cwds get distinct keys, no-signal fail-closed, system-prompt cwd: fallback, malformed request fail-closed. - 32 existing tracker tests updated to pass `workspace_key="ws-test"`. - 55/55 tests pass; ci-precheck green. Defense-in-depth follow-up -------------------------- The compression_store itself (`headroom/cache/compression_store.py`) also lacks workspace scoping — a CCR `headroom_retrieve` call from Project B for a hash created by Project A would succeed. The practical attack surface is closed by this PR (hashes only reach Project B's model via proactive expansion, now gated), but defense-in-depth hardening of the store is worth a separate PR. Filed as task #44. --- headroom/ccr/context_tracker.py | 72 ++++++- headroom/proxy/handlers/anthropic.py | 105 +++++++++- tests/test_ccr_context_tracker.py | 293 +++++++++++++++++++++++++-- tests/test_proxy_handler_helpers.py | 86 ++++++++ 4 files changed, 529 insertions(+), 27 deletions(-) diff --git a/headroom/ccr/context_tracker.py b/headroom/ccr/context_tracker.py index 65ffda525..bf8dffe74 100644 --- a/headroom/ccr/context_tracker.py +++ b/headroom/ccr/context_tracker.py @@ -34,7 +34,19 @@ logger = logging.getLogger(__name__) @dataclass class CompressedContext: - """Represents a piece of compressed context from the conversation.""" + """Represents a piece of compressed context from the conversation. + + The ``workspace_key`` field is **required**: it ties every tracked + compression to a single project/CWD identity so cross-project + proactive expansion cannot leak. The empty string is a valid value + (used by unit tests that don't exercise scoping) but the production + proxy NEVER passes empty — ``track_compression`` is gated on a + resolved workspace before the call. Reverting this to optional + re-opens the cross-project leak (incident reported by Jocelyn, + 2026-05-26): a tamag0 Python file surfaced inside a daphni-rails + Ruby session because the shared in-memory tracker had no provenance + key. + """ hash_key: str turn_number: int @@ -44,6 +56,7 @@ class CompressedContext: compressed_item_count: int query_context: str # The query/context when compression happened sample_content: str # Preview of what was compressed (for relevance matching) + workspace_key: str # Stable per-project identity (see ProjectResolver in storage_router) @dataclass @@ -123,6 +136,8 @@ class ContextTracker: tool_name: str | None, original_count: int, compressed_count: int, + *, + workspace_key: str, query_context: str = "", sample_content: str = "", ) -> None: @@ -134,6 +149,11 @@ class ContextTracker: tool_name: Name of the tool whose output was compressed. original_count: Original item count. compressed_count: Compressed item count. + workspace_key: Stable per-project identity (e.g. the + ``ProjectResolver`` key for the request's CWD). REQUIRED: + cross-workspace expansion is the bug class this guards + against. Pass the empty string only from tests that + explicitly exercise the no-scoping path. query_context: The user query when compression happened. sample_content: Sample of the content for relevance matching. """ @@ -149,6 +169,7 @@ class ContextTracker: compressed_item_count=compressed_count, query_context=query_context, sample_content=sample_content[:2000], # Limit sample size + workspace_key=workspace_key, ) # Add or update context @@ -173,12 +194,22 @@ class ContextTracker: self, query: str, current_turn: int | None = None, + *, + workspace_key: str, ) -> list[ExpansionRecommendation]: """Analyze a query to find relevant compressed contexts. Args: query: The user's query/message. current_turn: Current turn number (for age calculation). + workspace_key: Stable per-project identity. ONLY contexts + whose ``workspace_key`` matches will be considered for + expansion. This is the gate that prevents cross-project + leaks (e.g. Project A's Python code surfacing in + Project B's Ruby query). REQUIRED — callers MUST resolve + a workspace before invoking; the empty string short- + circuits to an empty result set rather than matching + empty-keyed test contexts to avoid accidental crossover. Returns: List of expansion recommendations, sorted by relevance. @@ -186,6 +217,19 @@ class ContextTracker: if not self.config.enabled or not self.config.proactive_expansion: return [] + # Empty workspace = caller couldn't resolve project identity. + # Fail closed: return nothing. The user loses the proactive + # expansion optimization on this turn (which is fine — it's an + # optimization, not correctness) and avoids any cross-workspace + # match. See `feedback_no_silent_fallbacks`: an empty workspace + # is the loud failure, not a license to match anything. + if not workspace_key: + logger.debug( + "CCR Tracker: analyze_query called with empty workspace_key; " + "returning no recommendations (fail-closed)" + ) + return [] + if current_turn is not None: self._current_turn = current_turn @@ -193,6 +237,12 @@ class ContextTracker: now = time.time() for hash_key, context in self._contexts.items(): + # Workspace filter — the cross-project leak gate. Skip + # entries that belong to a different project than the one + # the current request resolved to. + if context.workspace_key != workspace_key: + continue + # Check age age = now - context.timestamp if age > self.config.max_context_age_seconds: @@ -574,12 +624,28 @@ class ContextTracker: self._current_turn = 0 -# Global instance (per-session) +# Process-wide singleton — kept only for the unit-test API surface. +# The production proxy holds its tracker as ``self.ccr_context_tracker`` +# on the long-lived server object (see ``proxy/server.py:562``), NOT +# through this module-level handle. The old comment claiming this was +# "per-session" was wrong AND dangerous: it was the implicit license +# behind the cross-project leak Jocelyn reported (a single shared +# tracker has no way to keep Project A's compression sample out of +# Project B's analyze_query). Treat this handle as test-only. _context_tracker: ContextTracker | None = None def get_context_tracker() -> ContextTracker: - """Get the global context tracker.""" + """Get the process-wide context tracker (TEST-ONLY). + + Production code holds the tracker on the proxy server object so + one process can scope multiple workspaces via the + ``track_compression(..., workspace_key=...)`` / + ``analyze_query(..., workspace_key=...)`` parameters. Code paths + that reach here in a production-style flow should be considered + broken — there is no caller-provided workspace identity at this + layer. + """ global _context_tracker if _context_tracker is None: _context_tracker = ContextTracker() diff --git a/headroom/proxy/handlers/anthropic.py b/headroom/proxy/handlers/anthropic.py index e3b59ac34..df05de481 100644 --- a/headroom/proxy/handlers/anthropic.py +++ b/headroom/proxy/handlers/anthropic.py @@ -37,6 +37,64 @@ logger = logging.getLogger("headroom.proxy") class AnthropicHandlerMixin: """Mixin providing Anthropic API handler methods for HeadroomProxy.""" + @staticmethod + def _resolve_ccr_workspace( + request: Any, + body: Any, + ) -> tuple[str, str | None]: + """Resolve (workspace_key, workspace_label) for CCR scoping. + + Uses the same ``ProjectResolver`` the memory subsystem uses + (``headroom/memory/storage_router.py``) so CCR and memory always + agree on which project a request belongs to. Tier order matches: + ``x-headroom-project-id`` → ``x-headroom-cwd`` → CLI override → + ``cwd:`` line in the system prompt. + + Returns: + ``(workspace_key, workspace_label)``. If no signal yields a + project, returns ``("", None)`` — the empty key is the + fail-closed signal that callers gate on (skipping + ``track_compression`` and ``analyze_query`` entirely + rather than tracking under an empty workspace which would + create un-matchable entries). + + See also: the 2026-05-26 cross-project leak report which + motivated this scoping (Python content from project ``tamag0`` + surfaced inside a Ruby ``daphni-rails`` session). + """ + from headroom.memory.storage_router import ( + ProjectResolver, + ) + from headroom.memory.storage_router import ( + RequestContext as _CtxFor, + ) + from headroom.memory.storage_router import ( + extract_system_prompt as _extract_sys_prompt, + ) + + try: + ctx = _CtxFor( + headers=dict(request.headers), + system_prompt=_extract_sys_prompt(body), + base_user_id=request.headers.get("x-headroom-user-id", ""), + project_root_override=None, + ) + ident = ProjectResolver().resolve(ctx) + except Exception as exc: # noqa: BLE001 + # ProjectResolver is best-effort — log loudly and fail + # closed so a malformed request doesn't crash the proxy + # AND doesn't accidentally bypass the workspace filter. + logger.warning( + "event=ccr_workspace_resolve_failed error=%s; " + "CCR proactive expansion disabled for this request", + exc, + ) + return "", None + + if ident is None: + return "", None + return ident[0], ident[1] + @staticmethod def _tool_sort_key(tool: dict[str, Any]) -> tuple[str, str]: """Deterministic sort key for Anthropic/OpenAI-style tool definitions.""" @@ -1229,9 +1287,25 @@ class AnthropicHandlerMixin: f"hashes_seen={len(injector.detected_hashes)})" ) + # CCR workspace scoping: resolve a stable project identity + # for the request once and reuse it for both track_compression + # AND analyze_query. The shared `self.ccr_context_tracker` + # is process-global across all sessions/projects served by + # this proxy; without this gate, Project A's compressed + # sample content keyword-matches Project B's later query + # and gets surfaced as "relevant" — see + # `headroom/ccr/context_tracker.py` module docstring for + # the 2026-05-26 leak report (Python from tamag0 + # injected into a daphni-rails Ruby session). + ccr_workspace_key, ccr_workspace_label = self._resolve_ccr_workspace(request, body) + if injector.has_compressed_content: - # Track compression in context tracker for multi-turn awareness - if self.ccr_context_tracker: + # Track compression in context tracker for multi-turn awareness. + # Gated on a resolved workspace: tracking under an empty + # workspace would create entries that the workspace-filter + # in analyze_query can never match. Fail-closed per + # `feedback_no_silent_fallbacks`. + if self.ccr_context_tracker and ccr_workspace_key: self._turn_counter += 1 for hash_key in injector.detected_hashes: # Get compression metadata from store @@ -1244,12 +1318,24 @@ class AnthropicHandlerMixin: tool_name=entry.get("tool_name"), original_count=entry.get("original_item_count", 0), compressed_count=entry.get("compressed_item_count", 0), + workspace_key=ccr_workspace_key, query_context=entry.get("query_context", ""), sample_content=entry.get("compressed_content", "")[:500], ) + elif self.ccr_context_tracker and not ccr_workspace_key: + logger.info( + f"[{request_id}] CCR: workspace unresolved; skipping " + "track_compression (fail-closed — no x-headroom-cwd / " + "x-headroom-project-id header and no cwd: in system prompt)" + ) - # CCR Proactive Expansion: Check if current query needs expanded context - if self.ccr_context_tracker and self.config.ccr_proactive_expansion: + # CCR Proactive Expansion: Check if current query needs expanded context. + # Same workspace gate as track_compression above. + if ( + self.ccr_context_tracker + and self.config.ccr_proactive_expansion + and ccr_workspace_key + ): # Extract user query from messages user_query = "" for msg in reversed(messages): @@ -1266,14 +1352,19 @@ class AnthropicHandlerMixin: if user_query: recommendations = self.ccr_context_tracker.analyze_query( - user_query, self._turn_counter + user_query, + self._turn_counter, + workspace_key=ccr_workspace_key, ) if recommendations: expansions = self.ccr_context_tracker.execute_expansions(recommendations) if expansions: - # Add expanded context to the system message or as additional context + # Add expanded context to the system message or as additional context. + # Pass workspace_label so the injected block declares its provenance + # — symmetric with the memory-injection block header. expansion_text = self.ccr_context_tracker.format_expansions_for_context( - expansions + expansions, + workspace_label=ccr_workspace_label, ) logger.info( f"[{request_id}] CCR: Proactively expanded {len(expansions)} context(s) " diff --git a/tests/test_ccr_context_tracker.py b/tests/test_ccr_context_tracker.py index c517d0acd..127bfca0f 100644 --- a/tests/test_ccr_context_tracker.py +++ b/tests/test_ccr_context_tracker.py @@ -51,6 +51,7 @@ class TestContextTrackerBasics: compressed_count=10, query_context="find all python files", sample_content='["src/main.py", "src/auth.py"]', + workspace_key="ws-test", ) assert "abc123" in tracker.get_tracked_hashes() @@ -68,6 +69,7 @@ class TestContextTrackerBasics: tool_name="Bash", original_count=100, compressed_count=10, + workspace_key="ws-test", ) hashes = tracker.get_tracked_hashes() @@ -85,6 +87,7 @@ class TestContextTrackerBasics: tool_name="Bash", original_count=100, compressed_count=10, + workspace_key="ws-test", ) assert len(tracker.get_tracked_hashes()) == 0 @@ -94,13 +97,28 @@ class TestContextTrackerBasics: tracker = ContextTracker() tracker.track_compression( - hash_key="first", turn_number=1, tool_name=None, original_count=50, compressed_count=5 + hash_key="first", + turn_number=1, + tool_name=None, + original_count=50, + compressed_count=5, + workspace_key="ws-test", ) tracker.track_compression( - hash_key="second", turn_number=2, tool_name=None, original_count=50, compressed_count=5 + hash_key="second", + turn_number=2, + tool_name=None, + original_count=50, + compressed_count=5, + workspace_key="ws-test", ) tracker.track_compression( - hash_key="first", turn_number=3, tool_name=None, original_count=60, compressed_count=6 + hash_key="first", + turn_number=3, + tool_name=None, + original_count=60, + compressed_count=6, + workspace_key="ws-test", ) # Should still have 2 unique hashes @@ -131,6 +149,7 @@ class TestLRUEviction: tool_name=None, original_count=100, compressed_count=10, + workspace_key="ws-test", ) time.sleep(0.01) # Ensure different timestamps @@ -172,11 +191,11 @@ class TestQueryAnalysis: query_context="find authentication files", # Use more explicit content with keywords that will match sample_content="authentication middleware handler login security", + workspace_key="ws-test", ) recommendations = tracker.analyze_query( - query="show authentication middleware", - current_turn=2, + query="show authentication middleware", current_turn=2, workspace_key="ws-test" ) assert len(recommendations) >= 1 @@ -194,11 +213,11 @@ class TestQueryAnalysis: compressed_count=10, query_context="find database files", sample_content='["database.py", "models.py"]', + workspace_key="ws-test", ) recommendations = tracker.analyze_query( - query="What is the weather like?", - current_turn=2, + query="What is the weather like?", current_turn=2, workspace_key="ws-test" ) # Should not match unrelated query @@ -216,12 +235,12 @@ class TestQueryAnalysis: compressed_count=20, query_context="find python files", sample_content='["main.py", "utils.py", "config.py", "test_main.py"]', + workspace_key="ws-test", ) # Query with overlapping keywords recommendations = tracker.analyze_query( - query="Show me the main python file", - current_turn=2, + query="Show me the main python file", current_turn=2, workspace_key="ws-test" ) assert len(recommendations) >= 1 @@ -238,11 +257,11 @@ class TestQueryAnalysis: original_count=100, compressed_count=10, sample_content='["relevant.py"]', + workspace_key="ws-test", ) recommendations = tracker.analyze_query( - query="Show me relevant files", - current_turn=2, + query="Show me relevant files", current_turn=2, workspace_key="ws-test" ) assert len(recommendations) == 0 @@ -259,14 +278,14 @@ class TestQueryAnalysis: original_count=100, compressed_count=10, sample_content='["auth.py"]', + workspace_key="ws-test", ) # Wait for context to age time.sleep(2.1) recommendations = tracker.analyze_query( - query="Show me the authentication code", - current_turn=5, + query="Show me the authentication code", current_turn=5, workspace_key="ws-test" ) # Should not recommend aged-out context @@ -286,11 +305,11 @@ class TestQueryAnalysis: original_count=100, compressed_count=10, sample_content=f'["python_{i}.py", "main.py"]', + workspace_key="ws-test", ) recommendations = tracker.analyze_query( - query="Show me the python main file", - current_turn=10, + query="Show me the python main file", current_turn=10, workspace_key="ws-test" ) assert len(recommendations) <= 2 @@ -322,12 +341,12 @@ class TestRelevanceCalculation: original_count=100, compressed_count=10, sample_content="authentication_middleware.py, auth_handler.py", + workspace_key="ws-test", ) # Query with exact substring match recommendations = tracker.analyze_query( - query="authentication middleware", - current_turn=2, + query="authentication middleware", current_turn=2, workspace_key="ws-test" ) assert len(recommendations) >= 1 @@ -351,6 +370,7 @@ class TestExpansionTypeDetection: compressed_item_count=5, query_context="find files", sample_content="auth.py, middleware.py", + workspace_key="ws-test", ) expand_full, search_query = tracker._determine_expansion_type( @@ -375,6 +395,7 @@ class TestExpansionTypeDetection: compressed_item_count=5, query_context="find files", sample_content="file.py", + workspace_key="ws-test", ) expand_full, search_query = tracker._determine_expansion_type( @@ -398,6 +419,7 @@ class TestExpansionTypeDetection: compressed_item_count=20, query_context="find all files", sample_content="many files...", + workspace_key="ws-test", ) expand_full, search_query = tracker._determine_expansion_type( @@ -579,6 +601,7 @@ class TestGlobalTracker: tool_name=None, original_count=10, compressed_count=1, + workspace_key="ws-test", ) assert len(tracker.get_tracked_hashes()) == 1 @@ -632,6 +655,7 @@ class TestCompressedContextDataClass: compressed_item_count=10, query_context="find files", sample_content='["file1.py", "file2.py"]', + workspace_key="ws-test", ) assert context.hash_key == "abc123" @@ -683,6 +707,7 @@ class TestContextTrackerStats: tool_name="Bash", original_count=100, compressed_count=10, + workspace_key="ws-test", ) stats = tracker.get_stats() @@ -705,6 +730,7 @@ class TestContextTrackerStats: tool_name="Glob", original_count=50, compressed_count=5, + workspace_key="ws-test", ) stats = tracker.get_stats() @@ -730,6 +756,7 @@ class TestTrackerClear: tool_name=None, original_count=10, compressed_count=1, + workspace_key="ws-test", ) assert len(tracker.get_tracked_hashes()) == 5 @@ -739,3 +766,235 @@ class TestTrackerClear: assert len(tracker.get_tracked_hashes()) == 0 stats = tracker.get_stats() assert stats["current_turn"] == 0 + + +# ============================================================================ +# Workspace scoping (cross-project leak prevention). +# +# The bug: the ContextTracker is process-shared (one per proxy process, +# serving all sessions/projects). Without a workspace gate, Project A's +# compressed sample content keyword-matches Project B's later query and +# surfaces as "relevant" — which is exactly what Jocelyn reported on +# 2026-05-26: a tamag0 Python file (Ollama inference provider) appeared +# inside an unrelated daphni-rails Ruby/RSpec session. +# +# These tests pin the gate at the analyze_query level: entries are +# scoped by workspace_key, the same key the memory subsystem derives +# via ProjectResolver. Matching across workspaces is silently filtered; +# an empty workspace_key on analyze_query short-circuits to no +# recommendations (fail-closed per no-silent-fallbacks). +# ============================================================================ + + +class TestWorkspaceScoping: + """Cross-workspace leak prevention — the bug joce reported 2026-05-26.""" + + @pytest.fixture(autouse=True) + def reset_trackers(self): + reset_context_tracker() + reset_compression_store() + yield + reset_context_tracker() + reset_compression_store() + + def test_same_workspace_match_works(self): + """Within a single workspace, proactive expansion still functions normally.""" + config = ContextTrackerConfig(relevance_threshold=0.1) + tracker = ContextTracker(config) + + tracker.track_compression( + hash_key="auth_hash", + turn_number=1, + tool_name="Bash", + original_count=100, + compressed_count=10, + workspace_key="ws-rails", + query_context="find authentication files", + sample_content="authentication middleware handler login security", + ) + + recommendations = tracker.analyze_query( + query="show authentication middleware", + current_turn=2, + workspace_key="ws-rails", + ) + + # Same workspace — match expected (regression: don't accidentally over-filter). + assert len(recommendations) >= 1 + assert recommendations[0].hash_key == "auth_hash" + + def test_cross_workspace_match_silently_filtered(self): + """Workspace A's entry must NOT surface in Workspace B's analyze_query. + + This is the exact bug joce reported: Project tamag0 (workspace_key + "ws-tamag0") had Python content stored; daphni-rails workspace + queried for OAuth/session, the keyword overlap was high enough to + score above threshold, and without scoping the Python content + surfaced as "relevant" — wrong project, wrong language, real + contamination risk. + """ + config = ContextTrackerConfig(relevance_threshold=0.1) + tracker = ContextTracker(config) + + # Workspace A: tamag0 Python code. + tracker.track_compression( + hash_key="tamag0_ollama_provider", + turn_number=1, + tool_name="Read", + original_count=400, + compressed_count=40, + workspace_key="ws-tamag0", + query_context="ollama inference provider", + sample_content=( + "class OllamaInferenceProvider provider auth login generate chat embed " + "test_ollama_provider session token oauth user authentication middleware" + ), + ) + + # Workspace B: daphni-rails Ruby code — entirely unrelated repo. + # The query has heavy keyword overlap with the tamag0 sample + # above (provider, oauth, session, authentication, middleware) — + # exactly the surface-level lexical collision that triggered the + # production bug. + recommendations = tracker.analyze_query( + query="OAuth provider session cookie middleware authentication for Rails", + current_turn=2, + workspace_key="ws-daphni-rails", + ) + + assert len(recommendations) == 0, ( + "Cross-workspace entry must NOT surface — this is the leak class " + "Jocelyn reported on 2026-05-26 (tamag0 Python in daphni-rails Ruby session)." + ) + + def test_empty_workspace_key_returns_no_recommendations(self): + """Empty workspace_key short-circuits to empty result set (fail-closed).""" + config = ContextTrackerConfig(relevance_threshold=0.1) + tracker = ContextTracker(config) + + tracker.track_compression( + hash_key="some_hash", + turn_number=1, + tool_name="Bash", + original_count=100, + compressed_count=10, + workspace_key="ws-real", + sample_content="auth middleware", + ) + + # analyze_query with empty workspace_key — caller couldn't resolve a + # project identity for the inbound request. Fail closed: no matches. + recommendations = tracker.analyze_query( + query="show auth middleware", + current_turn=2, + workspace_key="", + ) + + assert recommendations == [], ( + "Empty workspace_key must return [] — fail-closed per " + "feedback_no_silent_fallbacks; otherwise an empty-keyed query " + "would match nothing on the explicit-workspace branch but might " + "still leak in any future fallback path." + ) + + def test_two_workspaces_each_see_only_their_own(self): + """Tracking two workspaces in one tracker — each query sees only its own.""" + config = ContextTrackerConfig(relevance_threshold=0.1) + tracker = ContextTracker(config) + + # Identical content surface in two different workspaces. + for ws in ("ws-a", "ws-b"): + tracker.track_compression( + hash_key=f"hash-{ws}", + turn_number=1, + tool_name="Read", + original_count=100, + compressed_count=10, + workspace_key=ws, + query_context="authentication code", + sample_content="auth middleware login session token oauth", + ) + + rec_a = tracker.analyze_query( + query="show auth middleware", + current_turn=2, + workspace_key="ws-a", + ) + rec_b = tracker.analyze_query( + query="show auth middleware", + current_turn=2, + workspace_key="ws-b", + ) + + # Each workspace sees exactly its own entry — no leak in either direction. + assert len(rec_a) == 1 and rec_a[0].hash_key == "hash-ws-a" + assert len(rec_b) == 1 and rec_b[0].hash_key == "hash-ws-b" + + def test_workspace_label_propagates_to_format(self): + """`format_expansions_for_context` emits the workspace label in the header.""" + tracker = ContextTracker() + + expansions = [ + { + "hash": "h1", + "type": "full", + "content": "expanded content here", + "item_count": 5, + "reason": "high relevance", + } + ] + + # Without label: header has no workspace decoration. + header_plain = tracker.format_expansions_for_context(expansions) + assert "workspace:" not in header_plain + + # With label: provenance appears in header — same surface as the + # memory-injection block (symmetric, see GH #462 Fix C). + header_labeled = tracker.format_expansions_for_context( + expansions, workspace_label="daphni-rails" + ) + assert "workspace: daphni-rails" in header_labeled, ( + "Label must appear in the proactive-expansion header so the " + "downstream model can reason about which project the expanded " + "content came from." + ) + + def test_track_with_one_workspace_then_query_with_another_skips_silently(self): + """LRU contents from prior workspace stay in tracker but don't leak. + + Regression for the production scenario: user works on tamag0, + tracker accumulates entries. User switches to daphni-rails (same + proxy process, ~minutes later). New queries on daphni-rails see + an empty result set despite the LRU being non-empty — because the + only entries present are scoped to tamag0. + """ + config = ContextTrackerConfig(relevance_threshold=0.1) + tracker = ContextTracker(config) + + # Populate workspace A. + for i in range(5): + tracker.track_compression( + hash_key=f"tamag0-{i}", + turn_number=i + 1, + tool_name="Read", + original_count=50, + compressed_count=5, + workspace_key="ws-tamag0", + sample_content=f"file_{i}.py provider auth middleware session", + ) + + # Now query as workspace B. Same lexical surface, different identity. + recommendations = tracker.analyze_query( + query="show me the auth middleware provider", + current_turn=10, + workspace_key="ws-daphni-rails", + ) + + assert recommendations == [], ( + "Cross-workspace queries must return [] — even with a fully " + "populated tracker. The workspace filter is the only gate." + ) + # And the tracker itself still has its entries (we're filtering on + # read, not purging on write — workspace A could come back and use + # them again within the age window). + assert len(tracker.get_tracked_hashes()) == 5 diff --git a/tests/test_proxy_handler_helpers.py b/tests/test_proxy_handler_helpers.py index 1c2b37c9f..fba2fba1c 100644 --- a/tests/test_proxy_handler_helpers.py +++ b/tests/test_proxy_handler_helpers.py @@ -345,3 +345,89 @@ def test_anthropic_assistant_message_helper_requires_assistant_role() -> None: assert AnthropicHandlerMixin._assistant_message_from_response_json( {"role": "assistant", "content": [{"type": "text", "text": "ok"}]} ) == {"role": "assistant", "content": [{"type": "text", "text": "ok"}]} + + +# ============================================================================ +# CCR workspace resolution (cross-project leak fix, 2026-05-26). +# +# These tests pin the `_resolve_ccr_workspace` static helper that the +# anthropic handler uses to scope the proactive-expansion cache by +# project identity. The resolver shares its tier order with the memory +# subsystem's ProjectResolver: x-headroom-project-id → x-headroom-cwd → +# system-prompt `cwd:` line. Returns `("", None)` on no signal — the +# fail-closed signal that callers gate on. +# ============================================================================ + + +def _fake_request(headers: dict[str, str]) -> SimpleNamespace: + """Minimal Starlette/FastAPI-shaped request object for resolver tests.""" + return SimpleNamespace(headers=headers) + + +def test_resolve_ccr_workspace_explicit_project_id_wins() -> None: + """x-headroom-project-id is the highest-priority signal.""" + request = _fake_request({"x-headroom-project-id": "my-cool-project"}) + body = {} + key, label = AnthropicHandlerMixin._resolve_ccr_workspace(request, body) + assert key == "my-cool-project" + assert label == "my-cool-project" + + +def test_resolve_ccr_workspace_cwd_header() -> None: + """x-headroom-cwd produces a stable per-cwd key + basename label.""" + request = _fake_request({"x-headroom-cwd": "/home/user/code/daphni-rails"}) + body = {} + key, label = AnthropicHandlerMixin._resolve_ccr_workspace(request, body) + # Key format: "{basename}-{sha256[:16]}" — stable per absolute cwd. + assert key.startswith("daphni-rails-") + assert len(key) >= len("daphni-rails-") + 16 + assert label == "daphni-rails" + + +def test_resolve_ccr_workspace_two_cwds_get_distinct_keys() -> None: + """Two different cwds produce different workspace keys (cross-leak prevention).""" + key_a, _ = AnthropicHandlerMixin._resolve_ccr_workspace( + _fake_request({"x-headroom-cwd": "/home/user/code/daphni-rails"}), {} + ) + key_b, _ = AnthropicHandlerMixin._resolve_ccr_workspace( + _fake_request({"x-headroom-cwd": "/home/user/code/tamag0"}), {} + ) + assert key_a != key_b, "different cwds must yield different workspace keys" + + +def test_resolve_ccr_workspace_no_signal_returns_empty() -> None: + """No project-id, no cwd header, no system prompt → fail-closed signal.""" + request = _fake_request({}) + body = {} + key, label = AnthropicHandlerMixin._resolve_ccr_workspace(request, body) + assert key == "" + assert label is None + + +def test_resolve_ccr_workspace_system_prompt_cwd_fallback() -> None: + """System prompt with `cwd:` line is the lowest-tier fallback.""" + request = _fake_request({}) + body = { + "system": [{"type": "text", "text": "You are helpful.\ncwd: /home/u/code/my-project\nGo."}] + } + key, label = AnthropicHandlerMixin._resolve_ccr_workspace(request, body) + # The label is the basename of the cwd extracted from the prompt. + assert label == "my-project" + assert key.startswith("my-project-") + + +def test_resolve_ccr_workspace_malformed_request_returns_empty() -> None: + """A request whose headers attribute can't be dict()-ed fails closed, not crashes.""" + + class _BrokenHeaders: + def __iter__(self): + raise RuntimeError("boom") + + request = SimpleNamespace(headers=_BrokenHeaders()) + body = {} + # The helper catches the exception, logs it, and returns the fail- + # closed sentinel ("", None). Critically, it does NOT raise — the + # proxy must continue serving the request even if CCR scoping fails. + key, label = AnthropicHandlerMixin._resolve_ccr_workspace(request, body) + assert key == "" + assert label is None