From 0a55bc7cb204ac4b61434b213165f48faa91c50c Mon Sep 17 00:00:00 2001 From: mhatipog Date: Fri, 16 Oct 2026 08:40:05 +0200 Subject: [PATCH 1/7] Python: bind FIDES quarantine clients to owning config --- .../packages/core/agent_framework/security.py | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/python/packages/core/agent_framework/security.py b/python/packages/core/agent_framework/security.py index acc7fbbb81f..ccb6ff9e49f 100644 --- a/python/packages/core/agent_framework/security.py +++ b/python/packages/core/agent_framework/security.py @@ -1379,6 +1379,8 @@ def __init__( self.auto_hide_untrusted = auto_hide_untrusted self.hide_threshold = hide_threshold self._standing_guidance_cache: WeakKeyDictionary[Any, tuple[str, ...]] = WeakKeyDictionary() + self._quarantine_client_bound = False + self._quarantine_client_override: SupportsChatGetResponse | None = None self._initialize_security_scope(security_scope, session_state_key=session_state_key) def _clone_for_scope(self, scope: _SecurityScope) -> LabelTrackingFunctionMiddleware: @@ -3364,15 +3366,11 @@ class SecureAgentConfig(ContextProvider): auto_hide_untrusted: Whether to automatically hide untrusted content. Note: - The quarantine chat client is stored per-instance (see ``get_quarantine_client``) - but is *also* registered in a single process-global slot via - ``set_quarantine_client``. The ``quarantined_llm`` tool always reads that global - slot, so the behavior is **last-writer-wins**: when multiple ``SecureAgentConfig`` - instances are constructed in the same process with different ``quarantine_chat_client`` - values, the most recently constructed instance's client is the one every agent's - ``quarantined_llm`` tool will use. Running multiple instances is supported, but they - share this one global quarantine client rather than each using their own. If you need - distinct quarantine clients per agent, run them in separate processes. + During calls mediated by this config's label-tracking middleware, + ``quarantined_llm`` uses this config's ``quarantine_chat_client``. + An explicitly missing client cannot inherit one from another config. + Direct standalone calls without an active config retain the legacy + process-global fallback configured by ``set_quarantine_client``. Examples: .. code-block:: python @@ -3433,9 +3431,8 @@ def __init__( If provided, the quarantined_llm tool will make actual isolated LLM calls instead of returning placeholder responses. This client should ideally be a separate instance using a cheaper model (e.g., gpt-4o-mini) since it - processes untrusted content. Note: this client is registered in a - process-global slot shared by all instances (last-writer-wins); see the - class docstring for details on running multiple instances. + processes untrusted content. Scoped agent calls use this client; + standalone calls retain the legacy process-global fallback. source_id: Optional source identifier for context provider attribution. Defaults to "secure_agent". @@ -3465,6 +3462,10 @@ class docstring for details on running multiple instances. default_integrity=default_integrity, default_confidentiality=default_confidentiality, ) + # A scoped invocation owns its quarantine client, including explicit None. + # These attributes survive the tracker copy in _clone_for_scope(). + self.label_tracker._quarantine_client_bound = True + self.label_tracker._quarantine_client_override = quarantine_chat_client self.policy_enforcer = ( PolicyEnforcementFunctionMiddleware( allow_untrusted_tools=set(self._allow_untrusted_tools), @@ -3974,7 +3975,12 @@ async def quarantined_llm( actual_metadata = metadata or {} # Build the response - use real LLM if quarantine client is configured - quarantine_client = get_quarantine_client() + # Scoped security middleware wins, including an explicitly missing client. + quarantine_client = ( + middleware._quarantine_client_override + if middleware is not None and middleware._quarantine_client_bound + else get_quarantine_client() + ) if quarantine_client is not None: # Build the quarantined prompt with retrieved content From 6dd499f524a1ea943194d531b0cea18886e58435 Mon Sep 17 00:00:00 2001 From: mhatipog Date: Fri, 16 Oct 2026 08:40:09 +0200 Subject: [PATCH 2/7] Python: bind FIDES quarantine clients to owning config --- python/packages/core/tests/test_security.py | 67 +++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/python/packages/core/tests/test_security.py b/python/packages/core/tests/test_security.py index 3ae96006831..c4957c9f0ed 100644 --- a/python/packages/core/tests/test_security.py +++ b/python/packages/core/tests/test_security.py @@ -8841,3 +8841,70 @@ async def my_tool(files: list[str]): ) assert rewritten_arguments(context) == {"files": {0, 1}} + + +async def test_quarantine_client_uses_its_own_security_config() -> None: + """Scoped quarantine calls must never inherit another agent's configured client.""" + from agent_framework.security import quarantined_llm, set_quarantine_client + + class FakeClient: + def __init__(self, name: str) -> None: + self.name = name + self.calls: list[str] = [] + + async def get_response(self, *, messages: Any, **kwargs: Any) -> SimpleNamespace: + value = "\n".join( + item.text or "" + for msg in messages for item in msg.contents + if getattr(item, "type", None) == "text" + ) + self.calls.append(value) + await asyncio.sleep(0) + return SimpleNamespace(text=self.name + " response") + + alpha, beta = FakeClient("A"), FakeClient("B") + a, b = SecureAgentConfig(quarantine_chat_client=alpha), SecureAgentConfig(quarantine_chat_client=beta) + empty = SecureAgentConfig(quarantine_chat_client=None) + + async def execute(cfg: SecureAgentConfig, marker: str) -> None: + session = AgentSession(session_id="synthetic-" + marker) + ctx = SessionContext(session_id=session.session_id, input_messages=[]) + await cfg.before_run( + agent=None, session=session, context=ctx, + state=session.state.setdefault(cfg.source_id, {}), + ) + tool = next(t for t in cfg.get_tools() if t.name == "quarantined_llm") + args = {"prompt": "Summarize", "labelled_data": {"record": { + "content": marker, + "security_label": {"integrity": "untrusted", "confidentiality": "private"}, + }}} + call = FunctionInvocationContext(function=tool, arguments=args, session=session) + + async def invoke(inner: FunctionInvocationContext) -> Any: + return await tool.invoke(arguments=inner.arguments, context=inner, skip_parsing=True) + + await FunctionMiddlewarePipeline(*ctx.middleware[cfg.source_id]).execute(call, invoke) + + try: + await asyncio.gather(execute(a, "ALPHA_MARKER"), execute(b, "BETA_MARKER")) + assert any("ALPHA_MARKER" in x for x in alpha.calls) + assert not any("ALPHA_MARKER" in x for x in beta.calls) + assert any("BETA_MARKER" in x for x in beta.calls) + assert not any("BETA_MARKER" in x for x in alpha.calls) + await execute(empty, "NO_CLIENT_MARKER") + assert not any("NO_CLIENT_MARKER" in x for x in beta.calls) + await execute(a, "ALPHA_SECOND_MARKER") + assert any("ALPHA_SECOND_MARKER" in x for x in alpha.calls) + assert not any("ALPHA_SECOND_MARKER" in x for x in beta.calls) + + # Standalone calls retain the legacy process-global fallback. + await quarantined_llm.invoke( + arguments={"prompt": "Direct", "labelled_data": {"record": { + "content": "STANDALONE_MARKER", + "security_label": {"integrity": "untrusted", "confidentiality": "public"}, + }}}, + skip_parsing=True, + ) + assert any("STANDALONE_MARKER" in x for x in beta.calls) + finally: + set_quarantine_client(None) From ee809c376ca6daae79ab35b8c8286262a79b592d Mon Sep 17 00:00:00 2001 From: mhatipog Date: Fri, 16 Oct 2026 09:28:20 +0200 Subject: [PATCH 3/7] Python: format FIDES quarantine routing regression with repo-pinned Ruff --- python/packages/core/tests/test_security.py | 34 +++++++++++++-------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/python/packages/core/tests/test_security.py b/python/packages/core/tests/test_security.py index c4957c9f0ed..1ed2cd7032b 100644 --- a/python/packages/core/tests/test_security.py +++ b/python/packages/core/tests/test_security.py @@ -8854,9 +8854,7 @@ def __init__(self, name: str) -> None: async def get_response(self, *, messages: Any, **kwargs: Any) -> SimpleNamespace: value = "\n".join( - item.text or "" - for msg in messages for item in msg.contents - if getattr(item, "type", None) == "text" + item.text or "" for msg in messages for item in msg.contents if getattr(item, "type", None) == "text" ) self.calls.append(value) await asyncio.sleep(0) @@ -8870,14 +8868,21 @@ async def execute(cfg: SecureAgentConfig, marker: str) -> None: session = AgentSession(session_id="synthetic-" + marker) ctx = SessionContext(session_id=session.session_id, input_messages=[]) await cfg.before_run( - agent=None, session=session, context=ctx, + agent=None, + session=session, + context=ctx, state=session.state.setdefault(cfg.source_id, {}), ) tool = next(t for t in cfg.get_tools() if t.name == "quarantined_llm") - args = {"prompt": "Summarize", "labelled_data": {"record": { - "content": marker, - "security_label": {"integrity": "untrusted", "confidentiality": "private"}, - }}} + args = { + "prompt": "Summarize", + "labelled_data": { + "record": { + "content": marker, + "security_label": {"integrity": "untrusted", "confidentiality": "private"}, + } + }, + } call = FunctionInvocationContext(function=tool, arguments=args, session=session) async def invoke(inner: FunctionInvocationContext) -> Any: @@ -8899,10 +8904,15 @@ async def invoke(inner: FunctionInvocationContext) -> Any: # Standalone calls retain the legacy process-global fallback. await quarantined_llm.invoke( - arguments={"prompt": "Direct", "labelled_data": {"record": { - "content": "STANDALONE_MARKER", - "security_label": {"integrity": "untrusted", "confidentiality": "public"}, - }}}, + arguments={ + "prompt": "Direct", + "labelled_data": { + "record": { + "content": "STANDALONE_MARKER", + "security_label": {"integrity": "untrusted", "confidentiality": "public"}, + } + }, + }, skip_parsing=True, ) assert any("STANDALONE_MARKER" in x for x in beta.calls) From cc840460ab37b98f1bb7db203e65a587c7c02e65 Mon Sep 17 00:00:00 2001 From: mhatipog Date: Fri, 16 Oct 2026 09:31:35 +0200 Subject: [PATCH 4/7] Python: bind active replacement FIDES trackers to their owning config --- .../packages/core/agent_framework/security.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/python/packages/core/agent_framework/security.py b/python/packages/core/agent_framework/security.py index ccb6ff9e49f..7093fb1a236 100644 --- a/python/packages/core/agent_framework/security.py +++ b/python/packages/core/agent_framework/security.py @@ -3462,10 +3462,6 @@ def __init__( default_integrity=default_integrity, default_confidentiality=default_confidentiality, ) - # A scoped invocation owns its quarantine client, including explicit None. - # These attributes survive the tracker copy in _clone_for_scope(). - self.label_tracker._quarantine_client_bound = True - self.label_tracker._quarantine_client_override = quarantine_chat_client self.policy_enforcer = ( PolicyEnforcementFunctionMiddleware( allow_untrusted_tools=set(self._allow_untrusted_tools), @@ -3493,14 +3489,23 @@ def _scope_for_session(self, session: AgentSession) -> _SecurityScope: raise ValueError("Security provider state must be a dictionary.") return _SecurityScope(cast(dict[str, Any], provider_state), scope_id=session.session_id) + def _bind_quarantine_client_to_stack(self, middleware_stack: list[FunctionMiddleware]) -> list[FunctionMiddleware]: + """Bind this config's client to each active tracker, including replacements.""" + for middleware in middleware_stack: + if isinstance(middleware, LabelTrackingFunctionMiddleware): + middleware._quarantine_client_bound = True + middleware._quarantine_client_override = self._quarantine_chat_client + return middleware_stack + def _middleware_for_scope(self, scope: _SecurityScope) -> list[FunctionMiddleware]: """Clone the current overridable middleware stack into one scope.""" - return [ + scoped = [ middleware._clone_for_scope(scope) # pyright: ignore[reportPrivateUsage] if isinstance(middleware, (LabelTrackingFunctionMiddleware, PolicyEnforcementFunctionMiddleware)) else middleware for middleware in self.get_middleware() ] + return self._bind_quarantine_client_to_stack(scoped) def _function_middleware_for_approval_resolution(self, session: AgentSession) -> list[FunctionMiddleware]: """Return provider-customized middleware bound to one restored session scope.""" @@ -3558,7 +3563,7 @@ def get_middleware(self) -> list[FunctionMiddleware]: middleware: list[FunctionMiddleware] = [self.label_tracker] if self.policy_enforcer: middleware.append(self.policy_enforcer) - return middleware + return self._bind_quarantine_client_to_stack(middleware) def get_audit_log(self, session: AgentSession | None = None) -> list[dict[str, Any]]: """Get the audit log for an optional session. From d7d255ca6f6210b5af84566c6b849c445b00aab5 Mon Sep 17 00:00:00 2001 From: mhatipog Date: Fri, 16 Oct 2026 09:32:21 +0200 Subject: [PATCH 5/7] Python: regress replacement trackers and custom provider middleware routing --- python/packages/core/tests/test_security.py | 55 ++++++++++++++++++--- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/python/packages/core/tests/test_security.py b/python/packages/core/tests/test_security.py index 1ed2cd7032b..0762850b558 100644 --- a/python/packages/core/tests/test_security.py +++ b/python/packages/core/tests/test_security.py @@ -8864,15 +8864,19 @@ async def get_response(self, *, messages: Any, **kwargs: Any) -> SimpleNamespace a, b = SecureAgentConfig(quarantine_chat_client=alpha), SecureAgentConfig(quarantine_chat_client=beta) empty = SecureAgentConfig(quarantine_chat_client=None) - async def execute(cfg: SecureAgentConfig, marker: str) -> None: + async def execute(cfg: SecureAgentConfig, marker: str, *, direct: bool = False) -> None: session = AgentSession(session_id="synthetic-" + marker) ctx = SessionContext(session_id=session.session_id, input_messages=[]) - await cfg.before_run( - agent=None, - session=session, - context=ctx, - state=session.state.setdefault(cfg.source_id, {}), - ) + if direct: + middleware = cfg.get_middleware() + else: + await cfg.before_run( + agent=None, + session=session, + context=ctx, + state=session.state.setdefault(cfg.source_id, {}), + ) + middleware = ctx.middleware[cfg.source_id] tool = next(t for t in cfg.get_tools() if t.name == "quarantined_llm") args = { "prompt": "Summarize", @@ -8888,7 +8892,7 @@ async def execute(cfg: SecureAgentConfig, marker: str) -> None: async def invoke(inner: FunctionInvocationContext) -> Any: return await tool.invoke(arguments=inner.arguments, context=inner, skip_parsing=True) - await FunctionMiddlewarePipeline(*ctx.middleware[cfg.source_id]).execute(call, invoke) + await FunctionMiddlewarePipeline(*middleware).execute(call, invoke) try: await asyncio.gather(execute(a, "ALPHA_MARKER"), execute(b, "BETA_MARKER")) @@ -8902,7 +8906,42 @@ async def invoke(inner: FunctionInvocationContext) -> Any: assert any("ALPHA_SECOND_MARKER" in x for x in alpha.calls) assert not any("ALPHA_SECOND_MARKER" in x for x in beta.calls) + # Swapping trackers after config creation is explicitly supported. + # Both session-cloned and direct stacks must honor the owning client. + a.label_tracker = LabelTrackingFunctionMiddleware() + b.label_tracker = LabelTrackingFunctionMiddleware() + empty.label_tracker = LabelTrackingFunctionMiddleware() + await asyncio.gather( + execute(a, "ALPHA_REPLACEMENT_MARKER"), + execute(b, "BETA_REPLACEMENT_MARKER"), + ) + assert any("ALPHA_REPLACEMENT_MARKER" in x for x in alpha.calls) + assert not any("ALPHA_REPLACEMENT_MARKER" in x for x in beta.calls) + assert any("BETA_REPLACEMENT_MARKER" in x for x in beta.calls) + assert not any("BETA_REPLACEMENT_MARKER" in x for x in alpha.calls) + await execute(empty, "NO_CLIENT_REPLACEMENT_MARKER") + assert not any("NO_CLIENT_REPLACEMENT_MARKER" in x for x in beta.calls) + await execute(a, "ALPHA_DIRECT_MARKER", direct=True) + assert any("ALPHA_DIRECT_MARKER" in x for x in alpha.calls) + assert not any("ALPHA_DIRECT_MARKER" in x for x in beta.calls) + + # The provider must also bind trackers supplied by an overriding + # get_middleware() implementation that does not call super(). + class CustomizedConfig(SecureAgentConfig): + def get_middleware(self) -> list[FunctionMiddleware]: + result: list[FunctionMiddleware] = [self.label_tracker] + if self.policy_enforcer is not None: + result.append(self.policy_enforcer) + return result + + custom = CustomizedConfig(quarantine_chat_client=alpha) + custom.label_tracker = LabelTrackingFunctionMiddleware() + await execute(custom, "ALPHA_CUSTOM_STACK_MARKER") + assert any("ALPHA_CUSTOM_STACK_MARKER" in x for x in alpha.calls) + assert not any("ALPHA_CUSTOM_STACK_MARKER" in x for x in beta.calls) + # Standalone calls retain the legacy process-global fallback. + set_quarantine_client(beta) await quarantined_llm.invoke( arguments={ "prompt": "Direct", From f17899b14b0c8202b9a6cba814dcfce41a8f6d47 Mon Sep 17 00:00:00 2001 From: mhatipog <29236961+mhatipog@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:04:18 +0200 Subject: [PATCH 6/7] fix(security): satisfy Pyright private-usage checks for scoped quarantine binding --- python/packages/core/agent_framework/security.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python/packages/core/agent_framework/security.py b/python/packages/core/agent_framework/security.py index 7093fb1a236..0ae5b5e77de 100644 --- a/python/packages/core/agent_framework/security.py +++ b/python/packages/core/agent_framework/security.py @@ -3493,8 +3493,8 @@ def _bind_quarantine_client_to_stack(self, middleware_stack: list[FunctionMiddle """Bind this config's client to each active tracker, including replacements.""" for middleware in middleware_stack: if isinstance(middleware, LabelTrackingFunctionMiddleware): - middleware._quarantine_client_bound = True - middleware._quarantine_client_override = self._quarantine_chat_client + middleware._quarantine_client_bound = True # pyright: ignore[reportPrivateUsage] + middleware._quarantine_client_override = self._quarantine_chat_client # pyright: ignore[reportPrivateUsage] return middleware_stack def _middleware_for_scope(self, scope: _SecurityScope) -> list[FunctionMiddleware]: @@ -3982,8 +3982,8 @@ async def quarantined_llm( # Build the response - use real LLM if quarantine client is configured # Scoped security middleware wins, including an explicitly missing client. quarantine_client = ( - middleware._quarantine_client_override - if middleware is not None and middleware._quarantine_client_bound + middleware._quarantine_client_override # pyright: ignore[reportPrivateUsage] + if middleware is not None and middleware._quarantine_client_bound # pyright: ignore[reportPrivateUsage] else get_quarantine_client() ) From 64645bfcf586abe17b03babfd34c51bc4b23ba3e Mon Sep 17 00:00:00 2001 From: mhatipog <29236961+mhatipog@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:04:34 +0200 Subject: [PATCH 7/7] test(security): type annotate quarantine mock clients and scoped middleware --- python/packages/core/tests/test_security.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/python/packages/core/tests/test_security.py b/python/packages/core/tests/test_security.py index 310f6c132c1..46de415b2e9 100644 --- a/python/packages/core/tests/test_security.py +++ b/python/packages/core/tests/test_security.py @@ -24,6 +24,7 @@ FunctionMiddleware, Message, SessionContext, + SupportsChatGetResponse, ) from agent_framework._middleware import FunctionMiddlewarePipeline, MiddlewareFailure, MiddlewareTermination from agent_framework._tools import ( @@ -8861,7 +8862,9 @@ async def get_response(self, *, messages: Any, **kwargs: Any) -> SimpleNamespace return SimpleNamespace(text=self.name + " response") alpha, beta = FakeClient("A"), FakeClient("B") - a, b = SecureAgentConfig(quarantine_chat_client=alpha), SecureAgentConfig(quarantine_chat_client=beta) + # The fake exposes only the chat methods exercised by these security tests. + a = SecureAgentConfig(quarantine_chat_client=cast(SupportsChatGetResponse, alpha)) + b = SecureAgentConfig(quarantine_chat_client=cast(SupportsChatGetResponse, beta)) empty = SecureAgentConfig(quarantine_chat_client=None) async def execute(cfg: SecureAgentConfig, marker: str, *, direct: bool = False) -> None: @@ -8876,7 +8879,7 @@ async def execute(cfg: SecureAgentConfig, marker: str, *, direct: bool = False) context=ctx, state=session.state.setdefault(cfg.source_id, {}), ) - middleware = ctx.middleware[cfg.source_id] + middleware = cast(list[FunctionMiddleware], ctx.middleware[cfg.source_id]) tool = next(t for t in cfg.get_tools() if t.name == "quarantined_llm") args = { "prompt": "Summarize", @@ -8934,14 +8937,14 @@ def get_middleware(self) -> list[FunctionMiddleware]: result.append(self.policy_enforcer) return result - custom = CustomizedConfig(quarantine_chat_client=alpha) + custom = CustomizedConfig(quarantine_chat_client=cast(SupportsChatGetResponse, alpha)) custom.label_tracker = LabelTrackingFunctionMiddleware() await execute(custom, "ALPHA_CUSTOM_STACK_MARKER") assert any("ALPHA_CUSTOM_STACK_MARKER" in x for x in alpha.calls) assert not any("ALPHA_CUSTOM_STACK_MARKER" in x for x in beta.calls) # Standalone calls retain the legacy process-global fallback. - set_quarantine_client(beta) + set_quarantine_client(cast(SupportsChatGetResponse, beta)) await quarantined_llm.invoke( arguments={ "prompt": "Direct",