fix(ccr): scope proactive expansion by workspace (cross-project leak)

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.
This commit is contained in:
chopratejas 2026-05-26 13:23:51 -07:00
parent 8eeb926168
commit 1bc163f5bc
4 changed files with 529 additions and 27 deletions

View file

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

View file

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

View file

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

View file

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