From 7dbd951b3efce2745e1ca14e02b7ebdd51f7f544 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 7 Sep 2026 20:41:29 +0200 Subject: [PATCH 1/6] Python: preserve confidentiality through FIDES security tools Keep resolved-input confidentiality on transformed and embedded-label results, publish authoritative inspect/quarantine labels, and avoid unwrapping ordinary response-shaped JSON. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/security.py | 104 +++++++-- python/packages/core/tests/test_security.py | 203 +++++++++++++++++- 2 files changed, 289 insertions(+), 18 deletions(-) diff --git a/python/packages/core/agent_framework/security.py b/python/packages/core/agent_framework/security.py index 1a46bd3687..55ffa5feac 100644 --- a/python/packages/core/agent_framework/security.py +++ b/python/packages/core/agent_framework/security.py @@ -1262,14 +1262,25 @@ def _update_context_label(self, new_content_label: ContentLabel) -> None: def _extract_primary_tool_content(expanded_content: Any) -> Any: """Return the tool-visible content for an expanded variable payload. - Some hidden results are stored as rich payloads containing fields such as - ``response``, ``security_label``, and ``metadata``. Tool arguments should - only receive the primary content they would normally have seen without - variable indirection. + Hidden ``quarantined_llm`` results are stored as rich payloads containing + the response plus security metadata. Tool arguments should receive only the + primary response they would have seen without variable indirection. Ordinary + mappings or JSON text that happen to contain a ``response`` key remain intact. """ + + def is_quarantine_payload(payload: dict[str, Any]) -> bool: + return ( + payload.get("quarantined") is True + and "response" in payload + and isinstance(payload.get("security_label"), MutableMapping) + and isinstance(payload.get("metadata"), MutableMapping) + and isinstance(payload.get("variables_processed"), list) + and isinstance(payload.get("content_summary"), list) + ) + if isinstance(expanded_content, dict): content_map = cast(dict[str, Any], expanded_content) - if "response" in content_map: + if is_quarantine_payload(content_map): return content_map["response"] return content_map @@ -1280,7 +1291,7 @@ def _extract_primary_tool_content(expanded_content: Any) -> Any: parsed = json.loads(stripped) if isinstance(parsed, dict): parsed_map = cast(dict[str, Any], parsed) - if "response" in parsed_map: + if is_quarantine_payload(parsed_map): return parsed_map["response"] return expanded_content @@ -1516,23 +1527,37 @@ async def process( declared_source_integrity = self._get_source_integrity(context) confidentiality = self._get_function_confidentiality(context) + # Expand hidden references before execution and retain their stored labels. + resolved_labels = self._expand_variable_references_in_context(context) + argument_labels = [*input_labels, *resolved_labels] + argument_label = combine_labels(*argument_labels) if argument_labels else ContentLabel() + + # Integrity may be declared by the source, but a transformer cannot + # implicitly declassify data derived from its inputs. + result_confidentiality = combine_labels( + ContentLabel(confidentiality=confidentiality), argument_label + ).confidentiality + + # Step 3: Build tiered fallback_label + # This label is used for result items that have NO embedded labels. + # Priority: source_integrity declaration (tier 2) > input labels join (tier 3) if declared_source_integrity is not None: fallback_label = ContentLabel( integrity=declared_source_integrity, - confidentiality=confidentiality, + confidentiality=result_confidentiality, metadata={"source": "source_integrity", "function_name": function_name}, ) elif input_labels: combined = combine_labels(*input_labels) fallback_label = ContentLabel( integrity=combined.integrity, - confidentiality=confidentiality, + confidentiality=result_confidentiality, metadata={"source": "input_labels_join", "function_name": function_name}, ) else: fallback_label = ContentLabel( integrity=self.default_integrity, - confidentiality=confidentiality, + confidentiality=result_confidentiality, metadata={"source": "default", "function_name": function_name}, ) @@ -1724,11 +1749,18 @@ def _extract_content_label( """ additional_props = _get_additional_properties(item) - # Check for standard security_label + # Embedded labels remain authoritative for integrity, but cannot + # declassify data inherited from the tool's inputs. label_data = additional_props.get("security_label") if label_data and isinstance(label_data, dict): try: - return ContentLabel.from_dict(cast(dict[str, Any], label_data)) + embedded_label = ContentLabel.from_dict(cast(dict[str, Any], label_data)) + combined_label = combine_labels(fallback_label, embedded_label) + return ContentLabel( + integrity=embedded_label.integrity, + confidentiality=combined_label.confidentiality, + metadata=combined_label.metadata, + ) except Exception as e: logger.warning(f"Failed to parse security_label from Content: {e}") @@ -2874,6 +2906,34 @@ def get_quarantine_client() -> SupportsChatGetResponse | None: """ +def _quarantined_llm_result_parser(result: Any) -> list[Content]: + """Publish quarantine output integrity and combined input confidentiality.""" + contents = FunctionTool.parse_result(result) + if not contents or not isinstance(result, dict): + return contents + + label_data = cast(dict[str, Any], result).get("security_label") + if not isinstance(label_data, MutableMapping): + return contents + + try: + parsed_label = ContentLabel.from_dict(cast(MutableMapping[str, Any], label_data)) + except (TypeError, ValueError) as exc: + logger.warning("Failed to parse quarantined_llm result label: %s", exc) + return contents + + quarantine_label = ContentLabel( + integrity=IntegrityLabel.UNTRUSTED, + confidentiality=parsed_label.confidentiality, + metadata=parsed_label.metadata, + ) + first = contents[0] + props = first.additional_properties or {} + props["security_label"] = quarantine_label.to_dict() + first.additional_properties = props + return contents + + @tool( description=( "Make an isolated LLM call with labeled data in a quarantined context. " @@ -2883,6 +2943,7 @@ def get_quarantine_client() -> SupportsChatGetResponse | None: "You can pass variable_ids directly to reference hidden content from VariableReferenceContent objects. " "UNTRUSTED results are automatically hidden by the middleware." ), + result_parser=_quarantined_llm_result_parser, additional_properties={ "confidentiality": "private", "accepts_untrusted": True, @@ -3122,6 +3183,9 @@ class InspectVariableInput(BaseModel): reason: str | None = Field(default=None, description="Reason for inspecting this variable (for audit purposes)") +_INSPECT_VARIABLE_CONFIDENTIALITY = ConfidentialityLabel.PRIVATE + + def _inspect_variable_result_parser(result: Any) -> list[Content]: """Parse ``inspect_variable``'s dict result while preserving its security label. @@ -3134,13 +3198,21 @@ def _inspect_variable_result_parser(result: Any) -> list[Content]: downgrading the real label. This parser stamps the inspected label back onto the produced Content so - ``LabelTrackingFunctionMiddleware`` propagates it faithfully. The error path - (``security_label`` is ``None``) is left unstamped so it safely falls back to - the tool's default label. + ``LabelTrackingFunctionMiddleware`` propagates it faithfully. Missing-variable + errors are labeled as trusted tool output so probing an absent or foreign id + does not falsely taint cumulative integrity. """ contents = FunctionTool.parse_result(result) + if not contents: + return contents + label = cast(dict[str, Any], result).get("security_label") if isinstance(result, dict) else None - if label and contents: + if label is None and isinstance(result, dict) and "error" in cast(dict[str, Any], result): + label = ContentLabel( + integrity=IntegrityLabel.TRUSTED, + confidentiality=_INSPECT_VARIABLE_CONFIDENTIALITY, + ).to_dict() + if label: first = contents[0] props = first.additional_properties or {} props["security_label"] = label @@ -3158,7 +3230,7 @@ def _inspect_variable_result_parser(result: Any) -> list[Content]: approval_mode="never_require", result_parser=_inspect_variable_result_parser, additional_properties={ - "confidentiality": "private", + "confidentiality": _INSPECT_VARIABLE_CONFIDENTIALITY.value, # No source_integrity declared: output inherits the label of the # inspected content via Tier 3. The variable store is just a # container — the data inside it is untrusted external content. diff --git a/python/packages/core/tests/test_security.py b/python/packages/core/tests/test_security.py index 903d64e0f2..fb64e96d98 100644 --- a/python/packages/core/tests/test_security.py +++ b/python/packages/core/tests/test_security.py @@ -536,6 +536,39 @@ async def next_fn() -> None: await middleware.process(context, next_fn) + async def test_json_response_object_is_not_mistaken_for_quarantine_payload(self, middleware) -> None: + """Ordinary JSON objects containing ``response`` survive expansion intact.""" + + class MessageArgs(BaseModel): + summary: str + + async def send_message(summary: str) -> str: + return summary + + message_tool = FunctionTool( + fn=send_message, + name="SendMessagetoSelf", + description="Send message", + args_schema=MessageArgs, + ) + stored_payload = json.dumps({"response": "keep", "other": "also keep"}) + variable_id = middleware.get_variable_store().store( + stored_payload, + ContentLabel(integrity=IntegrityLabel.UNTRUSTED), + ) + context = FunctionInvocationContext( + function=message_tool, + arguments=MessageArgs(summary=f"[{variable_id}]"), + ) + + async def next_fn() -> None: + current_args = context.arguments + assert isinstance(current_args, dict) + assert current_args["summary"] == stored_payload + context.result = [Content.from_text("sent")] + + await middleware.process(context, next_fn) + class TestPolicyEnforcementMiddleware: """Tests for PolicyEnforcementFunctionMiddleware.""" @@ -1750,7 +1783,9 @@ async def next_fn(): await middleware_no_auto_hide.process(context, next_fn) result_label = context.metadata["result_label"] + assert result_label.integrity == IntegrityLabel.UNTRUSTED assert result_label.confidentiality == ConfidentialityLabel.USER_IDENTITY + assert middleware_no_auto_hide.get_context_label().integrity == IntegrityLabel.UNTRUSTED assert middleware_no_auto_hide.get_context_label().confidentiality == ConfidentialityLabel.USER_IDENTITY @pytest.mark.asyncio @@ -1780,7 +1815,7 @@ async def next_fn(): @pytest.mark.asyncio async def test_inspect_variable_missing_var_does_not_crash(self, middleware_no_auto_hide): - """A missing variable id returns an error result and falls back safely.""" + """A missing variable id returns a trusted tool-generated error.""" from agent_framework.security import get_security_tools inspect_tool = next(tool for tool in get_security_tools() if tool.name == "inspect_variable") @@ -1798,8 +1833,37 @@ async def next_fn(): payload = json.loads(context.result[0].text) assert payload["security_label"] is None assert "error" in payload - # No embedded label -> falls back to the tool's default confidentiality. + assert context.metadata["result_label"].integrity == IntegrityLabel.TRUSTED + assert context.metadata["result_label"].confidentiality == ConfidentialityLabel.PRIVATE + assert middleware_no_auto_hide.get_context_label().integrity == IntegrityLabel.TRUSTED + + async def test_inspect_variable_foreign_var_does_not_taint_integrity(self) -> None: + """An id owned by another store returns a trusted tool-generated error.""" + from agent_framework.security import get_security_tools + + owner = LabelTrackingFunctionMiddleware() + foreign_id = owner.get_variable_store().store( + "foreign secret", + ContentLabel(integrity=IntegrityLabel.UNTRUSTED, confidentiality=ConfidentialityLabel.USER_IDENTITY), + ) + middleware = LabelTrackingFunctionMiddleware(auto_hide_untrusted=False) + inspect_tool = next(tool for tool in get_security_tools() if tool.name == "inspect_variable") + context = FunctionInvocationContext( + function=inspect_tool, + arguments={"variable_id": foreign_id, "reason": "foreign id"}, + ) + + async def next_fn() -> None: + context.result = await inspect_tool.invoke(arguments=context.arguments, context=context) + + await middleware.process(context, next_fn) + + payload = json.loads(context.result[0].text) + assert payload["security_label"] is None + assert "error" in payload + assert context.metadata["result_label"].integrity == IntegrityLabel.TRUSTED assert context.metadata["result_label"].confidentiality == ConfidentialityLabel.PRIVATE + assert middleware.get_context_label().integrity == IntegrityLabel.TRUSTED @pytest.mark.asyncio async def test_multiple_calls_accumulate_variables(self, middleware_auto_hide, mock_function): @@ -2869,6 +2933,36 @@ class TestQuarantinedLLM: via source_integrity="untrusted", not by quarantined_llm itself. """ + async def test_quarantined_llm_publishes_combined_confidentiality(self) -> None: + """Quarantine output is UNTRUSTED at the highest input confidentiality.""" + middleware = LabelTrackingFunctionMiddleware() + variable_id = middleware.get_variable_store().store( + "identity secret", + ContentLabel( + integrity=IntegrityLabel.TRUSTED, + confidentiality=ConfidentialityLabel.USER_IDENTITY, + ), + ) + quarantine_tool = next(tool for tool in middleware.get_security_tools() if tool.name == "quarantined_llm") + context = FunctionInvocationContext( + function=quarantine_tool, + arguments={"prompt": "Summarize", "variable_ids": [variable_id]}, + ) + + async def next_fn() -> None: + context.result = await quarantine_tool.invoke(arguments=context.arguments, context=context) + + await middleware.process(context, next_fn) + + assert context.metadata["result_label"].integrity == IntegrityLabel.UNTRUSTED + assert context.metadata["result_label"].confidentiality == ConfidentialityLabel.USER_IDENTITY + assert middleware.get_context_label().integrity == IntegrityLabel.TRUSTED + assert middleware.get_context_label().confidentiality == ConfidentialityLabel.USER_IDENTITY + hidden_reference = json.loads(context.result[0].text) + _, hidden_label = middleware.get_variable_store().retrieve(hidden_reference["variable_id"]) + assert hidden_label.integrity == IntegrityLabel.UNTRUSTED + assert hidden_label.confidentiality == ConfidentialityLabel.USER_IDENTITY + @pytest.mark.asyncio async def test_quarantined_llm_returns_response(self): """Test that quarantined_llm returns a plain response dict.""" @@ -3357,6 +3451,31 @@ async def next_fn(): parsed = json.loads(item.text) # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] assert parsed.get("type") == "variable_reference" + async def test_fully_hidden_result_updates_confidentiality_without_integrity_taint( + self, middleware, mock_function + ) -> None: + """Hidden content affects cumulative confidentiality but not integrity.""" + context = FunctionInvocationContext(function=mock_function, arguments=mock_function.args_schema()) + + async def next_fn() -> None: + context.result = [ + Content.from_text( + "hidden identity data", + additional_properties={ + "security_label": { + "integrity": "untrusted", + "confidentiality": "user_identity", + } + }, + ) + ] + + await middleware.process(context, next_fn) + + assert context.result[0].additional_properties["_variable_reference"] is True + assert middleware.get_context_label().integrity == IntegrityLabel.TRUSTED + assert middleware.get_context_label().confidentiality == ConfidentialityLabel.USER_IDENTITY + @pytest.mark.asyncio async def test_items_without_labels_use_fallback(self, middleware, mock_function): """Test that items without embedded labels use the fallback (call) label.""" @@ -3576,6 +3695,86 @@ async def next_fn(): # Tier 2 (source_integrity=trusted) wins over tier 3 (untrusted input) assert label.integrity == IntegrityLabel.TRUSTED + @pytest.mark.parametrize( + "confidentiality", + [ConfidentialityLabel.PRIVATE, ConfidentialityLabel.USER_IDENTITY], + ) + async def test_hidden_input_confidentiality_propagates_to_transform_result( + self, + middleware: LabelTrackingFunctionMiddleware, + confidentiality: ConfidentialityLabel, + ) -> None: + """A trusted transformer cannot implicitly declassify hidden input.""" + + class Args(BaseModel): + value: str + + async def transform(value: str) -> str: + return value.upper() + + function = FunctionTool( + fn=transform, + name="trusted_transformer", + description="Transform hidden input", + args_schema=Args, + additional_properties={"source_integrity": "trusted"}, + ) + variable_id = middleware.get_variable_store().store( + "secret", + ContentLabel(integrity=IntegrityLabel.UNTRUSTED, confidentiality=confidentiality), + ) + context = FunctionInvocationContext( + function=function, + arguments=Args(value=f"[{variable_id}]"), + ) + + async def next_fn() -> None: + current_args = context.arguments + assert isinstance(current_args, dict) + context.result = [Content.from_text(current_args["value"].upper())] + + await middleware.process(context, next_fn) + + assert context.metadata["result_label"].integrity == IntegrityLabel.TRUSTED + assert context.metadata["result_label"].confidentiality == confidentiality + assert middleware.get_context_label().confidentiality == confidentiality + + async def test_embedded_public_label_cannot_declassify_hidden_input(self, middleware) -> None: + """Embedded labels choose integrity without lowering input confidentiality.""" + + class Args(BaseModel): + value: str + + async def transform(value: str) -> str: + return value + + function = FunctionTool( + fn=transform, + name="embedded_label_transformer", + description="Transform hidden input", + args_schema=Args, + additional_properties={"source_integrity": "untrusted"}, + ) + variable_id = middleware.get_variable_store().store( + "private payload", + ContentLabel(integrity=IntegrityLabel.UNTRUSTED, confidentiality=ConfidentialityLabel.PRIVATE), + ) + context = FunctionInvocationContext(function=function, arguments=Args(value=f"[{variable_id}]")) + + async def next_fn() -> None: + context.result = [ + Content.from_text( + "transformed", + additional_properties={"security_label": {"integrity": "trusted", "confidentiality": "public"}}, + ) + ] + + await middleware.process(context, next_fn) + + assert context.metadata["result_label"].integrity == IntegrityLabel.TRUSTED + assert context.metadata["result_label"].confidentiality == ConfidentialityLabel.PRIVATE + assert middleware.get_context_label().confidentiality == ConfidentialityLabel.PRIVATE + @pytest.mark.asyncio async def test_embedded_labels_override_source_integrity(self, middleware): """Test that embedded labels (tier 1) override source_integrity (tier 2). From c894ae48c4c5ba711fd9b4ca3bc8f9fe93e8981f Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 8 Sep 2026 09:06:13 +0200 Subject: [PATCH 2/6] Python: extract quarantine payload predicate Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/security.py | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/python/packages/core/agent_framework/security.py b/python/packages/core/agent_framework/security.py index 55ffa5feac..456d70410e 100644 --- a/python/packages/core/agent_framework/security.py +++ b/python/packages/core/agent_framework/security.py @@ -90,6 +90,18 @@ def _get_additional_properties(obj: Any) -> dict[str, Any]: return cast(dict[str, Any], props) if isinstance(props, dict) else {} +def _is_quarantine_payload(payload: dict[str, Any]) -> bool: + """Return whether a mapping has the internal quarantined LLM result shape.""" + return ( + payload.get("quarantined") is True + and "response" in payload + and isinstance(payload.get("security_label"), MutableMapping) + and isinstance(payload.get("metadata"), MutableMapping) + and isinstance(payload.get("variables_processed"), list) + and isinstance(payload.get("content_summary"), list) + ) + + # ============================================================================= # Core Security Primitives # ============================================================================= @@ -1267,20 +1279,9 @@ def _extract_primary_tool_content(expanded_content: Any) -> Any: primary response they would have seen without variable indirection. Ordinary mappings or JSON text that happen to contain a ``response`` key remain intact. """ - - def is_quarantine_payload(payload: dict[str, Any]) -> bool: - return ( - payload.get("quarantined") is True - and "response" in payload - and isinstance(payload.get("security_label"), MutableMapping) - and isinstance(payload.get("metadata"), MutableMapping) - and isinstance(payload.get("variables_processed"), list) - and isinstance(payload.get("content_summary"), list) - ) - if isinstance(expanded_content, dict): content_map = cast(dict[str, Any], expanded_content) - if is_quarantine_payload(content_map): + if _is_quarantine_payload(content_map): return content_map["response"] return content_map @@ -1291,7 +1292,7 @@ def is_quarantine_payload(payload: dict[str, Any]) -> bool: parsed = json.loads(stripped) if isinstance(parsed, dict): parsed_map = cast(dict[str, Any], parsed) - if is_quarantine_payload(parsed_map): + if _is_quarantine_payload(parsed_map): return parsed_map["response"] return expanded_content From c730173f60098c6aa44f4829bcc73230e593a5d7 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 8 Sep 2026 09:26:02 +0200 Subject: [PATCH 3/6] Python: integrate confidentiality propagation with argument labels Resolve the stacked PR2/PR3 boundary so hidden arguments are expanded once, retain their combined label for policy, and preserve their confidentiality on transformed results.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- python/packages/core/agent_framework/security.py | 6 ++---- python/packages/core/tests/test_security.py | 6 +++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/python/packages/core/agent_framework/security.py b/python/packages/core/agent_framework/security.py index 456d70410e..904be0c2df 100644 --- a/python/packages/core/agent_framework/security.py +++ b/python/packages/core/agent_framework/security.py @@ -1548,8 +1548,8 @@ async def process( confidentiality=result_confidentiality, metadata={"source": "source_integrity", "function_name": function_name}, ) - elif input_labels: - combined = combine_labels(*input_labels) + elif argument_labels: + combined = combine_labels(*argument_labels) fallback_label = ContentLabel( integrity=combined.integrity, confidentiality=result_confidentiality, @@ -1562,8 +1562,6 @@ async def process( metadata={"source": "default", "function_name": function_name}, ) - resolved_labels = self._expand_variable_references_in_context(context) - argument_label = combine_labels(*resolved_labels) if resolved_labels else ContentLabel() context_label = self._context_label context.metadata["context_label"] = context_label context.metadata["argument_label"] = argument_label diff --git a/python/packages/core/tests/test_security.py b/python/packages/core/tests/test_security.py index fb64e96d98..54524159f5 100644 --- a/python/packages/core/tests/test_security.py +++ b/python/packages/core/tests/test_security.py @@ -4801,7 +4801,7 @@ async def test_private_hidden_argument_is_blocked_from_public_sink(self) -> None assert context.metadata["effective_invocation_label"].confidentiality == ConfidentialityLabel.PRIVATE assert policy.get_audit_log()[-1]["subtype"] == "max_allowed_confidentiality" - async def test_argument_labels_do_not_rewrite_result_labels(self) -> None: + async def test_argument_labels_preserve_result_confidentiality(self) -> None: tracker = LabelTrackingFunctionMiddleware() policy = PolicyEnforcementFunctionMiddleware() variable_id = tracker.get_variable_store().store( @@ -4822,8 +4822,8 @@ async def execute(_context: FunctionInvocationContext) -> list[Content]: await FunctionMiddlewarePipeline(tracker, policy).execute(context, execute) assert context.metadata["result_label"].integrity == IntegrityLabel.TRUSTED - assert context.metadata["result_label"].confidentiality == ConfidentialityLabel.PUBLIC - assert tracker.get_context_label().confidentiality == ConfidentialityLabel.PUBLIC + assert context.metadata["result_label"].confidentiality == ConfidentialityLabel.PRIVATE + assert tracker.get_context_label().confidentiality == ConfidentialityLabel.PRIVATE async def test_policy_approval_allows_exact_resolved_invocation(self) -> None: tracker = LabelTrackingFunctionMiddleware() From 384489e14ab5d86f8645d2c8818cc162d7a59996 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 9 Sep 2026 10:10:34 +0200 Subject: [PATCH 4/6] Python: harden FIDES result label authority Use middleware provenance for quarantine unwrapping, require explicit label fields, fail unknown quarantine inputs closed, and scope trusted inspect errors to clean parser-marked invocations.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/security.py | 164 ++++++++----- python/packages/core/tests/test_security.py | 227 +++++++++++++++++- 2 files changed, 331 insertions(+), 60 deletions(-) diff --git a/python/packages/core/agent_framework/security.py b/python/packages/core/agent_framework/security.py index 904be0c2df..29f8df7cda 100644 --- a/python/packages/core/agent_framework/security.py +++ b/python/packages/core/agent_framework/security.py @@ -75,6 +75,9 @@ _WHOLE_VAR_REF_RE = re.compile(rf"\s*(?:\[\s*(?P{_BRACKETED_VAR_ID})\s*\]|(?P{_BARE_VAR_ID}))\s*") _BARE_REFERENCE_WARNING = "Expanded a bare variable reference in a tool argument; models should use [var_] instead." _UNRESOLVED = object() +_AUTHORITATIVE_CONFIDENTIALITY = "_security_label_authoritative_confidentiality" +_INSPECT_VARIABLE_ERROR = "_inspect_variable_error" +_INTERNAL_RESULT_MARKER = object() # Tools that consume variable IDs literally (as opaque references) and therefore # must NOT have ``var_xxx`` arguments expanded to stored content before execution. @@ -90,15 +93,21 @@ def _get_additional_properties(obj: Any) -> dict[str, Any]: return cast(dict[str, Any], props) if isinstance(props, dict) else {} -def _is_quarantine_payload(payload: dict[str, Any]) -> bool: - """Return whether a mapping has the internal quarantined LLM result shape.""" - return ( - payload.get("quarantined") is True - and "response" in payload - and isinstance(payload.get("security_label"), MutableMapping) - and isinstance(payload.get("metadata"), MutableMapping) - and isinstance(payload.get("variables_processed"), list) - and isinstance(payload.get("content_summary"), list) +def _parse_content_label(label_data: MutableMapping[str, Any], *, source: str) -> ContentLabel: + """Parse explicit label fields while ignoring malformed optional metadata.""" + integrity = label_data.get("integrity") + confidentiality = label_data.get("confidentiality") + if not isinstance(integrity, str) or not isinstance(confidentiality, str): + raise ValueError(f"{source} security label requires integrity and confidentiality") + + metadata = label_data.get("metadata") + if metadata is not None and not isinstance(metadata, dict): + logger.warning("Ignoring malformed metadata from %s security label", source) + metadata = {} + return ContentLabel( + integrity=IntegrityLabel(integrity), + confidentiality=ConfidentialityLabel(confidentiality), + metadata=cast(dict[str, Any], metadata) if isinstance(metadata, dict) else None, ) @@ -1271,17 +1280,18 @@ def _update_context_label(self, new_content_label: ContentLabel) -> None: ) @staticmethod - def _extract_primary_tool_content(expanded_content: Any) -> Any: - """Return the tool-visible content for an expanded variable payload. + def _extract_primary_tool_content(expanded_content: Any, *, from_quarantined_llm: bool) -> Any: + """Return the primary response from a proven quarantined LLM result. - Hidden ``quarantined_llm`` results are stored as rich payloads containing - the response plus security metadata. Tool arguments should receive only the - primary response they would have seen without variable indirection. Ordinary - mappings or JSON text that happen to contain a ``response`` key remain intact. + Producer metadata is owned by the middleware and distinguishes internal + quarantine wrappers from ordinary, potentially attacker-controlled JSON. """ + if not from_quarantined_llm: + return expanded_content + if isinstance(expanded_content, dict): content_map = cast(dict[str, Any], expanded_content) - if _is_quarantine_payload(content_map): + if "response" in content_map: return content_map["response"] return content_map @@ -1292,7 +1302,7 @@ def _extract_primary_tool_content(expanded_content: Any) -> Any: parsed = json.loads(stripped) if isinstance(parsed, dict): parsed_map = cast(dict[str, Any], parsed) - if _is_quarantine_payload(parsed_map): + if "response" in parsed_map: return parsed_map["response"] return expanded_content @@ -1308,7 +1318,11 @@ def _lookup_variable(self, variable_id: str, labels: list[ContentLabel]) -> Any: except KeyError: return _UNRESOLVED labels.append(stored_label) - return self._extract_primary_tool_content(stored_content) + metadata = self.get_variable_metadata(variable_id) + return self._extract_primary_tool_content( + stored_content, + from_quarantined_llm=metadata is not None and metadata.get("function_name") == "quarantined_llm", + ) def _resolve_string(self, value: str, labels: list[ContentLabel]) -> Any: if not _EMBEDDED_VAR_REF_RE.search(value): @@ -1395,14 +1409,18 @@ def _extract_labels_recursive(value: Any) -> None: if "security_label" in value_dict: label_data = value_dict["security_label"] if isinstance(label_data, ContentLabel): - labels.append(label_data) + labels.append( + _parse_content_label( + cast(MutableMapping[str, Any], label_data.to_dict()), + source="input", + ) + ) elif isinstance(label_data, dict): - with contextlib.suppress(Exception): # nosec B110 - best-effort label extraction - labels.append(ContentLabel.from_dict(cast(dict[str, Any], label_data))) - # Fall back to "label" for backward compatibility + with contextlib.suppress(TypeError, ValueError): + labels.append(_parse_content_label(cast(dict[str, Any], label_data), source="input")) elif "label" in value_dict and isinstance(value_dict.get("label"), dict): - with contextlib.suppress(Exception): # nosec B110 - best-effort label extraction - labels.append(ContentLabel.from_dict(cast(dict[str, Any], value_dict["label"]))) + with contextlib.suppress(TypeError, ValueError): + labels.append(_parse_content_label(cast(dict[str, Any], value_dict["label"]), source="input")) # Recurse into dict values for v in value_dict.values(): _extract_labels_recursive(v) @@ -1712,7 +1730,7 @@ def _process_result_with_embedded_labels( visible_item_labels: list[ContentLabel] = [] for item in items: - item_label = self._extract_content_label(item, fallback_label) + item_label = self._extract_content_label(item, fallback_label, function_name) item_labels.append(item_label) if self._should_hide(item_label, function_name): @@ -1732,38 +1750,58 @@ def _extract_content_label( self, item: Content, fallback_label: ContentLabel, + function_name: str, ) -> ContentLabel: - """Extract the security label for a single Content item. - - Checks (in order): - 1. ``additional_properties.security_label`` (explicit label) - 2. Falls back to ``fallback_label`` + """Extract and constrain the security label for one result item. Args: item: The Content item to inspect. - fallback_label: The label to use if no embedded label is found. + fallback_label: The label inherited from the invocation. + function_name: The name of the tool that produced the item. Returns: The resolved ContentLabel for this item. """ additional_props = _get_additional_properties(item) + authoritative_marker = additional_props.pop(_AUTHORITATIVE_CONFIDENTIALITY, None) + inspect_error_marker = additional_props.pop(_INSPECT_VARIABLE_ERROR, None) + authoritative_confidentiality = ( + function_name == "quarantined_llm" and authoritative_marker is _INTERNAL_RESULT_MARKER + ) + inspect_error = function_name == "inspect_variable" and inspect_error_marker is _INTERNAL_RESULT_MARKER - # Embedded labels remain authoritative for integrity, but cannot - # declassify data inherited from the tool's inputs. label_data = additional_props.get("security_label") if label_data and isinstance(label_data, dict): try: - embedded_label = ContentLabel.from_dict(cast(dict[str, Any], label_data)) + embedded_label = _parse_content_label( + cast(dict[str, Any], label_data), + source="embedded", + ) combined_label = combine_labels(fallback_label, embedded_label) return ContentLabel( integrity=embedded_label.integrity, - confidentiality=combined_label.confidentiality, + confidentiality=( + embedded_label.confidentiality + if authoritative_confidentiality + else combined_label.confidentiality + ), metadata=combined_label.metadata, ) - except Exception as e: - logger.warning(f"Failed to parse security_label from Content: {e}") + except (TypeError, ValueError) as exc: + logger.warning("Failed to parse security_label from Content: %s", exc) + + if inspect_error: + integrity = ( + IntegrityLabel.TRUSTED + if fallback_label.metadata.get("source") == "default" + else fallback_label.integrity + ) + return ContentLabel( + integrity=integrity, + confidentiality=fallback_label.confidentiality, + metadata=fallback_label.metadata, + ) - # No embedded label — use fallback return fallback_label def _hide_item( @@ -2914,9 +2952,17 @@ def _quarantined_llm_result_parser(result: Any) -> list[Content]: label_data = cast(dict[str, Any], result).get("security_label") if not isinstance(label_data, MutableMapping): return contents + typed_label_data = cast(MutableMapping[str, Any], label_data) + confidentiality = typed_label_data.get("confidentiality") + if not isinstance(confidentiality, str): + logger.warning("quarantined_llm result label is missing confidentiality") + return contents try: - parsed_label = ContentLabel.from_dict(cast(MutableMapping[str, Any], label_data)) + parsed_label = _parse_content_label( + typed_label_data, + source="quarantined_llm result", + ) except (TypeError, ValueError) as exc: logger.warning("Failed to parse quarantined_llm result label: %s", exc) return contents @@ -2929,6 +2975,7 @@ def _quarantined_llm_result_parser(result: Any) -> list[Content]: first = contents[0] props = first.additional_properties or {} props["security_label"] = quarantine_label.to_dict() + props[_AUTHORITATIVE_CONFIDENTIALITY] = _INTERNAL_RESULT_MARKER first.additional_properties = props return contents @@ -3013,6 +3060,10 @@ async def quarantined_llm( variable_store = middleware.get_variable_store() if middleware else _global_variable_store labels: list[ContentLabel] = [] + unknown_input_label = ContentLabel( + integrity=IntegrityLabel.UNTRUSTED, + confidentiality=ConfidentialityLabel.PRIVATE, + ) retrieved_content: dict[str, Any] = {} # Retrieve content from variable_ids @@ -3025,7 +3076,7 @@ async def quarantined_llm( except KeyError: logger.warning("A requested quarantine variable was not found in the current security scope") # Still add untrusted label for unknown variables - labels.append(ContentLabel(integrity=IntegrityLabel.UNTRUSTED)) + labels.append(unknown_input_label) # Parse labels and content from labelled_data labelled_data_content: dict[str, Any] = {} @@ -3044,19 +3095,24 @@ async def quarantined_llm( try: label_data = value_dict[label_key] if isinstance(label_data, dict): - label = ContentLabel.from_dict(cast(dict[str, Any], label_data)) + label = _parse_content_label( + cast(dict[str, Any], label_data), + source="quarantine input", + ) elif isinstance(label_data, ContentLabel): label = label_data else: - label = ContentLabel(integrity=IntegrityLabel.UNTRUSTED) + label = unknown_input_label labels.append(label) - except Exception as e: + except (TypeError, ValueError) as e: logger.warning("Failed to parse a quarantine data security label; using UNTRUSTED") logger.debug("Quarantine label parse failure for %s: %s", key, e) - labels.append(ContentLabel(integrity=IntegrityLabel.UNTRUSTED)) + labels.append(unknown_input_label) else: # No label provided, default to UNTRUSTED - labels.append(ContentLabel(integrity=IntegrityLabel.UNTRUSTED)) + labels.append(unknown_input_label) + else: + labels.append(unknown_input_label) # Combine all labels (most restrictive) combined_label = combine_labels(*labels) if labels else ContentLabel(integrity=IntegrityLabel.UNTRUSTED) @@ -3197,25 +3253,21 @@ def _inspect_variable_result_parser(result: Any) -> list[Content]: downgrading the real label. This parser stamps the inspected label back onto the produced Content so - ``LabelTrackingFunctionMiddleware`` propagates it faithfully. Missing-variable - errors are labeled as trusted tool output so probing an absent or foreign id - does not falsely taint cumulative integrity. + ``LabelTrackingFunctionMiddleware`` propagates it faithfully. Error results + remain unstamped so their integrity inherits from the invocation fallback. """ contents = FunctionTool.parse_result(result) if not contents: return contents + first = contents[0] + props = first.additional_properties or {} label = cast(dict[str, Any], result).get("security_label") if isinstance(result, dict) else None - if label is None and isinstance(result, dict) and "error" in cast(dict[str, Any], result): - label = ContentLabel( - integrity=IntegrityLabel.TRUSTED, - confidentiality=_INSPECT_VARIABLE_CONFIDENTIALITY, - ).to_dict() if label: - first = contents[0] - props = first.additional_properties or {} props["security_label"] = label - first.additional_properties = props + if isinstance(result, dict) and "error" in cast(dict[str, Any], result): + props[_INSPECT_VARIABLE_ERROR] = _INTERNAL_RESULT_MARKER + first.additional_properties = props return contents diff --git a/python/packages/core/tests/test_security.py b/python/packages/core/tests/test_security.py index 54524159f5..e585ee70c7 100644 --- a/python/packages/core/tests/test_security.py +++ b/python/packages/core/tests/test_security.py @@ -515,10 +515,13 @@ async def send_message(summary: str) -> str: "variables_processed": ["var_1"], "content_summary": ["var_1: 10 chars"], }) - variable_id = middleware.get_variable_store().store( - stored_payload, + hidden_result = middleware._hide_item( + Content.from_text(stored_payload), ContentLabel(integrity=IntegrityLabel.UNTRUSTED), + "quarantined_llm", ) + assert hidden_result.text is not None + variable_id = json.loads(hidden_result.text)["variable_id"] context = FunctionInvocationContext( function=message_tool, arguments=MessageArgs(summary=f"Security review complete. [{variable_id}]"), @@ -551,7 +554,15 @@ async def send_message(summary: str) -> str: description="Send message", args_schema=MessageArgs, ) - stored_payload = json.dumps({"response": "keep", "other": "also keep"}) + stored_payload = json.dumps({ + "response": "keep", + "security_label": {"integrity": "untrusted", "confidentiality": "public"}, + "metadata": {}, + "quarantined": True, + "variables_processed": ["var_forged"], + "content_summary": ["var_forged: 10 chars"], + "other": "also keep", + }) variable_id = middleware.get_variable_store().store( stored_payload, ContentLabel(integrity=IntegrityLabel.UNTRUSTED), @@ -1865,6 +1876,36 @@ async def next_fn() -> None: assert context.metadata["result_label"].confidentiality == ConfidentialityLabel.PRIVATE assert middleware.get_context_label().integrity == IntegrityLabel.TRUSTED + async def test_inspect_variable_error_inherits_untrusted_invocation_integrity(self) -> None: + """Attacker-labeled missing IDs cannot produce trusted visible errors.""" + from agent_framework.security import get_security_tools + + middleware = LabelTrackingFunctionMiddleware(auto_hide_untrusted=False) + inspect_tool = next(tool for tool in get_security_tools() if tool.name == "inspect_variable") + context = FunctionInvocationContext( + function=inspect_tool, + arguments={ + "variable_id": "var_doesnotexist2", + "reason": "untrusted missing id", + "security_label": { + "integrity": "untrusted", + "confidentiality": "public", + }, + }, + ) + + async def next_fn() -> None: + context.result = await inspect_tool.invoke(arguments=context.arguments, context=context) + + await middleware.process(context, next_fn) + + payload = json.loads(context.result[0].text) + assert payload["security_label"] is None + assert "error" in payload + assert context.metadata["result_label"].integrity == IntegrityLabel.UNTRUSTED + assert context.metadata["result_label"].confidentiality == ConfidentialityLabel.PRIVATE + assert middleware.get_context_label().integrity == IntegrityLabel.UNTRUSTED + @pytest.mark.asyncio async def test_multiple_calls_accumulate_variables(self, middleware_auto_hide, mock_function): """Test that multiple tool calls accumulate variables in the store.""" @@ -2963,6 +3004,126 @@ async def next_fn() -> None: assert hidden_label.integrity == IntegrityLabel.UNTRUSTED assert hidden_label.confidentiality == ConfidentialityLabel.USER_IDENTITY + async def test_quarantined_llm_public_input_remains_public(self) -> None: + """A valid quarantine label overrides the fail-closed PRIVATE fallback.""" + from agent_framework.security import set_quarantine_client + + set_quarantine_client(None) + middleware = LabelTrackingFunctionMiddleware() + quarantine_tool = next(tool for tool in middleware.get_security_tools() if tool.name == "quarantined_llm") + context = FunctionInvocationContext( + function=quarantine_tool, + arguments={ + "prompt": "Summarize", + "labelled_data": { + "data": { + "content": "public information", + "security_label": { + "integrity": "trusted", + "confidentiality": "public", + "metadata": ["malformed"], + }, + } + }, + }, + ) + + async def next_fn() -> None: + context.result = await quarantine_tool.invoke(arguments=context.arguments, context=context) + + await middleware.process(context, next_fn) + + assert context.metadata["result_label"].integrity == IntegrityLabel.UNTRUSTED + assert context.metadata["result_label"].confidentiality == ConfidentialityLabel.PUBLIC + assert middleware.get_context_label().integrity == IntegrityLabel.TRUSTED + assert middleware.get_context_label().confidentiality == ConfidentialityLabel.PUBLIC + + async def test_quarantined_llm_invalid_label_falls_back_to_private(self) -> None: + """A partial parser label cannot override the PRIVATE tool fallback.""" + from agent_framework.security import _quarantined_llm_result_parser + + middleware = LabelTrackingFunctionMiddleware() + quarantine_tool = next(tool for tool in middleware.get_security_tools() if tool.name == "quarantined_llm") + context = FunctionInvocationContext( + function=quarantine_tool, + arguments={"prompt": "Summarize"}, + ) + + async def next_fn() -> None: + context.result = _quarantined_llm_result_parser({ + "response": "partial label", + "security_label": {"integrity": "untrusted"}, + }) + + await middleware.process(context, next_fn) + + assert context.metadata["result_label"].integrity == IntegrityLabel.UNTRUSTED + assert context.metadata["result_label"].confidentiality == ConfidentialityLabel.PRIVATE + hidden_reference = json.loads(context.result[0].text) + _, hidden_label = middleware.get_variable_store().retrieve(hidden_reference["variable_id"]) + assert hidden_label.integrity == IntegrityLabel.UNTRUSTED + assert hidden_label.confidentiality == ConfidentialityLabel.PRIVATE + + async def test_quarantined_llm_partial_input_label_is_private(self) -> None: + """An incomplete quarantine input label fails closed to PRIVATE.""" + from agent_framework.security import set_quarantine_client + + set_quarantine_client(None) + middleware = LabelTrackingFunctionMiddleware() + quarantine_tool = next(tool for tool in middleware.get_security_tools() if tool.name == "quarantined_llm") + context = FunctionInvocationContext( + function=quarantine_tool, + arguments={ + "prompt": "Summarize", + "labelled_data": { + "data": { + "content": "unknown information", + "security_label": {"integrity": "trusted"}, + } + }, + }, + ) + + async def next_fn() -> None: + context.result = await quarantine_tool.invoke(arguments=context.arguments, context=context) + + await middleware.process(context, next_fn) + + assert context.metadata["result_label"].integrity == IntegrityLabel.UNTRUSTED + assert context.metadata["result_label"].confidentiality == ConfidentialityLabel.PRIVATE + + async def test_quarantined_llm_malformed_input_is_private(self) -> None: + """A malformed item makes mixed quarantine input PRIVATE.""" + from agent_framework.security import set_quarantine_client + + set_quarantine_client(None) + middleware = LabelTrackingFunctionMiddleware() + quarantine_tool = next(tool for tool in middleware.get_security_tools() if tool.name == "quarantined_llm") + context = FunctionInvocationContext( + function=quarantine_tool, + arguments={ + "prompt": "Summarize", + "labelled_data": { + "public": { + "content": "public information", + "security_label": { + "integrity": "trusted", + "confidentiality": "public", + }, + }, + "malformed": "unlabeled information", + }, + }, + ) + + async def next_fn() -> None: + context.result = await quarantine_tool.invoke(arguments=context.arguments, context=context) + + await middleware.process(context, next_fn) + + assert context.metadata["result_label"].integrity == IntegrityLabel.UNTRUSTED + assert context.metadata["result_label"].confidentiality == ConfidentialityLabel.PRIVATE + @pytest.mark.asyncio async def test_quarantined_llm_returns_response(self): """Test that quarantined_llm returns a plain response dict.""" @@ -3476,6 +3637,61 @@ async def next_fn() -> None: assert middleware.get_context_label().integrity == IntegrityLabel.TRUSTED assert middleware.get_context_label().confidentiality == ConfidentialityLabel.USER_IDENTITY + async def test_malformed_embedded_metadata_preserves_mandatory_label_fields( + self, middleware, mock_function + ) -> None: + """Malformed optional metadata cannot discard integrity or confidentiality.""" + mock_function.additional_properties = {"source_integrity": "trusted"} + context = FunctionInvocationContext(function=mock_function, arguments=mock_function.args_schema()) + + async def next_fn() -> None: + context.result = [ + Content.from_text( + "hidden identity data", + additional_properties={ + "security_label": { + "integrity": "untrusted", + "confidentiality": "user_identity", + "metadata": ["malformed"], + } + }, + ) + ] + + await middleware.process(context, next_fn) + + result_label = context.metadata["result_label"] + assert result_label.integrity == IntegrityLabel.UNTRUSTED + assert result_label.confidentiality == ConfidentialityLabel.USER_IDENTITY + assert result_label.metadata["source"] == "source_integrity" + assert context.result[0].additional_properties["_variable_reference"] is True + assert middleware.get_context_label().integrity == IntegrityLabel.TRUSTED + assert middleware.get_context_label().confidentiality == ConfidentialityLabel.USER_IDENTITY + + async def test_partial_embedded_label_uses_untrusted_fallback(self, middleware, mock_function) -> None: + """A partial embedded label cannot promote an untrusted fallback.""" + context = FunctionInvocationContext(function=mock_function, arguments=mock_function.args_schema()) + + async def next_fn() -> None: + context.result = [ + Content.from_text( + "untrusted result", + additional_properties={ + "security_label": { + "metadata": {"note": "missing mandatory fields"}, + } + }, + ) + ] + + await middleware.process(context, next_fn) + + result_label = context.metadata["result_label"] + assert result_label.integrity == IntegrityLabel.UNTRUSTED + assert result_label.confidentiality == ConfidentialityLabel.PUBLIC + assert context.result[0].additional_properties["_variable_reference"] is True + assert middleware.get_context_label().integrity == IntegrityLabel.TRUSTED + @pytest.mark.asyncio async def test_items_without_labels_use_fallback(self, middleware, mock_function): """Test that items without embedded labels use the fallback (call) label.""" @@ -3765,7 +3981,10 @@ async def next_fn() -> None: context.result = [ Content.from_text( "transformed", - additional_properties={"security_label": {"integrity": "trusted", "confidentiality": "public"}}, + additional_properties={ + "security_label": {"integrity": "trusted", "confidentiality": "public"}, + "_security_label_authoritative_confidentiality": True, + }, ) ] From b021b5ef7f9e6d459f4a18249d05d1e623375859 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 7 Sep 2026 21:09:36 +0200 Subject: [PATCH 5/6] Python: bound and recover policy approvals Bound FIDES policy approvals per session with FIFO and TTL expiry, clean authenticated non-grants by occurrence, and persist visible replacement approvals so stale grants require a safe second approval.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../specs/004-python-function-calling-loop.md | 7 + .../core/agent_framework/_middleware.py | 31 +++ .../packages/core/agent_framework/_tools.py | 12 + .../packages/core/agent_framework/security.py | 161 +++++++++++++- .../core/test_function_invocation_logic.py | 19 ++ .../tests/core/test_harness_tool_approval.py | 203 +++++++++++++++++ python/packages/core/tests/test_security.py | 205 +++++++++++++++++- 7 files changed, 627 insertions(+), 11 deletions(-) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index e96dd9d8ee..90f6d8ab28 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -412,6 +412,10 @@ that manually replay messages own the equivalent rule: do not resend an approval - If policy middleware detects that the exact resolved invocation changed after approval, the old response executes nothing and yields a caller-visible, session-persisted replacement request for the same occurrence; execution requires a second approval and happens exactly once. +- If session-bound middleware no longer holds the reviewed authority because it expired or was evicted, the matched + response executes nothing and produces a replacement approval request with the same occurrence identity. The + replacement is caller-visible, becomes the authoritative pending session snapshot, and requires a second approval + before the tool can execute. Rejection or cancellation releases only the matching occurrence in the owning session. - Unmatched occurrence-aware responses leave the pending request intact for a corrected retry and produce an observable warning/log. A nested `call_id` is never accepted as an occurrence-identity alias. - Session-backed pending snapshots are trusted host state and require tenant-scoped, authorized storage. Consume-on-bind @@ -508,6 +512,8 @@ that manually replay messages own the equivalent rule: do not resend an approval | Approval re-entry after iteration budget | Pending approved calls resolve once even when prior model calls consumed `max_iterations`. | `packages/core/tests/core/test_harness_tool_approval.py::test_auto_approval_resolves_after_iteration_budget_is_exhausted` | | Approval resume with reasoning | Model-bound resume history retains reasoning before the call and terminal result in both modes. | `packages/core/tests/core/test_harness_tool_approval.py::test_approval_resume_replays_reasoning_with_function_call_group` | | Changed resolved policy invocation | The stale decision executes nothing; a same-occurrence replacement request is visible and persisted in both modes, and the second approval executes exactly once. | `packages/core/tests/core/test_harness_tool_approval.py::test_changed_hidden_snapshot_requires_visible_second_approval` | +| Expired or evicted policy authority | The old response executes nothing, surfaces and persists a same-occurrence replacement request in both modes, and executes exactly once only after the replacement is approved; model history remains balanced and stale replay is inert. | `packages/core/tests/core/test_harness_tool_approval.py::test_policy_reapproval_is_visible_persisted_and_executes_once` | +| Session-bound policy cleanup | FIFO/TTL lifecycle and authenticated rejection/cancellation cleanup use occurrence identity within only the owning session. | `packages/core/tests/test_security.py::TestPolicyEnforcementMiddleware::test_pending_policy_approvals_are_fifo_bounded_by_occurrence`, `test_pending_policy_approval_ttl_is_deterministic_and_durable`, `test_non_grant_cleanup_is_authenticated_session_and_occurrence_bound` | | Session-bound substituted response | A response is rebound to the immutable recorded call and cannot replace its call id, tool name, or arguments. | `packages/core/tests/core/test_function_invocation_logic.py::test_session_approval_binding_rebinds_consumes_and_rejects_duplicates` | | Occurrence-aware local binding | New local requests use `function_call.id`; missing, mismatched, or stale occurrence ids do not execute or consume pending state, while the canonical occurrence id binds without an embedded call. | `test_occurrence_aware_approval_rejects_stale_reused_call_id_response`, `test_occurrence_aware_approval_mismatched_identity_does_not_consume_pending`, `test_occurrence_aware_approval_binds_without_embedded_function_call` | | Legacy stored approval | A serialized pending request without `function_call.id` retains exact request-id binding once and warns only when resumed. | `test_legacy_serialized_pending_approval_resumes_once_with_migration_warning`, `packages/core/tests/core/test_types.py::test_legacy_function_call_deserialization_does_not_generate_an_occurrence_id` | @@ -525,6 +531,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Reused id after completion | A later round with the same id creates a second valid pair. | `test_replace_approval_contents_with_results_allows_reused_call_id_after_completion` | | Replayed approval wrapper | A duplicated wrapper does not restore another function call. | `test_replace_approval_contents_with_results_deduplicates_replayed_approval_request` | | Historical resolved response plus new round | The old response is removed from normalized input and is not converted into a rejection result. | `test_replace_approval_contents_with_results_ignores_already_resolved_response` | +| Replacement request with reused occurrence id | A request after a stale response starts a new unanswered round rather than inheriting the old decision. | `test_collect_unanswered_approval_requests_tracks_replacement_request` | | Multiple reused-id rounds | Approved and rejected rounds retain separate call/result occurrences. | `test_replace_approval_contents_with_results_correlates_reused_call_id_occurrences` | | Multi-content result with reused id | Every content produced by one execution stays with that approval occurrence and cannot bleed into the next reused-id round. | `test_replace_approval_contents_with_results_keeps_multi_content_group_with_reused_call_id` | | Follow-up request closes one occurrence | A user-input follow-up consumes only the preceding approval authority and leaves a later reused-id response pending. | `test_collect_approval_responses_consumes_matching_follow_up_request_occurrence` | diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index ba9efcf54a..9f160c3dd1 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -19,6 +19,7 @@ AgentRunInputs, ChatResponse, ChatResponseUpdate, + Content, Message, ResponseStream, normalize_messages, @@ -755,6 +756,25 @@ async def process( """ ... + def on_approval_responses( + self, + responses: Sequence[Content], + *, + session: AgentSession | None, + ) -> None: + """Observe authenticated approval responses that do not execute a function. + + The function loop calls this only after binding responses to the active session's + authoritative pending snapshot. Stateful middleware can discard rejected or + cancelled authority here; the default implementation retains no state. + + Args: + responses: Session-rebound approval responses. + + Keyword Args: + session: The active invocation session, if any. + """ + class ChatMiddleware(ABC): """Abstract base class for chat middleware that can intercept chat client requests. @@ -1218,6 +1238,17 @@ def matches(self, middleware: Sequence[FunctionMiddlewareTypes]) -> bool: """Return whether this pipeline was built from the provided middleware sequence.""" return self._source_middleware == tuple(middleware) + def notify_approval_responses( + self, + responses: Sequence[Content], + *, + session: AgentSession | None, + ) -> None: + """Notify class-based middleware of authenticated non-executing decisions.""" + for middleware in self._middleware: + if isinstance(middleware, FunctionMiddleware): + middleware.on_approval_responses(responses, session=session) + def _register_middleware(self, middleware: FunctionMiddlewareTypes) -> None: """Register a function middleware item. diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 5fa270ab80..90748a5fab 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -3239,6 +3239,7 @@ async def _resolve_approval_responses( max_errors: int, execute_function_calls: _FunctionCallExecutor, invocation_session: AgentSession | None = None, + middleware_pipeline: FunctionMiddlewarePipeline | None = None, settle_dangling_calls: Callable[[Sequence[Content]], Awaitable[None]] | None = None, ) -> _FunctionProcessingResult: """Resolve inbound approval responses before the next model call. @@ -3281,6 +3282,11 @@ async def _resolve_approval_responses( responses_to_execute = [ response for response in pending_approval_responses.values() if _is_approval_granted(response.approved) ] + responses_not_granted = [ + response for response in pending_approval_responses.values() if not _is_approval_granted(response.approved) + ] + if middleware_pipeline is not None and responses_not_granted: + middleware_pipeline.notify_approval_responses(responses_not_granted, session=invocation_session) execution_result_groups: list[list[Content]] = [] should_terminate = False reached_error_limit = False @@ -3577,6 +3583,7 @@ async def _get_response_with_function_invocation( invocation_session: AgentSession | None, budget_state: dict[str, Any], max_errors: int, + middleware_pipeline: FunctionMiddlewarePipeline | None = None, ) -> ChatResponse[Any]: """Run the non-streaming function invocation loop.""" from ._middleware import MiddlewareFailure @@ -3622,6 +3629,7 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non max_errors=max_errors, execute_function_calls=execute_function_calls, invocation_session=invocation_session, + middleware_pipeline=middleware_pipeline, settle_dangling_calls=settle_approval_replay_calls, ) function_call_messages.extend(approval_processing.response_messages) @@ -3762,6 +3770,7 @@ async def _stream_response_with_function_invocation( invocation_session: AgentSession | None, budget_state: dict[str, Any], max_errors: int, + middleware_pipeline: FunctionMiddlewarePipeline | None = None, ) -> AsyncIterable[ChatResponseUpdate]: """Run the streaming function invocation loop.""" from ._middleware import MiddlewareFailure @@ -3804,6 +3813,7 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non max_errors=max_errors, execute_function_calls=execute_function_calls, invocation_session=invocation_session, + middleware_pipeline=middleware_pipeline, settle_dangling_calls=settle_approval_replay_calls, ) errors_in_a_row = approval_processing.errors_in_a_row @@ -4175,6 +4185,7 @@ def get_response( invocation_session=invocation_session, budget_state=budget_state, max_errors=max_errors, + middleware_pipeline=function_middleware_pipeline, ) response_format = mutable_options.get("response_format") @@ -4191,6 +4202,7 @@ def get_response( invocation_session=invocation_session, budget_state=budget_state, max_errors=max_errors, + middleware_pipeline=function_middleware_pipeline, ), finalizer=partial(ChatResponse.from_updates, output_format_type=response_format), ) diff --git a/python/packages/core/agent_framework/security.py b/python/packages/core/agent_framework/security.py index 29f8df7cda..f068ccb0c6 100644 --- a/python/packages/core/agent_framework/security.py +++ b/python/packages/core/agent_framework/security.py @@ -21,11 +21,12 @@ import logging import math import re +import time import uuid -from collections.abc import Awaitable, Callable, Mapping, MutableMapping +from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence from contextvars import ContextVar, Token from copy import copy, deepcopy -from datetime import datetime +from datetime import datetime, timedelta from enum import Enum from typing import TYPE_CHECKING, Annotated, Any, NamedTuple, NoReturn, cast @@ -1947,7 +1948,20 @@ def get_current_middleware() -> LabelTrackingFunctionMiddleware | None: class _PendingPolicyApproval(NamedTuple): - """Exact, durable binding for one pending policy approval.""" + """Immutable binding record for a pending policy-violation approval. + + Captures every dimension a granted approval is bound to so a reused ``call_id`` cannot + re-authorize a call that differs in any of them. ``body_signature`` covers the function name and + the arguments as displayed for review (variable placeholders unexpanded); + ``resolved_signature`` the exact resolved snapshot the tool would actually receive, so an + approval granted while a placeholder resolved to one payload cannot authorize a replay in + which it resolves to something else (or no longer resolves at all); ``label_key`` the + conversation label shown for review and ``effective_label_key`` the label of everything the + invocation acts on, including hidden arguments; ``session_key`` the session the approval was + requested in; and ``disclosed_violations`` the canonical risks shown to the user. + ``created_at`` is a wall-clock timestamp so TTL expiration survives session serialization and + process restarts. Records remain isolated in the session-scoped security state. + """ body_signature: str resolved_signature: str @@ -1955,6 +1969,7 @@ class _PendingPolicyApproval(NamedTuple): effective_label_key: str session_key: str disclosed_violations: tuple[str, ...] + created_at: float def to_state(self) -> dict[str, Any]: return { @@ -1964,16 +1979,38 @@ def to_state(self) -> dict[str, Any]: "effective_label_key": self.effective_label_key, "session_key": self.session_key, "disclosed_violations": list(self.disclosed_violations), + "created_at": self.created_at, } + def binding_key(self) -> tuple[str, str, str, str, str, tuple[str, ...]]: + """Return every authorization dimension except lifecycle metadata.""" + return ( + self.body_signature, + self.resolved_signature, + self.label_key, + self.effective_label_key, + self.session_key, + self.disclosed_violations, + ) + @classmethod def from_state(cls, payload: Any) -> _PendingPolicyApproval | None: + """Rebuild a record from session state, or fail closed when malformed.""" if not isinstance(payload, dict): return None record = cast(dict[str, Any], payload) - keys = ("body_signature", "resolved_signature", "label_key", "effective_label_key", "session_key") - values = tuple(record.get(key) for key in keys) - violations = record.get("disclosed_violations") + try: + values = ( + record["body_signature"], + record["resolved_signature"], + record["label_key"], + record["effective_label_key"], + record["session_key"], + ) + violations = record["disclosed_violations"] + created_at = record["created_at"] + except KeyError: + return None if not all(type(value) is str for value in values): return None if not isinstance(violations, list): @@ -1981,8 +2018,22 @@ def from_state(cls, payload: Any) -> _PendingPolicyApproval | None: violation_items = cast(list[Any], violations) if not all(type(item) is str for item in violation_items): return None + if type(created_at) not in (int, float) or not math.isfinite(created_at): + return None typed_values = cast(tuple[str, str, str, str, str], values) - return cls(*typed_values, tuple(cast(list[str], violation_items))) + return cls( + body_signature=typed_values[0], + resolved_signature=typed_values[1], + label_key=typed_values[2], + effective_label_key=typed_values[3], + session_key=typed_values[4], + disclosed_violations=tuple(cast(list[str], violation_items)), + created_at=float(created_at), + ) + + +_DEFAULT_MAX_PENDING_APPROVALS = 256 +_DEFAULT_PENDING_APPROVAL_TTL = timedelta(hours=1) @experimental(feature_id=ExperimentalFeature.FIDES) @@ -2026,14 +2077,40 @@ def __init__( enable_audit_log: bool = True, approval_on_violation: bool = False, *, + max_pending_approvals: int = _DEFAULT_MAX_PENDING_APPROVALS, + pending_approval_ttl: timedelta | None = _DEFAULT_PENDING_APPROVAL_TTL, security_scope: _SecurityScope | None = None, session_state_key: str = _STANDALONE_SESSION_STATE_KEY, ) -> None: - """Initialize policy enforcement and bind its security-state selector.""" + """Initialize PolicyEnforcementFunctionMiddleware. + + Args: + allow_untrusted_tools: Set of tool names allowed to execute in an untrusted context. + block_on_violation: Whether to block execution on policy violations. + Ignored if approval_on_violation is True. + enable_audit_log: Whether to maintain an audit log of violations. + approval_on_violation: Whether to request user approval instead of blocking + when a policy violation is detected. If True, the middleware will return + a special result that triggers an approval request in the UI. After user + approval, the tool will execute with a warning about untrusted context. + + Keyword Args: + max_pending_approvals: Maximum pending policy approvals retained per security scope. + When the scope reaches this bound, the oldest occurrence is evicted first. + pending_approval_ttl: Maximum age of an unconsumed approval. ``None`` disables expiry. + security_scope: Internal fixed scope used by the context-provider path. + session_state_key: Internal session-state key shared by reusable middleware. + """ + if isinstance(max_pending_approvals, bool) or max_pending_approvals < 1: + raise ValueError("max_pending_approvals must be at least 1.") + if pending_approval_ttl is not None and pending_approval_ttl <= timedelta(0): + raise ValueError("pending_approval_ttl must be positive or None.") self.allow_untrusted_tools = allow_untrusted_tools or set() self.approval_on_violation = approval_on_violation self.block_on_violation = block_on_violation if not approval_on_violation else False self.enable_audit_log = enable_audit_log + self._max_pending_approvals = max_pending_approvals + self._pending_approval_ttl = pending_approval_ttl self._initialize_security_scope(security_scope, session_state_key=session_state_key) def _clone_for_scope(self, scope: _SecurityScope) -> PolicyEnforcementFunctionMiddleware: @@ -2051,11 +2128,32 @@ def audit_log(self) -> list[dict[str, Any]]: def _pending_policy_approvals(self) -> dict[str, Any]: return self._scope.pending_approvals + def _prune_pending_approvals(self, scope: _SecurityScope | None = None) -> None: + """Expire malformed or old records and enforce FIFO capacity in one scope.""" + pending_approvals = (scope or self._scope).pending_approvals + now = time.time() + ttl_seconds = self._pending_approval_ttl.total_seconds() if self._pending_approval_ttl is not None else None + for approval_id, payload in list(pending_approvals.items()): + record = _PendingPolicyApproval.from_state(payload) + if record is None or (ttl_seconds is not None and now - record.created_at >= ttl_seconds): + pending_approvals.pop(approval_id, None) + while len(pending_approvals) > self._max_pending_approvals: + evicted_id = next(iter(pending_approvals)) + pending_approvals.pop(evicted_id, None) + logger.debug("Evicted oldest pending policy approval occurrence %s.", evicted_id) + def _get_pending_approval(self, approval_id: str) -> _PendingPolicyApproval | None: + """Return the live stored binding record for *approval_id*.""" + self._prune_pending_approvals() return _PendingPolicyApproval.from_state(self._scope.pending_approvals.get(approval_id)) def _store_pending_approval(self, approval_id: str, record: _PendingPolicyApproval) -> None: - self._scope.pending_approvals[approval_id] = record.to_state() + """Persist a record as the newest occurrence and enforce the scope bound.""" + self._prune_pending_approvals() + pending_approvals = self._scope.pending_approvals + pending_approvals.pop(approval_id, None) + pending_approvals[approval_id] = record.to_state() + self._prune_pending_approvals() def _get_call_id(self, context: FunctionInvocationContext) -> str: """Get the tool call id for this invocation context.""" @@ -2154,6 +2252,7 @@ def _pending_record( effective_label_key=self._effective_label_key(context), session_key=self._session_key(context), disclosed_violations=self._violation_set_key(violations), + created_at=time.time(), ) def _signature_from_function_call(self, function_call: Any) -> str | None: @@ -2198,10 +2297,37 @@ def _matches_pending_approval( and approval_response.approved is True ): return False - return current_binding == pending and self._response_matches_pending( + return current_binding.binding_key() == pending.binding_key() and self._response_matches_pending( approval_response, approval_id, call_id, pending.body_signature ) + def on_approval_responses( + self, + responses: Sequence[Content], + *, + session: AgentSession | None, + ) -> None: + """Discard authenticated non-grants from only their owning security scope.""" + scope = self._scope_for_session(session) + self._prune_pending_approvals(scope) + session_key = session.session_id if session is not None else "" + for response in responses: + if response.type != "function_approval_response" or response.approved is True or response.id is None: + continue + function_call = response.function_call + if function_call is None or function_call.call_id is None: + continue + pending = _PendingPolicyApproval.from_state(scope.pending_approvals.get(response.id)) + if pending is None or pending.session_key != session_key: + continue + if self._response_matches_pending( + response, + response.id, + function_call.call_id, + pending.body_signature, + ): + scope.pending_approvals.pop(response.id, None) + def _consume_pending_approval(self, context: FunctionInvocationContext) -> None: self._pending_policy_approvals.pop(self._get_approval_id(context), None) @@ -2601,6 +2727,9 @@ def __init__( enable_policy_enforcement: bool = True, quarantine_chat_client: SupportsChatGetResponse | None = None, source_id: str | None = None, + *, + max_pending_approvals: int = _DEFAULT_MAX_PENDING_APPROVALS, + pending_approval_ttl: timedelta | None = _DEFAULT_PENDING_APPROVAL_TTL, ) -> None: """Initialize secure agent configuration. @@ -2626,7 +2755,15 @@ def __init__( class docstring for details on running multiple instances. source_id: Optional source identifier for context provider attribution. Defaults to "secure_agent". + + Keyword Args: + max_pending_approvals: Maximum pending policy approvals retained per session. + pending_approval_ttl: Maximum age of an unconsumed approval. ``None`` disables expiry. """ + if isinstance(max_pending_approvals, bool) or max_pending_approvals < 1: + raise ValueError("max_pending_approvals must be at least 1.") + if pending_approval_ttl is not None and pending_approval_ttl <= timedelta(0): + raise ValueError("pending_approval_ttl must be positive or None.") super().__init__(source_id or self.DEFAULT_SOURCE_ID) self._auto_hide_untrusted = auto_hide_untrusted self._default_integrity = default_integrity @@ -2637,6 +2774,8 @@ class docstring for details on running multiple instances. self._block_on_violation = block_on_violation self._approval_on_violation = approval_on_violation self._enable_audit_log = enable_audit_log + self._max_pending_approvals = max_pending_approvals + self._pending_approval_ttl = pending_approval_ttl self.enable_policy_enforcement = enable_policy_enforcement self.label_tracker = LabelTrackingFunctionMiddleware( auto_hide_untrusted=auto_hide_untrusted, @@ -2649,6 +2788,8 @@ class docstring for details on running multiple instances. block_on_violation=block_on_violation, approval_on_violation=approval_on_violation, enable_audit_log=enable_audit_log, + max_pending_approvals=max_pending_approvals, + pending_approval_ttl=pending_approval_ttl, ) if enable_policy_enforcement else None diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index 02a0f05b8a..7e688a2e13 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -3297,6 +3297,25 @@ def test_pending_approval_batch_filter_keeps_resolved_sibling_pair() -> None: ] +def test_collect_unanswered_approval_requests_tracks_replacement_request() -> None: + """A replacement request with the same occurrence id starts a new unanswered round.""" + from agent_framework._tools import _collect_unanswered_approval_requests + + _, original_request, stale_response = _build_approved_tool_roundtrip( + call_id="call_reapproval", + approval_id="approval_occurrence", + tool_name="guarded_tool", + ) + replacement_request = Content.from_dict(original_request.to_dict()) + messages = [ + Message(role="assistant", contents=[original_request]), + Message(role="user", contents=[stale_response]), + Message(role="assistant", contents=[replacement_request]), + ] + + assert _collect_unanswered_approval_requests(messages) == [replacement_request] + + def test_replace_approval_contents_with_results_uses_result_call_ids_without_placeholders() -> None: from agent_framework._tools import _collect_approval_responses, _replace_approval_contents_with_results diff --git a/python/packages/core/tests/core/test_harness_tool_approval.py b/python/packages/core/tests/core/test_harness_tool_approval.py index 032309accd..04168573e7 100644 --- a/python/packages/core/tests/core/test_harness_tool_approval.py +++ b/python/packages/core/tests/core/test_harness_tool_approval.py @@ -5,6 +5,7 @@ import json import warnings from collections.abc import Awaitable, Callable, MutableSequence +from datetime import timedelta from enum import Enum from pathlib import Path from typing import Any @@ -658,6 +659,208 @@ def guarded_sink(value: str) -> str: assert guarded_values == ["hidden payload"] +@pytest.mark.parametrize("lifecycle_event", ["expiry", "eviction"]) +@pytest.mark.parametrize("streaming", [False, True], ids=["non-streaming", "streaming"]) +async def test_policy_reapproval_is_visible_persisted_and_executes_once( + chat_client_base: MockBaseChatClient, + monkeypatch: pytest.MonkeyPatch, + lifecycle_event: str, + streaming: bool, +) -> None: + """An obsolete policy grant must surface and persist a resumable replacement.""" + now = 1_000.0 + monkeypatch.setattr("agent_framework.security.time.time", lambda: now) + calls = 0 + + @tool(name="policy_guarded_tool") + def policy_guarded_tool() -> str: + nonlocal calls + calls += 1 + return "approved result" + + class MarkUntrusted(FunctionMiddleware): + async def process( + self, + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + context.metadata["context_label"] = ContentLabel(integrity=IntegrityLabel.UNTRUSTED) + await call_next() + + policy = PolicyEnforcementFunctionMiddleware( + approval_on_violation=True, + max_pending_approvals=1, + pending_approval_ttl=timedelta(seconds=10) if lifecycle_event == "expiry" else None, + ) + agent = Agent( + client=chat_client_base, + tools=[policy_guarded_tool], + middleware=[MarkUntrusted(), policy], + context_providers=[InMemoryHistoryProvider()], + ) + session = AgentSession(session_id=f"policy-reapproval-{lifecycle_event}-{streaming}") + function_calls = [ + Content.from_function_call( + call_id="policy-provider-call", + name="policy_guarded_tool", + arguments="{}", + id="policy-approval-occurrence", + ) + ] + if lifecycle_event == "eviction": + function_calls.append( + Content.from_function_call( + call_id="other-provider-call", + name="policy_guarded_tool", + arguments="{}", + id="other-occurrence", + ) + ) + captured_model_calls: list[list[Message]] = [] + + def capture(messages: MutableSequence[Message]) -> None: + captured_model_calls.append([Message.from_dict(message.to_dict()) for message in messages]) + + if streaming: + original_stream = chat_client_base._get_streaming_response + + def capture_stream( + *, + messages: MutableSequence[Message], + options: dict[str, Any], + **kwargs: Any, + ) -> Any: + capture(messages) + return original_stream(messages=messages, options=options, **kwargs) + + monkeypatch.setattr(chat_client_base, "_get_streaming_response", capture_stream) + chat_client_base.streaming_responses = [ + [ChatResponseUpdate(role="assistant", contents=function_calls)], + [ChatResponseUpdate(role="assistant", contents=[Content.from_text("done")])], + [ChatResponseUpdate(role="assistant", contents=[Content.from_text("after stale replay")])], + ] + first_stream = agent.run("run policy tool", stream=True, session=session) + first_updates = [update async for update in first_stream] + first_response = await first_stream.get_final_response() + first_update_types = [content.type for update in first_updates for content in update.contents] + assert first_update_types.count("function_call") == len(function_calls) + assert first_update_types.count("function_approval_request") == len(function_calls) + else: + original_response = chat_client_base._get_non_streaming_response + + async def capture_response( + *, + messages: MutableSequence[Message], + options: dict[str, Any], + **kwargs: Any, + ) -> ChatResponse: + capture(messages) + return await original_response(messages=messages, options=options, **kwargs) + + monkeypatch.setattr(chat_client_base, "_get_non_streaming_response", capture_response) + chat_client_base.run_responses = [ + ChatResponse(messages=Message(role="assistant", contents=function_calls)), + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ChatResponse(messages=Message(role="assistant", contents=["after stale replay"])), + ] + first_response = await agent.run("run policy tool", session=session) + + policy_pending = policy._scope_for_session(session).pending_approvals + first_requests = first_response.user_input_requests + remaining_request: Content | None = None + if lifecycle_event == "expiry": + original_request = first_requests[0] + now = 1_010.0 + else: + assert len(first_requests) == 2 + original_request = next(request for request in first_requests if request.id not in policy_pending) + remaining_request = next(request for request in first_requests if request is not original_request) + assert original_request.id is not None + occurrence_id = original_request.id + assert calls == 0 + assert chat_client_base.call_count == 1 + + stale_approval = original_request.to_function_approval_response(True) + resume_contents = [stale_approval] + if remaining_request is not None: + resume_contents.append(remaining_request.to_function_approval_response(False)) + resume_message = Message(role="user", contents=resume_contents) + + if streaming: + stale_stream = agent.run(resume_message, stream=True, session=session) + stale_updates = [update async for update in stale_stream] + stale_response = await stale_stream.get_final_response() + stale_update_types = [content.type for update in stale_updates for content in update.contents] + assert stale_update_types.count("function_approval_request") == 1 + assert stale_update_types.count("function_result") == int(lifecycle_event == "eviction") + else: + stale_response = await agent.run(resume_message, session=session) + + assert calls == 0 + assert chat_client_base.call_count == 1 + replacement_requests = stale_response.user_input_requests + assert len(replacement_requests) == 1 + replacement = replacement_requests[0] + assert replacement.id != occurrence_id + assert replacement.function_call is not None + assert original_request.function_call is not None + assert replacement.function_call.id == occurrence_id + assert replacement.function_call.call_id == original_request.function_call.call_id + pending_snapshots = session.state["tool_approval"]["pending_approval_requests"] + assert [snapshot["id"] for snapshot in pending_snapshots] == [replacement.id] + + if streaming: + approved_stream = agent.run( + replacement.to_function_approval_response(True), + stream=True, + session=session, + ) + approved_updates = [update async for update in approved_stream] + approved_response = await approved_stream.get_final_response() + assert [content.type for update in approved_updates for content in update.contents] == [ + "function_result", + "text", + ] + else: + approved_response = await agent.run(replacement.to_function_approval_response(True), session=session) + + assert calls == 1 + assert chat_client_base.call_count == 2 + assert [[content.type for content in message.contents] for message in approved_response.messages] == [ + ["function_result"], + ["text"], + ] + model_contents = [content for message in captured_model_calls[-1] for content in message.contents] + model_types = [content.type for content in model_contents] + expected_occurrences = 2 if lifecycle_event == "eviction" else 1 + assert model_types.count("function_call") == expected_occurrences + assert model_types.count("function_result") == expected_occurrences + assert "function_approval_request" not in model_types + assert "function_approval_response" not in model_types + model_calls = [content for content in model_contents if content.type == "function_call"] + model_results = [content for content in model_contents if content.type == "function_result"] + assert {content.call_id for content in model_calls} == {content.call_id for content in model_results} + approved_model_call = next(content for content in model_calls if content.id == occurrence_id) + approved_model_result = next(content for content in model_results if content.call_id == approved_model_call.call_id) + assert approved_model_call.call_id == approved_model_result.call_id + + if streaming: + replay_stream = agent.run(stale_approval, stream=True, session=session) + _ = [update async for update in replay_stream] + await replay_stream.get_final_response() + else: + await agent.run(stale_approval, session=session) + + assert calls == 1 + assert chat_client_base.call_count == 3 + assert "pending_approval_requests" not in session.state["tool_approval"] + replayed_types = [content.type for message in captured_model_calls[-1] for content in message.contents] + assert replayed_types.count("function_call") == expected_occurrences + assert replayed_types.count("function_result") == expected_occurrences + assert "function_approval_request" not in replayed_types + assert "function_approval_response" not in replayed_types + + @pytest.mark.parametrize("approved", [True, False], ids=["approved", "rejected"]) @pytest.mark.parametrize("streaming", [False, True], ids=["non-streaming", "streaming"]) async def test_approval_resume_returns_result_without_mutating_inputs( diff --git a/python/packages/core/tests/test_security.py b/python/packages/core/tests/test_security.py index e585ee70c7..ffe151c432 100644 --- a/python/packages/core/tests/test_security.py +++ b/python/packages/core/tests/test_security.py @@ -5,6 +5,7 @@ import asyncio import json import logging +from datetime import timedelta from types import SimpleNamespace from typing import Any, cast @@ -22,7 +23,13 @@ SessionContext, ) from agent_framework._middleware import FunctionMiddlewarePipeline, MiddlewareTermination -from agent_framework._tools import FunctionTool, _auto_invoke_function, normalize_function_invocation_configuration +from agent_framework._tools import ( + FunctionTool, + _auto_invoke_function, + _resolve_approval_responses, + _store_pending_approval_requests, + normalize_function_invocation_configuration, +) from agent_framework._types import Content from agent_framework.security import ( ConfidentialityLabel, @@ -754,6 +761,202 @@ async def next_fn() -> None: assert context.result == [Content.from_text("approved result")] assert "call-approved" not in middleware._pending_policy_approvals + async def test_pending_policy_approvals_are_fifo_bounded_by_occurrence(self, mock_function) -> None: + """The oldest occurrence is evicted and its stale grant fails closed.""" + middleware = PolicyEnforcementFunctionMiddleware( + approval_on_violation=True, + max_pending_approvals=2, + pending_approval_ttl=None, + ) + session = AgentSession(session_id="fifo-policy-approvals") + + async def request(occurrence_id: str) -> Content: + context = FunctionInvocationContext( + function=mock_function, + arguments=mock_function.args_schema(arg="test"), + session=session, + kwargs={"session": session}, + ) + context.metadata.update({ + "context_label": ContentLabel(integrity=IntegrityLabel.UNTRUSTED), + "call_id": "reused-provider-call", + "function_call_occurrence_id": occurrence_id, + }) + + async def should_not_execute() -> None: + pytest.fail("Policy-violating tools require approval") + + with pytest.raises(MiddlewareTermination): + await middleware.process(context, should_not_execute) + assert isinstance(context.result, Content) + return context.result + + requests = [await request(f"occurrence-{index}") for index in range(3)] + pending = middleware._scope_for_session(session).pending_approvals + assert list(pending) == ["occurrence-1", "occurrence-2"] + + stale_context = FunctionInvocationContext( + function=mock_function, + arguments=mock_function.args_schema(arg="test"), + session=session, + kwargs={"session": session}, + ) + stale_context.metadata.update({ + "context_label": ContentLabel(integrity=IntegrityLabel.UNTRUSTED), + "call_id": "reused-provider-call", + "function_call_occurrence_id": "occurrence-0", + "approval_response": requests[0].to_function_approval_response(True), + }) + + async def should_not_execute_stale_grant() -> None: + pytest.fail("An evicted approval must not execute") + + with pytest.raises(MiddlewareTermination): + await middleware.process(stale_context, should_not_execute_stale_grant) + + assert isinstance(stale_context.result, Content) + assert stale_context.result.type == "function_approval_request" + assert stale_context.result.id != "occurrence-0" + assert stale_context.result.function_call is not None + assert stale_context.result.function_call.id == "occurrence-0" + assert list(pending) == ["occurrence-2", "occurrence-0"] + + async def test_pending_policy_approval_ttl_is_deterministic_and_durable( + self, + mock_function, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A restored approval expires at the configured boundary and is replaced.""" + now = 1_000.0 + monkeypatch.setattr("agent_framework.security.time.time", lambda: now) + middleware = PolicyEnforcementFunctionMiddleware( + approval_on_violation=True, + pending_approval_ttl=timedelta(seconds=5), + ) + session = AgentSession(session_id="ttl-policy-approval") + request_context = FunctionInvocationContext( + function=mock_function, + arguments=mock_function.args_schema(arg="test"), + session=session, + kwargs={"session": session}, + ) + request_context.metadata.update({ + "context_label": ContentLabel(integrity=IntegrityLabel.UNTRUSTED), + "call_id": "ttl-provider-call", + "function_call_occurrence_id": "ttl-occurrence", + }) + + async def should_not_execute() -> None: + pytest.fail("Policy-violating tools require approval") + + with pytest.raises(MiddlewareTermination): + await middleware.process(request_context, should_not_execute) + assert isinstance(request_context.result, Content) + approval_request = request_context.result + + restored = AgentSession.from_dict(session.to_dict()) + now = 1_005.0 + replay_context = FunctionInvocationContext( + function=mock_function, + arguments=mock_function.args_schema(arg="test"), + session=restored, + kwargs={"session": restored}, + ) + replay_context.metadata.update({ + "context_label": ContentLabel(integrity=IntegrityLabel.UNTRUSTED), + "call_id": "ttl-provider-call", + "function_call_occurrence_id": "ttl-occurrence", + "approval_response": approval_request.to_function_approval_response(True), + }) + + with pytest.raises(MiddlewareTermination): + await middleware.process(replay_context, should_not_execute) + + assert isinstance(replay_context.result, Content) + assert replay_context.result.type == "function_approval_request" + assert replay_context.result.id != "ttl-occurrence" + assert replay_context.result.function_call is not None + assert replay_context.result.function_call.id == "ttl-occurrence" + pending = middleware._scope_for_session(restored).pending_approvals["ttl-occurrence"] + assert pending["created_at"] == now + + @pytest.mark.parametrize("cancelled", [False, True], ids=["rejected", "cancelled"]) + async def test_non_grant_cleanup_is_authenticated_session_and_occurrence_bound( + self, + mock_function, + cancelled: bool, + ) -> None: + """Only a rebound non-grant clears its occurrence in the owning session.""" + middleware = PolicyEnforcementFunctionMiddleware(approval_on_violation=True) + owner = AgentSession(session_id="policy-owner") + interleaved = AgentSession(session_id="policy-interleaved") + + async def request(session: AgentSession, occurrence_id: str) -> Content: + context = FunctionInvocationContext( + function=mock_function, + arguments=mock_function.args_schema(arg="test"), + session=session, + kwargs={"session": session}, + ) + context.metadata.update({ + "context_label": ContentLabel(integrity=IntegrityLabel.UNTRUSTED), + "call_id": "shared-provider-call", + "function_call_occurrence_id": occurrence_id, + }) + + async def should_not_execute() -> None: + pytest.fail("Policy-violating tools require approval") + + with pytest.raises(MiddlewareTermination): + await middleware.process(context, should_not_execute) + assert isinstance(context.result, Content) + return context.result + + owner_request = await request(owner, "shared-occurrence") + await request(owner, "owner-second-occurrence") + interleaved_request = await request(interleaved, "shared-occurrence") + owner_pending = middleware._scope_for_session(owner).pending_approvals + interleaved_pending = middleware._scope_for_session(interleaved).pending_approvals + + _store_pending_approval_requests(interleaved, [interleaved_request]) + forged = interleaved_request.to_function_approval_response(False) + forged.id = "unissued-occurrence" + + async def should_not_execute_responses(**_kwargs: Any) -> Any: + pytest.fail("Non-grants must not execute tools") + + await _resolve_approval_responses( + prepared_messages=[Message(role="user", contents=[forged])], + options={"tools": [mock_function]}, + errors_in_a_row=0, + max_errors=3, + execute_function_calls=should_not_execute_responses, # type: ignore[arg-type] + invocation_session=interleaved, + middleware_pipeline=FunctionMiddlewarePipeline(middleware), + ) + assert "shared-occurrence" in interleaved_pending + + non_grant = interleaved_request.to_function_approval_response(False) + if cancelled: + non_grant.additional_properties["cancelled"] = True + resolved = await _resolve_approval_responses( + prepared_messages=[Message(role="user", contents=[non_grant])], + options={"tools": [mock_function]}, + errors_in_a_row=0, + max_errors=3, + execute_function_calls=should_not_execute_responses, # type: ignore[arg-type] + invocation_session=interleaved, + middleware_pipeline=FunctionMiddlewarePipeline(middleware), + ) + + assert "shared-occurrence" not in interleaved_pending + assert set(owner_pending) == {"shared-occurrence", "owner-second-occurrence"} + results = [content for message in resolved.response_messages for content in message.contents] + assert len(results) == 1 + assert results[0].type == "function_result" + assert results[0].call_id == "shared-provider-call" + assert owner_request.id == "shared-occurrence" + async def test_auto_invoke_passes_approval_response_to_middleware(self, mock_function): """Test the main tool loop passes approval response content via metadata.""" captured_metadata: dict[str, object] = {} From 40c862e916f4a3cbf380fa46949f2128384fce23 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 9 Sep 2026 14:16:13 +0200 Subject: [PATCH 6/6] Python: keep MCP labels subordinate to local FIDES policy Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/security.py | 110 +++++----------- python/packages/core/tests/core/test_mcp.py | 118 ++++++++++++++++- python/packages/core/tests/test_security.py | 122 +++++++++++------- 3 files changed, 216 insertions(+), 134 deletions(-) diff --git a/python/packages/core/agent_framework/security.py b/python/packages/core/agent_framework/security.py index f068ccb0c6..41f2e4f9e7 100644 --- a/python/packages/core/agent_framework/security.py +++ b/python/packages/core/agent_framework/security.py @@ -3624,80 +3624,33 @@ def _map_mcp_annotations_to_labels( *, default_integrity: IntegrityLabel = IntegrityLabel.UNTRUSTED, ) -> tuple[IntegrityLabel, ConfidentialityLabel | None, bool]: - """Map MCP ToolAnnotations to FIDES security labels. - - Uses the MCP hint fields (``readOnlyHint``, ``openWorldHint``) - to infer an appropriate ``source_integrity``, - ``max_allowed_confidentiality``, and ``accepts_untrusted`` flag. - - Mapping rules (conservative - when in doubt, default to UNTRUSTED *source* - and PUBLIC-only *sink*): - - * ``readOnlyHint=True`` -> ``accepts_untrusted=True`` (pure data source, - safe to call even when the context is tainted - it cannot exfiltrate) - and **no** ``max_allowed_confidentiality`` cap. - * ``readOnlyHint`` is anything other than ``True`` (``False`` *or* missing) - -> treated as a potential write / exfiltration sink: - ``max_allowed_confidentiality = PUBLIC`` and ``accepts_untrusted = False``. - This matters because real-world servers (e.g. GitHub's MCP) declare - ``readOnlyHint=True`` on read tools but leave the field unset on write - tools, so a strict ``readOnlyHint=False`` check would miss them. - * ``openWorldHint=True`` -> integrity ``UNTRUSTED`` (tool touches external - data); ``openWorldHint=False`` -> ``TRUSTED``. - * If ``openWorldHint`` is missing, integrity remains ``default_integrity``. - * All hints absent / ``None`` -> ``default_integrity`` (UNTRUSTED by default), - ``max_allowed_confidentiality=PUBLIC``, ``accepts_untrusted=False``. + """Map untrusted MCP ToolAnnotations to restriction-only FIDES labels. + + Server annotations are hints, not policy authority. They may make locally + configured policy more restrictive, but cannot grant trust, remove the + PUBLIC confidentiality cap, or authorize tainted context. Consequently, + only ``openWorldHint=True`` changes the local default. Explicit local + ``annotation_overrides`` are applied by :func:`apply_mcp_security_labels` + before this mapper is called. Args: annotations: An MCP ``ToolAnnotations`` object (or ``None``). - default_integrity: Fallback integrity when hints are absent. + default_integrity: Locally configured integrity when hints do not + require a stricter label. Returns: A ``(integrity, max_confidentiality, accepts_untrusted)`` tuple. - ``max_confidentiality`` is ``None`` for read-only / source tools and - ``PUBLIC`` for sinks. ``accepts_untrusted`` is ``True`` for read-only - tools that are safe to invoke in a tainted context. + Server annotations always retain the PUBLIC cap and reject untrusted + context. """ if annotations is None: - # No annotations at all - treat as both UNTRUSTED-by-default and a - # potential sink (max_conf=PUBLIC). We have no signal that the tool is - # safe to receive PRIVATE data, so we err on the side of blocking - # exfiltration. return (default_integrity, ConfidentialityLabel.PUBLIC, False) - read_only: bool | None = getattr(annotations, "readOnlyHint", None) open_world: bool | None = getattr(annotations, "openWorldHint", None) - - # --- Determine integrity --- integrity = default_integrity - if open_world is True: - # Interacts with external entities -> untrusted data integrity = IntegrityLabel.UNTRUSTED - elif open_world is False: - # Closed-world tool (e.g., local memory) -> data is trusted - integrity = IntegrityLabel.TRUSTED - - # --- Determine max_allowed_confidentiality (sink detection) --- - # Conservative rule: only tools that *explicitly* declare ``readOnlyHint=True`` - # are treated as pure data sources. Everything else - including tools whose - # server omits the hint entirely - is treated as a potential write / sink - # and capped at PUBLIC confidentiality. This matters because many real - # servers (notably GitHub's MCP) declare ``readOnlyHint=True`` on read - # tools but leave *all* hints as ``None`` on their write tools - # (``push_files``, ``create_or_update_file``, ``create_pull_request``, - # ``create_repository``, ``merge_pull_request``, ...). Without this default, - # those write tools would bypass the exfiltration gate entirely. - max_confidentiality: ConfidentialityLabel | None = None - if read_only is not True: - max_confidentiality = ConfidentialityLabel.PUBLIC - - # --- Determine accepts_untrusted --- - # Read-only tools are pure data sources; they cannot exfiltrate data, - # so they are safe to call even when the agent context is tainted. - accepts_untrusted = read_only is True - - return (integrity, max_confidentiality, accepts_untrusted) + return (integrity, ConfidentialityLabel.PUBLIC, False) @experimental(feature_id=ExperimentalFeature.FIDES) @@ -3710,14 +3663,16 @@ async def apply_mcp_security_labels( ) -> None: """Auto-assign FIDES security labels to every tool loaded from an MCP server. - Reads the MCP ``ToolAnnotations`` hints (``readOnlyHint``, ``openWorldHint``) - that the server advertises for - each tool and translates them into ``source_integrity`` and - ``max_allowed_confidentiality`` entries in each ``FunctionTool``'s + Reads the MCP ``ToolAnnotations`` hints that the server advertises for + each tool and translates them into restriction-only ``source_integrity`` + and ``max_allowed_confidentiality`` entries in each ``FunctionTool``'s ``additional_properties``. The existing :class:`LabelTrackingFunctionMiddleware` picks these up automatically (Tier 2 label propagation), so **no middleware changes are needed**. + Server annotations cannot relax local policy. Use ``annotation_overrides`` + for explicit local per-tool label authority. + Call this **after** the ``MCPTool`` is connected (tools already loaded). Args: @@ -3804,8 +3759,7 @@ async def apply_mcp_security_labels( if mark_write_tools_as_sinks and max_conf is not None: props["max_allowed_confidentiality"] = max_conf.value - # Allow read-only tools to execute even when context is tainted; - # explicitly block write tools in untrusted contexts. + # Server annotations cannot authorize tainted input. props["accepts_untrusted"] = accepts_untrusted logger.info( @@ -3860,17 +3814,11 @@ def _label_from_mcp_meta(meta: Any) -> ContentLabel | None: def _stamp_mcp_content_labels(contents: Any, static_label: ContentLabel) -> Any: """Stamp ``security_label`` on each Content in an MCP tool result. - The per-item label is sourced from ``additional_properties["_meta"]`` - (set by :meth:`MCPTool._parse_tool_result_from_mcp`) when the server - provided a parseable ``ifc`` payload; otherwise ``static_label`` is used. - The sentinel ``_meta`` key is consumed (removed) regardless - so downstream layers don't re-process it. - - By design the server-supplied label always wins over the static label. - Composition-time invariants (e.g. confidentiality ceilings on write - tools) are still enforced by :class:`LabelTrackingFunctionMiddleware` - and :class:`PolicyEnforcementFunctionMiddleware` via the standard - label-combination semantics. + A parseable server label from ``additional_properties["_meta"]`` can only + restrict ``static_label`` through standard FIDES label combination. The + local label is used unchanged when metadata is missing or malformed. The + sentinel ``_meta`` key is consumed regardless so downstream layers do not + re-process it. """ if not isinstance(contents, list): return contents @@ -3881,7 +3829,7 @@ def _stamp_mcp_content_labels(contents: Any, static_label: ContentLabel) -> Any: props = item.additional_properties or {} server_meta = props.pop(_MCP_RESULT_META_KEY, None) dynamic = _label_from_mcp_meta(server_meta) if server_meta else None - label = dynamic or static_label + label = combine_labels(static_label, dynamic) if dynamic is not None else static_label props["security_label"] = label.to_dict() item.additional_properties = props return contents_list @@ -4129,8 +4077,8 @@ async def _apply_labels(self) -> None: # After static labels are stamped on each FunctionTool, install a # per-tool wrapper that consumes any server-provided ``_meta.ifc`` # payload propagated by MCPTool and translates it into per-Content - # ``security_label`` entries. The server-supplied label always wins - # over the static label; the static label is the fallback when the - # server omits ``_meta`` (or it cannot be parsed). + # ``security_label`` entries. Server labels can restrict the static + # label but cannot relax it; the static label is the fallback when + # ``_meta`` is missing or cannot be parsed. for func_tool in getattr(self._mcp_tool, "functions", []): _wrap_mcp_function_for_ifc(func_tool, self._default_integrity) diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 76c12b5415..c604545151 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -86,6 +86,7 @@ async def _call_generated_mcp_tool( result_parser: Any = None, middleware_pipeline: FunctionMiddlewarePipeline | None = None, host_payload_budget: _FunctionResultPayloadBudget | None = None, + mcp_static_label: tuple[str, str] | None = None, **kwargs: Any, ) -> Content: function_kwargs: dict[str, Any] = {} @@ -98,6 +99,16 @@ async def _call_generated_mcp_tool( input_model={"type": "object", "properties": {name: {} for name in kwargs}}, **function_kwargs, ) + if mcp_static_label is not None: + from agent_framework.security import IntegrityLabel, _wrap_mcp_function_for_ifc + + source_integrity, max_confidentiality = mcp_static_label + function.additional_properties = { + "_mcp_remote_name": tool_name, + "source_integrity": source_integrity, + "max_allowed_confidentiality": max_confidentiality, + } + _wrap_mcp_function_for_ifc(function, IntegrityLabel(source_integrity)) return await _auto_invoke_function( Content.from_function_call(call_id=f"call-{tool_name}", name=tool_name, arguments=kwargs), config=normalize_function_invocation_configuration(None), @@ -1111,7 +1122,7 @@ async def test_secure_mcp_auto_hide_preserves_outer_host_payload() -> None: additional_properties={ "_mcp_remote_name": "widget", "source_integrity": "untrusted", - "max_allowed_confidentiality": "public", + "max_allowed_confidentiality": "private", }, ) _wrap_mcp_function_for_ifc(function, IntegrityLabel.UNTRUSTED) @@ -1133,11 +1144,14 @@ async def test_secure_mcp_auto_hide_preserves_outer_host_payload() -> None: assert len(function_result.items) == 1 for hidden_item in function_result.items: assert hidden_item.additional_properties["_variable_reference"] is True + assert hidden_item.additional_properties["security_label"]["integrity"] == "untrusted" + assert hidden_item.additional_properties["security_label"]["confidentiality"] == "private" assert "_meta" not in hidden_item.additional_properties assert hidden_item.text != "untrusted payload" -async def test_secure_mcp_builtin_parser_preserves_server_ifc_authority() -> None: +@pytest.mark.parametrize("result_shape", ["content", "structured", "both"]) +async def test_secure_mcp_builtin_parser_restricts_all_result_shapes(result_shape: str) -> None: from agent_framework.security import ( IntegrityLabel, LabelTrackingFunctionMiddleware, @@ -1145,7 +1159,12 @@ async def test_secure_mcp_builtin_parser_preserves_server_ifc_authority() -> Non ) mcp_result = types.CallToolResult( - content=[types.TextContent(type="text", text="server trusted payload")], + content=[types.TextContent(type="text", text="server trusted payload")] + if result_shape in ("content", "both") + else [], + structuredContent={"payload": "server trusted structured payload"} + if result_shape in ("structured", "both") + else None, _meta={"ifc": {"integrity": "trusted", "confidentiality": "public"}}, ) tool = MCPTool(name="helper") # type: ignore[abstract] @@ -1159,7 +1178,7 @@ async def test_secure_mcp_builtin_parser_preserves_server_ifc_authority() -> Non additional_properties={ "_mcp_remote_name": "widget", "source_integrity": "untrusted", - "max_allowed_confidentiality": "public", + "max_allowed_confidentiality": "private", }, ) _wrap_mcp_function_for_ifc(function, IntegrityLabel.UNTRUSTED) @@ -1173,9 +1192,41 @@ async def test_secure_mcp_builtin_parser_preserves_server_ifc_authority() -> Non ) assert function_result.items is not None - assert [item.text for item in function_result.items] == ["server trusted payload"] - assert function_result.items[0].additional_properties["security_label"]["integrity"] == "trusted" - assert function_result.items[0].additional_properties["_meta"] == mcp_result.meta + assert len(function_result.items) == (2 if result_shape == "both" else 1) + for hidden_item in function_result.items: + assert hidden_item.additional_properties["_variable_reference"] is True + assert hidden_item.additional_properties["security_label"]["integrity"] == "untrusted" + assert hidden_item.additional_properties["security_label"]["confidentiality"] == "private" + assert hidden_item.additional_properties["_meta"] == mcp_result.meta + assert function_result.additional_properties["_meta"] == mcp_result.meta + + +async def test_custom_mcp_parser_cannot_make_meta_authoritative() -> None: + forged_meta = {"ifc": {"integrity": "trusted", "confidentiality": "public"}} + mcp_result = types.CallToolResult( + content=[types.TextContent(type="text", text="server payload")], + _meta={"trace": "server-owned"}, + ) + tool = MCPTool( # type: ignore[abstract] + name="helper", + parse_tool_results=lambda _: [Content.from_text("projection", additional_properties={"_meta": forged_meta})], + ) + tool.session = Mock() + tool.session.call_tool = AsyncMock(return_value=mcp_result) + + function_result = await _call_generated_mcp_tool( + tool, + "widget", + mcp_static_label=("untrusted", "user_identity"), + ) + + assert function_result.items is not None + assert function_result.items[0].additional_properties["security_label"] == { + "integrity": "untrusted", + "confidentiality": "user_identity", + } + assert "_meta" not in function_result.items[0].additional_properties + assert function_result.additional_properties["_meta"] == {"trace": "server-owned"} def test_parse_tool_result_from_mcp_structured_content_none(): @@ -7908,6 +7959,59 @@ async def test_call_tool_as_task_fallback_preserves_custom_parser_host_payload() assert function_result.additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY]["_meta"] == {"source": "fallback"} +@pytest.mark.parametrize("result_path", ["fallback", "completed"], ids=["task-fallback", "completed-task"]) +async def test_secure_mcp_task_results_cannot_relax_local_label(result_path: str) -> None: + from agent_framework.security import LabelTrackingFunctionMiddleware + + tool = _make_task_tool() + result_meta = {"ifc": {"integrity": "trusted", "confidentiality": "public"}} + structured_content = {"widget": result_path} + if result_path == "fallback": + raw_result = types.CallToolResult( + content=[types.TextContent(type="text", text="fallback")], + structuredContent=structured_content, + _meta=result_meta, + ) + tool.session.send_request = AsyncMock( # type: ignore[method-assign, union-attr] # ty: ignore[invalid-assignment] + return_value=types.Result.model_validate(raw_result.model_dump(by_alias=True, exclude_none=True)) + ) + else: + tool.session.send_request = AsyncMock( # type: ignore[method-assign, union-attr] # ty: ignore[invalid-assignment] + side_effect=_send_request_dispatcher( + ("tools/call", _make_create_task_result()), + ("tasks/get", _make_task_snapshot(status="completed")), + ( + "tasks/result", + _make_payload( + "completed", + structured_content=structured_content, + meta=result_meta, + ), + ), + ) + ) + + function_result = await _call_generated_mcp_tool( + tool, + "slow_op", + middleware_pipeline=FunctionMiddlewarePipeline(LabelTrackingFunctionMiddleware(auto_hide_untrusted=True)), + host_payload_budget=_FunctionResultPayloadBudget(), + mcp_static_label=("untrusted", "private"), + ) + + assert function_result.items is not None + assert len(function_result.items) == 2 + for item in function_result.items: + assert item.additional_properties["_variable_reference"] is True + assert item.additional_properties["security_label"]["integrity"] == "untrusted" + assert item.additional_properties["security_label"]["confidentiality"] == "private" + assert item.additional_properties["_meta"] == result_meta + assert function_result.additional_properties["_meta"] == result_meta + assert function_result.additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY]["structuredContent"] == ( + structured_content + ) + + @pytest.mark.parametrize("result_path", ["fallback", "completed"], ids=["task-fallback", "completed-task"]) async def test_task_parser_failure_preserves_complete_host_payload(result_path: str) -> None: tool = _make_task_tool() diff --git a/python/packages/core/tests/test_security.py b/python/packages/core/tests/test_security.py index ffe151c432..ad499c7310 100644 --- a/python/packages/core/tests/test_security.py +++ b/python/packages/core/tests/test_security.py @@ -4574,12 +4574,19 @@ class TestMCPAnnotationMapping: @pytest.mark.parametrize( ("read_only", "open_world", "default_integrity", "expected_integrity", "expected_max_conf", "expected_accepts"), [ - (True, None, IntegrityLabel.UNTRUSTED, IntegrityLabel.UNTRUSTED, None, True), - (True, True, IntegrityLabel.TRUSTED, IntegrityLabel.UNTRUSTED, None, True), - (True, False, IntegrityLabel.UNTRUSTED, IntegrityLabel.TRUSTED, None, True), + ( + True, + None, + IntegrityLabel.UNTRUSTED, + IntegrityLabel.UNTRUSTED, + ConfidentialityLabel.PUBLIC, + False, + ), + (True, True, IntegrityLabel.TRUSTED, IntegrityLabel.UNTRUSTED, ConfidentialityLabel.PUBLIC, False), + (True, False, IntegrityLabel.UNTRUSTED, IntegrityLabel.UNTRUSTED, ConfidentialityLabel.PUBLIC, False), (False, None, IntegrityLabel.UNTRUSTED, IntegrityLabel.UNTRUSTED, ConfidentialityLabel.PUBLIC, False), (False, True, IntegrityLabel.TRUSTED, IntegrityLabel.UNTRUSTED, ConfidentialityLabel.PUBLIC, False), - (False, False, IntegrityLabel.UNTRUSTED, IntegrityLabel.TRUSTED, ConfidentialityLabel.PUBLIC, False), + (False, False, IntegrityLabel.UNTRUSTED, IntegrityLabel.UNTRUSTED, ConfidentialityLabel.PUBLIC, False), (None, None, IntegrityLabel.UNTRUSTED, IntegrityLabel.UNTRUSTED, ConfidentialityLabel.PUBLIC, False), (None, None, IntegrityLabel.TRUSTED, IntegrityLabel.TRUSTED, ConfidentialityLabel.PUBLIC, False), ], @@ -4629,12 +4636,11 @@ class TestMCPIFCMetaLabels: * ``_label_from_mcp_meta`` parsing (well-formed, missing, malformed). * ``MCPTool._parse_tool_result_from_mcp`` propagating ``_meta`` onto every Content via the ``_meta`` key. - * ``_stamp_mcp_content_labels`` enforcing server-wins-over-static with - a static fallback when the server omits/misformats ``_meta.ifc``. + * ``_stamp_mcp_content_labels`` combining server labels with local policy + and falling back when the server omits/misformats ``_meta.ifc``. * ``SecureMCPToolProxy`` wrapping each ``FunctionTool`` so an MCP tool - result carries per-item ``security_label`` derived from the server - when possible, regardless of whether the server is read-only or - a hypothetical write-tool (server label always wins). + result carries a per-item ``security_label`` that remote metadata can + restrict but cannot relax. """ def test_label_from_meta_well_formed(self): @@ -4713,34 +4719,68 @@ def get_mcp_client(self): contents = helper._parse_tool_result_from_mcp(mcp_result) assert "_meta" not in contents[0].additional_properties - def test_stamp_contents_server_wins_over_static(self): + def test_stamp_contents_combines_complete_local_and_remote_label_matrix(self): from agent_framework.security import _stamp_mcp_content_labels - static = ContentLabel(integrity=IntegrityLabel.TRUSTED, confidentiality=ConfidentialityLabel.PUBLIC) - contents = [ - Content.from_text( - "x", - additional_properties={"_meta": {"ifc": {"integrity": "untrusted", "confidentiality": "private"}}}, - ) - ] - _stamp_mcp_content_labels(contents, static) - # Server label wins. - assert contents[0].additional_properties["security_label"] == { - "integrity": "untrusted", - "confidentiality": "private", + confidentiality_rank = { + ConfidentialityLabel.PUBLIC: 0, + ConfidentialityLabel.PRIVATE: 1, + ConfidentialityLabel.USER_IDENTITY: 2, } - # Sentinel is consumed. - assert "_meta" not in contents[0].additional_properties + for local_integrity in IntegrityLabel: + for local_confidentiality in ConfidentialityLabel: + for remote_integrity in IntegrityLabel: + for remote_confidentiality in ConfidentialityLabel: + static = ContentLabel( + integrity=local_integrity, + confidentiality=local_confidentiality, + metadata={"source": "local_mcp_policy"}, + ) + contents = [ + Content.from_text( + "x", + additional_properties={ + "_meta": { + "ifc": { + "integrity": remote_integrity.value, + "confidentiality": remote_confidentiality.value, + "metadata": {"source": "forged_remote_policy"}, + } + } + }, + ) + ] + + _stamp_mcp_content_labels(contents, static) + + expected_integrity = ( + IntegrityLabel.UNTRUSTED + if IntegrityLabel.UNTRUSTED in (local_integrity, remote_integrity) + else IntegrityLabel.TRUSTED + ) + expected_confidentiality = max( + (local_confidentiality, remote_confidentiality), key=confidentiality_rank.__getitem__ + ) + assert contents[0].additional_properties["security_label"] == { + "integrity": expected_integrity.value, + "confidentiality": expected_confidentiality.value, + "metadata": {"source": "local_mcp_policy"}, + } + assert "_meta" not in contents[0].additional_properties - def test_stamp_contents_missing_meta_falls_back_to_static(self): + @pytest.mark.parametrize( + "confidentiality", + [ConfidentialityLabel.PRIVATE, ConfidentialityLabel.USER_IDENTITY], + ) + def test_stamp_contents_missing_meta_falls_back_to_static(self, confidentiality: ConfidentialityLabel): from agent_framework.security import _stamp_mcp_content_labels - static = ContentLabel(integrity=IntegrityLabel.UNTRUSTED, confidentiality=ConfidentialityLabel.PUBLIC) + static = ContentLabel(integrity=IntegrityLabel.UNTRUSTED, confidentiality=confidentiality) contents = [Content.from_text("x")] _stamp_mcp_content_labels(contents, static) assert contents[0].additional_properties["security_label"] == { "integrity": "untrusted", - "confidentiality": "public", + "confidentiality": confidentiality.value, } def test_stamp_contents_malformed_meta_falls_back_to_static(self): @@ -4790,9 +4830,8 @@ def test_stamp_contents_multi_item_all_stamped(self): "confidentiality": "public", } - @pytest.mark.asyncio - async def test_wrap_mcp_function_server_label_wins(self): - """End-to-end: the wrapper installed by SecureMCPToolProxy stamps server label.""" + async def test_wrap_mcp_function_remote_label_can_restrict_local_policy(self): + """End-to-end: remote metadata can make the locally derived label stricter.""" from agent_framework.security import _wrap_mcp_function_for_ifc async def fake_call(**kwargs): @@ -4821,9 +4860,8 @@ async def fake_call(**kwargs): "integrity": "untrusted", "confidentiality": "private", } - # Static fallback would have been trusted+public; server-wins changed it. + # Static policy was trusted+public; remote metadata restricted both dimensions. - @pytest.mark.asyncio async def test_wrap_mcp_function_static_fallback(self): """When the server omits ``_meta``, the static label is used.""" from agent_framework.security import _wrap_mcp_function_for_ifc @@ -4849,20 +4887,15 @@ async def fake_call(**kwargs): "confidentiality": "public", } - @pytest.mark.asyncio - async def test_wrap_mcp_function_write_tool_server_still_wins(self): - """Even for a tool marked as a write sink (max_allowed_confidentiality=public), - if a future MCP server emits ``_meta.ifc`` for a write result, the server - label is applied verbatim on the Content item. Sink invariants are enforced - elsewhere (by LabelTrackingFunctionMiddleware / PolicyEnforcementMiddleware - at composition time, not here).""" + async def test_wrap_mcp_function_remote_label_cannot_relax_local_policy(self): + """Remote MCP metadata cannot raise integrity or lower confidentiality.""" from agent_framework.security import _wrap_mcp_function_for_ifc async def fake_call(**kwargs): return [ Content.from_text( "wrote item", - additional_properties={"_meta": {"ifc": {"integrity": "trusted", "confidentiality": "private"}}}, + additional_properties={"_meta": {"ifc": {"integrity": "trusted", "confidentiality": "public"}}}, ) ] @@ -4872,7 +4905,7 @@ async def fake_call(**kwargs): description="", additional_properties={ "source_integrity": "untrusted", - "max_allowed_confidentiality": "public", # marked as a sink + "max_allowed_confidentiality": "user_identity", "accepts_untrusted": False, "_mcp_remote_name": "create_issue", }, @@ -4880,13 +4913,11 @@ async def fake_call(**kwargs): _wrap_mcp_function_for_ifc(func_tool, IntegrityLabel.UNTRUSTED) assert func_tool.func is not None result = await func_tool.func() - # Server label wins verbatim. assert result[0].additional_properties["security_label"] == { - "integrity": "trusted", - "confidentiality": "private", + "integrity": "untrusted", + "confidentiality": "user_identity", } - @pytest.mark.asyncio async def test_wrap_mcp_function_str_result_passes_through(self): """``str`` results (no per-item containers) are not modified by the wrapper.""" from agent_framework.security import _wrap_mcp_function_for_ifc @@ -4909,7 +4940,6 @@ async def fake_call(**kwargs): result = await func_tool.func() assert result == "plain string result" - @pytest.mark.asyncio async def test_wrap_mcp_function_is_idempotent(self): """Re-running ``_wrap_mcp_function_for_ifc`` (e.g. reconnect) does not double-wrap.""" from agent_framework.security import _wrap_mcp_function_for_ifc