From e7e0521f3ed4e1850f2957890da72ee7ec6d2e15 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 12 Mar 2026 13:35:56 -0700 Subject: [PATCH 1/6] Allow tools to set skipPermission --- dotnet/README.md | 18 ++++++++++++ dotnet/src/Client.cs | 7 +++-- go/README.md | 12 ++++++++ go/client_test.go | 41 +++++++++++++++++++++++++++ go/types.go | 1 + nodejs/README.md | 13 +++++++++ nodejs/src/client.ts | 2 ++ nodejs/src/types.ts | 5 ++++ nodejs/test/client.test.ts | 58 ++++++++++++++++++++++++++++++++++++++ python/README.md | 10 +++++++ python/copilot/client.py | 4 +++ python/copilot/tools.py | 4 +++ python/copilot/types.py | 1 + 13 files changed, 174 insertions(+), 2 deletions(-) diff --git a/dotnet/README.md b/dotnet/README.md index bdb3e8dab..dd3ff51c2 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -449,6 +449,24 @@ var session = await client.CreateSessionAsync(new SessionConfig }); ``` +#### Skipping Permission Prompts + +Set `skip_permission` in the tool's `AdditionalProperties` to allow it to execute without triggering a permission prompt: + +```csharp +var safeLookup = AIFunctionFactory.Create( + async ([Description("Lookup ID")] string id) => { + // your logic + }, + "safe_lookup", + "A read-only lookup that needs no confirmation", + new AIFunctionFactoryOptions + { + AdditionalProperties = new ReadOnlyDictionary( + new Dictionary { ["skip_permission"] = true }) + }); +``` + ### System Message Customization Control the system prompt using `SystemMessage` in session config: diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 0794043d8..d9ccd5d0f 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1470,13 +1470,16 @@ internal record ToolDefinition( string Name, string? Description, JsonElement Parameters, /* JSON schema */ - bool? OverridesBuiltInTool = null) + bool? OverridesBuiltInTool = null, + bool? SkipPermission = null) { public static ToolDefinition FromAIFunction(AIFunction function) { var overrides = function.AdditionalProperties.TryGetValue("is_override", out var val) && val is true; + var skipPerm = function.AdditionalProperties.TryGetValue("skip_permission", out var skipVal) && skipVal is true; return new ToolDefinition(function.Name, function.Description, function.JsonSchema, - overrides ? true : null); + overrides ? true : null, + skipPerm ? true : null); } } diff --git a/go/README.md b/go/README.md index 4cc73398c..d71d82026 100644 --- a/go/README.md +++ b/go/README.md @@ -281,6 +281,18 @@ editFile := copilot.DefineTool("edit_file", "Custom file editor with project-spe editFile.OverridesBuiltInTool = true ``` +#### Skipping Permission Prompts + +Set `SkipPermission = true` on a tool to allow it to execute without triggering a permission prompt: + +```go +safeLookup := copilot.DefineTool("safe_lookup", "A read-only lookup that needs no confirmation", + func(params LookupParams, inv copilot.ToolInvocation) (any, error) { + // your logic + }) +safeLookup.SkipPermission = true +``` + ## Streaming Enable streaming to receive assistant response chunks as they're generated: diff --git a/go/client_test.go b/go/client_test.go index 601215cbe..7860cc3bf 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -509,6 +509,47 @@ func TestOverridesBuiltInTool(t *testing.T) { }) } +func TestSkipPermission(t *testing.T) { + t.Run("SkipPermission is serialized in tool definition", func(t *testing.T) { + tool := Tool{ + Name: "my_tool", + Description: "A tool that skips permission", + SkipPermission: true, + Handler: func(_ ToolInvocation) (ToolResult, error) { return ToolResult{}, nil }, + } + data, err := json.Marshal(tool) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if v, ok := m["skipPermission"]; !ok || v != true { + t.Errorf("expected skipPermission=true, got %v", m) + } + }) + + t.Run("SkipPermission omitted when false", func(t *testing.T) { + tool := Tool{ + Name: "custom_tool", + Description: "A custom tool", + Handler: func(_ ToolInvocation) (ToolResult, error) { return ToolResult{}, nil }, + } + data, err := json.Marshal(tool) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if _, ok := m["skipPermission"]; ok { + t.Errorf("expected skipPermission to be omitted, got %v", m) + } + }) +} + func TestClient_CreateSession_RequiresPermissionHandler(t *testing.T) { t.Run("returns error when config is nil", func(t *testing.T) { client := NewClient(nil) diff --git a/go/types.go b/go/types.go index a139f294f..cd0c09922 100644 --- a/go/types.go +++ b/go/types.go @@ -414,6 +414,7 @@ type Tool struct { Description string `json:"description,omitempty"` Parameters map[string]any `json:"parameters,omitempty"` OverridesBuiltInTool bool `json:"overridesBuiltInTool,omitempty"` + SkipPermission bool `json:"skipPermission,omitempty"` Handler ToolHandler `json:"-"` } diff --git a/nodejs/README.md b/nodejs/README.md index 78a535b76..aea730dee 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -426,6 +426,19 @@ defineTool("edit_file", { }) ``` +#### Skipping Permission Prompts + +Set `skipPermission: true` on a tool definition to allow it to execute without triggering a permission prompt: + +```ts +defineTool("safe_lookup", { + description: "A read-only lookup that needs no confirmation", + parameters: z.object({ id: z.string() }), + skipPermission: true, + handler: async ({ id }) => { /* your logic */ }, +}) +``` + ### System Message Customization Control the system prompt using `systemMessage` in session config: diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index c96d4b691..abf65da62 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -580,6 +580,7 @@ export class CopilotClient { description: tool.description, parameters: toJsonSchema(tool.parameters), overridesBuiltInTool: tool.overridesBuiltInTool, + skipPermission: tool.skipPermission, })), systemMessage: config.systemMessage, availableTools: config.availableTools, @@ -682,6 +683,7 @@ export class CopilotClient { description: tool.description, parameters: toJsonSchema(tool.parameters), overridesBuiltInTool: tool.overridesBuiltInTool, + skipPermission: tool.skipPermission, })), provider: config.provider, requestPermission: true, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index cbc8b10ed..846b4ad08 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -167,6 +167,10 @@ export interface Tool { * will return an error. */ overridesBuiltInTool?: boolean; + /** + * When true, the tool can execute without a permission prompt. + */ + skipPermission?: boolean; } /** @@ -180,6 +184,7 @@ export function defineTool( parameters?: ZodSchema | Record; handler: ToolHandler; overridesBuiltInTool?: boolean; + skipPermission?: boolean; } ): Tool { return { name, ...config }; diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 6f3e4ef98..0beddbac0 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -378,6 +378,64 @@ describe("CopilotClient", () => { }); }); + describe("skipPermission in tool definitions", () => { + it("sends skipPermission in tool definition on session.create", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.createSession({ + onPermissionRequest: approveAll, + tools: [ + { + name: "my_tool", + description: "a tool that skips permission", + handler: async () => "ok", + skipPermission: true, + }, + ], + }); + + const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; + expect(payload.tools).toEqual([ + expect.objectContaining({ name: "my_tool", skipPermission: true }), + ]); + }); + + it("sends skipPermission in tool definition on session.resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + // Mock sendRequest to capture the call without hitting the runtime + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + tools: [ + { + name: "my_tool", + description: "a tool that skips permission", + handler: async () => "ok", + skipPermission: true, + }, + ], + }); + + const payload = spy.mock.calls.find((c) => c[0] === "session.resume")![1] as any; + expect(payload.tools).toEqual([ + expect.objectContaining({ name: "my_tool", skipPermission: true }), + ]); + spy.mockRestore(); + }); + }); + describe("agent parameter in session creation", () => { it("forwards agent in session.create request", async () => { const client = new CopilotClient(); diff --git a/python/README.md b/python/README.md index 5b87bb04e..9457dc162 100644 --- a/python/README.md +++ b/python/README.md @@ -232,6 +232,16 @@ async def edit_file(params: EditFileParams) -> str: # your logic ``` +#### Skipping Permission Prompts + +Set `skip_permission=True` on a tool definition to allow it to execute without triggering a permission prompt: + +```python +@define_tool(name="safe_lookup", description="A read-only lookup that needs no confirmation", skip_permission=True) +async def safe_lookup(params: LookupParams) -> str: + # your logic +``` + ## Image Support The SDK supports image attachments via the `attachments` parameter. You can attach images by providing their file path: diff --git a/python/copilot/client.py b/python/copilot/client.py index a7b558ad5..51b24fa48 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -507,6 +507,8 @@ async def create_session(self, config: SessionConfig) -> CopilotSession: definition["parameters"] = tool.parameters if tool.overrides_built_in_tool: definition["overridesBuiltInTool"] = True + if tool.skip_permission: + definition["skipPermission"] = True tool_defs.append(definition) payload: dict[str, Any] = {} @@ -697,6 +699,8 @@ async def resume_session(self, session_id: str, config: ResumeSessionConfig) -> definition["parameters"] = tool.parameters if tool.overrides_built_in_tool: definition["overridesBuiltInTool"] = True + if tool.skip_permission: + definition["skipPermission"] = True tool_defs.append(definition) payload: dict[str, Any] = {"sessionId": session_id} diff --git a/python/copilot/tools.py b/python/copilot/tools.py index 573992cd5..a66dadf87 100644 --- a/python/copilot/tools.py +++ b/python/copilot/tools.py @@ -26,6 +26,7 @@ def define_tool( *, description: str | None = None, overrides_built_in_tool: bool = False, + skip_permission: bool = False, ) -> Callable[[Callable[..., Any]], Tool]: ... @@ -37,6 +38,7 @@ def define_tool( handler: Callable[[T, ToolInvocation], R], params_type: type[T], overrides_built_in_tool: bool = False, + skip_permission: bool = False, ) -> Tool: ... @@ -47,6 +49,7 @@ def define_tool( handler: Callable[[Any, ToolInvocation], Any] | None = None, params_type: type[BaseModel] | None = None, overrides_built_in_tool: bool = False, + skip_permission: bool = False, ) -> Tool | Callable[[Callable[[Any, ToolInvocation], Any]], Tool]: """ Define a tool with automatic JSON schema generation from Pydantic models. @@ -154,6 +157,7 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult: parameters=schema, handler=wrapped_handler, overrides_built_in_tool=overrides_built_in_tool, + skip_permission=skip_permission, ) # If handler is provided, call decorator immediately diff --git a/python/copilot/types.py b/python/copilot/types.py index 9a397c708..3529b097c 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -150,6 +150,7 @@ class Tool: handler: ToolHandler parameters: dict[str, Any] | None = None overrides_built_in_tool: bool = False + skip_permission: bool = False # System message configuration (discriminated union) From d95b67d50c2f577895a77a87027aebce7a3e62d4 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 12 Mar 2026 13:59:50 -0700 Subject: [PATCH 2/6] PR feedback --- dotnet/test/ToolsTests.cs | 30 +++++++++++++ python/copilot/tools.py | 4 ++ python/test_client.py | 94 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+) diff --git a/dotnet/test/ToolsTests.cs b/dotnet/test/ToolsTests.cs index 095659889..8c1d1d1e2 100644 --- a/dotnet/test/ToolsTests.cs +++ b/dotnet/test/ToolsTests.cs @@ -181,6 +181,36 @@ static string CustomGrep([Description("Search query")] string query) => $"CUSTOM_GREP_RESULT: {query}"; } + [Fact] + public async Task SkipPermission_Sent_In_Tool_Definition() + { + [Description("A tool that skips permission")] + static string SafeLookup([Description("Lookup ID")] string id) + => $"RESULT: {id}"; + + var tool = AIFunctionFactory.Create((Delegate)SafeLookup, new AIFunctionFactoryOptions + { + Name = "safe_lookup", + AdditionalProperties = new ReadOnlyDictionary( + new Dictionary { ["skip_permission"] = true }) + }); + + var session = await CreateSessionAsync(new SessionConfig + { + Tools = [tool], + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + await session.SendAsync(new MessageOptions + { + Prompt = "Use safe_lookup to look up 'test123'" + }); + + var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session); + Assert.NotNull(assistantMessage); + Assert.Contains("RESULT", assistantMessage!.Data.Content ?? string.Empty); + } + [Fact(Skip = "Behaves as if no content was in the result. Likely that binary results aren't fully implemented yet.")] public async Task Can_Return_Binary_Result() { diff --git a/python/copilot/tools.py b/python/copilot/tools.py index a66dadf87..58e58d97e 100644 --- a/python/copilot/tools.py +++ b/python/copilot/tools.py @@ -82,6 +82,10 @@ def lookup_issue(params: LookupIssueParams) -> str: handler: Optional handler function (if not using as decorator) params_type: Optional Pydantic model type for parameters (inferred from type hints when using as decorator) + overrides_built_in_tool: When True, explicitly indicates this tool is intended + to override a built-in tool of the same name. If not set and the + name clashes with a built-in tool, the runtime will return an error. + skip_permission: When True, the tool can execute without a permission prompt. Returns: A Tool instance diff --git a/python/test_client.py b/python/test_client.py index 62ae7b188..12f302b45 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -237,6 +237,100 @@ def grep(params) -> str: await client.force_stop() +class TestSkipPermission: + @pytest.mark.asyncio + async def test_skip_permission_sent_in_tool_definition(self): + client = CopilotClient({"cli_path": CLI_PATH}) + await client.start() + + try: + captured = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + return await original_request(method, params) + + client._client.request = mock_request + + @define_tool(description="Safe lookup", skip_permission=True) + def safe_lookup(params) -> str: + return "ok" + + await client.create_session( + {"tools": [safe_lookup], "on_permission_request": PermissionHandler.approve_all} + ) + tool_defs = captured["session.create"]["tools"] + assert len(tool_defs) == 1 + assert tool_defs[0]["name"] == "safe_lookup" + assert tool_defs[0]["skipPermission"] is True + assert "overridesBuiltInTool" not in tool_defs[0] + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_resume_session_sends_skip_permission(self): + client = CopilotClient({"cli_path": CLI_PATH}) + await client.start() + + try: + session = await client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) + + captured = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + return await original_request(method, params) + + client._client.request = mock_request + + @define_tool(description="Safe lookup", skip_permission=True) + def safe_lookup(params) -> str: + return "ok" + + await client.resume_session( + session.session_id, + {"tools": [safe_lookup], "on_permission_request": PermissionHandler.approve_all}, + ) + tool_defs = captured["session.resume"]["tools"] + assert len(tool_defs) == 1 + assert tool_defs[0]["skipPermission"] is True + assert "overridesBuiltInTool" not in tool_defs[0] + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_skip_permission_omitted_when_false(self): + client = CopilotClient({"cli_path": CLI_PATH}) + await client.start() + + try: + captured = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + return await original_request(method, params) + + client._client.request = mock_request + + @define_tool(description="Normal tool") + def normal_tool(params) -> str: + return "ok" + + await client.create_session( + {"tools": [normal_tool], "on_permission_request": PermissionHandler.approve_all} + ) + tool_defs = captured["session.create"]["tools"] + assert len(tool_defs) == 1 + assert "skipPermission" not in tool_defs[0] + finally: + await client.force_stop() + + class TestOnListModels: @pytest.mark.asyncio async def test_list_models_with_custom_handler(self): From 6018054a9af3b55e49f917cad78aee6b251bac91 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 12 Mar 2026 14:06:38 -0700 Subject: [PATCH 3/6] Generate capture? --- ...kippermission_sent_in_tool_definition.yaml | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 test/snapshots/tools/skippermission_sent_in_tool_definition.yaml diff --git a/test/snapshots/tools/skippermission_sent_in_tool_definition.yaml b/test/snapshots/tools/skippermission_sent_in_tool_definition.yaml new file mode 100644 index 000000000..dfdfa63fa --- /dev/null +++ b/test/snapshots/tools/skippermission_sent_in_tool_definition.yaml @@ -0,0 +1,35 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Use safe_lookup to look up 'test123' + - role: assistant + content: I'll look up 'test123' for you. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: safe_lookup + arguments: '{"id":"test123"}' + - messages: + - role: system + content: ${system} + - role: user + content: Use safe_lookup to look up 'test123' + - role: assistant + content: I'll look up 'test123' for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: safe_lookup + arguments: '{"id":"test123"}' + - role: tool + tool_call_id: toolcall_0 + content: "RESULT: test123" + - role: assistant + content: 'The lookup for "test123" returned: RESULT: test123' From 41850ee0a3b220efa0b3d8f2b6ee4b4ca4c716d6 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 12 Mar 2026 15:57:53 -0700 Subject: [PATCH 4/6] All e2e tests --- go/client_test.go | 41 --------------- go/internal/e2e/tools_test.go | 38 ++++++++++++++ nodejs/test/client.test.ts | 58 --------------------- nodejs/test/e2e/tools.test.ts | 21 ++++++++ python/e2e/test_tools.py | 20 ++++++++ python/test_client.py | 94 ----------------------------------- 6 files changed, 79 insertions(+), 193 deletions(-) diff --git a/go/client_test.go b/go/client_test.go index 7860cc3bf..601215cbe 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -509,47 +509,6 @@ func TestOverridesBuiltInTool(t *testing.T) { }) } -func TestSkipPermission(t *testing.T) { - t.Run("SkipPermission is serialized in tool definition", func(t *testing.T) { - tool := Tool{ - Name: "my_tool", - Description: "A tool that skips permission", - SkipPermission: true, - Handler: func(_ ToolInvocation) (ToolResult, error) { return ToolResult{}, nil }, - } - data, err := json.Marshal(tool) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - var m map[string]any - if err := json.Unmarshal(data, &m); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - if v, ok := m["skipPermission"]; !ok || v != true { - t.Errorf("expected skipPermission=true, got %v", m) - } - }) - - t.Run("SkipPermission omitted when false", func(t *testing.T) { - tool := Tool{ - Name: "custom_tool", - Description: "A custom tool", - Handler: func(_ ToolInvocation) (ToolResult, error) { return ToolResult{}, nil }, - } - data, err := json.Marshal(tool) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - var m map[string]any - if err := json.Unmarshal(data, &m); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - if _, ok := m["skipPermission"]; ok { - t.Errorf("expected skipPermission to be omitted, got %v", m) - } - }) -} - func TestClient_CreateSession_RequiresPermissionHandler(t *testing.T) { t.Run("returns error when config is nil", func(t *testing.T) { client := NewClient(nil) diff --git a/go/internal/e2e/tools_test.go b/go/internal/e2e/tools_test.go index 83f3780c1..e3081cc5b 100644 --- a/go/internal/e2e/tools_test.go +++ b/go/internal/e2e/tools_test.go @@ -264,6 +264,44 @@ func TestTools(t *testing.T) { } }) + t.Run("skipPermission sent in tool definition", func(t *testing.T) { + ctx.ConfigureForTest(t) + + type LookupParams struct { + ID string `json:"id" jsonschema:"ID to look up"` + } + + safeLookupTool := copilot.DefineTool("safe_lookup", "A safe lookup that skips permission", + func(params LookupParams, inv copilot.ToolInvocation) (string, error) { + return "RESULT: " + params.ID, nil + }) + safeLookupTool.SkipPermission = true + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{ + safeLookupTool, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + _, err = session.Send(t.Context(), copilot.MessageOptions{Prompt: "Use safe_lookup to look up 'test123'"}) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + answer, err := testharness.GetFinalAssistantMessage(t.Context(), session) + if err != nil { + t.Fatalf("Failed to get assistant message: %v", err) + } + + if answer.Data.Content == nil || !strings.Contains(*answer.Data.Content, "RESULT: test123") { + t.Errorf("Expected answer to contain 'RESULT: test123', got %v", answer.Data.Content) + } + }) + t.Run("overrides built-in tool with custom tool", func(t *testing.T) { ctx.ConfigureForTest(t) diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 0beddbac0..6f3e4ef98 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -378,64 +378,6 @@ describe("CopilotClient", () => { }); }); - describe("skipPermission in tool definitions", () => { - it("sends skipPermission in tool definition on session.create", async () => { - const client = new CopilotClient(); - await client.start(); - onTestFinished(() => client.forceStop()); - - const spy = vi.spyOn((client as any).connection!, "sendRequest"); - await client.createSession({ - onPermissionRequest: approveAll, - tools: [ - { - name: "my_tool", - description: "a tool that skips permission", - handler: async () => "ok", - skipPermission: true, - }, - ], - }); - - const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; - expect(payload.tools).toEqual([ - expect.objectContaining({ name: "my_tool", skipPermission: true }), - ]); - }); - - it("sends skipPermission in tool definition on session.resume", async () => { - const client = new CopilotClient(); - await client.start(); - onTestFinished(() => client.forceStop()); - - const session = await client.createSession({ onPermissionRequest: approveAll }); - // Mock sendRequest to capture the call without hitting the runtime - const spy = vi - .spyOn((client as any).connection!, "sendRequest") - .mockImplementation(async (method: string, params: any) => { - if (method === "session.resume") return { sessionId: params.sessionId }; - throw new Error(`Unexpected method: ${method}`); - }); - await client.resumeSession(session.sessionId, { - onPermissionRequest: approveAll, - tools: [ - { - name: "my_tool", - description: "a tool that skips permission", - handler: async () => "ok", - skipPermission: true, - }, - ], - }); - - const payload = spy.mock.calls.find((c) => c[0] === "session.resume")![1] as any; - expect(payload.tools).toEqual([ - expect.objectContaining({ name: "my_tool", skipPermission: true }), - ]); - spy.mockRestore(); - }); - }); - describe("agent parameter in session creation", () => { it("forwards agent in session.create request", async () => { const client = new CopilotClient(); diff --git a/nodejs/test/e2e/tools.test.ts b/nodejs/test/e2e/tools.test.ts index 3f5c3e09f..db1c6bfda 100644 --- a/nodejs/test/e2e/tools.test.ts +++ b/nodejs/test/e2e/tools.test.ts @@ -159,6 +159,27 @@ describe("Custom tools", async () => { expect(customToolRequests[0].toolName).toBe("encrypt_string"); }); + it("skipPermission sent in tool definition", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + tools: [ + defineTool("safe_lookup", { + description: "A safe lookup that skips permission", + parameters: z.object({ + id: z.string().describe("ID to look up"), + }), + handler: ({ id }) => `RESULT: ${id}`, + skipPermission: true, + }), + ], + }); + + const assistantMessage = await session.sendAndWait({ + prompt: "Use safe_lookup to look up 'test123'", + }); + expect(assistantMessage?.data.content).toContain("RESULT: test123"); + }); + it("overrides built-in tool with custom tool", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, diff --git a/python/e2e/test_tools.py b/python/e2e/test_tools.py index b692e3f65..4c96f7460 100644 --- a/python/e2e/test_tools.py +++ b/python/e2e/test_tools.py @@ -138,6 +138,26 @@ def db_query(params: DbQueryParams, invocation: ToolInvocation) -> list[City]: assert "135460" in response_content.replace(",", "") assert "204356" in response_content.replace(",", "") + async def test_skippermission_sent_in_tool_definition(self, ctx: E2ETestContext): + class LookupParams(BaseModel): + id: str = Field(description="ID to look up") + + @define_tool( + "safe_lookup", + description="A safe lookup that skips permission", + skip_permission=True, + ) + def safe_lookup(params: LookupParams, invocation: ToolInvocation) -> str: + return f"RESULT: {params.id}" + + session = await ctx.client.create_session( + {"tools": [safe_lookup], "on_permission_request": PermissionHandler.approve_all} + ) + + await session.send({"prompt": "Use safe_lookup to look up 'test123'"}) + assistant_message = await get_final_assistant_message(session) + assert "RESULT: test123" in assistant_message.data.content + async def test_overrides_built_in_tool_with_custom_tool(self, ctx: E2ETestContext): class GrepParams(BaseModel): query: str = Field(description="Search query") diff --git a/python/test_client.py b/python/test_client.py index 12f302b45..62ae7b188 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -237,100 +237,6 @@ def grep(params) -> str: await client.force_stop() -class TestSkipPermission: - @pytest.mark.asyncio - async def test_skip_permission_sent_in_tool_definition(self): - client = CopilotClient({"cli_path": CLI_PATH}) - await client.start() - - try: - captured = {} - original_request = client._client.request - - async def mock_request(method, params): - captured[method] = params - return await original_request(method, params) - - client._client.request = mock_request - - @define_tool(description="Safe lookup", skip_permission=True) - def safe_lookup(params) -> str: - return "ok" - - await client.create_session( - {"tools": [safe_lookup], "on_permission_request": PermissionHandler.approve_all} - ) - tool_defs = captured["session.create"]["tools"] - assert len(tool_defs) == 1 - assert tool_defs[0]["name"] == "safe_lookup" - assert tool_defs[0]["skipPermission"] is True - assert "overridesBuiltInTool" not in tool_defs[0] - finally: - await client.force_stop() - - @pytest.mark.asyncio - async def test_resume_session_sends_skip_permission(self): - client = CopilotClient({"cli_path": CLI_PATH}) - await client.start() - - try: - session = await client.create_session( - {"on_permission_request": PermissionHandler.approve_all} - ) - - captured = {} - original_request = client._client.request - - async def mock_request(method, params): - captured[method] = params - return await original_request(method, params) - - client._client.request = mock_request - - @define_tool(description="Safe lookup", skip_permission=True) - def safe_lookup(params) -> str: - return "ok" - - await client.resume_session( - session.session_id, - {"tools": [safe_lookup], "on_permission_request": PermissionHandler.approve_all}, - ) - tool_defs = captured["session.resume"]["tools"] - assert len(tool_defs) == 1 - assert tool_defs[0]["skipPermission"] is True - assert "overridesBuiltInTool" not in tool_defs[0] - finally: - await client.force_stop() - - @pytest.mark.asyncio - async def test_skip_permission_omitted_when_false(self): - client = CopilotClient({"cli_path": CLI_PATH}) - await client.start() - - try: - captured = {} - original_request = client._client.request - - async def mock_request(method, params): - captured[method] = params - return await original_request(method, params) - - client._client.request = mock_request - - @define_tool(description="Normal tool") - def normal_tool(params) -> str: - return "ok" - - await client.create_session( - {"tools": [normal_tool], "on_permission_request": PermissionHandler.approve_all} - ) - tool_defs = captured["session.create"]["tools"] - assert len(tool_defs) == 1 - assert "skipPermission" not in tool_defs[0] - finally: - await client.force_stop() - - class TestOnListModels: @pytest.mark.asyncio async def test_list_models_with_custom_handler(self): From cd919b2fbb2642506bae7df674e713239acecb62 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Thu, 12 Mar 2026 16:32:42 -0700 Subject: [PATCH 5/6] Add todo --- dotnet/test/ToolsTests.cs | 2 ++ go/internal/e2e/tools_test.go | 2 ++ nodejs/test/e2e/tools.test.ts | 2 ++ python/e2e/test_tools.py | 2 ++ 4 files changed, 8 insertions(+) diff --git a/dotnet/test/ToolsTests.cs b/dotnet/test/ToolsTests.cs index 8c1d1d1e2..8122ecc9e 100644 --- a/dotnet/test/ToolsTests.cs +++ b/dotnet/test/ToolsTests.cs @@ -195,6 +195,8 @@ static string SafeLookup([Description("Lookup ID")] string id) new Dictionary { ["skip_permission"] = true }) }); + // TODO: Once the CLI respects skip_permission, use a tracking permission handler + // and assert it was NOT called for this tool. var session = await CreateSessionAsync(new SessionConfig { Tools = [tool], diff --git a/go/internal/e2e/tools_test.go b/go/internal/e2e/tools_test.go index e3081cc5b..4e68b8c1f 100644 --- a/go/internal/e2e/tools_test.go +++ b/go/internal/e2e/tools_test.go @@ -277,6 +277,8 @@ func TestTools(t *testing.T) { }) safeLookupTool.SkipPermission = true + // TODO: Once the CLI respects SkipPermission, use a tracking permission handler + // and assert it was NOT called for this tool. session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Tools: []copilot.Tool{ diff --git a/nodejs/test/e2e/tools.test.ts b/nodejs/test/e2e/tools.test.ts index db1c6bfda..0180c34a3 100644 --- a/nodejs/test/e2e/tools.test.ts +++ b/nodejs/test/e2e/tools.test.ts @@ -160,6 +160,8 @@ describe("Custom tools", async () => { }); it("skipPermission sent in tool definition", async () => { + // TODO: Once the CLI respects skipPermission, use a tracking permission handler + // and assert it was NOT called for this tool. const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ diff --git a/python/e2e/test_tools.py b/python/e2e/test_tools.py index 4c96f7460..a47f1a5b3 100644 --- a/python/e2e/test_tools.py +++ b/python/e2e/test_tools.py @@ -150,6 +150,8 @@ class LookupParams(BaseModel): def safe_lookup(params: LookupParams, invocation: ToolInvocation) -> str: return f"RESULT: {params.id}" + # TODO: Once the CLI respects skip_permission, use a tracking permission handler + # and assert it was NOT called for this tool. session = await ctx.client.create_session( {"tools": [safe_lookup], "on_permission_request": PermissionHandler.approve_all} ) From ef046b893d70ecbd4f0cc544a14069c47f6b4833 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 13 Mar 2026 12:40:12 +0000 Subject: [PATCH 6/6] Assert skipPermission tools do not trigger permission handler Replace approveAll with tracking permission handlers in all skipPermission tests (Node.js, Python, Go, .NET) and assert the handler is never called. Removes outdated TODO comments since the CLI already respects skipPermission. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Types.cs | 3 +++ dotnet/test/ToolsTests.cs | 10 +++++++--- go/internal/e2e/tools_test.go | 12 +++++++++--- go/types.go | 3 +++ nodejs/test/e2e/tools.test.ts | 9 ++++++--- python/e2e/test_tools.py | 12 +++++++++--- 6 files changed, 37 insertions(+), 12 deletions(-) diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 908c3e46e..a562ac904 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -283,6 +283,9 @@ public class ToolInvocation /// Gets the kind indicating the permission was denied interactively by the user. public static PermissionRequestResultKind DeniedInteractivelyByUser { get; } = new("denied-interactively-by-user"); + /// Gets the kind indicating the permission was denied interactively by the user. + public static PermissionRequestResultKind NoResult { get; } = new("no-result"); + /// Gets the underlying string value of this . public string Value => _value ?? string.Empty; diff --git a/dotnet/test/ToolsTests.cs b/dotnet/test/ToolsTests.cs index 8122ecc9e..c2350cbff 100644 --- a/dotnet/test/ToolsTests.cs +++ b/dotnet/test/ToolsTests.cs @@ -195,12 +195,15 @@ static string SafeLookup([Description("Lookup ID")] string id) new Dictionary { ["skip_permission"] = true }) }); - // TODO: Once the CLI respects skip_permission, use a tracking permission handler - // and assert it was NOT called for this tool. + var didRunPermissionRequest = false; var session = await CreateSessionAsync(new SessionConfig { Tools = [tool], - OnPermissionRequest = PermissionHandler.ApproveAll, + OnPermissionRequest = (_, _) => + { + didRunPermissionRequest = true; + return Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.NoResult }); + } }); await session.SendAsync(new MessageOptions @@ -211,6 +214,7 @@ await session.SendAsync(new MessageOptions var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session); Assert.NotNull(assistantMessage); Assert.Contains("RESULT", assistantMessage!.Data.Content ?? string.Empty); + Assert.False(didRunPermissionRequest); } [Fact(Skip = "Behaves as if no content was in the result. Likely that binary results aren't fully implemented yet.")] diff --git a/go/internal/e2e/tools_test.go b/go/internal/e2e/tools_test.go index 4e68b8c1f..c9676363f 100644 --- a/go/internal/e2e/tools_test.go +++ b/go/internal/e2e/tools_test.go @@ -277,10 +277,12 @@ func TestTools(t *testing.T) { }) safeLookupTool.SkipPermission = true - // TODO: Once the CLI respects SkipPermission, use a tracking permission handler - // and assert it was NOT called for this tool. + didRunPermissionRequest := false session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + didRunPermissionRequest = true + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindNoResult}, nil + }, Tools: []copilot.Tool{ safeLookupTool, }, @@ -302,6 +304,10 @@ func TestTools(t *testing.T) { if answer.Data.Content == nil || !strings.Contains(*answer.Data.Content, "RESULT: test123") { t.Errorf("Expected answer to contain 'RESULT: test123', got %v", answer.Data.Content) } + + if didRunPermissionRequest { + t.Errorf("Expected permission handler to NOT be called for skipPermission tool") + } }) t.Run("overrides built-in tool with custom tool", func(t *testing.T) { diff --git a/go/types.go b/go/types.go index cd0c09922..fbe5abe5e 100644 --- a/go/types.go +++ b/go/types.go @@ -123,6 +123,9 @@ const ( // PermissionRequestResultKindDeniedInteractivelyByUser indicates the permission was denied interactively by the user. PermissionRequestResultKindDeniedInteractivelyByUser PermissionRequestResultKind = "denied-interactively-by-user" + + // PermissionRequestResultKindNoResult indicates no permission decision was made. + PermissionRequestResultKindNoResult PermissionRequestResultKind = "no-result" ) // PermissionRequestResult represents the result of a permission request diff --git a/nodejs/test/e2e/tools.test.ts b/nodejs/test/e2e/tools.test.ts index 0180c34a3..83d733686 100644 --- a/nodejs/test/e2e/tools.test.ts +++ b/nodejs/test/e2e/tools.test.ts @@ -160,10 +160,12 @@ describe("Custom tools", async () => { }); it("skipPermission sent in tool definition", async () => { - // TODO: Once the CLI respects skipPermission, use a tracking permission handler - // and assert it was NOT called for this tool. + let didRunPermissionRequest = false; const session = await client.createSession({ - onPermissionRequest: approveAll, + onPermissionRequest: () => { + didRunPermissionRequest = true; + return { kind: "no-result" }; + }, tools: [ defineTool("safe_lookup", { description: "A safe lookup that skips permission", @@ -180,6 +182,7 @@ describe("Custom tools", async () => { prompt: "Use safe_lookup to look up 'test123'", }); expect(assistantMessage?.data.content).toContain("RESULT: test123"); + expect(didRunPermissionRequest).toBe(false); }); it("overrides built-in tool with custom tool", async () => { diff --git a/python/e2e/test_tools.py b/python/e2e/test_tools.py index a47f1a5b3..9bd7abbf0 100644 --- a/python/e2e/test_tools.py +++ b/python/e2e/test_tools.py @@ -150,15 +150,21 @@ class LookupParams(BaseModel): def safe_lookup(params: LookupParams, invocation: ToolInvocation) -> str: return f"RESULT: {params.id}" - # TODO: Once the CLI respects skip_permission, use a tracking permission handler - # and assert it was NOT called for this tool. + did_run_permission_request = False + + def tracking_handler(request, invocation): + nonlocal did_run_permission_request + did_run_permission_request = True + return PermissionRequestResult(kind="no-result") + session = await ctx.client.create_session( - {"tools": [safe_lookup], "on_permission_request": PermissionHandler.approve_all} + {"tools": [safe_lookup], "on_permission_request": tracking_handler} ) await session.send({"prompt": "Use safe_lookup to look up 'test123'"}) assistant_message = await get_final_assistant_message(session) assert "RESULT: test123" in assistant_message.data.content + assert not did_run_permission_request async def test_overrides_built_in_tool_with_custom_tool(self, ctx: E2ETestContext): class GrepParams(BaseModel):