Skip to content

Commit cb6e0d9

Browse files
Add permission checks for SDK-registered custom tools
Add 'custom-tool' to the PermissionRequest kind union in Node.js and Python types. Update all existing custom tool e2e tests across all four languages (Node.js, Python, Go, .NET) to provide an onPermissionRequest handler, and add new e2e tests verifying permission approval and denial flows for custom tools. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f0909a7 commit cb6e0d9

File tree

8 files changed

+335
-6
lines changed

8 files changed

+335
-6
lines changed

dotnet/test/ToolsTests.cs

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public async Task Invokes_Custom_Tool()
4242
var session = await Client.CreateSessionAsync(new SessionConfig
4343
{
4444
Tools = [AIFunctionFactory.Create(EncryptString, "encrypt_string")],
45+
OnPermissionRequest = PermissionHandler.ApproveAll,
4546
});
4647

4748
await session.SendAsync(new MessageOptions
@@ -66,7 +67,8 @@ public async Task Handles_Tool_Calling_Errors()
6667

6768
var session = await Client.CreateSessionAsync(new SessionConfig
6869
{
69-
Tools = [getUserLocation]
70+
Tools = [getUserLocation],
71+
OnPermissionRequest = PermissionHandler.ApproveAll,
7072
});
7173

7274
await session.SendAsync(new MessageOptions { Prompt = "What is my location? If you can't find out, just say 'unknown'." });
@@ -108,6 +110,7 @@ public async Task Can_Receive_And_Return_Complex_Types()
108110
var session = await Client.CreateSessionAsync(new SessionConfig
109111
{
110112
Tools = [AIFunctionFactory.Create(PerformDbQuery, "db_query", serializerOptions: ToolsTestsJsonContext.Default.Options)],
113+
OnPermissionRequest = PermissionHandler.ApproveAll,
111114
});
112115

113116
await session.SendAsync(new MessageOptions
@@ -154,6 +157,7 @@ public async Task Can_Return_Binary_Result()
154157
var session = await Client.CreateSessionAsync(new SessionConfig
155158
{
156159
Tools = [AIFunctionFactory.Create(GetImage, "get_image")],
160+
OnPermissionRequest = PermissionHandler.ApproveAll,
157161
});
158162

159163
await session.SendAsync(new MessageOptions
@@ -177,4 +181,68 @@ await session.SendAsync(new MessageOptions
177181
SessionLog = "Returned an image",
178182
});
179183
}
184+
185+
[Fact]
186+
public async Task Invokes_Custom_Tool_With_Permission_Handler()
187+
{
188+
var permissionRequests = new List<PermissionRequest>();
189+
190+
var session = await Client.CreateSessionAsync(new SessionConfig
191+
{
192+
Tools = [AIFunctionFactory.Create(EncryptStringForPermission, "encrypt_string")],
193+
OnPermissionRequest = (request, invocation) =>
194+
{
195+
permissionRequests.Add(request);
196+
return Task.FromResult(new PermissionRequestResult { Kind = "approved" });
197+
},
198+
});
199+
200+
await session.SendAsync(new MessageOptions
201+
{
202+
Prompt = "Use encrypt_string to encrypt this string: Hello"
203+
});
204+
205+
var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session);
206+
Assert.NotNull(assistantMessage);
207+
Assert.Contains("HELLO", assistantMessage!.Data.Content ?? string.Empty);
208+
209+
// Should have received a custom-tool permission request
210+
Assert.Contains(permissionRequests, r => r.Kind == "custom-tool");
211+
212+
[Description("Encrypts a string")]
213+
static string EncryptStringForPermission([Description("String to encrypt")] string input)
214+
=> input.ToUpperInvariant();
215+
}
216+
217+
[Fact]
218+
public async Task Denies_Custom_Tool_When_Permission_Denied()
219+
{
220+
var toolHandlerCalled = false;
221+
222+
var session = await Client.CreateSessionAsync(new SessionConfig
223+
{
224+
Tools = [AIFunctionFactory.Create(EncryptStringDenied, "encrypt_string")],
225+
OnPermissionRequest = (request, invocation) =>
226+
{
227+
return Task.FromResult(new PermissionRequestResult { Kind = "denied-interactively-by-user" });
228+
},
229+
});
230+
231+
await session.SendAsync(new MessageOptions
232+
{
233+
Prompt = "Use encrypt_string to encrypt this string: Hello"
234+
});
235+
236+
await TestHelper.GetFinalAssistantMessageAsync(session);
237+
238+
// The tool handler should NOT have been called since permission was denied
239+
Assert.False(toolHandlerCalled);
240+
241+
[Description("Encrypts a string")]
242+
string EncryptStringDenied([Description("String to encrypt")] string input)
243+
{
244+
toolHandlerCalled = true;
245+
return input.ToUpperInvariant();
246+
}
247+
}
180248
}

go/internal/e2e/tools_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"path/filepath"
77
"strings"
8+
"sync"
89
"testing"
910

1011
copilot "github.com/github/copilot-sdk/go"
@@ -61,6 +62,7 @@ func TestTools(t *testing.T) {
6162
return strings.ToUpper(params.Input), nil
6263
}),
6364
},
65+
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
6466
})
6567
if err != nil {
6668
t.Fatalf("Failed to create session: %v", err)
@@ -93,6 +95,7 @@ func TestTools(t *testing.T) {
9395
return nil, errors.New("Melbourne")
9496
}),
9597
},
98+
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
9699
})
97100
if err != nil {
98101
t.Fatalf("Failed to create session: %v", err)
@@ -210,6 +213,7 @@ func TestTools(t *testing.T) {
210213
}, nil
211214
}),
212215
},
216+
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
213217
})
214218
if err != nil {
215219
t.Fatalf("Failed to create session: %v", err)
@@ -259,4 +263,100 @@ func TestTools(t *testing.T) {
259263
t.Errorf("Expected session ID '%s', got '%s'", session.SessionID, receivedInvocation.SessionID)
260264
}
261265
})
266+
267+
t.Run("invokes custom tool with permission handler", func(t *testing.T) {
268+
ctx.ConfigureForTest(t)
269+
270+
type EncryptParams struct {
271+
Input string `json:"input" jsonschema:"String to encrypt"`
272+
}
273+
274+
var permissionRequests []copilot.PermissionRequest
275+
var mu sync.Mutex
276+
277+
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
278+
Tools: []copilot.Tool{
279+
copilot.DefineTool("encrypt_string", "Encrypts a string",
280+
func(params EncryptParams, inv copilot.ToolInvocation) (string, error) {
281+
return strings.ToUpper(params.Input), nil
282+
}),
283+
},
284+
OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) {
285+
mu.Lock()
286+
permissionRequests = append(permissionRequests, request)
287+
mu.Unlock()
288+
return copilot.PermissionRequestResult{Kind: "approved"}, nil
289+
},
290+
})
291+
if err != nil {
292+
t.Fatalf("Failed to create session: %v", err)
293+
}
294+
295+
_, err = session.Send(t.Context(), copilot.MessageOptions{Prompt: "Use encrypt_string to encrypt this string: Hello"})
296+
if err != nil {
297+
t.Fatalf("Failed to send message: %v", err)
298+
}
299+
300+
answer, err := testharness.GetFinalAssistantMessage(t.Context(), session)
301+
if err != nil {
302+
t.Fatalf("Failed to get assistant message: %v", err)
303+
}
304+
305+
if answer.Data.Content == nil || !strings.Contains(*answer.Data.Content, "HELLO") {
306+
t.Errorf("Expected answer to contain 'HELLO', got %v", answer.Data.Content)
307+
}
308+
309+
// Should have received a custom-tool permission request
310+
mu.Lock()
311+
customToolReqs := 0
312+
for _, req := range permissionRequests {
313+
if req.Kind == "custom-tool" {
314+
customToolReqs++
315+
}
316+
}
317+
mu.Unlock()
318+
if customToolReqs == 0 {
319+
t.Errorf("Expected at least one custom-tool permission request, got none")
320+
}
321+
})
322+
323+
t.Run("denies custom tool when permission denied", func(t *testing.T) {
324+
ctx.ConfigureForTest(t)
325+
326+
type EncryptParams struct {
327+
Input string `json:"input" jsonschema:"String to encrypt"`
328+
}
329+
330+
toolHandlerCalled := false
331+
332+
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
333+
Tools: []copilot.Tool{
334+
copilot.DefineTool("encrypt_string", "Encrypts a string",
335+
func(params EncryptParams, inv copilot.ToolInvocation) (string, error) {
336+
toolHandlerCalled = true
337+
return strings.ToUpper(params.Input), nil
338+
}),
339+
},
340+
OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) {
341+
return copilot.PermissionRequestResult{Kind: "denied-interactively-by-user"}, nil
342+
},
343+
})
344+
if err != nil {
345+
t.Fatalf("Failed to create session: %v", err)
346+
}
347+
348+
_, err = session.Send(t.Context(), copilot.MessageOptions{Prompt: "Use encrypt_string to encrypt this string: Hello"})
349+
if err != nil {
350+
t.Fatalf("Failed to send message: %v", err)
351+
}
352+
353+
_, err = testharness.GetFinalAssistantMessage(t.Context(), session)
354+
if err != nil {
355+
t.Fatalf("Failed to get assistant message: %v", err)
356+
}
357+
358+
if toolHandlerCalled {
359+
t.Errorf("Tool handler should NOT have been called since permission was denied")
360+
}
361+
})
262362
}

nodejs/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ export type SystemMessageConfig = SystemMessageAppendConfig | SystemMessageRepla
211211
* Permission request types from the server
212212
*/
213213
export interface PermissionRequest {
214-
kind: "shell" | "write" | "mcp" | "read" | "url";
214+
kind: "shell" | "write" | "mcp" | "read" | "url" | "custom-tool";
215215
toolCallId?: string;
216216
[key: string]: unknown;
217217
}

nodejs/test/e2e/tools.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { join } from "path";
77
import { assert, describe, expect, it } from "vitest";
88
import { z } from "zod";
99
import { defineTool, approveAll } from "../../src/index.js";
10+
import type { PermissionRequest, PermissionRequestResult } from "../../src/index.js";
1011
import { createSdkTestContext } from "./harness/sdkTestContext";
1112

1213
describe("Custom tools", async () => {
@@ -35,6 +36,7 @@ describe("Custom tools", async () => {
3536
handler: ({ input }) => input.toUpperCase(),
3637
}),
3738
],
39+
onPermissionRequest: approveAll,
3840
});
3941

4042
const assistantMessage = await session.sendAndWait({
@@ -53,6 +55,7 @@ describe("Custom tools", async () => {
5355
},
5456
}),
5557
],
58+
onPermissionRequest: approveAll,
5659
});
5760

5861
const answer = await session.sendAndWait({
@@ -108,6 +111,7 @@ describe("Custom tools", async () => {
108111
},
109112
}),
110113
],
114+
onPermissionRequest: approveAll,
111115
});
112116

113117
const assistantMessage = await session.sendAndWait({
@@ -124,4 +128,63 @@ describe("Custom tools", async () => {
124128
expect(responseContent.replace(/,/g, "")).toContain("135460");
125129
expect(responseContent.replace(/,/g, "")).toContain("204356");
126130
});
131+
132+
it("invokes custom tool with permission handler", async () => {
133+
const permissionRequests: PermissionRequest[] = [];
134+
135+
const session = await client.createSession({
136+
tools: [
137+
defineTool("encrypt_string", {
138+
description: "Encrypts a string",
139+
parameters: z.object({
140+
input: z.string().describe("String to encrypt"),
141+
}),
142+
handler: ({ input }) => input.toUpperCase(),
143+
}),
144+
],
145+
onPermissionRequest: (request) => {
146+
permissionRequests.push(request);
147+
return { kind: "approved" };
148+
},
149+
});
150+
151+
const assistantMessage = await session.sendAndWait({
152+
prompt: "Use encrypt_string to encrypt this string: Hello",
153+
});
154+
expect(assistantMessage?.data.content).toContain("HELLO");
155+
156+
// Should have received a custom-tool permission request
157+
const customToolRequests = permissionRequests.filter((req) => req.kind === "custom-tool");
158+
expect(customToolRequests.length).toBeGreaterThan(0);
159+
expect(customToolRequests[0].toolName).toBe("encrypt_string");
160+
});
161+
162+
it("denies custom tool when permission denied", async () => {
163+
let toolHandlerCalled = false;
164+
165+
const session = await client.createSession({
166+
tools: [
167+
defineTool("encrypt_string", {
168+
description: "Encrypts a string",
169+
parameters: z.object({
170+
input: z.string().describe("String to encrypt"),
171+
}),
172+
handler: ({ input }) => {
173+
toolHandlerCalled = true;
174+
return input.toUpperCase();
175+
},
176+
}),
177+
],
178+
onPermissionRequest: () => {
179+
return { kind: "denied-interactively-by-user" };
180+
},
181+
});
182+
183+
await session.sendAndWait({
184+
prompt: "Use encrypt_string to encrypt this string: Hello",
185+
});
186+
187+
// The tool handler should NOT have been called since permission was denied
188+
expect(toolHandlerCalled).toBe(false);
189+
});
127190
});

python/copilot/types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ class SystemMessageReplaceConfig(TypedDict):
169169
class PermissionRequest(TypedDict, total=False):
170170
"""Permission request from the server"""
171171

172-
kind: Literal["shell", "write", "mcp", "read", "url"]
172+
kind: Literal["shell", "write", "mcp", "read", "url", "custom-tool"]
173173
toolCallId: str
174174
# Additional fields vary by kind
175175

0 commit comments

Comments
 (0)