diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 31124b70dff..83925f13878 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -75,7 +75,8 @@ agent_framework/ - **`AgentSession`** - Manages conversation state and session metadata - **`ServiceSessionId`** - Mapping alias for structured service-owned continuation handles used in `AgentSession.service_session_id` -- **`SessionContext`** - Context object for session-scoped data during agent runs +- **`SessionContext`** - Context object for session-scoped data during agent runs. `extend_messages(...)` can attach + ordered, deduplicated `origin_session_ids` attribution when a provider injects content from other sessions. - **`ContextProvider`** - Base class for context providers (RAG, memory systems) - **`HistoryProvider`** - Base class for conversation history storage - **`InMemoryHistoryProvider`** - Built-in session-state history provider for local runs diff --git a/python/packages/core/agent_framework/_harness/_memory.py b/python/packages/core/agent_framework/_harness/_memory.py index c1be1d33527..04d80cf0491 100644 --- a/python/packages/core/agent_framework/_harness/_memory.py +++ b/python/packages/core/agent_framework/_harness/_memory.py @@ -1306,6 +1306,19 @@ async def consolidate_memories() -> str: ) if recent_history_messages: context.extend_messages(self.source_id, recent_history_messages) + + # Surface every cross-session origin so downstream context observers + # can distinguish injected memory from content native to the current + # session. Loaded topic files may carry contributions from multiple + # earlier sessions, tracked in ``MemoryTopicRecord.session_ids``. + current_session_id = context.session_id + cross_session_origins = [ + contributor + for record in selected_topics + for contributor in record.session_ids + if contributor and contributor != current_session_id + ] + context.extend_messages( self.source_id, [ @@ -1322,6 +1335,7 @@ async def consolidate_memories() -> str: ], ) ], + origin_session_ids=cross_session_origins, ) async def after_run( diff --git a/python/packages/core/agent_framework/_sessions.py b/python/packages/core/agent_framework/_sessions.py index 7577cd3e5ef..9a6dab9a7c1 100644 --- a/python/packages/core/agent_framework/_sessions.py +++ b/python/packages/core/agent_framework/_sessions.py @@ -22,7 +22,7 @@ import weakref from abc import abstractmethod from base64 import urlsafe_b64encode -from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence +from collections.abc import AsyncIterable, Awaitable, Callable, Iterable, Mapping, Sequence from contextlib import suppress from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, TypeGuard, cast @@ -66,6 +66,17 @@ def _default_json_loads(value: str | bytes) -> Any: return json.loads(value) +def _deduplicate_origin_session_ids(origin_session_ids: Iterable[str]) -> list[str]: + """Return origin session IDs in first-seen order without duplicates.""" + unique_origin_session_ids: list[str] = [] + seen_origin_session_ids: set[str] = set() + for origin_session_id in origin_session_ids: + if origin_session_id not in seen_origin_session_ids: + seen_origin_session_ids.add(origin_session_id) + unique_origin_session_ids.append(origin_session_id) + return unique_origin_session_ids + + def _is_middleware_sequence( middleware: MiddlewareTypes | Sequence[MiddlewareTypes], ) -> TypeGuard[Sequence[MiddlewareTypes]]: @@ -230,7 +241,13 @@ def response(self) -> AgentResponse | None: """The agent's response. Set by the framework after invocation, read-only for providers.""" return self._response - def extend_messages(self, source: str | object, messages: Sequence[Message]) -> None: + def extend_messages( + self, + source: str | object, + messages: Sequence[Message], + *, + origin_session_ids: Sequence[str] | None = None, + ) -> None: """Add context messages from a specific source. Messages are copied before attribution is added, so the caller's @@ -245,19 +262,56 @@ def extend_messages(self, source: str | object, messages: Sequence[Message]) -> object is passed, its class name is recorded as ``source_type`` in the attribution. messages: The messages to add. + + Keyword Args: + origin_session_ids: Optional session IDs that originally produced + these messages, when different from the current session. Set + by providers that inject content stored under other sessions + (cross-session memory). The IDs describe the contributing + sessions for every message supplied in this call; they are not + positionally paired with messages, and a composed message can + have multiple origins. The values are exposed under + ``additional_properties["_attribution"]["origin_session_ids"]`` + so downstream context observers can detect cross-session + content for governance, audit, or behavioral-analysis + purposes. Omit (default) when content originates in the + current session; absence of the field means that no origin + information was supplied. """ if isinstance(source, str): source_id = source - attribution: dict[str, str] = {"source_id": source_id} + attribution: dict[str, Any] = {"source_id": source_id} else: source_id = source.source_id # type: ignore[attr-defined] attribution = {"source_id": source_id, "source_type": type(source).__name__} + if origin_session_ids: + attribution["origin_session_ids"] = _deduplicate_origin_session_ids(origin_session_ids) copied: list[Message] = [] for message in messages: msg_copy = copy.copy(message) msg_copy.additional_properties = dict(message.additional_properties) - msg_copy.additional_properties.setdefault("_attribution", attribution) + message_attribution = dict(attribution) + if "origin_session_ids" in message_attribution: + message_attribution["origin_session_ids"] = list(message_attribution["origin_session_ids"]) + existing_attribution = msg_copy.additional_properties.get("_attribution") + if isinstance(existing_attribution, Mapping): + merged_attribution = dict(cast(Mapping[str, Any], existing_attribution)) + for key, value in message_attribution.items(): + if key == "origin_session_ids": + existing_origins = merged_attribution.get(key) + if isinstance(existing_origins, Sequence) and not isinstance(existing_origins, str): + existing_origin_values = cast(Sequence[Any], existing_origins) + value = _deduplicate_origin_session_ids( + [origin for origin in existing_origin_values if isinstance(origin, str)] + + cast(list[str], value) + ) + merged_attribution[key] = value + else: + merged_attribution.setdefault(key, value) + msg_copy.additional_properties["_attribution"] = merged_attribution + else: + msg_copy.additional_properties.setdefault("_attribution", message_attribution) copied.append(msg_copy) if source_id not in self.context_messages: self.context_messages[source_id] = [] diff --git a/python/packages/core/tests/core/test_harness_memory.py b/python/packages/core/tests/core/test_harness_memory.py index 00a74856cea..f5b2733e577 100644 --- a/python/packages/core/tests/core/test_harness_memory.py +++ b/python/packages/core/tests/core/test_harness_memory.py @@ -505,6 +505,94 @@ async def test_memory_context_provider_recent_turns_can_skip_tool_call_groups(tm assert with_tools_messages[4].text == "Second final answer" +async def test_memory_context_provider_marks_cross_session_origins(tmp_path) -> None: + """Injected memory should carry all prior session origins without duplicates. + + Exercises the cross-session attribution surface added to support downstream observers + detecting attacks of the class documented in Dai et al. (arXiv:2605.06158). + """ + session = AgentSession(session_id="session-current") + session.state["owner_id"] = "alice" + store = MemoryFileStore( + tmp_path, + owner_state_key="owner_id", + dumps=lambda value: json.dumps(value, separators=(",", ":"), sort_keys=True), + loads=json.loads, + ) + updated_at = datetime(2026, 4, 21, tzinfo=timezone.utc).replace(microsecond=0).isoformat() + store.write_topic( + session, + MemoryTopicRecord( + topic="travel preferences", + summary="Loves Oslo trips.", + memories=["Prefers Oslo in summer."], + updated_at=updated_at, + session_ids=["session-current", "session-prior-1", "session-prior-2", "session-prior-1"], + ), + source_id=DEFAULT_MEMORY_SOURCE_ID, + ) + + agent = Agent( + client=_MemoryHarnessClient(), # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type] + context_providers=[MemoryContextProvider(store=store)], + default_options=_no_store_options(), + ) + + session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage] + session=session, + input_messages=[Message(role="user", contents=["Tell me about my travel preferences."])], + ) + + memory_messages = [ + m for m in session_context.context_messages.get(DEFAULT_MEMORY_SOURCE_ID, []) if "### MEMORY.md" in m.text + ] + assert memory_messages, "expected an injected memory block under the memory source" + attribution: dict[str, Any] = memory_messages[0].additional_properties.get("_attribution") or {} + assert attribution.get("origin_session_ids") == ["session-prior-1", "session-prior-2"] + + +async def test_memory_context_provider_omits_origin_when_only_current_session(tmp_path) -> None: + """When all contributing topics are from the current session, attribution must NOT advertise an origin.""" + session = AgentSession(session_id="session-current") + session.state["owner_id"] = "alice" + store = MemoryFileStore( + tmp_path, + owner_state_key="owner_id", + dumps=lambda value: json.dumps(value, separators=(",", ":"), sort_keys=True), + loads=json.loads, + ) + updated_at = datetime(2026, 4, 21, tzinfo=timezone.utc).replace(microsecond=0).isoformat() + store.write_topic( + session, + MemoryTopicRecord( + topic="travel preferences", + summary="Loves Oslo trips.", + memories=["Prefers Oslo in summer."], + updated_at=updated_at, + session_ids=["session-current"], + ), + source_id=DEFAULT_MEMORY_SOURCE_ID, + ) + + agent = Agent( + client=_MemoryHarnessClient(), # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type] + context_providers=[MemoryContextProvider(store=store)], + default_options=_no_store_options(), + ) + + session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage] + session=session, + input_messages=[Message(role="user", contents=["Tell me about my travel preferences."])], + ) + + memory_messages = [ + m for m in session_context.context_messages.get(DEFAULT_MEMORY_SOURCE_ID, []) if "### MEMORY.md" in m.text + ] + assert memory_messages + attribution: dict[str, Any] = memory_messages[0].additional_properties.get("_attribution") or {} + assert "origin_session_ids" not in attribution + + async def test_memory_context_provider_uses_explicit_consolidation_client(tmp_path) -> None: """The memory provider should use the explicit consolidation client when one is configured.""" session = AgentSession(session_id="session-1") diff --git a/python/packages/core/tests/core/test_sessions.py b/python/packages/core/tests/core/test_sessions.py index 2fcf2f94bcc..c4f4061d42c 100644 --- a/python/packages/core/tests/core/test_sessions.py +++ b/python/packages/core/tests/core/test_sessions.py @@ -107,6 +107,92 @@ class MyProvider: stored = ctx.context_messages["rag"][0] assert stored.additional_properties["_attribution"] == {"source_id": "rag", "source_type": "MyProvider"} + def test_extend_messages_origin_session_ids_default_omits_field(self) -> None: + ctx = SessionContext(input_messages=[]) + msg = Message(role="system", contents=["ctx"]) + ctx.extend_messages("rag", [msg]) + stored = ctx.context_messages["rag"][0] + # Default (no origin_session_ids passed) preserves the historical attribution shape + # so observers can distinguish "no origin info" from "explicit cross-session marker." + assert "origin_session_ids" not in stored.additional_properties["_attribution"] + + def test_extend_messages_origin_session_ids_recorded_on_attribution(self) -> None: + ctx = SessionContext(session_id="current", input_messages=[]) + msg = Message(role="system", contents=["loaded from a prior session"]) + ctx.extend_messages( + "memory_provider", + [msg], + origin_session_ids=["prior-session-id", "another-session", "prior-session-id"], + ) + stored = ctx.context_messages["memory_provider"][0] + assert stored.additional_properties["_attribution"] == { + "source_id": "memory_provider", + "origin_session_ids": ["prior-session-id", "another-session"], + } + + def test_extend_messages_origin_session_ids_with_provider_object(self) -> None: + class MyMemoryProvider: + source_id = "memory" + + ctx = SessionContext(session_id="current", input_messages=[]) + msg = Message(role="assistant", contents=["consolidated memory content"]) + ctx.extend_messages(MyMemoryProvider(), [msg], origin_session_ids=["prior"]) + stored = ctx.context_messages["memory"][0] + assert stored.additional_properties["_attribution"] == { + "source_id": "memory", + "source_type": "MyMemoryProvider", + "origin_session_ids": ["prior"], + } + + def test_extend_messages_applies_all_origins_to_each_message(self) -> None: + ctx = SessionContext(session_id="current", input_messages=[]) + messages = [ + Message(role="assistant", contents=["first composed memory"]), + Message(role="assistant", contents=["second composed memory"]), + ] + + ctx.extend_messages("memory_provider", messages, origin_session_ids=["session-a", "session-b"]) + + stored_messages = ctx.context_messages["memory_provider"] + assert [message.additional_properties["_attribution"] for message in stored_messages] == [ + { + "source_id": "memory_provider", + "origin_session_ids": ["session-a", "session-b"], + }, + { + "source_id": "memory_provider", + "origin_session_ids": ["session-a", "session-b"], + }, + ] + + def test_extend_messages_adds_origin_to_existing_attribution(self) -> None: + ctx = SessionContext(session_id="current", input_messages=[]) + msg = Message( + role="system", + contents=["loaded from a prior session"], + additional_properties={ + "_attribution": { + "source_id": "custom", + "custom_key": "value", + "origin_session_ids": ["existing", "prior"], + } + }, + ) + + ctx.extend_messages("memory_provider", [msg], origin_session_ids=["prior", "new"]) + + stored = ctx.context_messages["memory_provider"][0] + assert stored.additional_properties["_attribution"] == { + "source_id": "custom", + "custom_key": "value", + "origin_session_ids": ["existing", "prior", "new"], + } + assert msg.additional_properties["_attribution"] == { + "source_id": "custom", + "custom_key": "value", + "origin_session_ids": ["existing", "prior"], + } + def test_extend_instructions_string(self) -> None: ctx = SessionContext(input_messages=[]) ctx.extend_instructions("sys", "Be helpful") diff --git a/python/samples/02-agents/context_providers/README.md b/python/samples/02-agents/context_providers/README.md index e49a472f397..f860230f8cd 100644 --- a/python/samples/02-agents/context_providers/README.md +++ b/python/samples/02-agents/context_providers/README.md @@ -7,6 +7,7 @@ These samples demonstrate how to use context providers to enrich agent conversat | File / Folder | Description | |---------------|-------------| | [`simple_context_provider.py`](simple_context_provider.py) | Implement a custom context provider by extending `ContextProvider` to extract and inject structured user information across turns. | +| [`cross_session_observer.py`](cross_session_observer.py) | Detect injected context messages whose origins differ from the current session, via the `Message.additional_properties["_attribution"]["origin_session_ids"]` field. Self-contained — no LLM credentials required. | | [`azure_ai_foundry_memory.py`](azure_ai_foundry_memory.py) | Use `FoundryMemoryProvider` to add semantic memory — automatically retrieves, searches, and stores memories via Azure AI Foundry. | | [`file_access_data_processing/`](file_access_data_processing/) | Use `FileAccessProvider` with `FileSystemAgentFileStore` to give an agent read/write/search access to a folder of CSV data files. See its own [README](file_access_data_processing/README.md). | | [`azure_ai_search/`](azure_ai_search/) | Retrieval Augmented Generation (RAG) with Azure AI Search in semantic and agentic modes. See its own [README](azure_ai_search/README.md). | @@ -15,6 +16,9 @@ These samples demonstrate how to use context providers to enrich agent conversat ## Prerequisites +**For `cross_session_observer.py`:** +- No external dependencies; runs against in-memory `SessionContext`. + **For `simple_context_provider.py`:** - `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint - `FOUNDRY_MODEL`: Model deployment name diff --git a/python/samples/02-agents/context_providers/cross_session_observer.py b/python/samples/02-agents/context_providers/cross_session_observer.py new file mode 100644 index 00000000000..dac88f39382 --- /dev/null +++ b/python/samples/02-agents/context_providers/cross_session_observer.py @@ -0,0 +1,156 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from collections.abc import Callable, Mapping, Sequence +from typing import Any, cast + +from agent_framework import AgentSession, ContextProvider, Message, SessionContext + +"""This sample demonstrates how to detect cross-session memory injection. + +When a context provider injects messages from a different ``session_id`` than +the requesting one — the legitimate cross-session memory use case (consolidated +memories, Mem0 with default scope, shared knowledge bases) — the framework +records the originating sessions under +``message.additional_properties["_attribution"]["origin_session_ids"]``. + +Downstream context observers can subscribe to this signal for governance, +audit, and behavioral analysis purposes. This is useful for defending against +the stateful-agent-backdoor attack class documented in Dai et al., +arXiv:2605.06158, in which an adversary chains sub-backdoors across sessions +under permission isolation via persisted memory state. + +The sample is self-contained: it constructs ``SessionContext`` directly and +invokes provider lifecycle methods manually, so no LLM credentials are +required to run it. +""" + + +class CrossSessionObserver(ContextProvider): + """Detect injected context messages whose origin differs from the current session. + + Subscribes via the standard ``ContextProvider`` pipeline. In ``before_run``, + walks the accumulated context messages and invokes a user-supplied + callback for each message whose ``_attribution["origin_session_ids"]`` + contains one or more sessions other than the current ``session_id``. + + The callback receives the source_id that injected the content, the + originating session IDs, the current session_id, and the message itself. + Use it to log, alert, increment metrics, or enforce policy — the observer + itself only surfaces the signal, leaving the response policy to the caller. + """ + + DEFAULT_SOURCE_ID = "cross_session_observer" + + def __init__( + self, + on_cross_session_access: Callable[[str, Sequence[str], str | None, Message], None], + *, + source_id: str = DEFAULT_SOURCE_ID, + ) -> None: + """Initialize the observer. + + Args: + on_cross_session_access: Callback invoked for each detected + cross-session message. Signature is + ``(source_id, origin_session_ids, current_session_id, message)``. + source_id: Unique identifier for this observer instance. + """ + super().__init__(source_id) + self._on_cross_session_access = on_cross_session_access + + async def before_run( + self, + *, + agent: Any, + session: AgentSession | None, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Inspect accumulated context messages for cross-session origin.""" + current_session_id = context.session_id + for source_id, messages in context.context_messages.items(): + if source_id == self.source_id: + continue + for message in messages: + attribution_raw = message.additional_properties.get("_attribution") + if not isinstance(attribution_raw, Mapping): + continue + attribution = cast(Mapping[str, Any], attribution_raw) + origins = attribution.get("origin_session_ids") + if not isinstance(origins, Sequence) or isinstance(origins, str): + continue + cross_session_origins = [ + origin for origin in origins if isinstance(origin, str) and origin != current_session_id + ] + if cross_session_origins: + self._on_cross_session_access(source_id, cross_session_origins, current_session_id, message) + + +def _on_detected(source_id: str, origins: Sequence[str], current: str | None, message: Message) -> None: + """Sample callback that logs cross-session detections to stdout.""" + preview = " ".join(message.text.split())[:80] + print( + f"[cross-session detected] source={source_id!r} " + f"origin_sessions={list(origins)!r} current_session={current!r} " + f"preview={preview!r}" + ) + + +async def main() -> None: + """Demonstrate the observer firing on cross-session injection.""" + observer = CrossSessionObserver(_on_detected) + + # --- Case 1: same-session injection (observer should be silent) --- + same_session_context = SessionContext( + session_id="session-A", + input_messages=[Message("user", ["What did we discuss last time?"])], + ) + # Simulate a same-session provider injecting same-session history. Omitting + # origin_session_ids means "no origin info"; observers treat it as equivalent + # to same-session for backward compatibility. + same_session_context.extend_messages( + "history_provider", + [Message("assistant", ["We talked about Q3 revenue projections."])], + ) + await observer.before_run( + agent=None, + session=None, + context=same_session_context, + state={}, + ) + print("--- Same-session case complete (no detections expected above) ---\n") + + # --- Case 2: cross-session injection (observer should fire) --- + cross_session_context = SessionContext( + session_id="session-B", + input_messages=[Message("user", ["Continue from where we left off."])], + ) + # Simulate a cross-session memory provider injecting content originally + # written in sessions A and C while we're now running in session B. + cross_session_context.extend_messages( + "memory_provider", + [Message("assistant", ["Remember: API key for prod is (from prior sessions)."])], + origin_session_ids=["session-A", "session-C"], + ) + await observer.before_run( + agent=None, + session=None, + context=cross_session_context, + state={}, + ) + print("--- Cross-session case complete (one detection expected above) ---") + + +if __name__ == "__main__": + asyncio.run(main()) + + +""" +Sample output: +--- Same-session case complete (no detections expected above) --- + +[cross-session detected] source='memory_provider' origin_sessions=['session-A', 'session-C'] \ +current_session='session-B' preview='Remember: API key for prod is (from prior sessions).' +--- Cross-session case complete (one detection expected above) --- +"""