Skip to content

Python: Harden FIDES replay and approval authority - #9876

Draft
Eduard van Valkenburg (eavanvalkenburg) wants to merge 1 commit into
fides-core-policy-foundationfrom
fides-replay-approval-authority
Draft

Eduard van Valkenburg (eavanvalkenburg) wants to merge 1 commit into
fides-core-policy-foundationfrom
fides-replay-approval-authority

Conversation

@eavanvalkenburg

@eavanvalkenburg Eduard van Valkenburg (eavanvalkenburg) commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Motivation & Context

FIDES security state must remain conservative when callers or history providers replay assistant, tool, function-result,
and approval content. Unstamped replay data, modified hidden placeholders, or caller-forged approval controls must not
inherit trust or execution authority from IDs alone.

This is the second layer of the upstream replacement stack for #9652. It builds on the policy, state, and
materialization foundation in #9875 and isolates replay classification, missing-label reconstruction, and approval
authority from the later service-history, AG-UI, and MCP carrier layers.

Description & Review Guide

  • What are the major changes? Replay now classifies unstamped assistant/tool content and data-bearing function
    results conservatively, preserves function-call argument confidentiality only while history is uncovered, and
    authenticates exact session-owned hidden placeholders without integrity taint. FIDES and ordinary approvals bind to
    canonical stored call bodies, exact live controls, and pending session snapshots. Missing labels are reconstructed
    transactionally after local history providers finish loading.
  • What is the impact of these changes? Replayed or forged content fails closed, replacement and occurrence-only
    approvals remain usable, and restored public history does not gain unnecessary confidentiality. There are no public
    API or breaking changes. Validation passed with:
    • cd python && uv run pytest -q packages/core/tests/test_security.py
    • cd python && uv run poe syntax -P core -C
    • cd python && uv run poe pyright -P core (0 errors and 0 warnings)
    • cd python && uv run poe test-typing -P core (mypy, pyrefly, pyright, ty, and zuban)
    • cd python && uv run poe test -P core (9,134 passed, 20 skipped, 2 xfailed)
    • cd python && uv run poe markdown-code-lint
  • What do you want reviewers to focus on? Please review live-object/token authority, canonical call-body
    comparison, duplicate-key and cyclic/deep failure handling, and the local-history reconstruction boundary. This
    layer intentionally excludes service-history ownership and continuation IDs, AG-UI adapters, and MCP result
    carriers.

Related Issue

Fixes #9652

#9652 is the original source draft. This PR is its replay-and-approval-authority replacement layer and is stacked on
the foundation in #9875 rather than competing with the original implementation.

AI Assistance

  • No material AI assistance was used.
  • This is an AI-assisted contribution. I reviewed, understood, and verified all submitted content and accept responsibility for it.

AI assistance details: GitHub Copilot assisted with implementation, regression tests, documentation, validation, and
this PR description.

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR links to an agreed issue with no competing open PR, or the Related Issue section documents a trivial-change or repository-automation exception.
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAF Automated Review — Iteration 1

Result: Findings reported
Scope: full PR (1 commit(s)): 40521719e14d
Model: gpt-5.6-sol

Overview

The change adds conservative replay classification, exact session-owned placeholder authentication, and canonical approval bindings with strong mutation, depth, duplicate-key, and lifecycle guards. Exact control tokens and pending-session snapshots substantially reduce approval replay authority. However, local per-service history can bypass classification entirely, restored-history coverage is represented by an unbound session-wide boolean, and pre-upgrade hidden placeholders are rejected by the new canonical encoding.

Reviewed the supplied pull-request change set across correctness, security/reliability, architecture, and failure behavior.
3 verified findings remained after source verification (2 high, 1 medium) across 2 files. Details are attached to the affected lines below.

Affected areas: python/packages/core/agent_framework/_agents.py, python/packages/core/agent_framework/security.py

for provider in self.context_providers
if callable(prepare := getattr(provider, "_prepare_approval_history", None))
]
if approval_history_consumers and not per_service_call_history_required:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When per-service-call persistence loads local history, this skips the only post-provider reconstruction hook. The FIDES chat middleware runs before PerServiceCallHistoryPersistingMiddleware and marks itself processed before that loader replaces context.messages, so unstamped assistant/tool history reaches the model while the scope remains trusted and protected tool calls can execute. Ensure local history is classified after the per-service loader and before downstream model/tool execution.

"""
restored_context_label = _STATE_CONTEXT_LABEL in state
reconstruct_history_unstamped = (
not restored_context_label or state.get(_STATE_HISTORY_RECONSTRUCTION_COMPLETE) is not True

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This session-wide boolean treats every future HistoryProvider message as already covered. If provider history is appended or replaced, or the provider set changes after the marker is written, new unstamped assistant/tool content gets the permissive fallback and can invoke protected tools under a trusted label. Bind completion to an authenticated source/message snapshot and conservatively classify every item not covered by it.

except ValueError:
return None

visible_label = _visible_variable_reference_label(stored_label)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Persisted placeholders from earlier releases were encoded with the full stored label, including metadata, but this authenticator only constructs the new stripped representation. An exact legacy placeholder therefore fails authentication and its embedded untrusted label permanently taints the restored session, blocking protected tools after upgrade. Recognize or migrate the exact legacy encoding bound to the same session-owned variable while normalizing metadata before model exposure.

import pytest
from pydantic import AfterValidator, BaseModel, field_validator

import agent_framework.security as security_module
Copilot AI balanced review requested due to automatic review settings October 30, 2026 13:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

4 open issues
What changed in this PR

Hardens FIDES handling of replayed/history-provided content and approval controls so that callers cannot regain trust/authority by replaying or forging IDs, and missing label state is reconstructed conservatively.

Changes:

  • Add conservative replay label classification (including depth/cycle/duplicate-key defenses) and authenticated hidden-placeholder verification.
  • Bind approval authority to canonical stored call bodies + exact live controls, and reconstruct history labels after history providers load.
  • Extend agent session preparation to run provider-specific approval-history preparation; expand regression coverage and documentation.
File Description
python/​packages/​core/​agent_framework/​security.py Implements replay classification, hidden-placeholder authentication, and approval replay authority binding.
python/​packages/​core/​agent_framework/​_agents.py Invokes provider _prepare_approval_history hook after providers run to finalize reconstruction/binding.
python/​packages/​core/​tests/​test_security.py Adds extensive regression tests for replay classification, placeholder integrity, and approval binding.
python/​samples/​02-agents/​security/​FIDES_DEVELOPER_GUIDE.md Documents conservative replay behavior and approval authority rules.
docs/​features/​FIDES_IMPLEMENTATION_SUMMARY.md Updates FIDES summary to include replay/history-provider behavior and trust boundary notes.

Review effort: Lite

Comment on lines +133 to +143
def _approval_control_authority_token(content: Content) -> Any | None:
"""Return a bounded authority token for one approval control."""
if content.type not in {"function_approval_request", "function_approval_response"} or content.id is None:
return None
try:
return _argument_authority_token(
content.to_dict(),
boundary="core approval replay",
)
except (RecursionError, TypeError, ValueError, MiddlewareFailure):
return None
Comment on lines +152 to +179
self._bindings: dict[int, tuple[Content, Any, Content]] = {}
self._snapshots: dict[Any, Content] = {}

def remember(self, content: Content, function_call: Content) -> None:
"""Record one session-authenticated control and its authoritative call."""
token = _approval_control_authority_token(content)
if token is None:
return
self._bindings[id(content)] = (content, token, function_call)
self._snapshots[token] = function_call

def reauthenticate(self, messages: Sequence[Message]) -> None:
"""Bind freshly deserialized controls only when they match an authenticated snapshot."""
for message in messages:
for content in message.contents:
token = _approval_control_authority_token(content)
if token is None:
continue
function_call = self._snapshots.get(token)
if function_call is not None:
self._bindings[id(content)] = (content, token, function_call)

def function_call_for(self, content: Content) -> Content | None:
"""Return the authoritative call only for this exact live control object."""
binding = self._bindings.get(id(content))
if binding is None or binding[0] is not content or binding[1] != _approval_control_authority_token(content):
return None
return binding[2]
Comment on lines +1267 to +1278
for occurrence_id, payload in scope.pending_approvals.items():
pending = _PendingPolicyApproval.from_state(payload)
if (
pending is not None
and occurrence_id == function_call.id
and pending.request_id == content.id
and pending.body_signature == body_signature
):
return True
return False


Comment on lines +1852 to +1861
for provider, prepare in approval_history_consumers:
prepared = prepare(
agent=self,
session=provider_session,
context=session_context,
state=provider_session.state.setdefault(provider.source_id, {}),
)
if isawaitable(prepared):
_ = await prepared

This branch was previously deployed

1 inactive deployment
github-app-auth — 85a6ad1a Deployed Oct 30, 2026 by eavanvalkenburg via add_label #26679
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Usage: [Issues, PRs], Target: documentation in the code base and learn docs python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants