.NET: [BREAKING] Bind tool-approval responses to surfaced approval requests#7111
Conversation
Harden the tool-approval flow so an approved tool call always matches the request the framework surfaced for approval. Add ApprovalResponseBindingChatClient as the outermost decorator above FunctionInvokingChatClient. It records each model-originated ToolApprovalRequestContent in the session state and, on the next request, binds every ToolApprovalResponseContent to its recorded request: the response tool call is rebound to the recorded call, matched entries are consumed for one-time use, and only approvals tied to a framework-issued request take effect. Apply the same binding in the ToolApprovalAgent harness by tracking the requests it surfaces and binding collected responses to them during a queue cycle. Add ChatClientAgentOptions.DisableApprovalResponseBinding (default off) and a UseApprovalResponseBinding builder extension for custom chat client stacks. Includes unit tests for the decorator and the harness.
There was a problem hiding this comment.
Automated Code Review
Reviewers: 5 | Confidence: 87%
✓ Correctness
The PR correctly implements approval-response binding at two levels: (1) AprovalResponseBindingChatClient as a DelegatingChatClient decorator that records model-originated requests and validates/rebinds inbound responses against them, and (2) ToolAprovalAgent harness binding during queue cycles. The logic for recording, matching, rebinding, consuming entries, and dropping forged/unbound content is sound. State management via AgentSessionStateBag and ToolApprovalState persists correctly across turns. The streaming and non-streaming paths are consistent. No correctness, security, or race condition issues found.
✓ Security Reliability
This PR adds a well-designed security hardening layer that binds inbound tool-approval responses to model-originated approval requests the framework surfaced, preventing forged or substituted tool calls from being honored. The implementation is sound: it uses AsyncLocal-based session scoping for thread safety, consumes matched entries to prevent replay, and rebinds tool calls to the framework-recorded originals. One minor defense-in-depth gap exists where duplicate responses with the same RequestId in a single inbound batch could all pass through (though rebound to the same legitimate tool call).
✓ Test Coverage
The PR adds comprehensive unit tests for the non-streaming path of AprovalResponseBindingChatClient (7 tests covering passthrough, recording, forged responses, rebinding, rejection, consumption, and no-session scenarios) and two security-focused ToolApprovalAgent tests. However, the streaming path (GetStreamingResponseAsync) of the ApprovalResponseBindingChatClient has a completely distinct recording mechanism (accumulating requests across async iterations in a try/finally block) that is entirely untested. There is also no test verifying the DisableApprovalResponseBinding option actually prevents the decorator from being registered.
✓ Failure Modes
The PR adds a well-structured two-layer approval response binding mechanism: AprovalResponseBindingChatClient as a pipeline decorator (handling the general case and single-request flows via the inner agent's session state) and ToolApprovalAgent harness binding (handling the multi-request queue cycle). The layers use independent state stores (session StateBag vs ToolApprovalState.SurfacedApprovalRequests) and don't interfere. Pending entries are consumed atomically after binding, preventing replay. The no-session passthrough is intentional (tested) and logged. Exception paths between consume-and-execute are acceptable: if FICC throws after an approval is consumed, a retry naturally re-surfaces the request for fresh approval. No silent failures, lost errors, or exploitable races identified.
✓ Design Approach
The new binding layer closes the forged/substituted-response hole, but both implementations still honor the same approval request more than once when a caller duplicates the same
RequestIdin a single inbound turn. That leaves a replay path open inside the very code path that is supposed to enforce one-time use of surfaced approvals.
Automated review by rogerbarreto's agents
There was a problem hiding this comment.
Pull request overview
This PR hardens the .NET tool-approval flow by binding approval responses to the specific approval requests that the framework surfaced, preventing forged or substituted tool calls from being honored downstream (notably by FunctionInvokingChatClient).
Changes:
- Adds
ApprovalResponseBindingChatClientand wires it into the defaultChatClientAgentpipeline as the outermost decorator (with opt-out viaChatClientAgentOptions.DisableApprovalResponseBinding). - Adds a
UseApprovalResponseBindingbuilder extension for custom chat client stacks. - Extends the
ToolApprovalAgentharness and adds new unit tests covering forged/substituted approval response scenarios.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalResponseBindingChatClient.cs | New decorator that records surfaced approval requests and binds inbound approval responses to them. |
| dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs | Registers the binding decorator as the outermost default middleware (unless disabled). |
| dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs | Adds UseApprovalResponseBinding for custom pipelines. |
| dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs | Adds DisableApprovalResponseBinding option and includes it in Clone(). |
| dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalState.cs | Persists surfaced approval requests for binding in the harness. |
| dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs | Tracks surfaced requests and attempts to bind collected responses to surfaced requests during approval cycles. |
| dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ApprovalResponseBindingChatClientTests.cs | New unit tests for request recording, binding/rebinding, replay prevention, and no-session behavior. |
| dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs | Updates expectations for the default outermost decorator type. |
| dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentTests.cs | Adds harness security tests for forged/substituted approvals during queue cycles. |
Comments suppressed due to low confidence (1)
dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs:266
- In the streaming path, surfaced approval requests are only recorded when
unapproved.Count > 1. With approval-response binding now keyed offSurfacedApprovalRequests, a single unapproved request can be surfaced without ever being recorded, leaving subsequent responses unbindable (and allowing forged/substituted approvals to pass through unchanged). Record the surfaced request(s) wheneverunapproved.Count > 0, and keep the queueing of excess requests conditional.
// 5. Queue excess unapproved requests and yield only the first to the caller.
if (unapproved.Count > 1)
{
// Record every unapproved request as surfaced so the caller's responses can be bound to a
// model-originated request during the queue cycle.
RecordSurfacedApprovalRequests(state, unapproved);
state.QueuedApprovalRequests.AddRange(unapproved.GetRange(1, unapproved.Count - 1));
}
this._sessionState.SaveState(session, state);
yield return new AgentResponseUpdate(ChatRole.Assistant, [unapproved[0]]);
Address review feedback on the approval-response binding decorator: consume a matched request from the per-turn lookup so a duplicate response with the same request id in one turn is honored only once, and return the materialized message list instead of the original enumerable so a single-use sequence is not enumerated twice. Rename the local pending list to pendingRequests for clarity. Adds a duplicate-response regression test.
…he harness Address review feedback on ToolApprovalAgent: store a snapshot of each surfaced/pending approval request (cloned tool call with a copied arguments dictionary) so a later mutation of the caller-visible instance cannot change the recorded call used to bind the response, and consume a surfaced request on match so a duplicate response with the same request id in one pass is honored only once. Apply both symmetrically in the harness and the ApprovalResponseBindingChatClient decorator. Adds regression tests for the snapshot and duplicate-response cases.
- Harness: store surfaced approval requests in a dictionary and consume matches directly, drop the extra hashset and the redundant record-time dedup; replace clear-on-resolution with a debug assert. - Harness pipeline: add UseApprovalResponseBinding() as the outermost decorator in HarnessAgent (it uses UseProvidedChatClientAsIs) behind a new DisableApprovalResponseBinding option, with tests. - Decorator: avoid message/content allocations when nothing changes, keep the original content when a response already matches the recorded call, clear pending each inbound turn, and shorten helpers.
…ool-approval-response-binding # Conflicts: # dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs
Motivation and Context
Tool approvals in the .NET stack are honored from the ToolApprovalResponseContent carried in the message stream. This change hardens that flow so an approved tool call always corresponds to the approval request the framework actually surfaced, keeping the human-in-the-loop control aligned with what a user was asked to approve.
Description
Adds
ApprovalResponseBindingChatClient, registered as the outermost decorator aboveFunctionInvokingChatClientin the defaultChatClientAgentpipeline:ToolApprovalRequestContentinto the sessionAgentSessionStateBag, keyed by request id.ToolApprovalResponseContentto its recorded request, rebinds the response tool call to the recorded call, consumes matched entries for one-time use, and honors only approvals tied to a framework-issued request.Applies the same binding in the
ToolApprovalAgentharness by tracking surfaced requests and binding collected responses to them during a queue cycle.Adds
ChatClientAgentOptions.DisableApprovalResponseBinding(default off) and aUseApprovalResponseBindingbuilder extension for custom chat client stacks. The Foundry hosting path already reconstructs the call from the server-side approval map and is unaffected.Contribution Checklist
dotnet formatclean on changed filesBreaking change
Approval-response binding introduces new session state for surfaced/recorded approval requests. A session serialized by an older build while an approval was still outstanding deserializes with this state empty, so its approval responses no longer bind to a request and are ignored on resume. Re-issue the request to obtain a fresh approval.