Skip to content

Commit ce4fcb2

Browse files
[BREAKING] Python: Isolate Foundry-hosted MAF state by sandbox (microsoft#8741)
* fix(python): isolate Foundry-hosted MAF state by sandbox * fix(python): trust platform sandbox identity for Foundry state * refactor(python): remove unused Foundry approval context option * fix(python): scope persisted Invocations to routed sandbox
1 parent 6bb47d4 commit ce4fcb2

16 files changed

Lines changed: 1047 additions & 59 deletions

File tree

‎python/packages/core/agent_framework/foundry/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"FoundryCheckpointStore": ("agent_framework_foundry_hosting", "agent-framework-foundry-hosting"),
4141
"ContextScopedStoreProvider": ("agent_framework_foundry_hosting", "agent-framework-foundry-hosting"),
4242
"FoundryFunctionApprovalStore": ("agent_framework_foundry_hosting", "agent-framework-foundry-hosting"),
43+
"FoundryRequestScope": ("agent_framework_foundry_hosting", "agent-framework-foundry-hosting"),
4344
"FunctionApprovalStore": ("agent_framework_foundry_hosting", "agent-framework-foundry-hosting"),
4445
"FunctionApprovalStoreProvider": ("agent_framework_foundry_hosting", "agent-framework-foundry-hosting"),
4546
"FoundryToolbox": ("agent_framework_foundry_hosting", "agent-framework-foundry-hosting"),

‎python/packages/core/agent_framework/foundry/__init__.pyi‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ from agent_framework_foundry_hosting import (
3737
FoundryAgentSessionStore,
3838
FoundryCheckpointStore,
3939
FoundryFunctionApprovalStore,
40+
FoundryRequestScope,
4041
FoundryToolbox,
4142
FunctionApprovalStore,
4243
FunctionApprovalStoreProvider,
@@ -75,6 +76,7 @@ __all__ = [
7576
"FoundryLocalClient",
7677
"FoundryLocalSettings",
7778
"FoundryMemoryProvider",
79+
"FoundryRequestScope",
7880
"FoundryToolbox",
7981
"FunctionApprovalStore",
8082
"FunctionApprovalStoreProvider",

‎python/packages/core/tests/core/test_foundry_namespace.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
FoundryMemoryProvider = _foundry.FoundryMemoryProvider
1414
FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY = _foundry.FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY
1515
FoundryAgentSessionStore = _foundry_hosting.FoundryAgentSessionStore
16+
FoundryRequestScope = _foundry_hosting.FoundryRequestScope
1617
ResponsesHostServer = _foundry_hosting.ResponsesHostServer
1718
FoundryLocalClient = _foundry_local.FoundryLocalClient
1819

@@ -22,12 +23,14 @@ def test_foundry_namespace_exposes_cloud_and_local_symbols() -> None:
2223
assert foundry.FoundryChatClient is FoundryChatClient
2324
assert foundry.FoundryMemoryProvider is FoundryMemoryProvider
2425
assert foundry.FoundryAgentSessionStore is FoundryAgentSessionStore
26+
assert foundry.FoundryRequestScope is FoundryRequestScope
2527
assert foundry.ResponsesHostServer is ResponsesHostServer
2628
assert foundry.FoundryLocalClient is FoundryLocalClient
2729
assert "FoundryChatClient" in dir(foundry)
2830
assert "FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY" in dir(foundry)
2931
assert "FoundryLocalClient" in dir(foundry)
3032
assert "FoundryAgentSessionStore" in dir(foundry)
33+
assert "FoundryRequestScope" in dir(foundry)
3134
assert "ResponsesHostServer" in dir(foundry)
3235

3336

‎python/packages/foundry_hosting/README.md‎

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,54 @@ encoding of the store name. For example:
9696
9797
### User isolation
9898

99-
When hosted on Foundry, the default state stores automatically isolate data by the
100-
platform user ID supplied with each request. Sessions, workflow checkpoints, and
101-
function approvals written for one user cannot be read or modified by another user.
102-
No additional partitioning configuration is required when using the default stores.
99+
Hosted requests require platform user and call IDs from the AgentServer request
100+
context. Responses also requires the platform-configured `FOUNDRY_AGENT_SESSION_ID`
101+
(`AgentConfig.session_id`) for sandbox identity; a different caller-supplied
102+
`agent_session_id` fails closed. For Invocations, the
103+
[platform routes by the `agent_session_id` query parameter](https://learn.microsoft.com/azure/foundry/agents/how-to/manage-hosted-sessions#how-each-protocol-binds-an-invocation-to-a-session).
104+
When `FOUNDRY_AGENT_SESSION_ID` is present, it must match that query and the
105+
resolved request context. When it is absent, only an explicit, nonempty routed
106+
query matching the request context can identify the sandbox; duplicate query
107+
IDs or a request without the query are rejected rather than using the SDK's
108+
generated fallback ID.
109+
This means an automatically created first Invocations session without a query
110+
cannot access default hosted state when the platform does not configure the
111+
session ID. The platform documentation does not guarantee that the environment
112+
variable is provided for every sandbox.
113+
114+
`FoundryRequestScope.from_context(config, platform_context)` requires the
115+
configured ID and remains strict for Responses and direct store providers. Only
116+
the Invocations host accepts the verified routed-query alternative. Its
117+
`storage_key` is a bounded hash of the framed user and sandbox IDs, not a caller
118+
conversation or response ID.
119+
120+
| Identifier | Purpose |
121+
| --- | --- |
122+
| Foundry session ID | Platform sandbox for the hosted request and its MAF state. |
123+
| Platform user ID / call ID | User isolation and per-request storage authorization/correlation. A call ID is **not** a conversation ID. |
124+
| Responses `response.id`, `previous_response_id`, `conversation` | Caller-visible continuation and conversation IDs, used as item keys *within* the sandbox. |
125+
| MAF `AgentSession.session_id` / `service_session_id` | Inner agent state and optional downstream service continuation; neither identifies the Foundry sandbox. |
126+
| Workflow checkpoint ID | Inner workflow state within a checkpoint context, not a Foundry session ID. |
127+
128+
The default hosted MAF session, checkpoint, and approval stores use a `v2` namespace
129+
derived from the hashed platform identity, with `user_isolation=True` and an explicit
130+
platform `call_id` on each item operation. Checkpoint context IDs are hashed as well.
131+
The same user in two hosted sandboxes cannot read the other's default MAF state.
132+
Locally, the existing single-user store names and file-based fallback remain unchanged;
133+
direct store constructors without a trusted `scope` also retain their existing local
134+
behavior. Applications that supply custom store providers must implement equivalent
135+
hosted user and sandbox isolation.
136+
137+
**Existing hosted state is not migrated.** The default stores never fall back to
138+
legacy unscoped `agent_sessions`, `invocation_sessions`,
139+
`checkpoints/<context_id>`, or `function_approvals` data: an old MAF session,
140+
workflow checkpoint, or pending approval cannot be resumed through the new
141+
default hosted stores. Start a fresh Responses conversation rather than reusing
142+
an old `previous_response_id` or conversation ID; Invocations starts with an
143+
empty MAF session in its scoped store. The separate AgentServer response store
144+
is not migrated by this change. Recovering old state requires a separately
145+
designed migration that verifies the original user's and sandbox's ownership;
146+
reading unscoped data by user alone is not safe.
103147

104148
### Agent Sessions
105149

@@ -108,6 +152,13 @@ durably. By default they use `FoundryAgentSessionStore`, backed by Foundry stora
108152
and file-based storage locally. Responses sessions use the `agent_sessions` logical store;
109153
Invocations sessions use the separate `invocation_sessions` store.
110154

155+
Loaded MAF sessions are saved with an ETag condition. A competing turn that has
156+
already advanced the same conversation causes a visible persistence failure instead
157+
of silently overwriting its state. New hosted session keys are created only if absent;
158+
turns using `previous_response_id` write their own new response ID, without applying
159+
the predecessor's ETag to a different key. Local callers can still upsert directly
160+
without first loading a session.
161+
111162
See the [custom storage provider sample](../../samples/04-hosting/foundry-hosted-agents/responses/custom_storage/)
112163
for an example that uses an in-memory session store locally and Azure Cosmos DB when hosted.
113164

@@ -129,9 +180,11 @@ their last write; an invocation after expiry starts a fresh session.
129180
Existing stores retain their creation-time settings, and custom providers own their retention
130181
policies.
131182

132-
Applications must coordinate overlapping requests for the same session; the store does not
133-
provide transactions or exactly-once execution. Independent local applications should use
134-
separate state roots or store providers.
183+
Default hosted stores reject stale ETag and duplicate-create writes for
184+
overlapping turns instead of overwriting a newer MAF session. This does not
185+
provide transactions or exactly-once execution for agent/tool side effects;
186+
applications still need to coordinate overlapping requests. Independent local
187+
applications should use separate state roots or store providers.
135188

136189
### Workflow checkpoints
137190

‎python/packages/foundry_hosting/agent_framework_foundry_hosting/__init__.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
if TYPE_CHECKING:
77
from ._invocations import InvocationsHostServer
88
from ._responses import ResponsesHostServer
9+
from ._scope import FoundryRequestScope
910
from ._state_store import (
1011
AgentSessionStoreProvider,
1112
CheckpointStoreProvider,
@@ -31,6 +32,7 @@
3132
"FoundryAgentSessionStore": "._state_store",
3233
"FoundryCheckpointStore": "._state_store",
3334
"FoundryFunctionApprovalStore": "._state_store",
35+
"FoundryRequestScope": "._scope",
3436
"FoundryToolbox": "._toolbox",
3537
"FunctionApprovalStore": "._state_store",
3638
"FunctionApprovalStoreProvider": "._state_store",
@@ -46,6 +48,7 @@
4648
"FoundryAgentSessionStore",
4749
"FoundryCheckpointStore",
4850
"FoundryFunctionApprovalStore",
51+
"FoundryRequestScope",
4952
"FoundryToolbox",
5053
"FunctionApprovalStore",
5154
"FunctionApprovalStoreProvider",

‎python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py‎

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from ._agent_source import is_agent, resolve_agent, validate_agent_source
2121
from ._feature_usage import FeatureIndex
22+
from ._scope import FoundryRequestScope
2223
from ._state_store import AgentSessionStoreProvider, StoreProvider
2324

2425
logger = logging.getLogger(__name__)
@@ -85,7 +86,12 @@ def __init__(
8586
self.invoke_handler(self._handle_invoke)
8687
mark_feature_used(FeatureIndex.FOUNDRY_HOSTING)
8788

88-
def _partition_key(self) -> str | tuple[str, str]:
89+
def _partition_key(
90+
self,
91+
*,
92+
context: FoundryAgentRequestContext | None = None,
93+
scope: FoundryRequestScope | None = None,
94+
) -> str | tuple[str, str]:
8995
"""Get the partition key for the current request.
9096
9197
A hosted partition key is a tuple containing the session ID and user ID,
@@ -99,15 +105,17 @@ def _partition_key(self) -> str | tuple[str, str]:
99105
Exceptions:
100106
RuntimeError: If the context doesn't contain the expected IDs.
101107
"""
102-
context = get_request_context()
108+
if context is None:
109+
context = get_request_context()
103110

104111
if self.config.is_hosted:
105-
if not context.session_id or not context.user_id:
112+
if not context.user_id:
106113
raise RuntimeError(
107-
"The hosted environment is missing session_id or user_id in the request context. "
114+
"The hosted environment is missing user_id in the request context. "
108115
"Please ensure that the request is coming from a valid Foundry platform service."
109116
)
110-
return context.session_id, context.user_id
117+
hosted_scope = scope or FoundryRequestScope.from_context(self.config, context)
118+
return hosted_scope.session_id, context.user_id
111119

112120
if not context.session_id:
113121
raise RuntimeError(
@@ -116,6 +124,34 @@ def _partition_key(self) -> str | tuple[str, str]:
116124

117125
return context.session_id
118126

127+
def _hosted_scope(self, request: Request, context: FoundryAgentRequestContext) -> FoundryRequestScope:
128+
"""Accept an unconfigured session only when the routed Invocations query identifies it."""
129+
if not context.user_id:
130+
raise RuntimeError("The hosted environment is missing user_id in the request context.")
131+
132+
routed_session_ids = request.query_params.getlist("agent_session_id")
133+
if len(routed_session_ids) > 1:
134+
raise RuntimeError("Hosted Invocations requires exactly one agent_session_id query parameter.")
135+
routed_session_id = routed_session_ids[0] if routed_session_ids else None
136+
if self.config.session_id:
137+
if routed_session_id is not None and routed_session_id != self.config.session_id:
138+
raise RuntimeError("The request agent_session_id does not match the platform session ID.")
139+
return FoundryRequestScope.from_context(self.config, context)
140+
141+
if not routed_session_id or routed_session_id != context.session_id:
142+
raise RuntimeError(
143+
"Hosted Invocations without FOUNDRY_AGENT_SESSION_ID require an explicit routed "
144+
"agent_session_id query parameter matching the request context."
145+
)
146+
if not context.call_id:
147+
raise RuntimeError("Foundry hosted requests require a trusted user ID and call ID.")
148+
return FoundryRequestScope(
149+
session_id=routed_session_id,
150+
user_id=context.user_id,
151+
call_id=context.call_id,
152+
is_hosted=True,
153+
)
154+
119155
@asynccontextmanager
120156
async def _request_agent(self) -> AsyncGenerator[SupportsAgentRun]:
121157
agent = await resolve_agent(self._agent)
@@ -126,13 +162,21 @@ async def _request_agent(self) -> AsyncGenerator[SupportsAgentRun]:
126162

127163
@asynccontextmanager
128164
async def _request_session(
129-
self, partition_key: str | tuple[str, str], context: FoundryAgentRequestContext
165+
self,
166+
partition_key: str | tuple[str, str],
167+
context: FoundryAgentRequestContext,
168+
*,
169+
hosted_scope: FoundryRequestScope | None = None,
130170
) -> AsyncGenerator[AgentSession]:
131171
session_id = (
132172
json.dumps(partition_key, separators=(",", ":")) if isinstance(partition_key, tuple) else partition_key
133173
)
134174
try:
135-
store = self._session_store_provider.get_store(config=self.config, platform_context=context)
175+
provider = self._session_store_provider
176+
if hosted_scope is not None and not self.config.session_id and type(provider) is AgentSessionStoreProvider:
177+
store = provider._get_scoped_store(context, hosted_scope) # pyright: ignore[reportPrivateUsage]
178+
else:
179+
store = provider.get_store(config=self.config, platform_context=context)
136180
session = await store.get(session_id)
137181
if session is None:
138182
session = AgentSession(session_id=session_id)
@@ -157,8 +201,10 @@ async def _request_session(
157201

158202
async def _handle_invoke(self, request: Request) -> Response:
159203
"""Invoke the agent with the given request."""
204+
context = get_request_context()
160205
try:
161-
partition_key = self._partition_key()
206+
hosted_scope = self._hosted_scope(request, context) if self.config.is_hosted else None
207+
partition_key = self._partition_key(context=context, scope=hosted_scope)
162208
except Exception as e:
163209
return Response(content=str(e), status_code=500)
164210

@@ -172,13 +218,11 @@ async def _handle_invoke(self, request: Request) -> Response:
172218
return StreamingResponse(content=error, status_code=400)
173219
return Response(content=error, status_code=400)
174220

175-
context = get_request_context()
176-
177221
if stream:
178222

179223
async def stream_response() -> AsyncGenerator[str]:
180224
async with (
181-
self._request_session(partition_key, context) as session,
225+
self._request_session(partition_key, context, hosted_scope=hosted_scope) as session,
182226
self._request_agent() as agent,
183227
):
184228
stream = agent.run(user_message, session=session, stream=True)
@@ -197,7 +241,7 @@ async def stream_response() -> AsyncGenerator[str]:
197241
return _InvocationStreamingResponse(stream_response())
198242

199243
async with (
200-
self._request_session(partition_key, context) as session,
244+
self._request_session(partition_key, context, hosted_scope=hosted_scope) as session,
201245
self._request_agent() as agent,
202246
):
203247
response = await agent.run([user_message], session=session)

‎python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686

8787
from ._agent_source import is_agent, resolve_agent, validate_agent_source
8888
from ._feature_usage import FeatureIndex
89+
from ._scope import FoundryRequestScope
8990
from ._state_store import (
9091
AgentSessionStoreProvider,
9192
CheckpointStoreProvider,
@@ -668,6 +669,15 @@ async def _handle_response(
668669
yield response_event_stream.emit_created()
669670
yield response_event_stream.emit_in_progress()
670671

672+
if self.config.is_hosted:
673+
try:
674+
FoundryRequestScope.from_context(self.config, get_request_context())
675+
except RuntimeError as exc:
676+
logger.error("Invalid Foundry hosted request context: %s", exc)
677+
for event in self._emit_failure(response_event_stream, None, exc):
678+
yield event
679+
return
680+
671681
terminal_event: ResponseStreamEvent | None = None
672682
agent = await resolve_agent(self._agent_source)
673683
configuration = self._configuration or _validate_agent_configuration(
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
"""Trusted request identity for Foundry-hosted state."""
4+
5+
from __future__ import annotations
6+
7+
import hashlib
8+
import json
9+
from dataclasses import dataclass
10+
11+
from azure.ai.agentserver.core import AgentConfig, FoundryAgentRequestContext
12+
13+
14+
@dataclass(frozen=True)
15+
class FoundryRequestScope:
16+
"""Separate the platform sandbox and user from caller-facing continuation IDs."""
17+
18+
session_id: str
19+
user_id: str | None
20+
call_id: str | None
21+
is_hosted: bool
22+
23+
@classmethod
24+
def from_context(
25+
cls,
26+
config: AgentConfig,
27+
context: FoundryAgentRequestContext,
28+
*,
29+
local_session_id: str | None = None,
30+
) -> FoundryRequestScope:
31+
"""Resolve hosted identity from platform configuration, not caller-controlled session IDs.
32+
33+
Args:
34+
config: AgentServer's configuration for this host.
35+
context: Request context with platform user/call IDs and a potentially caller-supplied session ID.
36+
local_session_id: Fallback session ID for local, non-hosted requests only.
37+
38+
Raises:
39+
RuntimeError: If a required platform identity is missing.
40+
"""
41+
if config.is_hosted:
42+
if not config.session_id.strip():
43+
raise RuntimeError("Foundry hosted requests require a platform FOUNDRY_AGENT_SESSION_ID.")
44+
if context.session_id and context.session_id != config.session_id:
45+
raise RuntimeError("The request agent_session_id does not match the platform session ID.")
46+
if not context.user_id or not context.call_id:
47+
raise RuntimeError("Foundry hosted requests require a trusted user ID and call ID.")
48+
session_id = config.session_id
49+
else:
50+
session_id = context.session_id or local_session_id
51+
if not session_id:
52+
raise RuntimeError("A Foundry agent session ID is required to handle the request.")
53+
return cls(
54+
session_id=session_id,
55+
user_id=context.user_id,
56+
call_id=context.call_id,
57+
is_hosted=config.is_hosted,
58+
)
59+
60+
@property
61+
def storage_key(self) -> str:
62+
"""Hash the framed platform identity so store names reveal neither ID."""
63+
identity = json.dumps([self.user_id, self.session_id], ensure_ascii=False, separators=(",", ":"))
64+
return hashlib.sha256(identity.encode("utf-8")).hexdigest()

0 commit comments

Comments
 (0)