Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 139 additions & 4 deletions dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,26 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
/// <param name="name">The name of the agent.</param>
/// <param name="description">The description of the agent.</param>
/// <param name="jsonSerializerOptions">Optional JSON serializer options. Defaults to <see cref="GitHubCopilotJsonUtilities.DefaultOptions"/>.</param>
/// <param name="onFunctionApproval">
/// Optional callback that approves or denies execution of custom function tools wrapped in
/// <see cref="ApprovalRequiredAIFunction"/>; it must return <see langword="true"/> 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 <c>SessionConfig.OnPermissionRequest</c>.
/// </param>
public GitHubCopilotAgent(
CopilotClient copilotClient,
SessionConfig? sessionConfig = null,
bool ownsClient = false,
string? id = null,
string? name = null,
string? description = null,
JsonSerializerOptions? jsonSerializerOptions = null)
JsonSerializerOptions? jsonSerializerOptions = null,
Func<FunctionCallContent, CancellationToken, ValueTask<bool>>? onFunctionApproval = null)

@westey-m westey-m Jun 23, 2026

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.

The SDK supports the OnPreToolUse hook, which seems to already provide the same functionality:
https://docs.github.com/en/copilot/how-tos/copilot-sdk/hooks/pre-tool-use

There is also the OnPermissionRequest hook. See: https://docs.github.com/en/copilot/how-tos/copilot-sdk/features/hooks

Do we really need our own?

{
_ = Throw.IfNull(copilotClient);

this._copilotClient = copilotClient;
this._sessionConfig = sessionConfig;
this._sessionConfig = WrapApprovalRequiredTools(sessionConfig, onFunctionApproval);
this._ownsClient = ownsClient;
this._id = id;
this._name = name ?? DefaultName;
Expand All @@ -73,6 +80,11 @@ public GitHubCopilotAgent(
/// <param name="tools">The tools to make available to the agent.</param>
/// <param name="instructions">Optional instructions to append as a system message.</param>
/// <param name="jsonSerializerOptions">Optional JSON serializer options. Defaults to <see cref="GitHubCopilotJsonUtilities.DefaultOptions"/>.</param>
/// <param name="onFunctionApproval">
/// Optional callback that approves or denies execution of custom function tools wrapped in
/// <see cref="ApprovalRequiredAIFunction"/>; it must return <see langword="true"/> to allow the call.
/// When not configured, or when it denies or throws, such tools are denied (secure-by-default).
/// </param>
public GitHubCopilotAgent(
CopilotClient copilotClient,
bool ownsClient = false,
Expand All @@ -81,15 +93,17 @@ public GitHubCopilotAgent(
string? description = null,
IList<AITool>? tools = null,
string? instructions = null,
JsonSerializerOptions? jsonSerializerOptions = null)
JsonSerializerOptions? jsonSerializerOptions = null,
Func<FunctionCallContent, CancellationToken, ValueTask<bool>>? onFunctionApproval = null)
: this(
copilotClient,
GetSessionConfig(tools, instructions),
ownsClient,
id,
name,
description,
jsonSerializerOptions)
jsonSerializerOptions,
onFunctionApproval)
{
}

Expand Down Expand Up @@ -519,6 +533,49 @@ private AgentResponseUpdate ConvertToAgentResponseUpdate(SessionEvent sessionEve
return new SessionConfig { Tools = mappedTools, SystemMessage = systemMessage };
}

/// <summary>
/// Returns a <see cref="SessionConfig"/> in which every tool wrapped in <see cref="ApprovalRequiredAIFunction"/>
/// is replaced with an <see cref="ApprovalGatedAIFunction"/>, so the GitHub Copilot SDK tool loop cannot invoke
/// an approval-required function without first passing the Agent Framework approval callback.
/// </summary>
/// <remarks>
/// The source <paramref name="sessionConfig"/> is returned unchanged when it contains no approval-required tools.
/// Otherwise a clone is returned so the caller-supplied configuration is not mutated.
/// </remarks>
private static SessionConfig? WrapApprovalRequiredTools(
SessionConfig? sessionConfig,
Func<FunctionCallContent, CancellationToken, ValueTask<bool>>? onFunctionApproval)
{
if (sessionConfig?.Tools is not { Count: > 0 } tools)
{
return sessionConfig;
}

List<AIFunctionDeclaration> mappedTools = new(tools.Count);
bool wrappedAny = false;
foreach (AIFunctionDeclaration tool in tools)
{
if (tool is AIFunction function && function.GetService<ApprovalRequiredAIFunction>() 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<AttachmentFile>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken)
Expand Down Expand Up @@ -563,4 +620,82 @@ private static void CleanupTempDir(string? tempDir)
}
}
}

/// <summary>
/// An <see cref="AIFunction"/> decorator that enforces an Agent Framework function-approval check
/// before invoking a tool wrapped in <see cref="ApprovalRequiredAIFunction"/>.
/// </summary>
/// <remarks>
/// <para>
/// The GitHub Copilot SDK owns the model tool-calling loop and invokes registered custom functions
/// directly, so the standard <c>FunctionInvokingChatClient</c> approval round-trip (emitting a
/// <c>ToolApprovalRequestContent</c> and waiting for a <c>ToolApprovalResponseContent</c>) never runs
/// for this provider. As a result, an <see cref="ApprovalRequiredAIFunction"/> — which is only a marker
/// and does not enforce approval on its own — would otherwise execute without any Agent Framework approval.
/// </para>
/// <para>
/// 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
/// <c>skip_permission</c> flag) from the inner function so the tool remains transparent to the SDK.
/// </para>
/// </remarks>
private sealed class ApprovalGatedAIFunction : DelegatingAIFunction
{
private readonly Func<FunctionCallContent, CancellationToken, ValueTask<bool>>? _onFunctionApproval;

public ApprovalGatedAIFunction(
AIFunction innerFunction,
Func<FunctionCallContent, CancellationToken, ValueTask<bool>>? onFunctionApproval)
: base(innerFunction)
{
this._onFunctionApproval = onFunctionApproval;
}

protected override async ValueTask<object?> 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<bool> 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);
}
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 other failure in the approval callback denies execution.
return false;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<IsReleaseCandidate>true</IsReleaseCandidate>
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<NoWarn>$(NoWarn);GHCP001</NoWarn>
<NoWarn>$(NoWarn);GHCP001;MEAI001</NoWarn>
</PropertyGroup>

<PropertyGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<bool> 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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<PropertyGroup>
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<NoWarn>$(NoWarn);GHCP001</NoWarn>
<NoWarn>$(NoWarn);GHCP001;MEAI001</NoWarn>
</PropertyGroup>

<ItemGroup>
Expand Down
Loading
Loading