From ca0e7f7a40311b0a42ad2482716113f6b27605f9 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 18 Feb 2026 17:56:31 +0000 Subject: [PATCH 01/16] feat!: default-deny permissions across all SDK languages Previously, the SDKs only set requestPermission=true in the JSON-RPC session.create/session.resume calls when an onPermissionRequest handler was provided. When no handler was registered, requestPermission was omitted/false, allowing the CLI to handle permissions itself (permissive default). This change makes requestPermission always true so that all permission requests are routed through the SDK. The SDK's existing deny-by-default behavior (returning 'denied-no-approval-rule-and-could-not-request-from-user' when no handler is registered) now takes effect for all sessions. BREAKING CHANGE: Apps that do not provide an onPermissionRequest handler will now have all privileged tool operations (file writes, shell commands, URL fetches, MCP calls) denied by default. Register a handler to approve operations: Node.js: onPermissionRequest: async (request) => ({ kind: 'approved' }) Python: 'on_permission_request': lambda req, inv: {'kind': 'approved'} Go: OnPermissionRequest: func(r PermissionRequest, i PermissionInvocation) (PermissionRequestResult, error) { return PermissionRequestResult{Kind: 'approved'}, nil } .NET: OnPermissionRequest = (req, inv) => Task.FromResult(new PermissionRequestResult { Kind = 'approved' }) Changes: - nodejs/src/client.ts: always send requestPermission:true - python/copilot/client.py: always set requestPermission=True - go/client.go: always set RequestPermission=true - dotnet/src/Client.cs: always pass true for RequestPermission - All samples updated to include onPermissionRequest approve-all handler - All language READMEs updated with Permission Requests documentation section - docs/compatibility.md: fix incorrect PermissionRequestResult format and add default-deny description - go/types.go: update OnPermissionRequest doc comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/compatibility.md | 12 +++++++----- dotnet/README.md | 37 +++++++++++++++++++++++++++++++++++++ dotnet/samples/Chat.cs | 10 +++++++++- dotnet/src/Client.cs | 4 ++-- go/README.md | 36 ++++++++++++++++++++++++++++++++++++ go/client.go | 8 ++------ go/samples/chat.go | 9 ++++++++- go/types.go | 8 ++++++-- nodejs/README.md | 36 ++++++++++++++++++++++++++++++++++++ nodejs/samples/chat.ts | 8 +++++++- nodejs/src/client.ts | 4 ++-- python/README.md | 36 ++++++++++++++++++++++++++++++++++++ python/copilot/client.py | 10 ++++------ python/samples/chat.py | 10 +++++++++- 14 files changed, 201 insertions(+), 27 deletions(-) diff --git a/docs/compatibility.md b/docs/compatibility.md index bc8f54cd3..6335113d9 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -124,19 +124,21 @@ The `--share` option is not available via SDK. Workarounds: ### Permission Control +The SDK uses a **deny-by-default** permission model. All permission requests (file writes, shell commands, URL fetches, etc.) are denied unless your app provides an `onPermissionRequest` handler. + Instead of `--allow-all-paths` or `--yolo`, use the permission handler: ```typescript const session = await client.createSession({ onPermissionRequest: async (request) => { // Auto-approve everything (equivalent to --yolo) - return { approved: true }; + return { kind: "approved" }; // Or implement custom logic - if (request.kind === "shell") { - return { approved: request.command.startsWith("git") }; - } - return { approved: true }; + // if (request.kind === "shell") { + // return { kind: "denied-interactively-by-user" }; + // } + // return { kind: "approved" }; }, }); ``` diff --git a/dotnet/README.md b/dotnet/README.md index bda10059d..c68238283 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -495,6 +495,43 @@ var session = await client.CreateSessionAsync(new SessionConfig }); ``` +## Permission Requests + +The SDK uses a **deny-by-default** permission model. When the Copilot agent needs to perform privileged operations (file writes, shell commands, URL fetches, etc.), it sends a permission request to the SDK. If no `OnPermissionRequest` handler is registered, all such requests are **automatically denied**. + +To allow operations, provide an `OnPermissionRequest` handler when creating a session: + +```csharp +var session = await client.CreateSessionAsync(new SessionConfig +{ + OnPermissionRequest = async (request, invocation) => + { + // request.Kind - The type of operation: "shell", "write", "read", "url", or "mcp" + + // Approve everything (equivalent to --yolo mode in the CLI) + return new PermissionRequestResult { Kind = "approved" }; + + // Or implement fine-grained policy: + // if (request.Kind == "shell") + // return new PermissionRequestResult { Kind = "denied-interactively-by-user" }; + // return new PermissionRequestResult { Kind = "approved" }; + } +}); +``` + +**Permission request kinds:** +- `"shell"` — Execute a shell command +- `"write"` — Write to a file +- `"read"` — Read a file +- `"url"` — Fetch a URL +- `"mcp"` — Call an MCP server tool + +**Permission result kinds:** +- `"approved"` — Allow the operation +- `"denied-interactively-by-user"` — User explicitly denied +- `"denied-by-rules"` — Denied by policy rules +- `"denied-no-approval-rule-and-could-not-request-from-user"` — Default deny (no handler) + ## User Input Requests Enable the agent to ask questions to the user using the `ask_user` tool by providing an `OnUserInputRequest` handler: diff --git a/dotnet/samples/Chat.cs b/dotnet/samples/Chat.cs index abaefc7b6..e8e7dda3c 100644 --- a/dotnet/samples/Chat.cs +++ b/dotnet/samples/Chat.cs @@ -1,7 +1,15 @@ using GitHub.Copilot.SDK; await using var client = new CopilotClient(); -await using var session = await client.CreateSessionAsync(); +await using var session = await client.CreateSessionAsync(new SessionConfig +{ + // Permission requests are denied by default. Provide a handler to approve operations. + OnPermissionRequest = (request, invocation) => + { + // Approve all permission requests. Customize this to implement your own policy. + return Task.FromResult(new PermissionRequestResult { Kind = "approved" }); + } +}); using var _ = session.On(evt => { diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index f000be805..0b77af866 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -377,7 +377,7 @@ public async Task CreateSessionAsync(SessionConfig? config = nul config?.AvailableTools, config?.ExcludedTools, config?.Provider, - config?.OnPermissionRequest != null ? true : null, + (bool?)true, config?.OnUserInputRequest != null ? true : null, hasHooks ? true : null, config?.WorkingDirectory, @@ -461,7 +461,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config?.AvailableTools, config?.ExcludedTools, config?.Provider, - config?.OnPermissionRequest != null ? true : null, + (bool?)true, config?.OnUserInputRequest != null ? true : null, hasHooks ? true : null, config?.WorkingDirectory, diff --git a/go/README.md b/go/README.md index 37cb7ce07..876cb26b7 100644 --- a/go/README.md +++ b/go/README.md @@ -445,6 +445,42 @@ session, err := client.CreateSession(context.Background(), &copilot.SessionConfi > - For Azure OpenAI endpoints (`*.openai.azure.com`), you **must** use `Type: "azure"`, not `Type: "openai"`. > - The `BaseURL` should be just the host (e.g., `https://my-resource.openai.azure.com`). Do **not** include `/openai/v1` in the URL - the SDK handles path construction automatically. +## Permission Requests + +The SDK uses a **deny-by-default** permission model. When the Copilot agent needs to perform privileged operations (file writes, shell commands, URL fetches, etc.), it sends a permission request to the SDK. If no `OnPermissionRequest` handler is registered, all such requests are **automatically denied**. + +To allow operations, provide an `OnPermissionRequest` handler when creating a session: + +```go +session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + // request.Kind - The type of operation: "shell", "write", "read", "url", or "mcp" + + // Approve everything (equivalent to --yolo mode in the CLI) + return copilot.PermissionRequestResult{Kind: "approved"}, nil + + // Or implement fine-grained policy: + // if request.Kind == "shell" { + // return copilot.PermissionRequestResult{Kind: "denied-interactively-by-user"}, nil + // } + // return copilot.PermissionRequestResult{Kind: "approved"}, nil + }, +}) +``` + +**Permission request kinds:** +- `"shell"` — Execute a shell command +- `"write"` — Write to a file +- `"read"` — Read a file +- `"url"` — Fetch a URL +- `"mcp"` — Call an MCP server tool + +**Permission result kinds:** +- `"approved"` — Allow the operation +- `"denied-interactively-by-user"` — User explicitly denied +- `"denied-by-rules"` — Denied by policy rules +- `"denied-no-approval-rule-and-could-not-request-from-user"` — Default deny (no handler) + ## User Input Requests Enable the agent to ask questions to the user using the `ask_user` tool by providing an `OnUserInputRequest` handler: diff --git a/go/client.go b/go/client.go index 77a9eeeda..15886f582 100644 --- a/go/client.go +++ b/go/client.go @@ -473,9 +473,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if config.Streaming { req.Streaming = Bool(true) } - if config.OnPermissionRequest != nil { - req.RequestPermission = Bool(true) - } + req.RequestPermission = Bool(true) if config.OnUserInputRequest != nil { req.RequestUserInput = Bool(true) } @@ -562,9 +560,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, if config.Streaming { req.Streaming = Bool(true) } - if config.OnPermissionRequest != nil { - req.RequestPermission = Bool(true) - } + req.RequestPermission = Bool(true) if config.OnUserInputRequest != nil { req.RequestUserInput = Bool(true) } diff --git a/go/samples/chat.go b/go/samples/chat.go index 0e6e0d9a2..96398e21a 100644 --- a/go/samples/chat.go +++ b/go/samples/chat.go @@ -23,7 +23,14 @@ func main() { } defer client.Stop() - session, err := client.CreateSession(ctx, nil) + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + CLIPath: cliPath, + // Permission requests are denied by default. Provide a handler to approve operations. + OnPermissionRequest: func(_ copilot.PermissionRequest, _ copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + // Approve all permission requests. Customize this to implement your own policy. + return copilot.PermissionRequestResult{Kind: "approved"}, nil + }, + }) if err != nil { panic(err) } diff --git a/go/types.go b/go/types.go index b9b649b68..fab0226c5 100644 --- a/go/types.go +++ b/go/types.go @@ -349,7 +349,9 @@ type SessionConfig struct { // ExcludedTools is a list of tool names to disable. All other tools remain available. // Ignored if AvailableTools is specified. ExcludedTools []string - // OnPermissionRequest is a handler for permission requests from the server + // OnPermissionRequest is a handler for permission requests from the server. + // If nil, all permission requests are denied by default. + // Provide a handler to approve operations (file writes, shell commands, URL fetches, etc.). OnPermissionRequest PermissionHandler // OnUserInputRequest is a handler for user input requests from the agent (enables ask_user tool) OnUserInputRequest UserInputHandler @@ -426,7 +428,9 @@ type ResumeSessionConfig struct { // ReasoningEffort level for models that support it. // Valid values: "low", "medium", "high", "xhigh" ReasoningEffort string - // OnPermissionRequest is a handler for permission requests from the server + // OnPermissionRequest is a handler for permission requests from the server. + // If nil, all permission requests are denied by default. + // Provide a handler to approve operations (file writes, shell commands, URL fetches, etc.). OnPermissionRequest PermissionHandler // OnUserInputRequest is a handler for user input requests from the agent (enables ask_user tool) OnUserInputRequest UserInputHandler diff --git a/nodejs/README.md b/nodejs/README.md index 31558b8ab..32c596990 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -565,6 +565,42 @@ const session = await client.createSession({ > - For Azure OpenAI endpoints (`*.openai.azure.com`), you **must** use `type: "azure"`, not `type: "openai"`. > - The `baseUrl` should be just the host (e.g., `https://my-resource.openai.azure.com`). Do **not** include `/openai/v1` in the URL - the SDK handles path construction automatically. +## Permission Requests + +The SDK uses a **deny-by-default** permission model. When the Copilot agent needs to perform privileged operations (file writes, shell commands, URL fetches, etc.), it sends a permission request to the SDK. If no `onPermissionRequest` handler is registered, all such requests are **automatically denied**. + +To allow operations, provide an `onPermissionRequest` handler when creating a session: + +```typescript +const session = await client.createSession({ + onPermissionRequest: async (request, invocation) => { + // request.kind - The type of operation: "shell" | "write" | "read" | "url" | "mcp" + + // Approve everything (equivalent to --yolo mode in the CLI) + return { kind: "approved" }; + + // Or implement fine-grained policy: + // if (request.kind === "shell") { + // return { kind: "denied-interactively-by-user" }; + // } + // return { kind: "approved" }; + }, +}); +``` + +**Permission request kinds:** +- `"shell"` — Execute a shell command +- `"write"` — Write to a file +- `"read"` — Read a file +- `"url"` — Fetch a URL +- `"mcp"` — Call an MCP server tool + +**Permission result kinds:** +- `"approved"` — Allow the operation +- `"denied-interactively-by-user"` — User explicitly denied +- `"denied-by-rules"` — Denied by policy rules +- `"denied-no-approval-rule-and-could-not-request-from-user"` — Default deny (no handler) + ## User Input Requests Enable the agent to ask questions to the user using the `ask_user` tool by providing an `onUserInputRequest` handler: diff --git a/nodejs/samples/chat.ts b/nodejs/samples/chat.ts index f0381bb8b..285d87e4d 100644 --- a/nodejs/samples/chat.ts +++ b/nodejs/samples/chat.ts @@ -3,7 +3,13 @@ import { CopilotClient, type SessionEvent } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient(); - const session = await client.createSession(); + const session = await client.createSession({ + // Permission requests are denied by default. Provide a handler to approve operations. + onPermissionRequest: async (_request) => { + // Approve all permission requests. Customize this to implement your own policy. + return { kind: "approved" }; + }, + }); session.on((event: SessionEvent) => { let output: string | null = null; diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 5d7413140..876ca719e 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -523,7 +523,7 @@ export class CopilotClient { availableTools: config.availableTools, excludedTools: config.excludedTools, provider: config.provider, - requestPermission: !!config.onPermissionRequest, + requestPermission: true, requestUserInput: !!config.onUserInputRequest, hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)), workingDirectory: config.workingDirectory, @@ -605,7 +605,7 @@ export class CopilotClient { parameters: toJsonSchema(tool.parameters), })), provider: config.provider, - requestPermission: !!config.onPermissionRequest, + requestPermission: true, requestUserInput: !!config.onUserInputRequest, hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)), workingDirectory: config.workingDirectory, diff --git a/python/README.md b/python/README.md index aa82e0c34..f11cf1bcb 100644 --- a/python/README.md +++ b/python/README.md @@ -392,6 +392,42 @@ session = await client.create_session({ > - For Azure OpenAI endpoints (`*.openai.azure.com`), you **must** use `type: "azure"`, not `type: "openai"`. > - The `base_url` should be just the host (e.g., `https://my-resource.openai.azure.com`). Do **not** include `/openai/v1` in the URL - the SDK handles path construction automatically. +## Permission Requests + +The SDK uses a **deny-by-default** permission model. When the Copilot agent needs to perform privileged operations (file writes, shell commands, URL fetches, etc.), it sends a permission request to the SDK. If no `on_permission_request` handler is registered, all such requests are **automatically denied**. + +To allow operations, provide an `on_permission_request` handler when creating a session: + +```python +def on_permission_request(request, invocation): + # request["kind"] - The type of operation: "shell", "write", "read", "url", or "mcp" + + # Approve everything (equivalent to --yolo mode in the CLI) + return {"kind": "approved"} + + # Or implement fine-grained policy: + # if request["kind"] == "shell": + # return {"kind": "denied-interactively-by-user"} + # return {"kind": "approved"} + +session = await client.create_session({ + "on_permission_request": on_permission_request, +}) +``` + +**Permission request kinds:** +- `"shell"` — Execute a shell command +- `"write"` — Write to a file +- `"read"` — Read a file +- `"url"` — Fetch a URL +- `"mcp"` — Call an MCP server tool + +**Permission result kinds:** +- `"approved"` — Allow the operation +- `"denied-interactively-by-user"` — User explicitly denied +- `"denied-by-rules"` — Denied by policy rules +- `"denied-no-approval-rule-and-could-not-request-from-user"` — Default deny (no handler) + ## User Input Requests Enable the agent to ask questions to the user using the `ask_user` tool by providing an `on_user_input_request` handler: diff --git a/python/copilot/client.py b/python/copilot/client.py index 99154f43e..c4e69c13d 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -485,10 +485,9 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo if excluded_tools: payload["excludedTools"] = excluded_tools - # Enable permission request callback if handler provided + # Always enable permission request callback (deny by default if no handler provided) on_permission_request = cfg.get("on_permission_request") - if on_permission_request: - payload["requestPermission"] = True + payload["requestPermission"] = True # Enable user input request callback if handler provided on_user_input_request = cfg.get("on_user_input_request") @@ -662,10 +661,9 @@ async def resume_session( if streaming is not None: payload["streaming"] = streaming - # Enable permission request callback if handler provided + # Always enable permission request callback (deny by default if no handler provided) on_permission_request = cfg.get("on_permission_request") - if on_permission_request: - payload["requestPermission"] = True + payload["requestPermission"] = True # Enable user input request callback if handler provided on_user_input_request = cfg.get("on_user_input_request") diff --git a/python/samples/chat.py b/python/samples/chat.py index cfdd2eee0..63a72a5ae 100644 --- a/python/samples/chat.py +++ b/python/samples/chat.py @@ -6,10 +6,18 @@ RESET = "\033[0m" +def on_permission_request(request, invocation): + # Permission requests are denied by default. Approve all here. + # Customize this to implement your own policy. + return {"kind": "approved"} + + async def main(): client = CopilotClient() await client.start() - session = await client.create_session() + session = await client.create_session({ + "on_permission_request": on_permission_request, + }) def on_event(event): output = None From 07bdcd1b86e40790820d9763b8fe35addfa6b553 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 18 Feb 2026 18:19:37 +0000 Subject: [PATCH 02/16] fix: add onPermissionRequest handlers to E2E tests for default-deny behavior Tests that use built-in tools (file reads, shell commands, MCP) now need an onPermissionRequest handler to approve tool execution, since the SDK now sends requestPermission=true by default. - Node.js: hooks.test.ts, tools.test.ts, session.test.ts, mcp_and_agents.test.ts - Python: test_hooks.py, test_tools.py, test_mcp_and_agents.py + fix chat.py formatting - Go: mcp_and_agents_test.go - .NET: HooksTests.cs, ToolsTests.cs, McpAndAgentsTests.cs, SessionTests.cs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/test/HooksTests.cs | 4 ++++ dotnet/test/McpAndAgentsTests.cs | 3 ++- dotnet/test/SessionTests.cs | 5 ++++- dotnet/test/ToolsTests.cs | 5 ++++- go/internal/e2e/mcp_and_agents_test.go | 3 +++ nodejs/test/e2e/hooks.test.ts | 3 +++ nodejs/test/e2e/mcp_and_agents.test.ts | 1 + nodejs/test/e2e/session.test.ts | 4 +++- nodejs/test/e2e/tools.test.ts | 4 +++- python/e2e/test_hooks.py | 24 ++++++++++++++++++++---- python/e2e/test_mcp_and_agents.py | 7 ++++++- python/e2e/test_tools.py | 4 +++- python/samples/chat.py | 8 +++++--- 13 files changed, 61 insertions(+), 14 deletions(-) diff --git a/dotnet/test/HooksTests.cs b/dotnet/test/HooksTests.cs index 34f6ecabf..ca9fc5d53 100644 --- a/dotnet/test/HooksTests.cs +++ b/dotnet/test/HooksTests.cs @@ -17,6 +17,7 @@ public async Task Should_Invoke_PreToolUse_Hook_When_Model_Runs_A_Tool() CopilotSession? session = null; session = await Client.CreateSessionAsync(new SessionConfig { + OnPermissionRequest = (request, invocation) => Task.FromResult(new PermissionRequestResult { Kind = "approved" }), Hooks = new SessionHooks { OnPreToolUse = (input, invocation) => @@ -52,6 +53,7 @@ public async Task Should_Invoke_PostToolUse_Hook_After_Model_Runs_A_Tool() CopilotSession? session = null; session = await Client.CreateSessionAsync(new SessionConfig { + OnPermissionRequest = (request, invocation) => Task.FromResult(new PermissionRequestResult { Kind = "approved" }), Hooks = new SessionHooks { OnPostToolUse = (input, invocation) => @@ -89,6 +91,7 @@ public async Task Should_Invoke_Both_PreToolUse_And_PostToolUse_Hooks_For_Single var session = await Client.CreateSessionAsync(new SessionConfig { + OnPermissionRequest = (request, invocation) => Task.FromResult(new PermissionRequestResult { Kind = "approved" }), Hooks = new SessionHooks { OnPreToolUse = (input, invocation) => @@ -130,6 +133,7 @@ public async Task Should_Deny_Tool_Execution_When_PreToolUse_Returns_Deny() var session = await Client.CreateSessionAsync(new SessionConfig { + OnPermissionRequest = (request, invocation) => Task.FromResult(new PermissionRequestResult { Kind = "approved" }), Hooks = new SessionHooks { OnPreToolUse = (input, invocation) => diff --git a/dotnet/test/McpAndAgentsTests.cs b/dotnet/test/McpAndAgentsTests.cs index 2e50e77bc..0f035487f 100644 --- a/dotnet/test/McpAndAgentsTests.cs +++ b/dotnet/test/McpAndAgentsTests.cs @@ -279,7 +279,8 @@ public async Task Should_Pass_Literal_Env_Values_To_Mcp_Server_Subprocess() var session = await Client.CreateSessionAsync(new SessionConfig { - McpServers = mcpServers + McpServers = mcpServers, + OnPermissionRequest = (request, invocation) => Task.FromResult(new PermissionRequestResult { Kind = "approved" }), }); Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index 920ee67d6..432613953 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -333,7 +333,10 @@ public async Task Should_Receive_Session_Events() [Fact] public async Task Send_Returns_Immediately_While_Events_Stream_In_Background() { - var session = await Client.CreateSessionAsync(); + var session = await Client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = (request, invocation) => Task.FromResult(new PermissionRequestResult { Kind = "approved" }), + }); var events = new List(); session.On(evt => events.Add(evt.Type)); diff --git a/dotnet/test/ToolsTests.cs b/dotnet/test/ToolsTests.cs index 3d7741c99..c06ce238b 100644 --- a/dotnet/test/ToolsTests.cs +++ b/dotnet/test/ToolsTests.cs @@ -21,7 +21,10 @@ await File.WriteAllTextAsync( Path.Combine(Ctx.WorkDir, "README.md"), "# ELIZA, the only chatbot you'll ever need"); - var session = await Client.CreateSessionAsync(); + var session = await Client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = (request, invocation) => Task.FromResult(new PermissionRequestResult { Kind = "approved" }), + }); await session.SendAsync(new MessageOptions { diff --git a/go/internal/e2e/mcp_and_agents_test.go b/go/internal/e2e/mcp_and_agents_test.go index 7ba851482..9aec1e1a5 100644 --- a/go/internal/e2e/mcp_and_agents_test.go +++ b/go/internal/e2e/mcp_and_agents_test.go @@ -127,6 +127,9 @@ func TestMCPServers(t *testing.T) { session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ MCPServers: mcpServers, + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: "approved"}, nil + }, }) if err != nil { t.Fatalf("Failed to create session: %v", err) diff --git a/nodejs/test/e2e/hooks.test.ts b/nodejs/test/e2e/hooks.test.ts index 0a91f466f..6ca8be785 100644 --- a/nodejs/test/e2e/hooks.test.ts +++ b/nodejs/test/e2e/hooks.test.ts @@ -20,6 +20,7 @@ describe("Session hooks", async () => { const preToolUseInputs: PreToolUseHookInput[] = []; const session = await client.createSession({ + onPermissionRequest: async () => ({ kind: "approved" as const }), hooks: { onPreToolUse: async (input, invocation) => { preToolUseInputs.push(input); @@ -50,6 +51,7 @@ describe("Session hooks", async () => { const postToolUseInputs: PostToolUseHookInput[] = []; const session = await client.createSession({ + onPermissionRequest: async () => ({ kind: "approved" as const }), hooks: { onPostToolUse: async (input, invocation) => { postToolUseInputs.push(input); @@ -81,6 +83,7 @@ describe("Session hooks", async () => { const postToolUseInputs: PostToolUseHookInput[] = []; const session = await client.createSession({ + onPermissionRequest: async () => ({ kind: "approved" as const }), hooks: { onPreToolUse: async (input) => { preToolUseInputs.push(input); diff --git a/nodejs/test/e2e/mcp_and_agents.test.ts b/nodejs/test/e2e/mcp_and_agents.test.ts index 2cd3f37d5..d67fa8166 100644 --- a/nodejs/test/e2e/mcp_and_agents.test.ts +++ b/nodejs/test/e2e/mcp_and_agents.test.ts @@ -108,6 +108,7 @@ describe("MCP Servers and Custom Agents", async () => { const session = await client.createSession({ mcpServers, + onPermissionRequest: async () => ({ kind: "approved" as const }), }); expect(session.sessionId).toBeDefined(); diff --git a/nodejs/test/e2e/session.test.ts b/nodejs/test/e2e/session.test.ts index de1e9e6d9..d28c14373 100644 --- a/nodejs/test/e2e/session.test.ts +++ b/nodejs/test/e2e/session.test.ts @@ -366,7 +366,9 @@ describe("Send Blocking Behavior", async () => { const { copilotClient: client } = await createSdkTestContext(); it("send returns immediately while events stream in background", async () => { - const session = await client.createSession(); + const session = await client.createSession({ + onPermissionRequest: async () => ({ kind: "approved" as const }), + }); const events: string[] = []; session.on((event) => { diff --git a/nodejs/test/e2e/tools.test.ts b/nodejs/test/e2e/tools.test.ts index 85960b839..11bd41880 100644 --- a/nodejs/test/e2e/tools.test.ts +++ b/nodejs/test/e2e/tools.test.ts @@ -15,7 +15,9 @@ describe("Custom tools", async () => { it("invokes built-in tools", async () => { await writeFile(join(workDir, "README.md"), "# ELIZA, the only chatbot you'll ever need"); - const session = await client.createSession(); + const session = await client.createSession({ + onPermissionRequest: async () => ({ kind: "approved" as const }), + }); const assistantMessage = await session.sendAndWait({ prompt: "What's the first line of README.md in this directory?", }); diff --git a/python/e2e/test_hooks.py b/python/e2e/test_hooks.py index b64628e0a..495c7713b 100644 --- a/python/e2e/test_hooks.py +++ b/python/e2e/test_hooks.py @@ -21,7 +21,12 @@ async def on_pre_tool_use(input_data, invocation): # Allow the tool to run return {"permissionDecision": "allow"} - session = await ctx.client.create_session({"hooks": {"on_pre_tool_use": on_pre_tool_use}}) + session = await ctx.client.create_session( + { + "hooks": {"on_pre_tool_use": on_pre_tool_use}, + "on_permission_request": lambda _req, _inv: {"kind": "approved"}, + } + ) # Create a file for the model to read write_file(ctx.work_dir, "hello.txt", "Hello from the test!") @@ -49,7 +54,12 @@ async def on_post_tool_use(input_data, invocation): assert invocation["session_id"] == session.session_id return None - session = await ctx.client.create_session({"hooks": {"on_post_tool_use": on_post_tool_use}}) + session = await ctx.client.create_session( + { + "hooks": {"on_post_tool_use": on_post_tool_use}, + "on_permission_request": lambda _req, _inv: {"kind": "approved"}, + } + ) # Create a file for the model to read write_file(ctx.work_dir, "world.txt", "World from the test!") @@ -87,7 +97,8 @@ async def on_post_tool_use(input_data, invocation): "hooks": { "on_pre_tool_use": on_pre_tool_use, "on_post_tool_use": on_post_tool_use, - } + }, + "on_permission_request": lambda _req, _inv: {"kind": "approved"}, } ) @@ -118,7 +129,12 @@ async def on_pre_tool_use(input_data, invocation): # Deny all tool calls return {"permissionDecision": "deny"} - session = await ctx.client.create_session({"hooks": {"on_pre_tool_use": on_pre_tool_use}}) + session = await ctx.client.create_session( + { + "hooks": {"on_pre_tool_use": on_pre_tool_use}, + "on_permission_request": lambda _req, _inv: {"kind": "approved"}, + } + ) # Create a file original_content = "Original content that should not be modified" diff --git a/python/e2e/test_mcp_and_agents.py b/python/e2e/test_mcp_and_agents.py index 3dd7f4aab..fa5717e41 100644 --- a/python/e2e/test_mcp_and_agents.py +++ b/python/e2e/test_mcp_and_agents.py @@ -87,7 +87,12 @@ async def test_should_pass_literal_env_values_to_mcp_server_subprocess( } } - session = await ctx.client.create_session({"mcp_servers": mcp_servers}) + session = await ctx.client.create_session( + { + "mcp_servers": mcp_servers, + "on_permission_request": lambda _req, _inv: {"kind": "approved"}, + } + ) assert session.session_id is not None diff --git a/python/e2e/test_tools.py b/python/e2e/test_tools.py index 2e024887c..a4a326049 100644 --- a/python/e2e/test_tools.py +++ b/python/e2e/test_tools.py @@ -18,7 +18,9 @@ async def test_invokes_built_in_tools(self, ctx: E2ETestContext): with open(readme_path, "w") as f: f.write("# ELIZA, the only chatbot you'll ever need") - session = await ctx.client.create_session() + session = await ctx.client.create_session( + {"on_permission_request": lambda _req, _inv: {"kind": "approved"}} + ) await session.send({"prompt": "What's the first line of README.md in this directory?"}) assistant_message = await get_final_assistant_message(session) diff --git a/python/samples/chat.py b/python/samples/chat.py index 63a72a5ae..7eedf47d9 100644 --- a/python/samples/chat.py +++ b/python/samples/chat.py @@ -15,9 +15,11 @@ def on_permission_request(request, invocation): async def main(): client = CopilotClient() await client.start() - session = await client.create_session({ - "on_permission_request": on_permission_request, - }) + session = await client.create_session( + { + "on_permission_request": on_permission_request, + } + ) def on_event(event): output = None From 7ba9d36f699a15959ba5bf41f7e681e30c013814 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 18 Feb 2026 19:28:33 +0000 Subject: [PATCH 03/16] fix: add OnPermissionRequest handlers to Go hooks tests Go TestHooks tests also need approve-all permission handlers since requestPermission=true is now always sent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/internal/e2e/hooks_test.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/go/internal/e2e/hooks_test.go b/go/internal/e2e/hooks_test.go index 9f1a9ec05..94c866675 100644 --- a/go/internal/e2e/hooks_test.go +++ b/go/internal/e2e/hooks_test.go @@ -22,6 +22,9 @@ func TestHooks(t *testing.T) { var mu sync.Mutex session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: "approved"}, nil + }, Hooks: &copilot.SessionHooks{ OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { mu.Lock() @@ -80,6 +83,9 @@ func TestHooks(t *testing.T) { var mu sync.Mutex session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: "approved"}, nil + }, Hooks: &copilot.SessionHooks{ OnPostToolUse: func(input copilot.PostToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PostToolUseHookOutput, error) { mu.Lock() @@ -145,6 +151,9 @@ func TestHooks(t *testing.T) { var mu sync.Mutex session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: "approved"}, nil + }, Hooks: &copilot.SessionHooks{ OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { mu.Lock() @@ -214,6 +223,9 @@ func TestHooks(t *testing.T) { var mu sync.Mutex session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: "approved"}, nil + }, Hooks: &copilot.SessionHooks{ OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { mu.Lock() From 64256b925bcde7615ab09a861ac80f72cdbe995f Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 18 Feb 2026 19:41:40 +0000 Subject: [PATCH 04/16] refactor: add PermissionHandlers.approveAll helper, remove verbose README sections Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/README.md | 37 -------------------------- dotnet/samples/Chat.cs | 7 +---- dotnet/src/PermissionHandlers.cs | 13 +++++++++ dotnet/test/HooksTests.cs | 8 +++--- dotnet/test/McpAndAgentsTests.cs | 2 +- dotnet/test/SessionTests.cs | 2 +- dotnet/test/ToolsTests.cs | 2 +- go/README.md | 36 ------------------------- go/internal/e2e/hooks_test.go | 16 +++-------- go/internal/e2e/mcp_and_agents_test.go | 4 +-- go/permissions.go | 11 ++++++++ go/samples/chat.go | 8 ++---- nodejs/README.md | 36 ------------------------- nodejs/samples/chat.ts | 8 ++---- nodejs/src/index.ts | 2 +- nodejs/src/types.ts | 4 +++ nodejs/test/e2e/hooks.test.ts | 7 ++--- nodejs/test/e2e/mcp_and_agents.test.ts | 3 ++- nodejs/test/e2e/session.test.ts | 4 +-- nodejs/test/e2e/tools.test.ts | 4 +-- python/README.md | 36 ------------------------- python/copilot/__init__.py | 2 ++ python/copilot/types.py | 6 +++++ python/e2e/test_hooks.py | 10 ++++--- python/e2e/test_mcp_and_agents.py | 4 +-- python/e2e/test_tools.py | 4 +-- python/samples/chat.py | 10 ++----- 27 files changed, 76 insertions(+), 210 deletions(-) create mode 100644 dotnet/src/PermissionHandlers.cs create mode 100644 go/permissions.go diff --git a/dotnet/README.md b/dotnet/README.md index c68238283..bda10059d 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -495,43 +495,6 @@ var session = await client.CreateSessionAsync(new SessionConfig }); ``` -## Permission Requests - -The SDK uses a **deny-by-default** permission model. When the Copilot agent needs to perform privileged operations (file writes, shell commands, URL fetches, etc.), it sends a permission request to the SDK. If no `OnPermissionRequest` handler is registered, all such requests are **automatically denied**. - -To allow operations, provide an `OnPermissionRequest` handler when creating a session: - -```csharp -var session = await client.CreateSessionAsync(new SessionConfig -{ - OnPermissionRequest = async (request, invocation) => - { - // request.Kind - The type of operation: "shell", "write", "read", "url", or "mcp" - - // Approve everything (equivalent to --yolo mode in the CLI) - return new PermissionRequestResult { Kind = "approved" }; - - // Or implement fine-grained policy: - // if (request.Kind == "shell") - // return new PermissionRequestResult { Kind = "denied-interactively-by-user" }; - // return new PermissionRequestResult { Kind = "approved" }; - } -}); -``` - -**Permission request kinds:** -- `"shell"` — Execute a shell command -- `"write"` — Write to a file -- `"read"` — Read a file -- `"url"` — Fetch a URL -- `"mcp"` — Call an MCP server tool - -**Permission result kinds:** -- `"approved"` — Allow the operation -- `"denied-interactively-by-user"` — User explicitly denied -- `"denied-by-rules"` — Denied by policy rules -- `"denied-no-approval-rule-and-could-not-request-from-user"` — Default deny (no handler) - ## User Input Requests Enable the agent to ask questions to the user using the `ask_user` tool by providing an `OnUserInputRequest` handler: diff --git a/dotnet/samples/Chat.cs b/dotnet/samples/Chat.cs index e8e7dda3c..72b4f22bf 100644 --- a/dotnet/samples/Chat.cs +++ b/dotnet/samples/Chat.cs @@ -3,12 +3,7 @@ await using var client = new CopilotClient(); await using var session = await client.CreateSessionAsync(new SessionConfig { - // Permission requests are denied by default. Provide a handler to approve operations. - OnPermissionRequest = (request, invocation) => - { - // Approve all permission requests. Customize this to implement your own policy. - return Task.FromResult(new PermissionRequestResult { Kind = "approved" }); - } + OnPermissionRequest = PermissionHandlers.ApproveAll }); using var _ = session.On(evt => diff --git a/dotnet/src/PermissionHandlers.cs b/dotnet/src/PermissionHandlers.cs new file mode 100644 index 000000000..f975a918c --- /dev/null +++ b/dotnet/src/PermissionHandlers.cs @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +namespace GitHub.Copilot.SDK; + +/// Provides pre-built implementations. +public static class PermissionHandlers +{ + /// A that approves all permission requests. + public static PermissionHandler ApproveAll { get; } = + (_, _) => Task.FromResult(new PermissionRequestResult { Kind = "approved" }); +} diff --git a/dotnet/test/HooksTests.cs b/dotnet/test/HooksTests.cs index ca9fc5d53..fd9822801 100644 --- a/dotnet/test/HooksTests.cs +++ b/dotnet/test/HooksTests.cs @@ -17,7 +17,7 @@ public async Task Should_Invoke_PreToolUse_Hook_When_Model_Runs_A_Tool() CopilotSession? session = null; session = await Client.CreateSessionAsync(new SessionConfig { - OnPermissionRequest = (request, invocation) => Task.FromResult(new PermissionRequestResult { Kind = "approved" }), + OnPermissionRequest = PermissionHandlers.ApproveAll, Hooks = new SessionHooks { OnPreToolUse = (input, invocation) => @@ -53,7 +53,7 @@ public async Task Should_Invoke_PostToolUse_Hook_After_Model_Runs_A_Tool() CopilotSession? session = null; session = await Client.CreateSessionAsync(new SessionConfig { - OnPermissionRequest = (request, invocation) => Task.FromResult(new PermissionRequestResult { Kind = "approved" }), + OnPermissionRequest = PermissionHandlers.ApproveAll, Hooks = new SessionHooks { OnPostToolUse = (input, invocation) => @@ -91,7 +91,7 @@ public async Task Should_Invoke_Both_PreToolUse_And_PostToolUse_Hooks_For_Single var session = await Client.CreateSessionAsync(new SessionConfig { - OnPermissionRequest = (request, invocation) => Task.FromResult(new PermissionRequestResult { Kind = "approved" }), + OnPermissionRequest = PermissionHandlers.ApproveAll, Hooks = new SessionHooks { OnPreToolUse = (input, invocation) => @@ -133,7 +133,7 @@ public async Task Should_Deny_Tool_Execution_When_PreToolUse_Returns_Deny() var session = await Client.CreateSessionAsync(new SessionConfig { - OnPermissionRequest = (request, invocation) => Task.FromResult(new PermissionRequestResult { Kind = "approved" }), + OnPermissionRequest = PermissionHandlers.ApproveAll, Hooks = new SessionHooks { OnPreToolUse = (input, invocation) => diff --git a/dotnet/test/McpAndAgentsTests.cs b/dotnet/test/McpAndAgentsTests.cs index 0f035487f..382a2102e 100644 --- a/dotnet/test/McpAndAgentsTests.cs +++ b/dotnet/test/McpAndAgentsTests.cs @@ -280,7 +280,7 @@ public async Task Should_Pass_Literal_Env_Values_To_Mcp_Server_Subprocess() var session = await Client.CreateSessionAsync(new SessionConfig { McpServers = mcpServers, - OnPermissionRequest = (request, invocation) => Task.FromResult(new PermissionRequestResult { Kind = "approved" }), + OnPermissionRequest = PermissionHandlers.ApproveAll, }); Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index 432613953..db1305362 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -335,7 +335,7 @@ public async Task Send_Returns_Immediately_While_Events_Stream_In_Background() { var session = await Client.CreateSessionAsync(new SessionConfig { - OnPermissionRequest = (request, invocation) => Task.FromResult(new PermissionRequestResult { Kind = "approved" }), + OnPermissionRequest = PermissionHandlers.ApproveAll, }); var events = new List(); diff --git a/dotnet/test/ToolsTests.cs b/dotnet/test/ToolsTests.cs index c06ce238b..5e1bf273d 100644 --- a/dotnet/test/ToolsTests.cs +++ b/dotnet/test/ToolsTests.cs @@ -23,7 +23,7 @@ await File.WriteAllTextAsync( var session = await Client.CreateSessionAsync(new SessionConfig { - OnPermissionRequest = (request, invocation) => Task.FromResult(new PermissionRequestResult { Kind = "approved" }), + OnPermissionRequest = PermissionHandlers.ApproveAll, }); await session.SendAsync(new MessageOptions diff --git a/go/README.md b/go/README.md index 876cb26b7..37cb7ce07 100644 --- a/go/README.md +++ b/go/README.md @@ -445,42 +445,6 @@ session, err := client.CreateSession(context.Background(), &copilot.SessionConfi > - For Azure OpenAI endpoints (`*.openai.azure.com`), you **must** use `Type: "azure"`, not `Type: "openai"`. > - The `BaseURL` should be just the host (e.g., `https://my-resource.openai.azure.com`). Do **not** include `/openai/v1` in the URL - the SDK handles path construction automatically. -## Permission Requests - -The SDK uses a **deny-by-default** permission model. When the Copilot agent needs to perform privileged operations (file writes, shell commands, URL fetches, etc.), it sends a permission request to the SDK. If no `OnPermissionRequest` handler is registered, all such requests are **automatically denied**. - -To allow operations, provide an `OnPermissionRequest` handler when creating a session: - -```go -session, err := client.CreateSession(ctx, &copilot.SessionConfig{ - OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { - // request.Kind - The type of operation: "shell", "write", "read", "url", or "mcp" - - // Approve everything (equivalent to --yolo mode in the CLI) - return copilot.PermissionRequestResult{Kind: "approved"}, nil - - // Or implement fine-grained policy: - // if request.Kind == "shell" { - // return copilot.PermissionRequestResult{Kind: "denied-interactively-by-user"}, nil - // } - // return copilot.PermissionRequestResult{Kind: "approved"}, nil - }, -}) -``` - -**Permission request kinds:** -- `"shell"` — Execute a shell command -- `"write"` — Write to a file -- `"read"` — Read a file -- `"url"` — Fetch a URL -- `"mcp"` — Call an MCP server tool - -**Permission result kinds:** -- `"approved"` — Allow the operation -- `"denied-interactively-by-user"` — User explicitly denied -- `"denied-by-rules"` — Denied by policy rules -- `"denied-no-approval-rule-and-could-not-request-from-user"` — Default deny (no handler) - ## User Input Requests Enable the agent to ask questions to the user using the `ask_user` tool by providing an `OnUserInputRequest` handler: diff --git a/go/internal/e2e/hooks_test.go b/go/internal/e2e/hooks_test.go index 94c866675..646585731 100644 --- a/go/internal/e2e/hooks_test.go +++ b/go/internal/e2e/hooks_test.go @@ -22,9 +22,7 @@ func TestHooks(t *testing.T) { var mu sync.Mutex session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { - return copilot.PermissionRequestResult{Kind: "approved"}, nil - }, + OnPermissionRequest: copilot.PermissionHandlers.ApproveAll, Hooks: &copilot.SessionHooks{ OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { mu.Lock() @@ -83,9 +81,7 @@ func TestHooks(t *testing.T) { var mu sync.Mutex session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { - return copilot.PermissionRequestResult{Kind: "approved"}, nil - }, + OnPermissionRequest: copilot.PermissionHandlers.ApproveAll, Hooks: &copilot.SessionHooks{ OnPostToolUse: func(input copilot.PostToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PostToolUseHookOutput, error) { mu.Lock() @@ -151,9 +147,7 @@ func TestHooks(t *testing.T) { var mu sync.Mutex session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { - return copilot.PermissionRequestResult{Kind: "approved"}, nil - }, + OnPermissionRequest: copilot.PermissionHandlers.ApproveAll, Hooks: &copilot.SessionHooks{ OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { mu.Lock() @@ -223,9 +217,7 @@ func TestHooks(t *testing.T) { var mu sync.Mutex session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { - return copilot.PermissionRequestResult{Kind: "approved"}, nil - }, + OnPermissionRequest: copilot.PermissionHandlers.ApproveAll, Hooks: &copilot.SessionHooks{ OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { mu.Lock() diff --git a/go/internal/e2e/mcp_and_agents_test.go b/go/internal/e2e/mcp_and_agents_test.go index 9aec1e1a5..c5ca1f581 100644 --- a/go/internal/e2e/mcp_and_agents_test.go +++ b/go/internal/e2e/mcp_and_agents_test.go @@ -127,9 +127,7 @@ func TestMCPServers(t *testing.T) { session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ MCPServers: mcpServers, - OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { - return copilot.PermissionRequestResult{Kind: "approved"}, nil - }, + OnPermissionRequest: copilot.PermissionHandlers.ApproveAll, }) if err != nil { t.Fatalf("Failed to create session: %v", err) diff --git a/go/permissions.go b/go/permissions.go new file mode 100644 index 000000000..a8a21fb73 --- /dev/null +++ b/go/permissions.go @@ -0,0 +1,11 @@ +package copilot + +// PermissionHandlers provides pre-built OnPermissionRequest implementations. +var PermissionHandlers = struct { + // ApproveAll approves all permission requests. + ApproveAll func(PermissionRequest, PermissionInvocation) (PermissionRequestResult, error) +}{ + ApproveAll: func(_ PermissionRequest, _ PermissionInvocation) (PermissionRequestResult, error) { + return PermissionRequestResult{Kind: "approved"}, nil + }, +} diff --git a/go/samples/chat.go b/go/samples/chat.go index 96398e21a..7d1190caf 100644 --- a/go/samples/chat.go +++ b/go/samples/chat.go @@ -24,12 +24,8 @@ func main() { defer client.Stop() session, err := client.CreateSession(ctx, &copilot.SessionConfig{ - CLIPath: cliPath, - // Permission requests are denied by default. Provide a handler to approve operations. - OnPermissionRequest: func(_ copilot.PermissionRequest, _ copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { - // Approve all permission requests. Customize this to implement your own policy. - return copilot.PermissionRequestResult{Kind: "approved"}, nil - }, + CLIPath: cliPath, + OnPermissionRequest: copilot.PermissionHandlers.ApproveAll, }) if err != nil { panic(err) diff --git a/nodejs/README.md b/nodejs/README.md index 32c596990..31558b8ab 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -565,42 +565,6 @@ const session = await client.createSession({ > - For Azure OpenAI endpoints (`*.openai.azure.com`), you **must** use `type: "azure"`, not `type: "openai"`. > - The `baseUrl` should be just the host (e.g., `https://my-resource.openai.azure.com`). Do **not** include `/openai/v1` in the URL - the SDK handles path construction automatically. -## Permission Requests - -The SDK uses a **deny-by-default** permission model. When the Copilot agent needs to perform privileged operations (file writes, shell commands, URL fetches, etc.), it sends a permission request to the SDK. If no `onPermissionRequest` handler is registered, all such requests are **automatically denied**. - -To allow operations, provide an `onPermissionRequest` handler when creating a session: - -```typescript -const session = await client.createSession({ - onPermissionRequest: async (request, invocation) => { - // request.kind - The type of operation: "shell" | "write" | "read" | "url" | "mcp" - - // Approve everything (equivalent to --yolo mode in the CLI) - return { kind: "approved" }; - - // Or implement fine-grained policy: - // if (request.kind === "shell") { - // return { kind: "denied-interactively-by-user" }; - // } - // return { kind: "approved" }; - }, -}); -``` - -**Permission request kinds:** -- `"shell"` — Execute a shell command -- `"write"` — Write to a file -- `"read"` — Read a file -- `"url"` — Fetch a URL -- `"mcp"` — Call an MCP server tool - -**Permission result kinds:** -- `"approved"` — Allow the operation -- `"denied-interactively-by-user"` — User explicitly denied -- `"denied-by-rules"` — Denied by policy rules -- `"denied-no-approval-rule-and-could-not-request-from-user"` — Default deny (no handler) - ## User Input Requests Enable the agent to ask questions to the user using the `ask_user` tool by providing an `onUserInputRequest` handler: diff --git a/nodejs/samples/chat.ts b/nodejs/samples/chat.ts index 285d87e4d..b4c33bb4c 100644 --- a/nodejs/samples/chat.ts +++ b/nodejs/samples/chat.ts @@ -1,14 +1,10 @@ import * as readline from "node:readline"; -import { CopilotClient, type SessionEvent } from "@github/copilot-sdk"; +import { CopilotClient, PermissionHandlers, type SessionEvent } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient(); const session = await client.createSession({ - // Permission requests are denied by default. Provide a handler to approve operations. - onPermissionRequest: async (_request) => { - // Approve all permission requests. Customize this to implement your own policy. - return { kind: "approved" }; - }, + onPermissionRequest: PermissionHandlers.approveAll, }); session.on((event: SessionEvent) => { diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 5e73a1bb2..2c0cdb366 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -10,7 +10,7 @@ export { CopilotClient } from "./client.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; -export { defineTool } from "./types.js"; +export { defineTool, PermissionHandlers } from "./types.js"; export type { ConnectionState, CopilotClientOptions, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index c28068043..2c4503f81 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -230,6 +230,10 @@ export type PermissionHandler = ( invocation: { sessionId: string } ) => Promise | PermissionRequestResult; +export const PermissionHandlers = { + approveAll: (): PermissionRequestResult => ({ kind: "approved" }), +} as const; + // ============================================================================ // User Input Request Types // ============================================================================ diff --git a/nodejs/test/e2e/hooks.test.ts b/nodejs/test/e2e/hooks.test.ts index 6ca8be785..5d5fa32bc 100644 --- a/nodejs/test/e2e/hooks.test.ts +++ b/nodejs/test/e2e/hooks.test.ts @@ -11,6 +11,7 @@ import type { PostToolUseHookInput, PostToolUseHookOutput, } from "../../src/index.js"; +import { PermissionHandlers } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; describe("Session hooks", async () => { @@ -20,7 +21,7 @@ describe("Session hooks", async () => { const preToolUseInputs: PreToolUseHookInput[] = []; const session = await client.createSession({ - onPermissionRequest: async () => ({ kind: "approved" as const }), + onPermissionRequest: PermissionHandlers.approveAll, hooks: { onPreToolUse: async (input, invocation) => { preToolUseInputs.push(input); @@ -51,7 +52,7 @@ describe("Session hooks", async () => { const postToolUseInputs: PostToolUseHookInput[] = []; const session = await client.createSession({ - onPermissionRequest: async () => ({ kind: "approved" as const }), + onPermissionRequest: PermissionHandlers.approveAll, hooks: { onPostToolUse: async (input, invocation) => { postToolUseInputs.push(input); @@ -83,7 +84,7 @@ describe("Session hooks", async () => { const postToolUseInputs: PostToolUseHookInput[] = []; const session = await client.createSession({ - onPermissionRequest: async () => ({ kind: "approved" as const }), + onPermissionRequest: PermissionHandlers.approveAll, hooks: { onPreToolUse: async (input) => { preToolUseInputs.push(input); diff --git a/nodejs/test/e2e/mcp_and_agents.test.ts b/nodejs/test/e2e/mcp_and_agents.test.ts index d67fa8166..eea676f28 100644 --- a/nodejs/test/e2e/mcp_and_agents.test.ts +++ b/nodejs/test/e2e/mcp_and_agents.test.ts @@ -6,6 +6,7 @@ import { dirname, resolve } from "path"; import { fileURLToPath } from "url"; import { describe, expect, it } from "vitest"; import type { CustomAgentConfig, MCPLocalServerConfig, MCPServerConfig } from "../../src/index.js"; +import { PermissionHandlers } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; const __filename = fileURLToPath(import.meta.url); @@ -108,7 +109,7 @@ describe("MCP Servers and Custom Agents", async () => { const session = await client.createSession({ mcpServers, - onPermissionRequest: async () => ({ kind: "approved" as const }), + onPermissionRequest: PermissionHandlers.approveAll, }); expect(session.sessionId).toBeDefined(); diff --git a/nodejs/test/e2e/session.test.ts b/nodejs/test/e2e/session.test.ts index d28c14373..d913e84bb 100644 --- a/nodejs/test/e2e/session.test.ts +++ b/nodejs/test/e2e/session.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, onTestFinished } from "vitest"; import { ParsedHttpExchange } from "../../../test/harness/replayingCapiProxy.js"; -import { CopilotClient } from "../../src/index.js"; +import { CopilotClient, PermissionHandlers } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; import { getFinalAssistantMessage, getNextEventOfType } from "./harness/sdkTestHelper.js"; @@ -367,7 +367,7 @@ describe("Send Blocking Behavior", async () => { it("send returns immediately while events stream in background", async () => { const session = await client.createSession({ - onPermissionRequest: async () => ({ kind: "approved" as const }), + onPermissionRequest: PermissionHandlers.approveAll, }); const events: string[] = []; diff --git a/nodejs/test/e2e/tools.test.ts b/nodejs/test/e2e/tools.test.ts index 11bd41880..c9567a494 100644 --- a/nodejs/test/e2e/tools.test.ts +++ b/nodejs/test/e2e/tools.test.ts @@ -6,7 +6,7 @@ import { writeFile } from "fs/promises"; import { join } from "path"; import { assert, describe, expect, it } from "vitest"; import { z } from "zod"; -import { defineTool } from "../../src/index.js"; +import { defineTool, PermissionHandlers } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext"; describe("Custom tools", async () => { @@ -16,7 +16,7 @@ describe("Custom tools", async () => { await writeFile(join(workDir, "README.md"), "# ELIZA, the only chatbot you'll ever need"); const session = await client.createSession({ - onPermissionRequest: async () => ({ kind: "approved" as const }), + onPermissionRequest: PermissionHandlers.approveAll, }); const assistantMessage = await session.sendAndWait({ prompt: "What's the first line of README.md in this directory?", diff --git a/python/README.md b/python/README.md index f11cf1bcb..aa82e0c34 100644 --- a/python/README.md +++ b/python/README.md @@ -392,42 +392,6 @@ session = await client.create_session({ > - For Azure OpenAI endpoints (`*.openai.azure.com`), you **must** use `type: "azure"`, not `type: "openai"`. > - The `base_url` should be just the host (e.g., `https://my-resource.openai.azure.com`). Do **not** include `/openai/v1` in the URL - the SDK handles path construction automatically. -## Permission Requests - -The SDK uses a **deny-by-default** permission model. When the Copilot agent needs to perform privileged operations (file writes, shell commands, URL fetches, etc.), it sends a permission request to the SDK. If no `on_permission_request` handler is registered, all such requests are **automatically denied**. - -To allow operations, provide an `on_permission_request` handler when creating a session: - -```python -def on_permission_request(request, invocation): - # request["kind"] - The type of operation: "shell", "write", "read", "url", or "mcp" - - # Approve everything (equivalent to --yolo mode in the CLI) - return {"kind": "approved"} - - # Or implement fine-grained policy: - # if request["kind"] == "shell": - # return {"kind": "denied-interactively-by-user"} - # return {"kind": "approved"} - -session = await client.create_session({ - "on_permission_request": on_permission_request, -}) -``` - -**Permission request kinds:** -- `"shell"` — Execute a shell command -- `"write"` — Write to a file -- `"read"` — Read a file -- `"url"` — Fetch a URL -- `"mcp"` — Call an MCP server tool - -**Permission result kinds:** -- `"approved"` — Allow the operation -- `"denied-interactively-by-user"` — User explicitly denied -- `"denied-by-rules"` — Denied by policy rules -- `"denied-no-approval-rule-and-could-not-request-from-user"` — Default deny (no handler) - ## User Input Requests Enable the agent to ask questions to the user using the `ask_user` tool by providing an `on_user_input_request` handler: diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index f5f7ed0b1..456de8cf6 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -22,6 +22,7 @@ ModelInfo, ModelPolicy, PermissionHandler, + PermissionHandlers, PermissionRequest, PermissionRequestResult, PingResponse, @@ -58,6 +59,7 @@ "ModelInfo", "ModelPolicy", "PermissionHandler", + "PermissionHandlers", "PermissionRequest", "PermissionRequestResult", "PingResponse", diff --git a/python/copilot/types.py b/python/copilot/types.py index 0f127d445..577ff7698 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -192,6 +192,12 @@ class PermissionRequestResult(TypedDict, total=False): ] +class PermissionHandlers: + @staticmethod + def approve_all(request: Any, invocation: Any) -> dict: + return {"kind": "approved"} + + # ============================================================================ # User Input Request Types # ============================================================================ diff --git a/python/e2e/test_hooks.py b/python/e2e/test_hooks.py index 495c7713b..fd38d15a2 100644 --- a/python/e2e/test_hooks.py +++ b/python/e2e/test_hooks.py @@ -4,6 +4,8 @@ import pytest +from copilot import PermissionHandlers + from .testharness import E2ETestContext from .testharness.helper import write_file @@ -24,7 +26,7 @@ async def on_pre_tool_use(input_data, invocation): session = await ctx.client.create_session( { "hooks": {"on_pre_tool_use": on_pre_tool_use}, - "on_permission_request": lambda _req, _inv: {"kind": "approved"}, + "on_permission_request": PermissionHandlers.approve_all, } ) @@ -57,7 +59,7 @@ async def on_post_tool_use(input_data, invocation): session = await ctx.client.create_session( { "hooks": {"on_post_tool_use": on_post_tool_use}, - "on_permission_request": lambda _req, _inv: {"kind": "approved"}, + "on_permission_request": PermissionHandlers.approve_all, } ) @@ -98,7 +100,7 @@ async def on_post_tool_use(input_data, invocation): "on_pre_tool_use": on_pre_tool_use, "on_post_tool_use": on_post_tool_use, }, - "on_permission_request": lambda _req, _inv: {"kind": "approved"}, + "on_permission_request": PermissionHandlers.approve_all, } ) @@ -132,7 +134,7 @@ async def on_pre_tool_use(input_data, invocation): session = await ctx.client.create_session( { "hooks": {"on_pre_tool_use": on_pre_tool_use}, - "on_permission_request": lambda _req, _inv: {"kind": "approved"}, + "on_permission_request": PermissionHandlers.approve_all, } ) diff --git a/python/e2e/test_mcp_and_agents.py b/python/e2e/test_mcp_and_agents.py index fa5717e41..075c01dca 100644 --- a/python/e2e/test_mcp_and_agents.py +++ b/python/e2e/test_mcp_and_agents.py @@ -6,7 +6,7 @@ import pytest -from copilot import CustomAgentConfig, MCPServerConfig +from copilot import CustomAgentConfig, MCPServerConfig, PermissionHandlers from .testharness import E2ETestContext, get_final_assistant_message @@ -90,7 +90,7 @@ async def test_should_pass_literal_env_values_to_mcp_server_subprocess( session = await ctx.client.create_session( { "mcp_servers": mcp_servers, - "on_permission_request": lambda _req, _inv: {"kind": "approved"}, + "on_permission_request": PermissionHandlers.approve_all, } ) diff --git a/python/e2e/test_tools.py b/python/e2e/test_tools.py index a4a326049..cfdac198c 100644 --- a/python/e2e/test_tools.py +++ b/python/e2e/test_tools.py @@ -5,7 +5,7 @@ import pytest from pydantic import BaseModel, Field -from copilot import ToolInvocation, define_tool +from copilot import PermissionHandlers, ToolInvocation, define_tool from .testharness import E2ETestContext, get_final_assistant_message @@ -19,7 +19,7 @@ async def test_invokes_built_in_tools(self, ctx: E2ETestContext): f.write("# ELIZA, the only chatbot you'll ever need") session = await ctx.client.create_session( - {"on_permission_request": lambda _req, _inv: {"kind": "approved"}} + {"on_permission_request": PermissionHandlers.approve_all} ) await session.send({"prompt": "What's the first line of README.md in this directory?"}) diff --git a/python/samples/chat.py b/python/samples/chat.py index 7eedf47d9..b60ea178d 100644 --- a/python/samples/chat.py +++ b/python/samples/chat.py @@ -1,23 +1,17 @@ import asyncio -from copilot import CopilotClient +from copilot import CopilotClient, PermissionHandlers BLUE = "\033[34m" RESET = "\033[0m" -def on_permission_request(request, invocation): - # Permission requests are denied by default. Approve all here. - # Customize this to implement your own policy. - return {"kind": "approved"} - - async def main(): client = CopilotClient() await client.start() session = await client.create_session( { - "on_permission_request": on_permission_request, + "on_permission_request": PermissionHandlers.approve_all, } ) From a7bee9dda98864469665648520afb08ba20f4d07 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 18 Feb 2026 19:45:25 +0000 Subject: [PATCH 05/16] Rename PermissionHandlers to PermissionHandler across all SDK languages - Node.js: rename const PermissionHandlers -> PermissionHandler in types.ts/index.ts - Python: rename class PermissionHandlers -> PermissionHandler; rename type alias PermissionHandler -> _PermissionHandlerFn (internal) - Go: rename var PermissionHandlers -> PermissionHandler in permissions.go - .NET: rename class PermissionHandlers -> PermissionHandler; rename delegate PermissionHandler -> PermissionRequestHandler Update all tests and samples to use PermissionHandler.ApproveAll / PermissionHandler.approve_all. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/samples/Chat.cs | 2 +- dotnet/src/PermissionHandlers.cs | 8 ++++---- dotnet/src/Session.cs | 6 +++--- dotnet/src/Types.cs | 6 +++--- dotnet/test/HooksTests.cs | 8 ++++---- dotnet/test/McpAndAgentsTests.cs | 2 +- dotnet/test/SessionTests.cs | 2 +- dotnet/test/ToolsTests.cs | 2 +- go/internal/e2e/hooks_test.go | 8 ++++---- go/internal/e2e/mcp_and_agents_test.go | 2 +- go/permissions.go | 4 ++-- go/samples/chat.go | 2 +- nodejs/samples/chat.ts | 4 ++-- nodejs/src/index.ts | 2 +- nodejs/src/types.ts | 2 +- nodejs/test/e2e/hooks.test.ts | 8 ++++---- nodejs/test/e2e/mcp_and_agents.test.ts | 4 ++-- nodejs/test/e2e/session.test.ts | 4 ++-- nodejs/test/e2e/tools.test.ts | 4 ++-- python/copilot/__init__.py | 2 -- python/copilot/session.py | 6 +++--- python/copilot/types.py | 10 +++++----- python/e2e/test_hooks.py | 10 +++++----- python/e2e/test_mcp_and_agents.py | 4 ++-- python/e2e/test_tools.py | 4 ++-- python/samples/chat.py | 4 ++-- 26 files changed, 59 insertions(+), 61 deletions(-) diff --git a/dotnet/samples/Chat.cs b/dotnet/samples/Chat.cs index 72b4f22bf..f4f12cfa2 100644 --- a/dotnet/samples/Chat.cs +++ b/dotnet/samples/Chat.cs @@ -3,7 +3,7 @@ await using var client = new CopilotClient(); await using var session = await client.CreateSessionAsync(new SessionConfig { - OnPermissionRequest = PermissionHandlers.ApproveAll + OnPermissionRequest = PermissionHandler.ApproveAll }); using var _ = session.On(evt => diff --git a/dotnet/src/PermissionHandlers.cs b/dotnet/src/PermissionHandlers.cs index f975a918c..22e5bdb17 100644 --- a/dotnet/src/PermissionHandlers.cs +++ b/dotnet/src/PermissionHandlers.cs @@ -4,10 +4,10 @@ namespace GitHub.Copilot.SDK; -/// Provides pre-built implementations. -public static class PermissionHandlers +/// Provides pre-built implementations. +public static class PermissionHandler { - /// A that approves all permission requests. - public static PermissionHandler ApproveAll { get; } = + /// A that approves all permission requests. + public static PermissionRequestHandler ApproveAll { get; } = (_, _) => Task.FromResult(new PermissionRequestResult { Kind = "approved" }); } diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 34f4d02d5..4feeb9f95 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -47,7 +47,7 @@ public partial class CopilotSession : IAsyncDisposable private readonly HashSet _eventHandlers = new(); private readonly Dictionary _toolHandlers = new(); private readonly JsonRpc _rpc; - private PermissionHandler? _permissionHandler; + private PermissionRequestHandler? _permissionHandler; private readonly SemaphoreSlim _permissionHandlerLock = new(1, 1); private UserInputHandler? _userInputHandler; private readonly SemaphoreSlim _userInputHandlerLock = new(1, 1); @@ -292,7 +292,7 @@ internal void RegisterTools(ICollection tools) /// When the assistant needs permission to perform certain actions (e.g., file operations), /// this handler is called to approve or deny the request. /// - internal void RegisterPermissionHandler(PermissionHandler handler) + internal void RegisterPermissionHandler(PermissionRequestHandler handler) { _permissionHandlerLock.Wait(); try @@ -313,7 +313,7 @@ internal void RegisterPermissionHandler(PermissionHandler handler) internal async Task HandlePermissionRequestAsync(JsonElement permissionRequestData) { await _permissionHandlerLock.WaitAsync(); - PermissionHandler? handler; + PermissionRequestHandler? handler; try { handler = _permissionHandler; diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 50e39ff7c..277e88b86 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -166,7 +166,7 @@ public class PermissionInvocation public string SessionId { get; set; } = string.Empty; } -public delegate Task PermissionHandler(PermissionRequest request, PermissionInvocation invocation); +public delegate Task PermissionRequestHandler(PermissionRequest request, PermissionInvocation invocation); // ============================================================================ // User Input Handler Types @@ -793,7 +793,7 @@ protected SessionConfig(SessionConfig? other) /// Handler for permission requests from the server. /// When provided, the server will call this handler to request permission for operations. /// - public PermissionHandler? OnPermissionRequest { get; set; } + public PermissionRequestHandler? OnPermissionRequest { get; set; } /// /// Handler for user input requests from the agent. @@ -932,7 +932,7 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) /// Handler for permission requests from the server. /// When provided, the server will call this handler to request permission for operations. /// - public PermissionHandler? OnPermissionRequest { get; set; } + public PermissionRequestHandler? OnPermissionRequest { get; set; } /// /// Handler for user input requests from the agent. diff --git a/dotnet/test/HooksTests.cs b/dotnet/test/HooksTests.cs index fd9822801..44a6e66c2 100644 --- a/dotnet/test/HooksTests.cs +++ b/dotnet/test/HooksTests.cs @@ -17,7 +17,7 @@ public async Task Should_Invoke_PreToolUse_Hook_When_Model_Runs_A_Tool() CopilotSession? session = null; session = await Client.CreateSessionAsync(new SessionConfig { - OnPermissionRequest = PermissionHandlers.ApproveAll, + OnPermissionRequest = PermissionHandler.ApproveAll, Hooks = new SessionHooks { OnPreToolUse = (input, invocation) => @@ -53,7 +53,7 @@ public async Task Should_Invoke_PostToolUse_Hook_After_Model_Runs_A_Tool() CopilotSession? session = null; session = await Client.CreateSessionAsync(new SessionConfig { - OnPermissionRequest = PermissionHandlers.ApproveAll, + OnPermissionRequest = PermissionHandler.ApproveAll, Hooks = new SessionHooks { OnPostToolUse = (input, invocation) => @@ -91,7 +91,7 @@ public async Task Should_Invoke_Both_PreToolUse_And_PostToolUse_Hooks_For_Single var session = await Client.CreateSessionAsync(new SessionConfig { - OnPermissionRequest = PermissionHandlers.ApproveAll, + OnPermissionRequest = PermissionHandler.ApproveAll, Hooks = new SessionHooks { OnPreToolUse = (input, invocation) => @@ -133,7 +133,7 @@ public async Task Should_Deny_Tool_Execution_When_PreToolUse_Returns_Deny() var session = await Client.CreateSessionAsync(new SessionConfig { - OnPermissionRequest = PermissionHandlers.ApproveAll, + OnPermissionRequest = PermissionHandler.ApproveAll, Hooks = new SessionHooks { OnPreToolUse = (input, invocation) => diff --git a/dotnet/test/McpAndAgentsTests.cs b/dotnet/test/McpAndAgentsTests.cs index 382a2102e..644a70bf3 100644 --- a/dotnet/test/McpAndAgentsTests.cs +++ b/dotnet/test/McpAndAgentsTests.cs @@ -280,7 +280,7 @@ public async Task Should_Pass_Literal_Env_Values_To_Mcp_Server_Subprocess() var session = await Client.CreateSessionAsync(new SessionConfig { McpServers = mcpServers, - OnPermissionRequest = PermissionHandlers.ApproveAll, + OnPermissionRequest = PermissionHandler.ApproveAll, }); Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index db1305362..c9a152ce9 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -335,7 +335,7 @@ public async Task Send_Returns_Immediately_While_Events_Stream_In_Background() { var session = await Client.CreateSessionAsync(new SessionConfig { - OnPermissionRequest = PermissionHandlers.ApproveAll, + OnPermissionRequest = PermissionHandler.ApproveAll, }); var events = new List(); diff --git a/dotnet/test/ToolsTests.cs b/dotnet/test/ToolsTests.cs index 5e1bf273d..ad1ab7a21 100644 --- a/dotnet/test/ToolsTests.cs +++ b/dotnet/test/ToolsTests.cs @@ -23,7 +23,7 @@ await File.WriteAllTextAsync( var session = await Client.CreateSessionAsync(new SessionConfig { - OnPermissionRequest = PermissionHandlers.ApproveAll, + OnPermissionRequest = PermissionHandler.ApproveAll, }); await session.SendAsync(new MessageOptions diff --git a/go/internal/e2e/hooks_test.go b/go/internal/e2e/hooks_test.go index 646585731..70aa6ec71 100644 --- a/go/internal/e2e/hooks_test.go +++ b/go/internal/e2e/hooks_test.go @@ -22,7 +22,7 @@ func TestHooks(t *testing.T) { var mu sync.Mutex session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: copilot.PermissionHandlers.ApproveAll, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Hooks: &copilot.SessionHooks{ OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { mu.Lock() @@ -81,7 +81,7 @@ func TestHooks(t *testing.T) { var mu sync.Mutex session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: copilot.PermissionHandlers.ApproveAll, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Hooks: &copilot.SessionHooks{ OnPostToolUse: func(input copilot.PostToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PostToolUseHookOutput, error) { mu.Lock() @@ -147,7 +147,7 @@ func TestHooks(t *testing.T) { var mu sync.Mutex session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: copilot.PermissionHandlers.ApproveAll, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Hooks: &copilot.SessionHooks{ OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { mu.Lock() @@ -217,7 +217,7 @@ func TestHooks(t *testing.T) { var mu sync.Mutex session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: copilot.PermissionHandlers.ApproveAll, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Hooks: &copilot.SessionHooks{ OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { mu.Lock() diff --git a/go/internal/e2e/mcp_and_agents_test.go b/go/internal/e2e/mcp_and_agents_test.go index c5ca1f581..74ba607dc 100644 --- a/go/internal/e2e/mcp_and_agents_test.go +++ b/go/internal/e2e/mcp_and_agents_test.go @@ -127,7 +127,7 @@ func TestMCPServers(t *testing.T) { session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ MCPServers: mcpServers, - OnPermissionRequest: copilot.PermissionHandlers.ApproveAll, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, }) if err != nil { t.Fatalf("Failed to create session: %v", err) diff --git a/go/permissions.go b/go/permissions.go index a8a21fb73..31b4b6999 100644 --- a/go/permissions.go +++ b/go/permissions.go @@ -1,7 +1,7 @@ package copilot -// PermissionHandlers provides pre-built OnPermissionRequest implementations. -var PermissionHandlers = struct { +// PermissionHandler provides pre-built OnPermissionRequest implementations. +var PermissionHandler = struct { // ApproveAll approves all permission requests. ApproveAll func(PermissionRequest, PermissionInvocation) (PermissionRequestResult, error) }{ diff --git a/go/samples/chat.go b/go/samples/chat.go index 7d1190caf..4fc11ffda 100644 --- a/go/samples/chat.go +++ b/go/samples/chat.go @@ -25,7 +25,7 @@ func main() { session, err := client.CreateSession(ctx, &copilot.SessionConfig{ CLIPath: cliPath, - OnPermissionRequest: copilot.PermissionHandlers.ApproveAll, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, }) if err != nil { panic(err) diff --git a/nodejs/samples/chat.ts b/nodejs/samples/chat.ts index b4c33bb4c..19c0eac7c 100644 --- a/nodejs/samples/chat.ts +++ b/nodejs/samples/chat.ts @@ -1,10 +1,10 @@ import * as readline from "node:readline"; -import { CopilotClient, PermissionHandlers, type SessionEvent } from "@github/copilot-sdk"; +import { CopilotClient, PermissionHandler, type SessionEvent } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient(); const session = await client.createSession({ - onPermissionRequest: PermissionHandlers.approveAll, + onPermissionRequest: PermissionHandler.approveAll, }); session.on((event: SessionEvent) => { diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 2c0cdb366..610205b52 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -10,7 +10,7 @@ export { CopilotClient } from "./client.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; -export { defineTool, PermissionHandlers } from "./types.js"; +export { defineTool, PermissionHandler } from "./types.js"; export type { ConnectionState, CopilotClientOptions, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 2c4503f81..d9ff9ed25 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -230,7 +230,7 @@ export type PermissionHandler = ( invocation: { sessionId: string } ) => Promise | PermissionRequestResult; -export const PermissionHandlers = { +export const PermissionHandler = { approveAll: (): PermissionRequestResult => ({ kind: "approved" }), } as const; diff --git a/nodejs/test/e2e/hooks.test.ts b/nodejs/test/e2e/hooks.test.ts index 5d5fa32bc..784174c10 100644 --- a/nodejs/test/e2e/hooks.test.ts +++ b/nodejs/test/e2e/hooks.test.ts @@ -11,7 +11,7 @@ import type { PostToolUseHookInput, PostToolUseHookOutput, } from "../../src/index.js"; -import { PermissionHandlers } from "../../src/index.js"; +import { PermissionHandler } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; describe("Session hooks", async () => { @@ -21,7 +21,7 @@ describe("Session hooks", async () => { const preToolUseInputs: PreToolUseHookInput[] = []; const session = await client.createSession({ - onPermissionRequest: PermissionHandlers.approveAll, + onPermissionRequest: PermissionHandler.approveAll, hooks: { onPreToolUse: async (input, invocation) => { preToolUseInputs.push(input); @@ -52,7 +52,7 @@ describe("Session hooks", async () => { const postToolUseInputs: PostToolUseHookInput[] = []; const session = await client.createSession({ - onPermissionRequest: PermissionHandlers.approveAll, + onPermissionRequest: PermissionHandler.approveAll, hooks: { onPostToolUse: async (input, invocation) => { postToolUseInputs.push(input); @@ -84,7 +84,7 @@ describe("Session hooks", async () => { const postToolUseInputs: PostToolUseHookInput[] = []; const session = await client.createSession({ - onPermissionRequest: PermissionHandlers.approveAll, + onPermissionRequest: PermissionHandler.approveAll, hooks: { onPreToolUse: async (input) => { preToolUseInputs.push(input); diff --git a/nodejs/test/e2e/mcp_and_agents.test.ts b/nodejs/test/e2e/mcp_and_agents.test.ts index eea676f28..6626936c3 100644 --- a/nodejs/test/e2e/mcp_and_agents.test.ts +++ b/nodejs/test/e2e/mcp_and_agents.test.ts @@ -6,7 +6,7 @@ import { dirname, resolve } from "path"; import { fileURLToPath } from "url"; import { describe, expect, it } from "vitest"; import type { CustomAgentConfig, MCPLocalServerConfig, MCPServerConfig } from "../../src/index.js"; -import { PermissionHandlers } from "../../src/index.js"; +import { PermissionHandler } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; const __filename = fileURLToPath(import.meta.url); @@ -109,7 +109,7 @@ describe("MCP Servers and Custom Agents", async () => { const session = await client.createSession({ mcpServers, - onPermissionRequest: PermissionHandlers.approveAll, + onPermissionRequest: PermissionHandler.approveAll, }); expect(session.sessionId).toBeDefined(); diff --git a/nodejs/test/e2e/session.test.ts b/nodejs/test/e2e/session.test.ts index d913e84bb..df745143c 100644 --- a/nodejs/test/e2e/session.test.ts +++ b/nodejs/test/e2e/session.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, onTestFinished } from "vitest"; import { ParsedHttpExchange } from "../../../test/harness/replayingCapiProxy.js"; -import { CopilotClient, PermissionHandlers } from "../../src/index.js"; +import { CopilotClient, PermissionHandler } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; import { getFinalAssistantMessage, getNextEventOfType } from "./harness/sdkTestHelper.js"; @@ -367,7 +367,7 @@ describe("Send Blocking Behavior", async () => { it("send returns immediately while events stream in background", async () => { const session = await client.createSession({ - onPermissionRequest: PermissionHandlers.approveAll, + onPermissionRequest: PermissionHandler.approveAll, }); const events: string[] = []; diff --git a/nodejs/test/e2e/tools.test.ts b/nodejs/test/e2e/tools.test.ts index c9567a494..2e03219fe 100644 --- a/nodejs/test/e2e/tools.test.ts +++ b/nodejs/test/e2e/tools.test.ts @@ -6,7 +6,7 @@ import { writeFile } from "fs/promises"; import { join } from "path"; import { assert, describe, expect, it } from "vitest"; import { z } from "zod"; -import { defineTool, PermissionHandlers } from "../../src/index.js"; +import { defineTool, PermissionHandler } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext"; describe("Custom tools", async () => { @@ -16,7 +16,7 @@ describe("Custom tools", async () => { await writeFile(join(workDir, "README.md"), "# ELIZA, the only chatbot you'll ever need"); const session = await client.createSession({ - onPermissionRequest: PermissionHandlers.approveAll, + onPermissionRequest: PermissionHandler.approveAll, }); const assistantMessage = await session.sendAndWait({ prompt: "What's the first line of README.md in this directory?", diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 456de8cf6..f5f7ed0b1 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -22,7 +22,6 @@ ModelInfo, ModelPolicy, PermissionHandler, - PermissionHandlers, PermissionRequest, PermissionRequestResult, PingResponse, @@ -59,7 +58,6 @@ "ModelInfo", "ModelPolicy", "PermissionHandler", - "PermissionHandlers", "PermissionRequest", "PermissionRequestResult", "PingResponse", diff --git a/python/copilot/session.py b/python/copilot/session.py index d7bd1a3f4..3460dec5c 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -14,7 +14,7 @@ from .generated.session_events import SessionEvent, SessionEventType, session_event_from_dict from .types import ( MessageOptions, - PermissionHandler, + _PermissionHandlerFn, SessionHooks, Tool, ToolHandler, @@ -74,7 +74,7 @@ def __init__(self, session_id: str, client: Any, workspace_path: Optional[str] = self._event_handlers_lock = threading.Lock() self._tool_handlers: dict[str, ToolHandler] = {} self._tool_handlers_lock = threading.Lock() - self._permission_handler: Optional[PermissionHandler] = None + self._permission_handler: Optional[_PermissionHandlerFn] = None self._permission_handler_lock = threading.Lock() self._user_input_handler: Optional[UserInputHandler] = None self._user_input_handler_lock = threading.Lock() @@ -291,7 +291,7 @@ def _get_tool_handler(self, name: str) -> Optional[ToolHandler]: with self._tool_handlers_lock: return self._tool_handlers.get(name) - def _register_permission_handler(self, handler: Optional[PermissionHandler]) -> None: + def _register_permission_handler(self, handler: Optional[_PermissionHandlerFn]) -> None: """ Register a handler for permission requests. diff --git a/python/copilot/types.py b/python/copilot/types.py index 577ff7698..5fe7ee380 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -186,13 +186,13 @@ class PermissionRequestResult(TypedDict, total=False): rules: list[Any] -PermissionHandler = Callable[ +_PermissionHandlerFn = Callable[ [PermissionRequest, dict[str, str]], Union[PermissionRequestResult, Awaitable[PermissionRequestResult]], ] -class PermissionHandlers: +class PermissionHandler: @staticmethod def approve_all(request: Any, invocation: Any) -> dict: return {"kind": "approved"} @@ -479,7 +479,7 @@ class SessionConfig(TypedDict, total=False): # List of tool names to disable (ignored if available_tools is set) excluded_tools: list[str] # Handler for permission requests from the server - on_permission_request: PermissionHandler + on_permission_request: _PermissionHandlerFn # Handler for user input requests from the agent (enables ask_user tool) on_user_input_request: UserInputHandler # Hook handlers for intercepting session lifecycle events @@ -546,8 +546,8 @@ class ResumeSessionConfig(TypedDict, total=False): provider: ProviderConfig # Reasoning effort level for models that support it. reasoning_effort: ReasoningEffort - on_permission_request: PermissionHandler - # Handler for user input requests from the agent (enables ask_user tool) + on_permission_request: _PermissionHandlerFn + # Handler for user input requestsfrom the agent (enables ask_user tool) on_user_input_request: UserInputHandler # Hook handlers for intercepting session lifecycle events hooks: SessionHooks diff --git a/python/e2e/test_hooks.py b/python/e2e/test_hooks.py index fd38d15a2..8278fb33c 100644 --- a/python/e2e/test_hooks.py +++ b/python/e2e/test_hooks.py @@ -4,7 +4,7 @@ import pytest -from copilot import PermissionHandlers +from copilot import PermissionHandler from .testharness import E2ETestContext from .testharness.helper import write_file @@ -26,7 +26,7 @@ async def on_pre_tool_use(input_data, invocation): session = await ctx.client.create_session( { "hooks": {"on_pre_tool_use": on_pre_tool_use}, - "on_permission_request": PermissionHandlers.approve_all, + "on_permission_request": PermissionHandler.approve_all, } ) @@ -59,7 +59,7 @@ async def on_post_tool_use(input_data, invocation): session = await ctx.client.create_session( { "hooks": {"on_post_tool_use": on_post_tool_use}, - "on_permission_request": PermissionHandlers.approve_all, + "on_permission_request": PermissionHandler.approve_all, } ) @@ -100,7 +100,7 @@ async def on_post_tool_use(input_data, invocation): "on_pre_tool_use": on_pre_tool_use, "on_post_tool_use": on_post_tool_use, }, - "on_permission_request": PermissionHandlers.approve_all, + "on_permission_request": PermissionHandler.approve_all, } ) @@ -134,7 +134,7 @@ async def on_pre_tool_use(input_data, invocation): session = await ctx.client.create_session( { "hooks": {"on_pre_tool_use": on_pre_tool_use}, - "on_permission_request": PermissionHandlers.approve_all, + "on_permission_request": PermissionHandler.approve_all, } ) diff --git a/python/e2e/test_mcp_and_agents.py b/python/e2e/test_mcp_and_agents.py index 075c01dca..7ca4b8c2b 100644 --- a/python/e2e/test_mcp_and_agents.py +++ b/python/e2e/test_mcp_and_agents.py @@ -6,7 +6,7 @@ import pytest -from copilot import CustomAgentConfig, MCPServerConfig, PermissionHandlers +from copilot import CustomAgentConfig, MCPServerConfig, PermissionHandler from .testharness import E2ETestContext, get_final_assistant_message @@ -90,7 +90,7 @@ async def test_should_pass_literal_env_values_to_mcp_server_subprocess( session = await ctx.client.create_session( { "mcp_servers": mcp_servers, - "on_permission_request": PermissionHandlers.approve_all, + "on_permission_request": PermissionHandler.approve_all, } ) diff --git a/python/e2e/test_tools.py b/python/e2e/test_tools.py index cfdac198c..10e61cf15 100644 --- a/python/e2e/test_tools.py +++ b/python/e2e/test_tools.py @@ -5,7 +5,7 @@ import pytest from pydantic import BaseModel, Field -from copilot import PermissionHandlers, ToolInvocation, define_tool +from copilot import PermissionHandler, ToolInvocation, define_tool from .testharness import E2ETestContext, get_final_assistant_message @@ -19,7 +19,7 @@ async def test_invokes_built_in_tools(self, ctx: E2ETestContext): f.write("# ELIZA, the only chatbot you'll ever need") session = await ctx.client.create_session( - {"on_permission_request": PermissionHandlers.approve_all} + {"on_permission_request": PermissionHandler.approve_all} ) await session.send({"prompt": "What's the first line of README.md in this directory?"}) diff --git a/python/samples/chat.py b/python/samples/chat.py index b60ea178d..eb781e4e2 100644 --- a/python/samples/chat.py +++ b/python/samples/chat.py @@ -1,6 +1,6 @@ import asyncio -from copilot import CopilotClient, PermissionHandlers +from copilot import CopilotClient, PermissionHandler BLUE = "\033[34m" RESET = "\033[0m" @@ -11,7 +11,7 @@ async def main(): await client.start() session = await client.create_session( { - "on_permission_request": PermissionHandlers.approve_all, + "on_permission_request": PermissionHandler.approve_all, } ) From ae9a0383d7b6a7c427ebede338b03baa623f2a48 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 18 Feb 2026 20:34:34 +0000 Subject: [PATCH 06/16] Fix ruff import sort in session.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/copilot/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/copilot/session.py b/python/copilot/session.py index 3460dec5c..7332f6c5f 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -14,13 +14,13 @@ from .generated.session_events import SessionEvent, SessionEventType, session_event_from_dict from .types import ( MessageOptions, - _PermissionHandlerFn, SessionHooks, Tool, ToolHandler, UserInputHandler, UserInputRequest, UserInputResponse, + _PermissionHandlerFn, ) from .types import ( SessionEvent as SessionEventTypeAlias, From 11357090c133a14ef671383b40d7c5494cec05ae Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 18 Feb 2026 20:38:28 +0000 Subject: [PATCH 07/16] Node.js: export approveAll directly instead of PermissionHandler namespace Replace PermissionHandler.approveAll with a top-level approveAll export. Update samples, tests, and docs/compatibility.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/compatibility.md | 11 +---------- nodejs/samples/chat.ts | 4 ++-- nodejs/src/index.ts | 2 +- nodejs/src/types.ts | 4 +--- nodejs/test/e2e/hooks.test.ts | 8 ++++---- nodejs/test/e2e/mcp_and_agents.test.ts | 4 ++-- nodejs/test/e2e/session.test.ts | 4 ++-- nodejs/test/e2e/tools.test.ts | 4 ++-- 8 files changed, 15 insertions(+), 26 deletions(-) diff --git a/docs/compatibility.md b/docs/compatibility.md index 6335113d9..268c077a3 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -130,16 +130,7 @@ Instead of `--allow-all-paths` or `--yolo`, use the permission handler: ```typescript const session = await client.createSession({ - onPermissionRequest: async (request) => { - // Auto-approve everything (equivalent to --yolo) - return { kind: "approved" }; - - // Or implement custom logic - // if (request.kind === "shell") { - // return { kind: "denied-interactively-by-user" }; - // } - // return { kind: "approved" }; - }, + onPermissionRequest: approveAll, }); ``` diff --git a/nodejs/samples/chat.ts b/nodejs/samples/chat.ts index 19c0eac7c..e2e05fdc3 100644 --- a/nodejs/samples/chat.ts +++ b/nodejs/samples/chat.ts @@ -1,10 +1,10 @@ import * as readline from "node:readline"; -import { CopilotClient, PermissionHandler, type SessionEvent } from "@github/copilot-sdk"; +import { CopilotClient, approveAll, type SessionEvent } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient(); const session = await client.createSession({ - onPermissionRequest: PermissionHandler.approveAll, + onPermissionRequest: approveAll, }); session.on((event: SessionEvent) => { diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 610205b52..f2655f2fc 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -10,7 +10,7 @@ export { CopilotClient } from "./client.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; -export { defineTool, PermissionHandler } from "./types.js"; +export { defineTool, approveAll } from "./types.js"; export type { ConnectionState, CopilotClientOptions, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index d9ff9ed25..516d65558 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -230,9 +230,7 @@ export type PermissionHandler = ( invocation: { sessionId: string } ) => Promise | PermissionRequestResult; -export const PermissionHandler = { - approveAll: (): PermissionRequestResult => ({ kind: "approved" }), -} as const; +export const approveAll: PermissionHandler = () => ({ kind: "approved" }); // ============================================================================ // User Input Request Types diff --git a/nodejs/test/e2e/hooks.test.ts b/nodejs/test/e2e/hooks.test.ts index 784174c10..18cc9fea0 100644 --- a/nodejs/test/e2e/hooks.test.ts +++ b/nodejs/test/e2e/hooks.test.ts @@ -11,7 +11,7 @@ import type { PostToolUseHookInput, PostToolUseHookOutput, } from "../../src/index.js"; -import { PermissionHandler } from "../../src/index.js"; +import { approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; describe("Session hooks", async () => { @@ -21,7 +21,7 @@ describe("Session hooks", async () => { const preToolUseInputs: PreToolUseHookInput[] = []; const session = await client.createSession({ - onPermissionRequest: PermissionHandler.approveAll, + onPermissionRequest: approveAll, hooks: { onPreToolUse: async (input, invocation) => { preToolUseInputs.push(input); @@ -52,7 +52,7 @@ describe("Session hooks", async () => { const postToolUseInputs: PostToolUseHookInput[] = []; const session = await client.createSession({ - onPermissionRequest: PermissionHandler.approveAll, + onPermissionRequest: approveAll, hooks: { onPostToolUse: async (input, invocation) => { postToolUseInputs.push(input); @@ -84,7 +84,7 @@ describe("Session hooks", async () => { const postToolUseInputs: PostToolUseHookInput[] = []; const session = await client.createSession({ - onPermissionRequest: PermissionHandler.approveAll, + onPermissionRequest: approveAll, hooks: { onPreToolUse: async (input) => { preToolUseInputs.push(input); diff --git a/nodejs/test/e2e/mcp_and_agents.test.ts b/nodejs/test/e2e/mcp_and_agents.test.ts index 6626936c3..7b7aabf06 100644 --- a/nodejs/test/e2e/mcp_and_agents.test.ts +++ b/nodejs/test/e2e/mcp_and_agents.test.ts @@ -6,7 +6,7 @@ import { dirname, resolve } from "path"; import { fileURLToPath } from "url"; import { describe, expect, it } from "vitest"; import type { CustomAgentConfig, MCPLocalServerConfig, MCPServerConfig } from "../../src/index.js"; -import { PermissionHandler } from "../../src/index.js"; +import { approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; const __filename = fileURLToPath(import.meta.url); @@ -109,7 +109,7 @@ describe("MCP Servers and Custom Agents", async () => { const session = await client.createSession({ mcpServers, - onPermissionRequest: PermissionHandler.approveAll, + onPermissionRequest: approveAll, }); expect(session.sessionId).toBeDefined(); diff --git a/nodejs/test/e2e/session.test.ts b/nodejs/test/e2e/session.test.ts index df745143c..09c293a53 100644 --- a/nodejs/test/e2e/session.test.ts +++ b/nodejs/test/e2e/session.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, onTestFinished } from "vitest"; import { ParsedHttpExchange } from "../../../test/harness/replayingCapiProxy.js"; -import { CopilotClient, PermissionHandler } from "../../src/index.js"; +import { CopilotClient, approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; import { getFinalAssistantMessage, getNextEventOfType } from "./harness/sdkTestHelper.js"; @@ -367,7 +367,7 @@ describe("Send Blocking Behavior", async () => { it("send returns immediately while events stream in background", async () => { const session = await client.createSession({ - onPermissionRequest: PermissionHandler.approveAll, + onPermissionRequest: approveAll, }); const events: string[] = []; diff --git a/nodejs/test/e2e/tools.test.ts b/nodejs/test/e2e/tools.test.ts index 2e03219fe..3db24dff7 100644 --- a/nodejs/test/e2e/tools.test.ts +++ b/nodejs/test/e2e/tools.test.ts @@ -6,7 +6,7 @@ import { writeFile } from "fs/promises"; import { join } from "path"; import { assert, describe, expect, it } from "vitest"; import { z } from "zod"; -import { defineTool, PermissionHandler } from "../../src/index.js"; +import { defineTool, approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext"; describe("Custom tools", async () => { @@ -16,7 +16,7 @@ describe("Custom tools", async () => { await writeFile(join(workDir, "README.md"), "# ELIZA, the only chatbot you'll ever need"); const session = await client.createSession({ - onPermissionRequest: PermissionHandler.approveAll, + onPermissionRequest: approveAll, }); const assistantMessage = await session.sendAndWait({ prompt: "What's the first line of README.md in this directory?", From 2ade8c3d9e514e97365776633a43cfb0aa2ede1c Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 18 Feb 2026 20:39:25 +0000 Subject: [PATCH 08/16] go fmt Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/internal/e2e/mcp_and_agents_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/internal/e2e/mcp_and_agents_test.go b/go/internal/e2e/mcp_and_agents_test.go index 74ba607dc..f8325b9f4 100644 --- a/go/internal/e2e/mcp_and_agents_test.go +++ b/go/internal/e2e/mcp_and_agents_test.go @@ -126,7 +126,7 @@ func TestMCPServers(t *testing.T) { } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - MCPServers: mcpServers, + MCPServers: mcpServers, OnPermissionRequest: copilot.PermissionHandler.ApproveAll, }) if err != nil { From f12448547be7fd62ac778b19a60023cef7c775b7 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 18 Feb 2026 21:13:34 +0000 Subject: [PATCH 09/16] debug: log all events in default-deny test to inspect event structure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/test/e2e/permissions.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/nodejs/test/e2e/permissions.test.ts b/nodejs/test/e2e/permissions.test.ts index 91bad2b03..14431d74c 100644 --- a/nodejs/test/e2e/permissions.test.ts +++ b/nodejs/test/e2e/permissions.test.ts @@ -63,6 +63,22 @@ describe("Permission callbacks", async () => { await session.destroy(); }); + it("should deny tool operations by default when no handler is provided", async () => { + const allEvents: unknown[] = []; + + const session = await client.createSession(); + session.on((event) => { + allEvents.push(event); + console.log("EVENT:", JSON.stringify(event, null, 2)); + }); + + await session.sendAndWait({ prompt: "Run 'node --version'" }); + + console.log("ALL EVENTS:", JSON.stringify(allEvents, null, 2)); + + await session.destroy(); + }); + it("should work without permission handler (default behavior)", async () => { // Create session without onPermissionRequest handler const session = await client.createSession(); From 9941b3a74d5227c4263bf49976d7061d11af12ae Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 18 Feb 2026 21:33:06 +0000 Subject: [PATCH 10/16] Fix default-deny e2e tests: check Permission denied in tool.execution_complete event - Fix Go naming conflict: rename type PermissionHandler to PermissionHandlerFunc - Update Go default-deny test to use session.On and check for Permission denied in result - Update Python default-deny test to check result.content contains Permission denied - Update .NET default-deny test to use session.On and check ToolExecutionCompleteEvent - Fix Node.js default-deny test to assert on permissionDenied flag - All 4 language tests now use Run 'node --version' prompt and share the same snapshot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/test/PermissionTests.cs | 24 +++++++++ go/internal/e2e/permissions_test.go | 34 +++++++++++++ go/permissions.go | 2 +- go/session.go | 6 +-- go/types.go | 8 +-- nodejs/test/e2e/permissions.test.ts | 13 +++-- python/e2e/test_permissions.py | 31 ++++++++++++ ...y_default_when_no_handler_is_provided.yaml | 49 +++++++++++++++++++ 8 files changed, 155 insertions(+), 12 deletions(-) create mode 100644 test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided.yaml diff --git a/dotnet/test/PermissionTests.cs b/dotnet/test/PermissionTests.cs index 237eb1f68..25f995741 100644 --- a/dotnet/test/PermissionTests.cs +++ b/dotnet/test/PermissionTests.cs @@ -70,6 +70,30 @@ await session.SendAsync(new MessageOptions Assert.Equal("protected content", content); } + [Fact] + public async Task Should_Deny_Tool_Operations_By_Default_When_No_Handler_Is_Provided() + { + var session = await Client.CreateSessionAsync(new SessionConfig()); + var permissionDenied = false; + + session.On(evt => + { + if (evt is ToolExecutionCompleteEvent toolEvt && + !toolEvt.Data.Success && + toolEvt.Data.Result?.Content.Contains("Permission denied") == true) + { + permissionDenied = true; + } + }); + + await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Run 'node --version'" + }); + + Assert.True(permissionDenied, "Expected a tool.execution_complete event with Permission denied result"); + } + [Fact] public async Task Should_Work_Without_Permission_Handler__Default_Behavior_() { diff --git a/go/internal/e2e/permissions_test.go b/go/internal/e2e/permissions_test.go index a891548c7..c85e14040 100644 --- a/go/internal/e2e/permissions_test.go +++ b/go/internal/e2e/permissions_test.go @@ -157,6 +157,40 @@ func TestPermissions(t *testing.T) { } }) + t.Run("should deny tool operations by default when no handler is provided", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), nil) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + var mu sync.Mutex + permissionDenied := false + + session.On(func(event copilot.SessionEvent) { + if event.Type == copilot.ToolExecutionComplete && + event.Data.Success != nil && !*event.Data.Success && + event.Data.Result != nil && strings.Contains(event.Data.Result.Content, "Permission denied") { + mu.Lock() + permissionDenied = true + mu.Unlock() + } + }) + + if _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Run 'node --version'", + }); err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if !permissionDenied { + t.Error("Expected a tool.execution_complete event with Permission denied result") + } + }) + t.Run("without permission handler", func(t *testing.T) { ctx.ConfigureForTest(t) diff --git a/go/permissions.go b/go/permissions.go index 31b4b6999..91ff776cf 100644 --- a/go/permissions.go +++ b/go/permissions.go @@ -3,7 +3,7 @@ package copilot // PermissionHandler provides pre-built OnPermissionRequest implementations. var PermissionHandler = struct { // ApproveAll approves all permission requests. - ApproveAll func(PermissionRequest, PermissionInvocation) (PermissionRequestResult, error) + ApproveAll PermissionHandlerFunc }{ ApproveAll: func(_ PermissionRequest, _ PermissionInvocation) (PermissionRequestResult, error) { return PermissionRequestResult{Kind: "approved"}, nil diff --git a/go/session.go b/go/session.go index ce1a3eff0..12d1b1afa 100644 --- a/go/session.go +++ b/go/session.go @@ -58,7 +58,7 @@ type Session struct { handlerMutex sync.RWMutex toolHandlers map[string]ToolHandler toolHandlersM sync.RWMutex - permissionHandler PermissionHandler + permissionHandler PermissionHandlerFunc permissionMux sync.RWMutex userInputHandler UserInputHandler userInputMux sync.RWMutex @@ -290,14 +290,14 @@ func (s *Session) getToolHandler(name string) (ToolHandler, bool) { // operations), this handler is called to approve or deny the request. // // This method is internal and typically called when creating a session. -func (s *Session) registerPermissionHandler(handler PermissionHandler) { +func (s *Session) registerPermissionHandler(handler PermissionHandlerFunc) { s.permissionMux.Lock() defer s.permissionMux.Unlock() s.permissionHandler = handler } // getPermissionHandler returns the currently registered permission handler, or nil. -func (s *Session) getPermissionHandler() PermissionHandler { +func (s *Session) getPermissionHandler() PermissionHandlerFunc { s.permissionMux.RLock() defer s.permissionMux.RUnlock() return s.permissionHandler diff --git a/go/types.go b/go/types.go index fab0226c5..b0f6b7e22 100644 --- a/go/types.go +++ b/go/types.go @@ -112,9 +112,9 @@ type PermissionRequestResult struct { Rules []any `json:"rules,omitempty"` } -// PermissionHandler executes a permission request +// PermissionHandlerFunc executes a permission request // The handler should return a PermissionRequestResult. Returning an error denies the permission. -type PermissionHandler func(request PermissionRequest, invocation PermissionInvocation) (PermissionRequestResult, error) +type PermissionHandlerFunc func(request PermissionRequest, invocation PermissionInvocation) (PermissionRequestResult, error) // PermissionInvocation provides context about a permission request type PermissionInvocation struct { @@ -352,7 +352,7 @@ type SessionConfig struct { // OnPermissionRequest is a handler for permission requests from the server. // If nil, all permission requests are denied by default. // Provide a handler to approve operations (file writes, shell commands, URL fetches, etc.). - OnPermissionRequest PermissionHandler + OnPermissionRequest PermissionHandlerFunc // OnUserInputRequest is a handler for user input requests from the agent (enables ask_user tool) OnUserInputRequest UserInputHandler // Hooks configures hook handlers for session lifecycle events @@ -431,7 +431,7 @@ type ResumeSessionConfig struct { // OnPermissionRequest is a handler for permission requests from the server. // If nil, all permission requests are denied by default. // Provide a handler to approve operations (file writes, shell commands, URL fetches, etc.). - OnPermissionRequest PermissionHandler + OnPermissionRequest PermissionHandlerFunc // OnUserInputRequest is a handler for user input requests from the agent (enables ask_user tool) OnUserInputRequest UserInputHandler // Hooks configures hook handlers for session lifecycle events diff --git a/nodejs/test/e2e/permissions.test.ts b/nodejs/test/e2e/permissions.test.ts index 14431d74c..c8ef59e7f 100644 --- a/nodejs/test/e2e/permissions.test.ts +++ b/nodejs/test/e2e/permissions.test.ts @@ -64,17 +64,22 @@ describe("Permission callbacks", async () => { }); it("should deny tool operations by default when no handler is provided", async () => { - const allEvents: unknown[] = []; + let permissionDenied = false; const session = await client.createSession(); session.on((event) => { - allEvents.push(event); - console.log("EVENT:", JSON.stringify(event, null, 2)); + if ( + event.type === "tool.execution_complete" && + !event.data.success && + event.data.result?.content.includes("Permission denied") + ) { + permissionDenied = true; + } }); await session.sendAndWait({ prompt: "Run 'node --version'" }); - console.log("ALL EVENTS:", JSON.stringify(allEvents, null, 2)); + expect(permissionDenied).toBe(true); await session.destroy(); }); diff --git a/python/e2e/test_permissions.py b/python/e2e/test_permissions.py index 7635219d4..374a27af8 100644 --- a/python/e2e/test_permissions.py +++ b/python/e2e/test_permissions.py @@ -68,6 +68,37 @@ def on_permission_request( await session.destroy() + async def test_should_deny_tool_operations_by_default_when_no_handler_is_provided( + self, ctx: E2ETestContext + ): + import asyncio + + session = await ctx.client.create_session() + + denied_events = [] + done_event = asyncio.Event() + + def on_event(event): + if ( + event.type.value == "tool.execution_complete" + and event.data.success is False + and event.data.result is not None + and event.data.result.content is not None + and "Permission denied" in event.data.result.content + ): + denied_events.append(event) + elif event.type.value == "session.idle": + done_event.set() + + session.on(on_event) + + await session.send({"prompt": "Run 'node --version'"}) + await asyncio.wait_for(done_event.wait(), timeout=60) + + assert len(denied_events) > 0 + + await session.destroy() + async def test_should_work_without_permission_handler__default_behavior_( self, ctx: E2ETestContext ): diff --git a/test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided.yaml b/test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided.yaml new file mode 100644 index 000000000..4413bb20a --- /dev/null +++ b/test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided.yaml @@ -0,0 +1,49 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Run 'node --version' + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Checking Node.js version"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"node --version","description":"Check Node.js version"}' + - messages: + - role: system + content: ${system} + - role: user + content: Run 'node --version' + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Checking Node.js version"}' + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"node --version","description":"Check Node.js version"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: Permission denied and could not request permission from user + - role: assistant + content: I received a permission denied error. It appears I don't have permission to execute the `node --version` + command in this environment. This might be due to security restrictions or the command not being available in + the current context. From 6091fd7dae87ca9e0855a952e587ecca5a08672a Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 18 Feb 2026 21:44:08 +0000 Subject: [PATCH 11/16] fix: check error.message not result.content for permission denied detection When a tool call is denied due to no permission handler, the SDK emits a tool.execution_complete event with success=false and the denial message in the error.message field (not result.content). Update all 4 language e2e tests to assert on error.message. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/test/PermissionTests.cs | 2 +- go/internal/e2e/permissions_test.go | 3 ++- nodejs/test/e2e/permissions.test.ts | 2 +- python/e2e/test_permissions.py | 13 +++++-------- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/dotnet/test/PermissionTests.cs b/dotnet/test/PermissionTests.cs index 25f995741..7f584aafa 100644 --- a/dotnet/test/PermissionTests.cs +++ b/dotnet/test/PermissionTests.cs @@ -80,7 +80,7 @@ public async Task Should_Deny_Tool_Operations_By_Default_When_No_Handler_Is_Prov { if (evt is ToolExecutionCompleteEvent toolEvt && !toolEvt.Data.Success && - toolEvt.Data.Result?.Content.Contains("Permission denied") == true) + toolEvt.Data.Error?.Message.Contains("Permission denied") == true) { permissionDenied = true; } diff --git a/go/internal/e2e/permissions_test.go b/go/internal/e2e/permissions_test.go index c85e14040..f1a8fe2c5 100644 --- a/go/internal/e2e/permissions_test.go +++ b/go/internal/e2e/permissions_test.go @@ -171,7 +171,8 @@ func TestPermissions(t *testing.T) { session.On(func(event copilot.SessionEvent) { if event.Type == copilot.ToolExecutionComplete && event.Data.Success != nil && !*event.Data.Success && - event.Data.Result != nil && strings.Contains(event.Data.Result.Content, "Permission denied") { + event.Data.Error != nil && event.Data.Error.ErrorClass != nil && + strings.Contains(event.Data.Error.ErrorClass.Message, "Permission denied") { mu.Lock() permissionDenied = true mu.Unlock() diff --git a/nodejs/test/e2e/permissions.test.ts b/nodejs/test/e2e/permissions.test.ts index c8ef59e7f..41da06768 100644 --- a/nodejs/test/e2e/permissions.test.ts +++ b/nodejs/test/e2e/permissions.test.ts @@ -71,7 +71,7 @@ describe("Permission callbacks", async () => { if ( event.type === "tool.execution_complete" && !event.data.success && - event.data.result?.content.includes("Permission denied") + event.data.error?.message.includes("Permission denied") ) { permissionDenied = true; } diff --git a/python/e2e/test_permissions.py b/python/e2e/test_permissions.py index 374a27af8..90247cc25 100644 --- a/python/e2e/test_permissions.py +++ b/python/e2e/test_permissions.py @@ -79,14 +79,11 @@ async def test_should_deny_tool_operations_by_default_when_no_handler_is_provide done_event = asyncio.Event() def on_event(event): - if ( - event.type.value == "tool.execution_complete" - and event.data.success is False - and event.data.result is not None - and event.data.result.content is not None - and "Permission denied" in event.data.result.content - ): - denied_events.append(event) + if event.type.value == "tool.execution_complete" and event.data.success is False: + error = event.data.error + msg = error if isinstance(error, str) else (getattr(error, "message", None) if error is not None else None) + if msg and "Permission denied" in msg: + denied_events.append(event) elif event.type.value == "session.idle": done_event.set() From 9ca45540560ff8aafd1de7e5a864964a73ea725c Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 18 Feb 2026 22:15:53 +0000 Subject: [PATCH 12/16] fix: move requestPermission outside config nil-guard in Go; add resume deny-by-default e2e tests - Go CreateSession/ResumeSession: requestPermission=true was inside the 'if config != nil' block, so nil config skipped it entirely. Move it unconditionally after the block (matching Node.js/.NET/Python). - Add 'deny by default after resume' e2e test to all 4 languages, verifying that resumeSession with no handler also denies tool calls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/test/PermissionTests.cs | 31 +++++++++++ go/client.go | 4 +- go/internal/e2e/permissions_test.go | 46 ++++++++++++++++ nodejs/test/e2e/permissions.test.ts | 25 +++++++++ python/e2e/test_permissions.py | 44 ++++++++++++++- ...n_no_handler_is_provided_after_resume.yaml | 55 +++++++++++++++++++ 6 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided_after_resume.yaml diff --git a/dotnet/test/PermissionTests.cs b/dotnet/test/PermissionTests.cs index 7f584aafa..b1295be91 100644 --- a/dotnet/test/PermissionTests.cs +++ b/dotnet/test/PermissionTests.cs @@ -185,6 +185,37 @@ await session.SendAsync(new MessageOptions Assert.Matches("fail|cannot|unable|permission", message?.Data.Content?.ToLowerInvariant() ?? string.Empty); } + [Fact] + public async Task Should_Deny_Tool_Operations_By_Default_When_No_Handler_Is_Provided_After_Resume() + { + var session1 = await Client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll + }); + var sessionId = session1.SessionId; + await session1.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" }); + + var session2 = await Client.ResumeSessionAsync(sessionId); + var permissionDenied = false; + + session2.On(evt => + { + if (evt is ToolExecutionCompleteEvent toolEvt && + !toolEvt.Data.Success && + toolEvt.Data.Error?.Message.Contains("Permission denied") == true) + { + permissionDenied = true; + } + }); + + await session2.SendAndWaitAsync(new MessageOptions + { + Prompt = "Run 'node --version'" + }); + + Assert.True(permissionDenied, "Expected a tool.execution_complete event with Permission denied result"); + } + [Fact] public async Task Should_Receive_ToolCallId_In_Permission_Requests() { diff --git a/go/client.go b/go/client.go index 15886f582..e415ab777 100644 --- a/go/client.go +++ b/go/client.go @@ -473,7 +473,6 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if config.Streaming { req.Streaming = Bool(true) } - req.RequestPermission = Bool(true) if config.OnUserInputRequest != nil { req.RequestUserInput = Bool(true) } @@ -486,6 +485,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.Hooks = Bool(true) } } + req.RequestPermission = Bool(true) result, err := c.client.Request("session.create", req) if err != nil { @@ -560,7 +560,6 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, if config.Streaming { req.Streaming = Bool(true) } - req.RequestPermission = Bool(true) if config.OnUserInputRequest != nil { req.RequestUserInput = Bool(true) } @@ -584,6 +583,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.DisabledSkills = config.DisabledSkills req.InfiniteSessions = config.InfiniteSessions } + req.RequestPermission = Bool(true) result, err := c.client.Request("session.resume", req) if err != nil { diff --git a/go/internal/e2e/permissions_test.go b/go/internal/e2e/permissions_test.go index f1a8fe2c5..1584f0244 100644 --- a/go/internal/e2e/permissions_test.go +++ b/go/internal/e2e/permissions_test.go @@ -192,6 +192,52 @@ func TestPermissions(t *testing.T) { } }) + t.Run("should deny tool operations by default when no handler is provided after resume", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + sessionID := session1.SessionID + if _, err = session1.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "What is 1+1?"}); err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + session2, err := client.ResumeSession(t.Context(), sessionID) + if err != nil { + t.Fatalf("Failed to resume session: %v", err) + } + + var mu sync.Mutex + permissionDenied := false + + session2.On(func(event copilot.SessionEvent) { + if event.Type == copilot.ToolExecutionComplete && + event.Data.Success != nil && !*event.Data.Success && + event.Data.Error != nil && event.Data.Error.ErrorClass != nil && + strings.Contains(event.Data.Error.ErrorClass.Message, "Permission denied") { + mu.Lock() + permissionDenied = true + mu.Unlock() + } + }) + + if _, err = session2.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Run 'node --version'", + }); err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if !permissionDenied { + t.Error("Expected a tool.execution_complete event with Permission denied result") + } + }) + t.Run("without permission handler", func(t *testing.T) { ctx.ConfigureForTest(t) diff --git a/nodejs/test/e2e/permissions.test.ts b/nodejs/test/e2e/permissions.test.ts index 41da06768..b68446ee9 100644 --- a/nodejs/test/e2e/permissions.test.ts +++ b/nodejs/test/e2e/permissions.test.ts @@ -6,6 +6,7 @@ import { readFile, writeFile } from "fs/promises"; import { join } from "path"; import { describe, expect, it } from "vitest"; import type { PermissionRequest, PermissionRequestResult } from "../../src/index.js"; +import { approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; describe("Permission callbacks", async () => { @@ -84,6 +85,30 @@ describe("Permission callbacks", async () => { await session.destroy(); }); + it("should deny tool operations by default when no handler is provided after resume", async () => { + const session1 = await client.createSession({ onPermissionRequest: approveAll }); + const sessionId = session1.sessionId; + await session1.sendAndWait({ prompt: "What is 1+1?" }); + + const session2 = await client.resumeSession(sessionId); + let permissionDenied = false; + session2.on((event) => { + if ( + event.type === "tool.execution_complete" && + !event.data.success && + event.data.error?.message.includes("Permission denied") + ) { + permissionDenied = true; + } + }); + + await session2.sendAndWait({ prompt: "Run 'node --version'" }); + + expect(permissionDenied).toBe(true); + + await session2.destroy(); + }); + it("should work without permission handler (default behavior)", async () => { // Create session without onPermissionRequest handler const session = await client.createSession(); diff --git a/python/e2e/test_permissions.py b/python/e2e/test_permissions.py index 90247cc25..d1f27757b 100644 --- a/python/e2e/test_permissions.py +++ b/python/e2e/test_permissions.py @@ -81,7 +81,11 @@ async def test_should_deny_tool_operations_by_default_when_no_handler_is_provide def on_event(event): if event.type.value == "tool.execution_complete" and event.data.success is False: error = event.data.error - msg = error if isinstance(error, str) else (getattr(error, "message", None) if error is not None else None) + msg = ( + error + if isinstance(error, str) + else (getattr(error, "message", None) if error is not None else None) + ) if msg and "Permission denied" in msg: denied_events.append(event) elif event.type.value == "session.idle": @@ -96,6 +100,44 @@ def on_event(event): await session.destroy() + async def test_should_deny_tool_operations_by_default_when_no_handler_is_provided_after_resume( + self, ctx: E2ETestContext + ): + import asyncio + + session1 = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) + session_id = session1.session_id + await session1.send_and_wait({"prompt": "What is 1+1?"}) + + session2 = await ctx.client.resume_session(session_id) + + denied_events = [] + done_event = asyncio.Event() + + def on_event(event): + if event.type.value == "tool.execution_complete" and event.data.success is False: + error = event.data.error + msg = ( + error + if isinstance(error, str) + else (getattr(error, "message", None) if error is not None else None) + ) + if msg and "Permission denied" in msg: + denied_events.append(event) + elif event.type.value == "session.idle": + done_event.set() + + session2.on(on_event) + + await session2.send({"prompt": "Run 'node --version'"}) + await asyncio.wait_for(done_event.wait(), timeout=60) + + assert len(denied_events) > 0 + + await session2.destroy() + async def test_should_work_without_permission_handler__default_behavior_( self, ctx: E2ETestContext ): diff --git a/test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided_after_resume.yaml b/test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided_after_resume.yaml new file mode 100644 index 000000000..788a1a783 --- /dev/null +++ b/test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided_after_resume.yaml @@ -0,0 +1,55 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 1+1? + - role: assistant + content: 1+1 = 2 + - role: user + content: Run 'node --version' + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Checking Node.js version"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"node --version","description":"Check Node.js version"}' + - messages: + - role: system + content: ${system} + - role: user + content: What is 1+1? + - role: assistant + content: 1+1 = 2 + - role: user + content: Run 'node --version' + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Checking Node.js version"}' + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"node --version","description":"Check Node.js version"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: Permission denied and could not request permission from user + - role: assistant + content: Permission was denied to run the command. I don't have access to execute shell commands in this environment. From a5cc0b7490271f244d5121ec96768c615b1347b0 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 18 Feb 2026 22:17:59 +0000 Subject: [PATCH 13/16] fix: import PermissionHandler in Python e2e test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/e2e/test_permissions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/e2e/test_permissions.py b/python/e2e/test_permissions.py index d1f27757b..b76729763 100644 --- a/python/e2e/test_permissions.py +++ b/python/e2e/test_permissions.py @@ -6,7 +6,7 @@ import pytest -from copilot import PermissionRequest, PermissionRequestResult +from copilot import PermissionHandler, PermissionRequest, PermissionRequestResult from .testharness import E2ETestContext from .testharness.helper import read_file, write_file From 594196b1c1c8af5cccfac23b71660927a6555b6a Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 18 Feb 2026 22:18:58 +0000 Subject: [PATCH 14/16] fix: remove redundant inline asyncio imports in Python e2e tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/e2e/test_permissions.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/python/e2e/test_permissions.py b/python/e2e/test_permissions.py index b76729763..80b69ebba 100644 --- a/python/e2e/test_permissions.py +++ b/python/e2e/test_permissions.py @@ -71,8 +71,6 @@ def on_permission_request( async def test_should_deny_tool_operations_by_default_when_no_handler_is_provided( self, ctx: E2ETestContext ): - import asyncio - session = await ctx.client.create_session() denied_events = [] @@ -103,8 +101,6 @@ def on_event(event): async def test_should_deny_tool_operations_by_default_when_no_handler_is_provided_after_resume( self, ctx: E2ETestContext ): - import asyncio - session1 = await ctx.client.create_session( {"on_permission_request": PermissionHandler.approve_all} ) From 63852b53a7f5e535bef93b6b804fbb9ac4dfb0f4 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 18 Feb 2026 22:30:09 +0000 Subject: [PATCH 15/16] fix: add approveAll to invokes_built-in_tools test and regenerate snapshot The test reads a file using built-in tools. Now that requestPermission=true is always sent, it needs a permission handler to avoid denial. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/internal/e2e/tools_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/go/internal/e2e/tools_test.go b/go/internal/e2e/tools_test.go index 5af9079ce..d54bdcb14 100644 --- a/go/internal/e2e/tools_test.go +++ b/go/internal/e2e/tools_test.go @@ -25,7 +25,9 @@ func TestTools(t *testing.T) { t.Fatalf("Failed to write test file: %v", err) } - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) if err != nil { t.Fatalf("Failed to create session: %v", err) } From 8aa82f411d968762acf976477f5251133c7d71a2 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 18 Feb 2026 22:43:47 +0000 Subject: [PATCH 16/16] fix(go): add permission handler to abort test and regenerate snapshot The abort test creates a session with nil config and waits for tool.execution_start. After the default-deny fix (requestPermission=true always sent), tool calls are denied before execution, so the event never fires and the test times out. Add OnPermissionRequest: PermissionHandler.ApproveAll so the shell tool can start executing, then be interrupted by the abort. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/internal/e2e/session_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/go/internal/e2e/session_test.go b/go/internal/e2e/session_test.go index 6a98da60a..87341838a 100644 --- a/go/internal/e2e/session_test.go +++ b/go/internal/e2e/session_test.go @@ -463,7 +463,9 @@ func TestSession(t *testing.T) { t.Run("should abort a session", func(t *testing.T) { ctx.ConfigureForTest(t) - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) if err != nil { t.Fatalf("Failed to create session: %v", err) }