From 05c1762fdfd51f3e4a1a59ccf0ed142a43d72932 Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Mon, 22 Jun 2026 13:35:34 -0700 Subject: [PATCH 1/2] .NET: Enforce ApprovalRequiredAIFunction in GitHub Copilot provider The GitHub Copilot SDK owns the tool-calling loop and invokes registered custom functions directly, so the standard FunctionInvokingChatClient approval round-trip never runs for this provider. As a result a tool wrapped in ApprovalRequiredAIFunction (only a marker) could execute without any Agent Framework approval. Add an agent-level onFunctionApproval callback and wrap approval-required tools in an ApprovalGatedAIFunction that enforces approval before invoking the underlying function. Secure-by-default: with no callback, or when the callback denies or throws, execution is denied. The gate forwards tool metadata (including the Copilot skip_permission flag) so it stays transparent to the SDK. This mirrors the Python provider's behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GitHubCopilotAgent.cs | 138 ++++++++++++++- .../Microsoft.Agents.AI.GitHub.Copilot.csproj | 2 +- .../GitHubCopilotAgentTests.cs | 58 +++++++ ....AI.GitHub.Copilot.IntegrationTests.csproj | 2 +- .../GitHubCopilotAgentTests.cs | 163 ++++++++++++++++++ ....Agents.AI.GitHub.Copilot.UnitTests.csproj | 2 +- 6 files changed, 358 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs index 006e191b3eb..e82a2361406 100644 --- a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs @@ -42,6 +42,12 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable /// The name of the agent. /// The description of the agent. /// Optional JSON serializer options. Defaults to . + /// + /// Optional callback that approves or denies execution of custom function tools wrapped in + /// ; it must return to allow the call. + /// When not configured, or when it denies or throws, such tools are denied (secure-by-default). This is the + /// agent-level approval gate for this provider, independent of SessionConfig.OnPermissionRequest. + /// public GitHubCopilotAgent( CopilotClient copilotClient, SessionConfig? sessionConfig = null, @@ -49,12 +55,13 @@ public GitHubCopilotAgent( string? id = null, string? name = null, string? description = null, - JsonSerializerOptions? jsonSerializerOptions = null) + JsonSerializerOptions? jsonSerializerOptions = null, + Func>? onFunctionApproval = null) { _ = Throw.IfNull(copilotClient); this._copilotClient = copilotClient; - this._sessionConfig = sessionConfig; + this._sessionConfig = WrapApprovalRequiredTools(sessionConfig, onFunctionApproval); this._ownsClient = ownsClient; this._id = id; this._name = name ?? DefaultName; @@ -73,6 +80,11 @@ public GitHubCopilotAgent( /// The tools to make available to the agent. /// Optional instructions to append as a system message. /// Optional JSON serializer options. Defaults to . + /// + /// Optional callback that approves or denies execution of custom function tools wrapped in + /// ; it must return to allow the call. + /// When not configured, or when it denies or throws, such tools are denied (secure-by-default). + /// public GitHubCopilotAgent( CopilotClient copilotClient, bool ownsClient = false, @@ -81,7 +93,8 @@ public GitHubCopilotAgent( string? description = null, IList? tools = null, string? instructions = null, - JsonSerializerOptions? jsonSerializerOptions = null) + JsonSerializerOptions? jsonSerializerOptions = null, + Func>? onFunctionApproval = null) : this( copilotClient, GetSessionConfig(tools, instructions), @@ -89,7 +102,8 @@ public GitHubCopilotAgent( id, name, description, - jsonSerializerOptions) + jsonSerializerOptions, + onFunctionApproval) { } @@ -519,6 +533,49 @@ private AgentResponseUpdate ConvertToAgentResponseUpdate(SessionEvent sessionEve return new SessionConfig { Tools = mappedTools, SystemMessage = systemMessage }; } + /// + /// Returns a in which every tool wrapped in + /// is replaced with an , so the GitHub Copilot SDK tool loop cannot invoke + /// an approval-required function without first passing the Agent Framework approval callback. + /// + /// + /// The source is returned unchanged when it contains no approval-required tools. + /// Otherwise a clone is returned so the caller-supplied configuration is not mutated. + /// + private static SessionConfig? WrapApprovalRequiredTools( + SessionConfig? sessionConfig, + Func>? onFunctionApproval) + { + if (sessionConfig?.Tools is not { Count: > 0 } tools) + { + return sessionConfig; + } + + List mappedTools = new(tools.Count); + bool wrappedAny = false; + foreach (AIFunctionDeclaration tool in tools) + { + if (tool is AIFunction function && function.GetService() is not null) + { + mappedTools.Add(new ApprovalGatedAIFunction(function, onFunctionApproval)); + wrappedAny = true; + } + else + { + mappedTools.Add(tool); + } + } + + if (!wrappedAny) + { + return sessionConfig; + } + + SessionConfig wrapped = sessionConfig.Clone(); + wrapped.Tools = mappedTools; + return wrapped; + } + private static async Task<(List? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync( IEnumerable messages, CancellationToken cancellationToken) @@ -563,4 +620,77 @@ private static void CleanupTempDir(string? tempDir) } } } + + /// + /// An decorator that enforces an Agent Framework function-approval check + /// before invoking a tool wrapped in . + /// + /// + /// + /// The GitHub Copilot SDK owns the model tool-calling loop and invokes registered custom functions + /// directly, so the standard FunctionInvokingChatClient approval round-trip (emitting a + /// ToolApprovalRequestContent and waiting for a ToolApprovalResponseContent) never runs + /// for this provider. As a result, an — which is only a marker + /// and does not enforce approval on its own — would otherwise execute without any Agent Framework approval. + /// + /// + /// This decorator restores the approval boundary: the underlying function is only invoked after the + /// configured approval callback explicitly approves the call. When no callback is configured, or the + /// callback denies or throws, execution is denied (secure-by-default). The decorator forwards all tool + /// metadata (name, description, JSON schema, and additional properties such as the Copilot + /// skip_permission flag) from the inner function so the tool remains transparent to the SDK. + /// + /// + private sealed class ApprovalGatedAIFunction : DelegatingAIFunction + { + private readonly Func>? _onFunctionApproval; + + public ApprovalGatedAIFunction( + AIFunction innerFunction, + Func>? onFunctionApproval) + : base(innerFunction) + { + this._onFunctionApproval = onFunctionApproval; + } + + protected override async ValueTask InvokeCoreAsync( + AIFunctionArguments arguments, + CancellationToken cancellationToken) + { + if (!await this.IsApprovedAsync(arguments, cancellationToken).ConfigureAwait(false)) + { + return this._onFunctionApproval is null + ? $"Tool '{this.Name}' requires human approval but no approval callback is configured on the agent; the request was denied." + : $"Tool '{this.Name}' requires human approval and the request was denied."; + } + + return await base.InvokeCoreAsync(arguments, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask IsApprovedAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + // Secure-by-default: with no callback configured an approval-required tool can never execute. + if (this._onFunctionApproval is null) + { + return false; + } + + FunctionCallContent request = new( + callId: $"github-copilot-approval::{this.Name}", + name: this.Name, + arguments: arguments); + + try + { + return await this._onFunctionApproval(request, cancellationToken).ConfigureAwait(false); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception) +#pragma warning restore CA1031 + { + // Secure-by-default: any failure in the approval callback denies execution. + return false; + } + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj index dad0b31f59d..4feccdc691a 100644 --- a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj +++ b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj @@ -4,7 +4,7 @@ true $(TargetFrameworksCore) - $(NoWarn);GHCP001 + $(NoWarn);GHCP001;MEAI001 diff --git a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/GitHubCopilotAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/GitHubCopilotAgentTests.cs index cfdc8198725..5e4a4fc0ca6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/GitHubCopilotAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/GitHubCopilotAgentTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using GitHub.Copilot; using GitHub.Copilot.Rpc; @@ -131,6 +132,63 @@ public async Task RunAsync_WithFunctionTool_InvokesToolAsync() } } + [Fact] + public async Task RunAsync_WithApprovalRequiredTool_ExecutesWhenCallbackApprovesAsync() + { + // Arrange + SkipIfCopilotNotConfigured(); + + bool toolInvoked = false; + bool approvalRequested = false; + + AIFunction weatherTool = AIFunctionFactory.Create((string location) => + { + toolInvoked = true; + return $"The weather in {location} is sunny with a high of 25C."; + }, "GetWeather", "Get the weather for a given location."); + ApprovalRequiredAIFunction approvalRequiredTool = new(weatherTool); + + ValueTask approveAsync(FunctionCallContent request, CancellationToken cancellationToken) + { + approvalRequested = true; + return new(true); + } + + await using CopilotClient client = new(new CopilotClientOptions()); + await client.StartAsync(); + + SessionConfig sessionConfig = new() + { + Tools = [approvalRequiredTool], + OnPermissionRequest = OnPermissionRequestAsync, + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Append, + Content = "You are a weather assistant. Always use the GetWeather tool to answer weather questions.", + }, + }; + + await using GitHubCopilotAgent agent = new(client, sessionConfig, onFunctionApproval: approveAsync); + AgentSession session = await agent.CreateSessionAsync(); + + try + { + // Act + AgentResponse response = await agent.RunAsync("What's the weather like in Seattle?", session); + + // Assert - the SDK tool loop must dispatch through the Agent Framework approval gate, + // and the underlying function only runs because the callback approved the call. + Assert.NotNull(response); + Assert.NotEmpty(response.Messages); + Assert.True(approvalRequested); + Assert.True(toolInvoked); + } + finally + { + await DeleteSessionAsync(client, session); + } + } + [Fact] public async Task RunAsync_WithSession_MaintainsContextAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests.csproj index 3fd692527bb..22f15d517cc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests.csproj @@ -3,7 +3,7 @@ $(TargetFrameworksCore) - $(NoWarn);GHCP001 + $(NoWarn);GHCP001;MEAI001 diff --git a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs index 8faa842eb04..ae85d8d3c23 100644 --- a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using GitHub.Copilot; using GitHub.Copilot.Rpc; @@ -382,4 +383,166 @@ public void ConvertToAgentResponseUpdate_AssistantMessageEventWhenNotStreaming_H Assert.Null(result.MessageId); Assert.Null(result.ResponseId); } + + [Fact] + public async Task Constructor_WithApprovalRequiredTool_GatesExecutionAndDeniesWithoutCallbackAsync() + { + // Arrange + bool invoked = false; + AIFunction dangerousTool = AIFunctionFactory.Create( + () => { invoked = true; return "sensitive operation completed"; }, + "ApprovalRequiredOperation", + "Performs an approval-required operation."); + ApprovalRequiredAIFunction approvalRequiredTool = new(dangerousTool); + + CopilotClient copilotClient = new(new CopilotClientOptions()); + var agent = new GitHubCopilotAgent(copilotClient, tools: [approvalRequiredTool]); + + // Act + AIFunction exposedTool = GetExposedFunction(agent); + object? result = await exposedTool.InvokeAsync(new AIFunctionArguments()); + + // Assert - the provider must NOT expose the same directly-invokable approval-required object, + // and invoking the gate without an approval callback must deny execution. + Assert.NotSame(approvalRequiredTool, exposedTool); + Assert.False(invoked); + Assert.Contains("requires human approval", result?.ToString()); + } + + [Fact] + public async Task Constructor_WithApprovalRequiredTool_ExecutesWhenCallbackApprovesAsync() + { + // Arrange + bool invoked = false; + AIFunction dangerousTool = AIFunctionFactory.Create( + () => { invoked = true; return "sensitive operation completed"; }, + "ApprovalRequiredOperation", + "Performs an approval-required operation."); + ApprovalRequiredAIFunction approvalRequiredTool = new(dangerousTool); + + FunctionCallContent? observedRequest = null; + ValueTask approveAsync(FunctionCallContent request, CancellationToken _) { observedRequest = request; return new(true); } + + CopilotClient copilotClient = new(new CopilotClientOptions()); + var agent = new GitHubCopilotAgent(copilotClient, tools: [approvalRequiredTool], onFunctionApproval: approveAsync); + + // Act + AIFunction exposedTool = GetExposedFunction(agent); + object? result = await exposedTool.InvokeAsync(new AIFunctionArguments()); + + // Assert + Assert.True(invoked); + Assert.NotNull(observedRequest); + Assert.Equal("ApprovalRequiredOperation", observedRequest!.Name); + Assert.Contains("sensitive operation completed", result?.ToString()); + } + + [Fact] + public async Task Constructor_WithApprovalRequiredTool_DeniesWhenCallbackRejectsAsync() + { + // Arrange + bool invoked = false; + AIFunction dangerousTool = AIFunctionFactory.Create( + () => { invoked = true; return "sensitive operation completed"; }, + "ApprovalRequiredOperation", + "Performs an approval-required operation."); + ApprovalRequiredAIFunction approvalRequiredTool = new(dangerousTool); + + ValueTask denyAsync(FunctionCallContent request, CancellationToken cancellationToken) => new(false); + + CopilotClient copilotClient = new(new CopilotClientOptions()); + var agent = new GitHubCopilotAgent(copilotClient, tools: [approvalRequiredTool], onFunctionApproval: denyAsync); + + // Act + AIFunction exposedTool = GetExposedFunction(agent); + object? result = await exposedTool.InvokeAsync(new AIFunctionArguments()); + + // Assert + Assert.False(invoked); + Assert.Contains("the request was denied", result?.ToString()); + } + + [Fact] + public async Task Constructor_WithApprovalRequiredTool_DeniesWhenCallbackThrowsAsync() + { + // Arrange + bool invoked = false; + AIFunction dangerousTool = AIFunctionFactory.Create( + () => { invoked = true; return "sensitive operation completed"; }, + "ApprovalRequiredOperation", + "Performs an approval-required operation."); + ApprovalRequiredAIFunction approvalRequiredTool = new(dangerousTool); + + ValueTask throwingAsync(FunctionCallContent request, CancellationToken cancellationToken) => throw new InvalidOperationException("callback failure"); + + CopilotClient copilotClient = new(new CopilotClientOptions()); + var agent = new GitHubCopilotAgent(copilotClient, tools: [approvalRequiredTool], onFunctionApproval: throwingAsync); + + // Act + AIFunction exposedTool = GetExposedFunction(agent); + object? result = await exposedTool.InvokeAsync(new AIFunctionArguments()); + + // Assert - secure-by-default: a throwing callback denies execution rather than propagating. + Assert.False(invoked); + Assert.Contains("the request was denied", result?.ToString()); + } + + [Fact] + public void Constructor_WithApprovalRequiredTool_PreservesSkipPermissionMetadata() + { + // Arrange + AIFunction dangerousTool = CopilotTool.DefineTool( + () => "ok", + toolOptions: new CopilotToolOptions { SkipPermission = true }, + factoryOptions: new AIFunctionFactoryOptions + { + Name = "WriteSensitiveMarker", + Description = "Writes a sensitive marker file." + }); + ApprovalRequiredAIFunction approvalRequiredTool = new(dangerousTool); + + CopilotClient copilotClient = new(new CopilotClientOptions()); + var agent = new GitHubCopilotAgent(copilotClient, tools: [approvalRequiredTool]); + + // Act + AIFunction exposedTool = GetExposedFunction(agent); + + // Assert - the gate must remain transparent to the Copilot SDK, preserving the skip_permission flag. + Assert.NotSame(approvalRequiredTool, exposedTool); + Assert.Equal("WriteSensitiveMarker", exposedTool.Name); + Assert.NotNull(exposedTool.AdditionalProperties); + Assert.True(exposedTool.AdditionalProperties.TryGetValue("skip_permission", out object? skip)); + Assert.Equal(true, skip); + } + + [Fact] + public void Constructor_WithNonApprovalTool_LeavesToolUnchanged() + { + // Arrange + AIFunction plainTool = AIFunctionFactory.Create(() => "test", "TestFunc", "Test function"); + + CopilotClient copilotClient = new(new CopilotClientOptions()); + var agent = new GitHubCopilotAgent(copilotClient, tools: [plainTool]); + + // Act + AIFunction exposedTool = GetExposedFunction(agent); + + // Assert - tools that do not require approval pass through untouched. + Assert.Same(plainTool, exposedTool); + } + + private static AIFunction GetExposedFunction(GitHubCopilotAgent agent) + { + SessionConfig sessionConfig = GetSessionConfigFromAgent(agent); + AIFunctionDeclaration declaration = Assert.Single(sessionConfig.Tools!); + return declaration.GetService()!; + } + + private static SessionConfig GetSessionConfigFromAgent(GitHubCopilotAgent agent) + { + System.Reflection.FieldInfo field = typeof(GitHubCopilotAgent).GetField( + "_sessionConfig", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + return (SessionConfig)field.GetValue(agent)!; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj index 185130f9f39..d1bd009f80d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj @@ -3,7 +3,7 @@ $(TargetFrameworksCore) - $(NoWarn);GHCP001 + $(NoWarn);GHCP001;MEAI001 From de9d68f55229e8c8e4b09755687208f238a955ee Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Mon, 22 Jun 2026 13:52:19 -0700 Subject: [PATCH 2/2] .NET: Propagate cancellation from GitHub Copilot approval callback Let OperationCanceledException propagate from the approval callback instead of swallowing it into a denial, so cooperative cancellation is honored. Other callback failures still deny by default. Added a unit test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GitHubCopilotAgent.cs | 7 +++++- .../GitHubCopilotAgentTests.cs | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs index e82a2361406..53834bad754 100644 --- a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs @@ -684,11 +684,16 @@ private async ValueTask IsApprovedAsync(AIFunctionArguments arguments, Can { return await this._onFunctionApproval(request, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) + { + // Honor cooperative cancellation rather than treating it as a denial. + throw; + } #pragma warning disable CA1031 // Do not catch general exception types catch (Exception) #pragma warning restore CA1031 { - // Secure-by-default: any failure in the approval callback denies execution. + // Secure-by-default: any other failure in the approval callback denies execution. return false; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs index ae85d8d3c23..f8a73b86427 100644 --- a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs @@ -487,6 +487,30 @@ public async Task Constructor_WithApprovalRequiredTool_DeniesWhenCallbackThrowsA Assert.Contains("the request was denied", result?.ToString()); } + [Fact] + public async Task Constructor_WithApprovalRequiredTool_PropagatesCancellationAsync() + { + // Arrange + bool invoked = false; + AIFunction dangerousTool = AIFunctionFactory.Create( + () => { invoked = true; return "sensitive operation completed"; }, + "ApprovalRequiredOperation", + "Performs an approval-required operation."); + ApprovalRequiredAIFunction approvalRequiredTool = new(dangerousTool); + + ValueTask cancelingAsync(FunctionCallContent request, CancellationToken cancellationToken) + => throw new OperationCanceledException(cancellationToken); + + CopilotClient copilotClient = new(new CopilotClientOptions()); + var agent = new GitHubCopilotAgent(copilotClient, tools: [approvalRequiredTool], onFunctionApproval: cancelingAsync); + + // Act & Assert - cancellation must propagate rather than being swallowed into a denial. + AIFunction exposedTool = GetExposedFunction(agent); + await Assert.ThrowsAsync( + async () => await exposedTool.InvokeAsync(new AIFunctionArguments())); + Assert.False(invoked); + } + [Fact] public void Constructor_WithApprovalRequiredTool_PreservesSkipPermissionMetadata() {