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 bdb3e8da..dd3ff51c 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 0794043d..d9ccd5d0 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 4cc73398..d71d8202 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 601215cb..7860cc3b 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 a139f294..cd0c0992 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 78a535b7..aea730de 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 c96d4b69..abf65da6 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 cbc8b10e..846b4ad0 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 6f3e4ef9..0beddbac 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 5b87bb04..9457dc16 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 a7b558ad..51b24fa4 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 573992cd..a66dadf8 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 9a397c70..3529b097 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 09565988..8c1d1d1e 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 a66dadf8..58e58d97 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 62ae7b18..12f302b4 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 00000000..dfdfa63f --- /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 7860cc3b..601215cb 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 83f3780c..e3081cc5 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 0beddbac..6f3e4ef9 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 3f5c3e09..db1c6bfd 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 b692e3f6..4c96f746 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 12f302b4..62ae7b18 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 8c1d1d1e..8122ecc9 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 e3081cc5..4e68b8c1 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 db1c6bfd..0180c34a 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 4c96f746..a47f1a5b 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 908c3e46..a562ac90 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 8122ecc9..c2350cbf 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 4e68b8c1..c9676363 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 cd0c0992..fbe5abe5 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 0180c34a..83d73368 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 a47f1a5b..9bd7abbf 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):