Python: information-flow control prompt injection defense (#5331)

* Python: Information-flow control based prompt injection defense (#5024)

* fides integration

* documentation

* documentation

* documentation

* human-approval on policy violation

* numenous hyena 'works'

* IFC based implementation

* minor edits in documentation

* rebasing the branch and running the email example

* Add security tests for IFC middleware

* Fix Role.TOOL NameError in approval handling

* tiered labelling scheme

* 3 tier labelling scheme in middleware

* Adapt security middleware to list[Content] tool results

* Refactor SecureAgentConfig as context provider and address Copilot review comments

* Update FIDES docs to reflect context provider pattern and update code for ContextProvider rename

* Fix security examples: use OpenAIChatClient instead of non-existent AzureOpenAIChatClient

* Address PR review: consolidate security modules, remove ContentLineage, update docs

* remove unrelated files

* remove comment from _tools.py and rename decision file

* Fix CI failures: Bandit B110, broken md links, hosted approval passthrough

* apply template to decision doc 0024

* minor fixes to decision doc 0024

---------

Co-authored-by: Aashish <t-akolluri@microsoft.com>

* Python: follow up FIDES security flow (#5330)

* Python: follow up FIDES security flow

Refine the secure approval path, mark the security classes with the FIDES experimental feature label, and clean up the related docs/tests. Also fix workspace-level validation regressions uncovered while running the full Python check suite.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Python: remove FIDES GitHub MCP sample

Drop the GitHub MCP security sample from the FIDES follow-up branch while keeping the remaining security docs and samples intact.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review: fix paths and update FIDES implementation (#5352)

* Python: updated import naming and comment from review (#5421)

* updated import naming and comment from review

* Add approval replay None call-id test

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Python: Address PR 5331 comments and track sesssion while calling Agent in email_security_example (#5446)

* Address PR review: fix paths and update FIDES implementation

* Address PR comments and add session tracking in email example in samples

* Fix session creation and resolve merge conflict in docstring example

* Resolve merge conflict in docstring example

* Python: add test for empty-message pruning in approval result replacement (#5617)

Adds test coverage for the second-pass logic in
`_replace_approval_contents_with_results` that removes messages whose
`contents` list becomes empty after first-pass content removal.

Addresses review comment on PR #5331:
https://github.com/microsoft/agent-framework/pull/5331#discussion_r3129039445

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: shrutitople <shruti.tople@gmail.com>
Co-authored-by: Aashish <t-akolluri@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-05-05 20:08:08 +02:00
committed by GitHub
Unverified
parent 806075ae61
commit ddfbdf5c7a
15 changed files with 7978 additions and 31 deletions
@@ -37,6 +37,18 @@ def _group_id(message: Message) -> str | None:
return value if isinstance(value, str) else None
def _build_approved_tool_roundtrip(
*,
call_id: str,
approval_id: str,
tool_name: str,
) -> tuple[Content, Content, Content]:
function_call = Content.from_function_call(call_id=call_id, name=tool_name, arguments="{}")
approval_request = Content.from_function_approval_request(id=approval_id, function_call=function_call)
approval_response = approval_request.to_function_approval_response(approved=True)
return function_call, approval_request, approval_response
async def test_base_client_with_function_calling(chat_client_base: SupportsChatGetResponse):
exec_counter = 0
@@ -2008,6 +2020,162 @@ def test_is_hosted_tool_approval_without_server_label():
assert _is_hosted_tool_approval("not a content") is False
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
call_one, request_one, response_one = _build_approved_tool_roundtrip(
call_id="call_1", approval_id="approval_1", tool_name="first_tool"
)
call_two, request_two, response_two = _build_approved_tool_roundtrip(
call_id="call_2", approval_id="approval_2", tool_name="second_tool"
)
messages = [
Message(role="assistant", contents=[call_one, request_one, call_two, request_two]),
Message(role="user", contents=[response_one, response_two]),
]
_replace_approval_contents_with_results(
messages,
_collect_approval_responses(messages),
[
Content.from_function_result(call_id="call_2", result="second result"),
Content.from_function_result(call_id="call_1", result="first result"),
],
)
assert len(messages) == 2
assert messages[0].contents == [call_one, call_two]
assert messages[1].role == "tool"
assert [(content.call_id, content.result) for content in messages[1].contents] == [
("call_1", "first result"),
("call_2", "second result"),
]
def test_replace_approval_contents_with_results_uses_result_call_ids_for_placeholders() -> None:
from agent_framework._tools import _collect_approval_responses, _replace_approval_contents_with_results
call_one, request_one, response_one = _build_approved_tool_roundtrip(
call_id="call_1", approval_id="approval_1", tool_name="first_tool"
)
call_two, request_two, response_two = _build_approved_tool_roundtrip(
call_id="call_2", approval_id="approval_2", tool_name="second_tool"
)
messages = [
Message(role="assistant", contents=[call_one, request_one, call_two, request_two]),
Message(
role="tool",
contents=[
Content.from_function_result(call_id="call_1", result="[APPROVAL_PENDING] first placeholder"),
Content.from_function_result(call_id="call_2", result="[APPROVAL_PENDING] second placeholder"),
],
),
Message(role="user", contents=[response_one, response_two]),
]
_replace_approval_contents_with_results(
messages,
_collect_approval_responses(messages),
[
Content.from_function_result(call_id="call_2", result="second result"),
Content.from_function_result(call_id="call_1", result="first result"),
],
)
assert len(messages) == 2
assert messages[0].contents == [call_one, call_two]
assert [(content.call_id, content.result) for content in messages[1].contents] == [
("call_1", "first result"),
("call_2", "second result"),
]
def test_replace_approval_contents_with_results_skips_results_without_call_id() -> None:
from agent_framework._tools import _collect_approval_responses, _replace_approval_contents_with_results
call_one, request_one, response_one = _build_approved_tool_roundtrip(
call_id="call_1", approval_id="approval_1", tool_name="first_tool"
)
messages = [
Message(role="assistant", contents=[call_one, request_one]),
Message(
role="tool",
contents=[Content.from_function_result(call_id="call_1", result="[APPROVAL_PENDING] placeholder")],
),
Message(role="user", contents=[response_one]),
]
_replace_approval_contents_with_results(
messages,
_collect_approval_responses(messages),
[
Content.from_function_result(call_id=None, result="ignored result"),
Content.from_function_result(call_id="call_1", result="first result"),
],
)
assert len(messages) == 2
assert messages[0].contents == [call_one]
assert [(content.call_id, content.result) for content in messages[1].contents] == [("call_1", "first result")]
def test_replace_approval_contents_with_results_prunes_emptied_messages() -> None:
"""Messages whose contents are fully consumed during the first pass should be removed.
When approval responses are paired with placeholder results, the responses are marked
for removal in the first pass. If a message contained only such responses, it ends up
with an empty `contents` list and the second pass should drop it from `messages`.
"""
from agent_framework._tools import _collect_approval_responses, _replace_approval_contents_with_results
call_one, request_one, response_one = _build_approved_tool_roundtrip(
call_id="call_1", approval_id="approval_1", tool_name="first_tool"
)
call_two, request_two, response_two = _build_approved_tool_roundtrip(
call_id="call_2", approval_id="approval_2", tool_name="second_tool"
)
messages = [
Message(role="assistant", contents=[call_one, request_one, call_two, request_two]),
Message(
role="tool",
contents=[
Content.from_function_result(call_id="call_1", result="[APPROVAL_PENDING] first placeholder"),
Content.from_function_result(call_id="call_2", result="[APPROVAL_PENDING] second placeholder"),
],
),
# This user message holds only approval_responses whose placeholders are replaced
# in the tool message above, so every content here is marked for removal and the
# message itself becomes empty -> it must be pruned by the second pass.
Message(role="user", contents=[response_one, response_two]),
]
_replace_approval_contents_with_results(
messages,
_collect_approval_responses(messages),
[
Content.from_function_result(call_id="call_1", result="first result"),
Content.from_function_result(call_id="call_2", result="second result"),
],
)
# The now-empty user message should have been pruned, leaving just the assistant
# message and the tool message with the resolved results.
assert len(messages) == 2
assert messages[0].role == "assistant"
assert messages[0].contents == [call_one, call_two]
assert messages[1].role == "tool"
assert [(content.call_id, content.result) for content in messages[1].contents] == [
("call_1", "first result"),
("call_2", "second result"),
]
# Sanity-check: no leftover empty messages.
assert all(msg.contents for msg in messages)
async def test_mixed_local_and_hosted_approval_flow(chat_client_base: SupportsChatGetResponse):
"""Test that mixed local + hosted MCP approvals are handled correctly.
File diff suppressed because it is too large Load Diff