mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
8eeb926168
commit
1bc163f5bc
4 changed files with 529 additions and 27 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) "
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue