Skip to content

Commit 93e8267

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 9d998fb commit 93e8267

File tree

8 files changed

+323
-3
lines changed

8 files changed

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

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 () => {
@@ -36,6 +37,7 @@ describe("Custom tools", async () => {
3637
handler: ({ input }) => input.toUpperCase(),
3738
}),
3839
],
40+
onPermissionRequest: approveAll,
3941
});
4042

4143
const assistantMessage = await session.sendAndWait({
@@ -55,6 +57,7 @@ describe("Custom tools", async () => {
5557
},
5658
}),
5759
],
60+
onPermissionRequest: approveAll,
5861
});
5962

6063
const answer = await session.sendAndWait({
@@ -111,6 +114,7 @@ describe("Custom tools", async () => {
111114
},
112115
}),
113116
],
117+
onPermissionRequest: approveAll,
114118
});
115119

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

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)