From 03184d26a5602e3eda848aeaf6c2ed66957190dd Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:29:33 -0700 Subject: [PATCH 01/31] Expose managed approval requirement on permission requests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 18f1bbc1-6001-43e2-b293-724505087f6a --- nodejs/src/types.ts | 10 ++++++++-- nodejs/test/session-event-types.test.ts | 12 ++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 5f89ca3bed..ce558190be 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1097,9 +1097,15 @@ export type SystemMessageConfig = * discriminated union from the runtime schema — switch on `kind` to * access the variant-specific fields (e.g. shell `commands`, write * `fileName`/`diff`, mcp `toolName`/`args`). + * + * `managedApprovalRequired` indicates that managed policy requires an explicit + * user decision. Hosts should bypass automatic approval and present their + * normal confirmation UI. */ -export type { PermissionRequest } from "./generated/session-events.js"; -import type { PermissionRequest } from "./generated/session-events.js"; +import type { PermissionRequest as GeneratedPermissionRequest } from "./generated/session-events.js"; +export type PermissionRequest = GeneratedPermissionRequest & { + readonly managedApprovalRequired?: boolean; +}; import type { PermissionDecisionRequest } from "./generated/rpc.js"; diff --git a/nodejs/test/session-event-types.test.ts b/nodejs/test/session-event-types.test.ts index 37b49f8a48..9d32fc2eae 100644 --- a/nodejs/test/session-event-types.test.ts +++ b/nodejs/test/session-event-types.test.ts @@ -18,6 +18,7 @@ import { describe, expect, it } from "vitest"; import type { // The aggregate union; must still resolve via the package root. SessionEvent, + PermissionRequest, // *Data payload types from the v0.3.0 generated session-event schema. AssistantMessageData, @@ -103,6 +104,17 @@ describe("Session event type exports (#1156)", () => { expect(data.turnId).toBe("turn-1"); }); + it("exposes whether managed policy requires explicit user approval", () => { + const request: PermissionRequest = { + kind: "read", + path: "/workspace/file.txt", + intention: "Read a file", + managedApprovalRequired: true, + }; + + expect(request.managedApprovalRequired).toBe(true); + }); + it("wraps ToolExecutionStartData inside the exported ToolExecutionStartEvent", () => { const event: ToolExecutionStartEvent = { id: "evt-1", From e5417bd35727d015c97babc7dc992d407c5873dd Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:30:51 -0700 Subject: [PATCH 02/31] docs: align managed selector name with Domain Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7f00b84-b0a7-4cdf-aca9-ffd49737f26e --- nodejs/src/types.ts | 3 ++- nodejs/test/session-event-types.test.ts | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index ce558190be..f6e5c81dc1 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1100,7 +1100,8 @@ export type SystemMessageConfig = * * `managedApprovalRequired` indicates that managed policy requires an explicit * user decision. Hosts should bypass automatic approval and present their - * normal confirmation UI. + * normal confirmation UI. The runtime currently emits it for managed Shell, + * Read, Edit, and Domain selector asks. */ import type { PermissionRequest as GeneratedPermissionRequest } from "./generated/session-events.js"; export type PermissionRequest = GeneratedPermissionRequest & { diff --git a/nodejs/test/session-event-types.test.ts b/nodejs/test/session-event-types.test.ts index 9d32fc2eae..4f68f7d633 100644 --- a/nodejs/test/session-event-types.test.ts +++ b/nodejs/test/session-event-types.test.ts @@ -104,11 +104,11 @@ describe("Session event type exports (#1156)", () => { expect(data.turnId).toBe("turn-1"); }); - it("exposes whether managed policy requires explicit user approval", () => { + it("exposes explicit user approval metadata for managed Domain requests", () => { const request: PermissionRequest = { - kind: "read", - path: "/workspace/file.txt", - intention: "Read a file", + kind: "url", + url: "https://api.example.com/data", + intention: "Fetch domain data", managedApprovalRequired: true, }; From c942cf1447ecaff1cd97eb0374459af25f6b4662 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:35:31 -0700 Subject: [PATCH 03/31] Fix managed permission approval surfaces Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- nodejs/src/index.ts | 15 ++++++----- nodejs/src/types.ts | 27 ++++++++++++++++--- nodejs/test/client.test.ts | 19 ++++++++++++++ nodejs/test/session-event-types.test.ts | 35 +++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 10 deletions(-) diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 352afc6a94..e9702148d8 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -39,13 +39,14 @@ export { // consumers can import them directly from "@github/copilot-sdk" instead of // reaching into the package's internal dist layout. See issue #1156. // -// Three names from this file are also explicitly exported elsewhere in this +// Five names from this file are also explicitly exported elsewhere in this // module — `SessionEvent` (re-exported below from `./types.js`), -// `PermissionRequest` (re-exported below from `./types.js`), and -// `AssistantMessageEvent` (re-exported above from `./session.js`). Per the -// ECMAScript module spec, the explicit named re-exports shadow the names -// arriving via `export type *`, so the hand-authored public API surface for -// those three identifiers is preserved unchanged. +// `PermissionRequest` (re-exported below from `./types.js`), +// `PermissionRequestedData`/`PermissionRequestedEvent` (also re-exported below +// from `./types.js`), and `AssistantMessageEvent` (re-exported above from +// `./session.js`). Per the ECMAScript module spec, the explicit named re-exports +// shadow the names arriving via `export type *`, so the hand-authored public API +// surface for those five identifiers is preserved unchanged. export type * from "./generated/session-events.js"; export type { CommandContext, @@ -109,6 +110,8 @@ export type { NamedProviderConfig, PermissionHandler, PermissionRequest, + PermissionRequestedData, + PermissionRequestedEvent, PermissionRequestResult, ProviderConfig, ProviderModelConfig, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index f6e5c81dc1..2a6d3949ad 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -11,6 +11,9 @@ import type { Canvas } from "./canvas.js"; import type { SessionFsProvider } from "./sessionFsProvider.js"; import type { CopilotRequestHandler } from "./copilotRequestHandler.js"; import type { + PermissionRequest as GeneratedPermissionRequest, + PermissionRequestedData as GeneratedPermissionRequestedData, + PermissionRequestedEvent as GeneratedPermissionRequestedEvent, ReasoningSummary, SessionLimitsConfig, SessionEvent as GeneratedSessionEvent, @@ -35,7 +38,9 @@ export type { ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, } from "./generated/rpc.js"; -export type SessionEvent = GeneratedSessionEvent; +export type SessionEvent = + | Exclude + | PermissionRequestedEvent; export type { ReasoningSummary } from "./generated/session-events.js"; export type { SessionFsProvider } from "./sessionFsProvider.js"; export { createSessionFsAdapter } from "./sessionFsProvider.js"; @@ -1092,6 +1097,8 @@ export type SystemMessageConfig = | SystemMessageReplaceConfig | SystemMessageCustomizeConfig; +import type { PermissionDecisionRequest } from "./generated/rpc.js"; + /** * Permission request types from the server. This is the generated * discriminated union from the runtime schema — switch on `kind` to @@ -1103,12 +1110,20 @@ export type SystemMessageConfig = * normal confirmation UI. The runtime currently emits it for managed Shell, * Read, Edit, and Domain selector asks. */ -import type { PermissionRequest as GeneratedPermissionRequest } from "./generated/session-events.js"; export type PermissionRequest = GeneratedPermissionRequest & { readonly managedApprovalRequired?: boolean; }; -import type { PermissionDecisionRequest } from "./generated/rpc.js"; +export type PermissionRequestedData = Omit< + GeneratedPermissionRequestedData, + "permissionRequest" +> & { + permissionRequest: PermissionRequest; +}; + +export type PermissionRequestedEvent = Omit & { + data: PermissionRequestedData; +}; /** * Permission decision result returned from a {@link PermissionHandler}. @@ -1123,7 +1138,11 @@ export type PermissionHandler = ( invocation: { sessionId: string } ) => Promise | PermissionRequestResult; -export const approveAll: PermissionHandler = () => ({ kind: "approve-once" }); +/** + * Approves permission requests unless managed policy requires an explicit human decision. + */ +export const approveAll: PermissionHandler = (request) => + request.managedApprovalRequired ? { kind: "no-result" } : { kind: "approve-once" }; export const defaultJoinSessionPermissionHandler: PermissionHandler = (): PermissionRequestResult => ({ diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 77149bc4b9..4805b1148a 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -19,6 +19,25 @@ async function stopClient(client: CopilotClient): Promise { await client.stop(); } +describe("approveAll", () => { + const request = { + kind: "url" as const, + url: "https://api.example.com/data", + intention: "Fetch domain data", + }; + const invocation = { sessionId: "session-1" }; + + it("approves ordinary permission requests", () => { + expect(approveAll(request, invocation)).toEqual({ kind: "approve-once" }); + }); + + it("leaves managed permission requests pending for human approval", () => { + expect(approveAll({ ...request, managedApprovalRequired: true }, invocation)).toEqual({ + kind: "no-result", + }); + }); +}); + describe("CopilotClient", () => { it("disposes the stdio connection when child stdin emits an error", async () => { const client = new CopilotClient(); diff --git a/nodejs/test/session-event-types.test.ts b/nodejs/test/session-event-types.test.ts index 4f68f7d633..c5e00833d0 100644 --- a/nodejs/test/session-event-types.test.ts +++ b/nodejs/test/session-event-types.test.ts @@ -19,6 +19,8 @@ import type { // The aggregate union; must still resolve via the package root. SessionEvent, PermissionRequest, + PermissionRequestedData, + PermissionRequestedEvent, // *Data payload types from the v0.3.0 generated session-event schema. AssistantMessageData, @@ -81,6 +83,11 @@ type _AssistantMessageEventStaysAlignedWithSessionEventUnion = _AssertEqual< Extract >; const _assistantMessageEventAlignmentCheck: _AssistantMessageEventStaysAlignedWithSessionEventUnion = true; +type _PermissionRequestedEventStaysAlignedWithSessionEventUnion = _AssertEqual< + PermissionRequestedEvent, + Extract +>; +const _permissionRequestedEventAlignmentCheck: _PermissionRequestedEventStaysAlignedWithSessionEventUnion = true; describe("Session event type exports (#1156)", () => { it("exposes the headline ToolExecutionStartData type with a usable shape", () => { @@ -115,6 +122,32 @@ describe("Session event type exports (#1156)", () => { expect(request.managedApprovalRequired).toBe(true); }); + it("exposes managed approval metadata through permission event types", () => { + const data: PermissionRequestedData = { + permissionRequest: { + kind: "url", + url: "https://api.example.com/data", + intention: "Fetch domain data", + managedApprovalRequired: true, + }, + requestId: "permission-1", + }; + const event: SessionEvent = { + id: "evt-permission-1", + parentId: null, + timestamp: "2026-01-01T00:00:00.000Z", + type: "permission.requested", + data, + }; + + if (event.type !== "permission.requested") { + throw new Error("expected permission.requested narrowing"); + } + + const permissionEvent: PermissionRequestedEvent = event; + expect(permissionEvent.data.permissionRequest.managedApprovalRequired).toBe(true); + }); + it("wraps ToolExecutionStartData inside the exported ToolExecutionStartEvent", () => { const event: ToolExecutionStartEvent = { id: "evt-1", @@ -172,6 +205,7 @@ describe("Session event type exports (#1156)", () => { assertImportable(); assertImportable(); assertImportable(); + assertImportable(); assertImportable(); assertImportable(); @@ -181,6 +215,7 @@ describe("Session event type exports (#1156)", () => { assertImportable(); assertImportable(); assertImportable(); + assertImportable(); // Supporting auxiliary types referenced by the *Data shapes — these // must round-trip through the package root too, otherwise consumers From 0da1c5995fa4fffe3aecffc94221d6eb21ded841 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:40:10 -0700 Subject: [PATCH 04/31] docs: clarify managed approval behavior Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- nodejs/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/nodejs/README.md b/nodejs/README.md index e591c7dbc6..6fd2b3d790 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -36,7 +36,7 @@ import { CopilotClient, approveAll } from "@github/copilot-sdk"; const client = new CopilotClient(); await client.start(); -// Create a session (onPermissionRequest is optional; approveAll allows every tool) +// approveAll approves ordinary requests; managed requests still require a human decision. const session = await client.createSession({ model: "gpt-5", onPermissionRequest: approveAll, @@ -130,7 +130,7 @@ Create a new conversation session. - `systemMessage?: SystemMessageConfig` - System message customization (see below) - `infiniteSessions?: InfiniteSessionConfig` - Configure automatic context compaction (see below) - `provider?: ProviderConfig` - Custom API provider configuration (BYOK - Bring Your Own Key). See [Custom Providers](#custom-providers) section. -- `onPermissionRequest?: PermissionHandler` - Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. Use `approveAll` to allow everything, or provide a custom function for fine-grained control. See [Permission Handling](#permission-handling) section. +- `onPermissionRequest?: PermissionHandler` - Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. Use `approveAll` to approve ordinary requests automatically; requests with `managedApprovalRequired: true` remain pending for explicit resolution through a human-facing host flow. Provide a custom function for other fine-grained control. See [Permission Handling](#permission-handling) section. - `onUserInputRequest?: UserInputHandler` - Handler for user input requests from the agent. Enables the `ask_user` tool. See [User Input Requests](#user-input-requests) section. - `onElicitationRequest?: ElicitationHandler` - Handler for elicitation requests dispatched by the server. Enables this client to present form-based UI dialogs on behalf of the agent or other session participants. See [Elicitation Requests](#elicitation-requests) section. - `hooks?: SessionHooks` - Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. @@ -855,7 +855,7 @@ An `onPermissionRequest` handler is optional when you create or resume a session ### Approve All (simplest) -Use the built-in `approveAll` helper to allow every tool call without any checks: +Use the built-in `approveAll` helper to approve ordinary permission requests automatically: ```typescript import { CopilotClient, approveAll } from "@github/copilot-sdk"; @@ -866,6 +866,8 @@ const session = await client.createSession({ }); ``` +For requests with `managedApprovalRequired: true`, `approveAll` returns `{ kind: "no-result" }`. The request remains pending and the host must present a human-facing confirmation flow to resolve it explicitly. + ### Custom Permission Handler Provide your own function to inspect each request and apply custom logic: From dea81193b798980086df96cfef3e968c5750bd68 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:41:48 -0700 Subject: [PATCH 05/31] docs: guard managed custom approvals Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- nodejs/README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/nodejs/README.md b/nodejs/README.md index 6fd2b3d790..b85c3f22b9 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -870,7 +870,7 @@ For requests with `managedApprovalRequired: true`, `approveAll` returns `{ kind: ### Custom Permission Handler -Provide your own function to inspect each request and apply custom logic: +Provide your own function to inspect each request and apply custom logic. Check `managedApprovalRequired` before any automatic approval: ```typescript import type { PermissionRequest, PermissionRequestResult } from "@github/copilot-sdk"; @@ -878,6 +878,11 @@ import type { PermissionRequest, PermissionRequestResult } from "@github/copilot const session = await client.createSession({ model: "gpt-5", onPermissionRequest: (request: PermissionRequest, invocation): PermissionRequestResult => { + if (request.managedApprovalRequired === true) { + // Leave the request pending for the host's human-facing confirmation flow. + return { kind: "no-result" }; + } + // request.kind — what type of operation is being requested: // "shell" — executing a shell command // "write" — writing or editing a file From 0b9b0ff75f1e7b11d9af07de4279450cacaf4e49 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:06:44 -0700 Subject: [PATCH 06/31] Expose managed approvals across SDKs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/README.md | 15 +++- dotnet/src/PermissionHandlers.cs | 10 ++- dotnet/src/PermissionRequest.cs | 18 +++++ dotnet/test/Unit/PermissionHandlerTests.cs | 65 ++++++++++++++++ go/README.md | 14 +++- go/permissions.go | 8 +- go/permissions_test.go | 56 +++++++++++++ go/rpc/permission_request_managed_approval.go | 78 +++++++++++++++++++ go/rpc/zsession_events.go | 21 +++++ java/README.md | 23 ++++++ .../github/copilot/RpcHandlerDispatcher.java | 6 +- .../github/copilot/rpc/PermissionHandler.java | 15 +++- .../github/copilot/rpc/PermissionRequest.java | 63 +++++++++++++++ .../rpc/PermissionRequestResultKind.java | 8 +- .../copilot/PermissionRequestResultTest.java | 53 +++++++++++++ .../copilot/RpcHandlerDispatcherTest.java | 10 +-- python/README.md | 18 +++-- python/copilot/generated/session_events.py | 50 ++++++++++++ python/copilot/session.py | 2 + python/test_managed_permissions.py | 44 +++++++++++ rust/README.md | 8 +- rust/src/generated/session_events.rs | 30 +++++++ rust/src/handler.rs | 28 +++++-- rust/src/permission.rs | 23 +++++- rust/src/session.rs | 39 +++++++--- rust/src/types.rs | 3 + rust/tests/api_types_test.rs | 20 +++++ scripts/codegen/go.ts | 8 +- scripts/codegen/python.ts | 5 +- scripts/codegen/rust.ts | 5 +- scripts/codegen/utils.ts | 41 ++++++++++ 31 files changed, 731 insertions(+), 56 deletions(-) create mode 100644 dotnet/src/PermissionRequest.cs create mode 100644 dotnet/test/Unit/PermissionHandlerTests.cs create mode 100644 go/permissions_test.go create mode 100644 go/rpc/permission_request_managed_approval.go create mode 100644 python/test_managed_permissions.py diff --git a/dotnet/README.md b/dotnet/README.md index 796ef22549..8ba11eecd9 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -37,7 +37,7 @@ using GitHub.Copilot; await using var client = new CopilotClient(); await client.StartAsync(); -// Create a session (OnPermissionRequest is optional; ApproveAll allows every tool) +// ApproveAll approves ordinary requests; managed requests still require a human decision. await using var session = await client.CreateSessionAsync(new SessionConfig { Model = "gpt-5", @@ -125,7 +125,7 @@ Create a new conversation session. - `Provider` - Custom API provider configuration (BYOK) - `Streaming` - Enable streaming of response chunks (default: false) - `InfiniteSessions` - Configure automatic context compaction (see below) -- `OnPermissionRequest` - Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. Use `PermissionHandler.ApproveAll` to allow everything, or provide a custom function for fine-grained control. See [Permission Handling](#permission-handling) section. +- `OnPermissionRequest` - Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. `PermissionHandler.ApproveAll` approves ordinary requests automatically; requests with `ManagedApprovalRequired == true` remain pending for explicit resolution through a human-facing host flow. See [Permission Handling](#permission-handling) section. - `OnUserInputRequest` - Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section. - `Hooks` - Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. @@ -775,7 +775,7 @@ An `OnPermissionRequest` handler is optional when you create or resume a session ### Approve All (simplest) -Use the built-in `PermissionHandler.ApproveAll` helper to allow every tool call without any checks: +Use the built-in `PermissionHandler.ApproveAll` helper to approve ordinary permission requests automatically: ```csharp using GitHub.Copilot; @@ -787,9 +787,11 @@ var session = await client.CreateSessionAsync(new SessionConfig }); ``` +When `ManagedApprovalRequired` is `true`, `ApproveAll` returns `PermissionDecision.NoResult()`. The request remains pending and the host must present a human-facing confirmation flow to resolve it explicitly. + ### Custom Permission Handler -Provide your own permission handler (`Func>`) to inspect each request and apply custom logic: +Provide your own permission handler (`Func>`) to inspect each request and apply custom logic. Check `ManagedApprovalRequired` before any automatic approval: ```csharp var session = await client.CreateSessionAsync(new SessionConfig @@ -797,6 +799,11 @@ var session = await client.CreateSessionAsync(new SessionConfig Model = "gpt-5", OnPermissionRequest = async (request, invocation) => { + if (request.ManagedApprovalRequired == true) + { + return PermissionDecision.NoResult(); + } + // Pattern-match on the discriminated PermissionRequest union to access // per-kind fields (FullCommandText, Path, ToolName, …). return request switch diff --git a/dotnet/src/PermissionHandlers.cs b/dotnet/src/PermissionHandlers.cs index 4386e8ba64..e4e286534a 100644 --- a/dotnet/src/PermissionHandlers.cs +++ b/dotnet/src/PermissionHandlers.cs @@ -9,7 +9,13 @@ namespace GitHub.Copilot; /// Provides pre-built permission request handlers. public static class PermissionHandler { - /// A permission handler that approves all permission requests. + /// + /// A permission handler that approves ordinary requests and leaves managed + /// requests pending for an explicit human decision. + /// public static Func> ApproveAll { get; } = - (_, _) => Task.FromResult(PermissionDecision.ApproveOnce()); + (request, _) => Task.FromResult( + request.ManagedApprovalRequired == true + ? PermissionDecision.NoResult() + : PermissionDecision.ApproveOnce()); } diff --git a/dotnet/src/PermissionRequest.cs b/dotnet/src/PermissionRequest.cs new file mode 100644 index 0000000000..3752bb3c6b --- /dev/null +++ b/dotnet/src/PermissionRequest.cs @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System.Text.Json.Serialization; + +namespace GitHub.Copilot; + +public partial class PermissionRequest +{ + /// + /// Gets or sets whether managed policy requires an explicit human decision. + /// Automatic approval must be bypassed when this value is . + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("managedApprovalRequired")] + public bool? ManagedApprovalRequired { get; set; } +} diff --git a/dotnet/test/Unit/PermissionHandlerTests.cs b/dotnet/test/Unit/PermissionHandlerTests.cs new file mode 100644 index 0000000000..e33d4e0afa --- /dev/null +++ b/dotnet/test/Unit/PermissionHandlerTests.cs @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using GitHub.Copilot.Rpc; +using Xunit; + +namespace GitHub.Copilot.Test.Unit; + +public class PermissionHandlerTests +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }; + + [Fact] + public void PermissionEventExposesManagedApprovalRequired() + { + const string json = """ + { + "permissionRequest": { + "kind": "read", + "intention": "Read managed content", + "path": "/workspace/file.txt", + "managedApprovalRequired": true + }, + "requestId": "permission-1" + } + """; + + var data = JsonSerializer.Deserialize( + json, + SerializerOptions); + + Assert.NotNull(data); + Assert.True(data.PermissionRequest.ManagedApprovalRequired); + } + + [Fact] + public async Task ApproveAllLeavesManagedRequestPending() + { + var request = new PermissionRequest + { + Kind = "read", + ManagedApprovalRequired = true, + }; + + var decision = await PermissionHandler.ApproveAll(request, new PermissionInvocation()); + + Assert.IsType(decision); + } + + [Fact] + public async Task ApproveAllApprovesOrdinaryRequest() + { + var request = new PermissionRequest { Kind = "read" }; + + var decision = await PermissionHandler.ApproveAll(request, new PermissionInvocation()); + + Assert.IsType(decision); + } +} diff --git a/go/README.md b/go/README.md index 78350c86a4..3929d2ac40 100644 --- a/go/README.md +++ b/go/README.md @@ -55,7 +55,7 @@ func main() { } defer client.Stop() - // Create a session (OnPermissionRequest is optional; ApproveAll allows every tool) + // ApproveAll approves ordinary requests; managed requests still require a human decision. session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ Model: "gpt-5", OnPermissionRequest: copilot.PermissionHandler.ApproveAll, @@ -215,7 +215,7 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec - `Provider` (\*ProviderConfig): Custom API provider configuration (BYOK). See [Custom Providers](#custom-providers) section. - `Streaming` (*bool): Enable streaming delta events (nil = runtime default) - `InfiniteSessions` (\*InfiniteSessionConfig): Automatic context compaction configuration -- `OnPermissionRequest` (PermissionHandlerFunc): Optional handler called before each tool execution to approve or deny it. When nil, permission requests are emitted as events and left pending for manual resolution. Use `copilot.PermissionHandler.ApproveAll` to allow everything, or provide a custom function for fine-grained control. See [Permission Handling](#permission-handling) section. +- `OnPermissionRequest` (PermissionHandlerFunc): Optional handler called before each tool execution to approve or deny it. When nil, permission requests are emitted as events and left pending for manual resolution. `copilot.PermissionHandler.ApproveAll` approves ordinary requests automatically; requests where `RequiresManagedApproval()` is `true` remain pending for explicit resolution through a human-facing host flow. See [Permission Handling](#permission-handling) section. - `OnUserInputRequest` (UserInputHandler): Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section. - `Hooks` (\*SessionHooks): Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. - `Commands` ([]CommandDefinition): Slash-commands registered for this session. See [Commands](#commands) section. @@ -674,7 +674,7 @@ An `OnPermissionRequest` handler is optional when you create or resume a session ### Approve All (simplest) -Use the built-in `PermissionHandler.ApproveAll` helper to allow every tool call without any checks: +Use the built-in `PermissionHandler.ApproveAll` helper to approve ordinary permission requests automatically: ```go session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ @@ -683,9 +683,11 @@ session, err := client.CreateSession(context.Background(), &copilot.SessionConfi }) ``` +When `RequiresManagedApproval()` returns `true`, `ApproveAll` returns `PermissionDecisionNoResult`. The request remains pending and the host must present a human-facing confirmation flow to resolve it explicitly. + ### Custom Permission Handler -Provide your own `PermissionHandlerFunc` to inspect each request and apply custom logic: +Provide your own `PermissionHandlerFunc` to inspect each request and apply custom logic. Check `RequiresManagedApproval()` before any automatic approval: ```go import ( @@ -698,6 +700,10 @@ import ( session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ Model: "gpt-5", OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (rpc.PermissionDecision, error) { + if request.RequiresManagedApproval() { + return &rpc.PermissionDecisionNoResult{}, nil + } + // Type-switch on the discriminated PermissionRequest variants to // access per-kind fields: if shell, ok := request.(*copilot.PermissionRequestShell); ok { diff --git a/go/permissions.go b/go/permissions.go index f86a726834..dcbd5fb111 100644 --- a/go/permissions.go +++ b/go/permissions.go @@ -6,10 +6,14 @@ import ( // PermissionHandler provides pre-built OnPermissionRequest implementations. var PermissionHandler = struct { - // ApproveAll approves all permission requests. + // ApproveAll approves ordinary permission requests. Requests that require + // managed approval remain pending for an explicit human decision. ApproveAll PermissionHandlerFunc }{ - ApproveAll: func(_ PermissionRequest, _ PermissionInvocation) (rpc.PermissionDecision, error) { + ApproveAll: func(request PermissionRequest, _ PermissionInvocation) (rpc.PermissionDecision, error) { + if request.RequiresManagedApproval() { + return &rpc.PermissionDecisionNoResult{}, nil + } return &rpc.PermissionDecisionApproveOnce{}, nil }, } diff --git a/go/permissions_test.go b/go/permissions_test.go new file mode 100644 index 0000000000..086060542c --- /dev/null +++ b/go/permissions_test.go @@ -0,0 +1,56 @@ +package copilot_test + +import ( + "encoding/json" + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/rpc" +) + +func TestPermissionEventExposesManagedApprovalRequired(t *testing.T) { + var data copilot.PermissionRequestedData + err := json.Unmarshal([]byte(`{ + "permissionRequest": { + "kind": "read", + "intention": "Read managed content", + "path": "/workspace/file.txt", + "managedApprovalRequired": true + }, + "requestId": "permission-1" + }`), &data) + if err != nil { + t.Fatal(err) + } + + if !data.PermissionRequest.RequiresManagedApproval() { + t.Fatal("expected managed approval to be required") + } +} + +func TestApproveAllLeavesManagedRequestPending(t *testing.T) { + required := true + decision, err := copilot.PermissionHandler.ApproveAll( + &copilot.PermissionRequestRead{ManagedApprovalRequired: &required}, + copilot.PermissionInvocation{SessionID: "session-1"}, + ) + if err != nil { + t.Fatal(err) + } + if _, ok := decision.(*rpc.PermissionDecisionNoResult); !ok { + t.Fatalf("expected PermissionDecisionNoResult, got %T", decision) + } +} + +func TestApproveAllApprovesOrdinaryRequest(t *testing.T) { + decision, err := copilot.PermissionHandler.ApproveAll( + &copilot.PermissionRequestRead{}, + copilot.PermissionInvocation{SessionID: "session-1"}, + ) + if err != nil { + t.Fatal(err) + } + if _, ok := decision.(*rpc.PermissionDecisionApproveOnce); !ok { + t.Fatalf("expected PermissionDecisionApproveOnce, got %T", decision) + } +} diff --git a/go/rpc/permission_request_managed_approval.go b/go/rpc/permission_request_managed_approval.go new file mode 100644 index 0000000000..601c77f0e6 --- /dev/null +++ b/go/rpc/permission_request_managed_approval.go @@ -0,0 +1,78 @@ +// Copyright (c) GitHub. All rights reserved. + +package rpc + +import "encoding/json" + +func managedApprovalRequired(value *bool) bool { + return value != nil && *value +} + +// RequiresManagedApproval reports whether managed policy requires an explicit +// human decision for this request. +func (r PermissionRequestCustomTool) RequiresManagedApproval() bool { + return managedApprovalRequired(r.ManagedApprovalRequired) +} + +// RequiresManagedApproval reports whether managed policy requires an explicit +// human decision for this request. +func (r PermissionRequestExtensionManagement) RequiresManagedApproval() bool { + return managedApprovalRequired(r.ManagedApprovalRequired) +} + +// RequiresManagedApproval reports whether managed policy requires an explicit +// human decision for this request. +func (r PermissionRequestExtensionPermissionAccess) RequiresManagedApproval() bool { + return managedApprovalRequired(r.ManagedApprovalRequired) +} + +// RequiresManagedApproval reports whether managed policy requires an explicit +// human decision for this request. +func (r PermissionRequestHook) RequiresManagedApproval() bool { + return managedApprovalRequired(r.ManagedApprovalRequired) +} + +// RequiresManagedApproval reports whether managed policy requires an explicit +// human decision for this request. +func (r PermissionRequestMCP) RequiresManagedApproval() bool { + return managedApprovalRequired(r.ManagedApprovalRequired) +} + +// RequiresManagedApproval reports whether managed policy requires an explicit +// human decision for this request. +func (r PermissionRequestMemory) RequiresManagedApproval() bool { + return managedApprovalRequired(r.ManagedApprovalRequired) +} + +// RequiresManagedApproval reports whether managed policy requires an explicit +// human decision for this request. +func (r PermissionRequestRead) RequiresManagedApproval() bool { + return managedApprovalRequired(r.ManagedApprovalRequired) +} + +// RequiresManagedApproval reports whether managed policy requires an explicit +// human decision for this request. +func (r PermissionRequestShell) RequiresManagedApproval() bool { + return managedApprovalRequired(r.ManagedApprovalRequired) +} + +// RequiresManagedApproval reports whether managed policy requires an explicit +// human decision for this request. +func (r PermissionRequestURL) RequiresManagedApproval() bool { + return managedApprovalRequired(r.ManagedApprovalRequired) +} + +// RequiresManagedApproval reports whether managed policy requires an explicit +// human decision for this request. +func (r PermissionRequestWrite) RequiresManagedApproval() bool { + return managedApprovalRequired(r.ManagedApprovalRequired) +} + +// RequiresManagedApproval reports whether an unknown request carries managed +// approval metadata. +func (r RawPermissionRequest) RequiresManagedApproval() bool { + var metadata struct { + ManagedApprovalRequired *bool `json:"managedApprovalRequired"` + } + return json.Unmarshal(r.Raw, &metadata) == nil && managedApprovalRequired(metadata.ManagedApprovalRequired) +} diff --git a/go/rpc/zsession_events.go b/go/rpc/zsession_events.go index 44f49948f4..431b5c01df 100644 --- a/go/rpc/zsession_events.go +++ b/go/rpc/zsession_events.go @@ -2822,6 +2822,7 @@ func (PermissionPromptRequestWrite) Kind() PermissionPromptRequestKind { type PermissionRequest interface { permissionRequest() Kind() PermissionRequestKind + RequiresManagedApproval() bool } type RawPermissionRequest struct { @@ -2838,6 +2839,8 @@ func (r RawPermissionRequest) Kind() PermissionRequestKind { type PermissionRequestCustomTool struct { // Arguments to pass to the custom tool Args any `json:"args,omitempty"` + // When true, managed policy requires an explicit user decision and automatic approval must be bypassed. + ManagedApprovalRequired *bool `json:"managedApprovalRequired,omitempty"` // Tool call ID that triggered this permission request ToolCallID *string `json:"toolCallId,omitempty"` // Description of what the custom tool does @@ -2855,6 +2858,8 @@ func (PermissionRequestCustomTool) Kind() PermissionRequestKind { type PermissionRequestExtensionManagement struct { // Name of the extension being managed ExtensionName *string `json:"extensionName,omitempty"` + // When true, managed policy requires an explicit user decision and automatic approval must be bypassed. + ManagedApprovalRequired *bool `json:"managedApprovalRequired,omitempty"` // The extension management operation (scaffold, reload) Operation string `json:"operation"` // Tool call ID that triggered this permission request @@ -2872,6 +2877,8 @@ type PermissionRequestExtensionPermissionAccess struct { Capabilities []string `json:"capabilities"` // Name of the extension requesting permission access ExtensionName string `json:"extensionName"` + // When true, managed policy requires an explicit user decision and automatic approval must be bypassed. + ManagedApprovalRequired *bool `json:"managedApprovalRequired,omitempty"` // Tool call ID that triggered this permission request ToolCallID *string `json:"toolCallId,omitempty"` } @@ -2885,6 +2892,8 @@ func (PermissionRequestExtensionPermissionAccess) Kind() PermissionRequestKind { type PermissionRequestHook struct { // Optional message from the hook explaining why confirmation is needed HookMessage *string `json:"hookMessage,omitempty"` + // When true, managed policy requires an explicit user decision and automatic approval must be bypassed. + ManagedApprovalRequired *bool `json:"managedApprovalRequired,omitempty"` // Arguments of the tool call being gated ToolArgs any `json:"toolArgs,omitempty"` // Tool call ID that triggered this permission request @@ -2902,6 +2911,8 @@ func (PermissionRequestHook) Kind() PermissionRequestKind { type PermissionRequestMCP struct { // Arguments to pass to the MCP tool Args any `json:"args,omitempty"` + // When true, managed policy requires an explicit user decision and automatic approval must be bypassed. + ManagedApprovalRequired *bool `json:"managedApprovalRequired,omitempty"` // Whether this MCP tool is read-only (no side effects) ReadOnly bool `json:"readOnly"` // Name of the MCP server providing the tool @@ -2929,6 +2940,8 @@ type PermissionRequestMemory struct { Direction *PermissionRequestMemoryDirection `json:"direction,omitempty"` // The fact being stored or voted on Fact string `json:"fact"` + // When true, managed policy requires an explicit user decision and automatic approval must be bypassed. + ManagedApprovalRequired *bool `json:"managedApprovalRequired,omitempty"` // Reason for the vote (vote only) Reason *string `json:"reason,omitempty"` // Topic or subject of the memory (store only) @@ -2946,6 +2959,8 @@ func (PermissionRequestMemory) Kind() PermissionRequestKind { type PermissionRequestRead struct { // Human-readable description of why the file is being read Intention string `json:"intention"` + // When true, managed policy requires an explicit user decision and automatic approval must be bypassed. + ManagedApprovalRequired *bool `json:"managedApprovalRequired,omitempty"` // Path of the file or directory being read Path string `json:"path"` // True when the model has requested to run this search outside the sandbox (it set requestSandboxBypass: true and the host opted in via sandbox.allowBypass). This is a request, not a grant: the search runs unsandboxed only if the user approves this permission request. Hosts should highlight the elevated risk in the approval UI. @@ -2973,6 +2988,8 @@ type PermissionRequestShell struct { HasWriteFileRedirection bool `json:"hasWriteFileRedirection"` // Human-readable description of what the command intends to do Intention string `json:"intention"` + // When true, managed policy requires an explicit user decision and automatic approval must be bypassed. + ManagedApprovalRequired *bool `json:"managedApprovalRequired,omitempty"` // File paths that may be read or written by the command PossiblePaths []string `json:"possiblePaths"` // URLs that may be accessed by the command @@ -2996,6 +3013,8 @@ func (PermissionRequestShell) Kind() PermissionRequestKind { type PermissionRequestURL struct { // Human-readable description of why the URL is being accessed Intention string `json:"intention"` + // When true, managed policy requires an explicit user decision and automatic approval must be bypassed. + ManagedApprovalRequired *bool `json:"managedApprovalRequired,omitempty"` // True when this URL fetch is requesting to bypass the sandbox network policy: either the model set requestSandboxBypass: true, or the tool re-issued the request as an interactive bypass after the network policy denied the approved URL (host opted in via sandbox.allowBypass). This is a request, not a grant: the fetch runs only if the user approves this permission request. Hosts should highlight the elevated risk in the approval UI. RequestSandboxBypass *bool `json:"requestSandboxBypass,omitempty"` // Model-provided justification for the sandbox-bypass request. Only meaningful when requestSandboxBypass is true. @@ -3021,6 +3040,8 @@ type PermissionRequestWrite struct { FileName string `json:"fileName"` // Human-readable description of the intended file change Intention string `json:"intention"` + // When true, managed policy requires an explicit user decision and automatic approval must be bypassed. + ManagedApprovalRequired *bool `json:"managedApprovalRequired,omitempty"` // Complete new file contents for newly created files NewFileContents *string `json:"newFileContents,omitempty"` // True when a built-in file tool (apply_patch / str_replace_editor) asked to write a path the sandbox filesystem policy would block, and the host opted in via sandbox.allowBypass. This is a request, not a grant: the write happens unsandboxed only if the user approves this permission request. Hosts should highlight the elevated risk in the approval UI. diff --git a/java/README.md b/java/README.md index 2a31788232..c527c10028 100644 --- a/java/README.md +++ b/java/README.md @@ -120,6 +120,29 @@ public class CopilotSDK { } ``` +## Permission Handling + +`PermissionHandler.APPROVE_ALL` approves ordinary requests automatically. When `request.getManagedApprovalRequired()` is `true`, it returns `no-result`; the request remains pending and the host must present a human-facing confirmation flow to resolve it explicitly. + +When handling `PermissionRequestedEvent` directly, convert its generated event value with `PermissionRequest.fromJsonValue(event.getData().permissionRequest())` to access the typed metadata. + +Custom handlers must check managed approval before applying kind-specific automatic decisions: + +```java +import java.util.concurrent.CompletableFuture; + +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.PermissionRequestResult; + +PermissionHandler handler = (request, invocation) -> { + if (Boolean.TRUE.equals(request.getManagedApprovalRequired())) { + return CompletableFuture.completedFuture(PermissionRequestResult.noResult()); + } + + return CompletableFuture.completedFuture(PermissionRequestResult.approveOnce()); +}; +``` + ## Try it with JBang You can run the SDK without setting up a full Java project, by using [JBang](https://www.jbang.dev/). diff --git a/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java b/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java index d2dff958dc..f19f14522d 100644 --- a/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java +++ b/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java @@ -220,11 +220,7 @@ private void handlePermissionRequest(JsonRpcClient rpc, String requestId, JsonNo session.handlePermissionRequest(permissionRequest).thenAccept(result -> { try { if (PermissionRequestResultKind.NO_RESULT.getValue().equalsIgnoreCase(result.getKind())) { - // Protocol v2 does not support NO_RESULT — the server - // expects exactly one response per request, so abstaining - // would leave it hanging. - throw new IllegalStateException( - "Permission handlers cannot return 'no-result' when connected to a protocol v2 server."); + return; } rpc.sendResponse(requestIdLong, Map.of("result", result)); } catch (IOException e) { diff --git a/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java b/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java index bd8e70b750..49164afc6d 100644 --- a/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java +++ b/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java @@ -17,6 +17,10 @@ * *
{@code
  * PermissionHandler handler = (request, invocation) -> {
+ * 	if (Boolean.TRUE.equals(request.getManagedApprovalRequired())) {
+ * 		return CompletableFuture.completedFuture(PermissionRequestResult.noResult());
+ * 	}
+ *
  * 	// Check the permission kind
  * 	if ("dangerous-action".equals(request.getKind())) {
  * 		// Deny dangerous actions
@@ -43,12 +47,17 @@
 public interface PermissionHandler {
 
     /**
-     * A pre-built handler that approves all permission requests.
+     * A pre-built handler that approves ordinary permission requests.
+     * 

+ * Requests that require managed approval return {@code no-result} and remain + * pending for an explicit human decision. * * @since 1.0.11 */ - PermissionHandler APPROVE_ALL = (request, invocation) -> CompletableFuture - .completedFuture(new PermissionRequestResult().setKind(PermissionRequestResultKind.APPROVED)); + PermissionHandler APPROVE_ALL = (request, + invocation) -> CompletableFuture.completedFuture(Boolean.TRUE.equals(request.getManagedApprovalRequired()) + ? PermissionRequestResult.noResult() + : PermissionRequestResult.approveOnce()); /** * Handles a permission request from the assistant. diff --git a/java/src/main/java/com/github/copilot/rpc/PermissionRequest.java b/java/src/main/java/com/github/copilot/rpc/PermissionRequest.java index 51a303feb0..2886491cd2 100644 --- a/java/src/main/java/com/github/copilot/rpc/PermissionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/PermissionRequest.java @@ -5,9 +5,13 @@ package com.github.copilot.rpc; import java.util.Map; +import java.util.HashMap; +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; /** * Represents a permission request from the AI assistant. @@ -22,14 +26,36 @@ @JsonInclude(JsonInclude.Include.NON_NULL) public class PermissionRequest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + @JsonProperty("kind") private String kind; @JsonProperty("toolCallId") private String toolCallId; + @JsonProperty("managedApprovalRequired") + private Boolean managedApprovalRequired; + private Map extensionData; + /** + * Converts the value exposed by a {@code permission.requested} event into a + * typed permission request. + * + * @param value + * the event's {@code permissionRequest} value + * @return the typed permission request + * @throws IllegalArgumentException + * if the value cannot be converted + */ + public static PermissionRequest fromJsonValue(Object value) { + if (value instanceof PermissionRequest request) { + return request; + } + return MAPPER.convertValue(value, PermissionRequest.class); + } + /** * Gets the kind of permission being requested. * @@ -68,11 +94,32 @@ public void setToolCallId(String toolCallId) { this.toolCallId = toolCallId; } + /** + * Gets whether managed policy requires an explicit human decision. + * + * @return {@code true} when automatic approval must be bypassed, otherwise + * {@code false} or {@code null} + */ + public Boolean getManagedApprovalRequired() { + return managedApprovalRequired; + } + + /** + * Sets whether managed policy requires an explicit human decision. + * + * @param managedApprovalRequired + * whether managed approval is required + */ + public void setManagedApprovalRequired(Boolean managedApprovalRequired) { + this.managedApprovalRequired = managedApprovalRequired; + } + /** * Gets additional extension data for the request. * * @return the extension data map */ + @JsonAnyGetter public Map getExtensionData() { return extensionData; } @@ -86,4 +133,20 @@ public Map getExtensionData() { public void setExtensionData(Map extensionData) { this.extensionData = extensionData; } + + /** + * Captures variant-specific permission request fields. + * + * @param name + * the JSON property name + * @param value + * the JSON property value + */ + @JsonAnySetter + public void setExtensionData(String name, Object value) { + if (extensionData == null) { + extensionData = new HashMap<>(); + } + extensionData.put(name, value); + } } diff --git a/java/src/main/java/com/github/copilot/rpc/PermissionRequestResultKind.java b/java/src/main/java/com/github/copilot/rpc/PermissionRequestResultKind.java index 95476c36f6..b17bffdc89 100644 --- a/java/src/main/java/com/github/copilot/rpc/PermissionRequestResultKind.java +++ b/java/src/main/java/com/github/copilot/rpc/PermissionRequestResultKind.java @@ -49,12 +49,8 @@ public final class PermissionRequestResultKind { * When the SDK is used as an extension and the extension's permission handler * cannot or chooses not to handle a given permission request, it can return * {@code NO_RESULT} to leave the request unanswered, allowing another client to - * handle it. - *

- * Warning: This kind is only valid with protocol v3 servers - * (broadcast permission model). When connected to a protocol v2 server, the SDK - * will throw {@link IllegalStateException} because v2 expects exactly one - * response per permission request. + * handle it. The SDK suppresses its response for this result so the request + * remains pending. */ public static final PermissionRequestResultKind NO_RESULT = new PermissionRequestResultKind("no-result"); diff --git a/java/src/test/java/com/github/copilot/PermissionRequestResultTest.java b/java/src/test/java/com/github/copilot/PermissionRequestResultTest.java index 4a1ff03137..ea80b0bed6 100644 --- a/java/src/test/java/com/github/copilot/PermissionRequestResultTest.java +++ b/java/src/test/java/com/github/copilot/PermissionRequestResultTest.java @@ -8,7 +8,11 @@ import org.junit.jupiter.api.Test; +import com.github.copilot.generated.PermissionRequestedEvent; import com.github.copilot.rpc.PermissionRequestResult; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.PermissionInvocation; +import com.github.copilot.rpc.PermissionRequest; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.annotation.JsonInclude; @@ -70,4 +74,53 @@ void testFeedbackNotSerializedWhenNull() throws Exception { var json = MAPPER.writeValueAsString(result); assertFalse(json.contains("feedback")); } + + @Test + void testPermissionRequestExposesManagedApprovalRequired() throws Exception { + var request = MAPPER.readValue(""" + { + "kind": "read", + "path": "/workspace/file.txt", + "managedApprovalRequired": true + } + """, PermissionRequest.class); + + assertTrue(request.getManagedApprovalRequired()); + assertEquals("/workspace/file.txt", request.getExtensionData().get("path")); + } + + @Test + void testPermissionEventValueConvertsToTypedRequest() { + var event = MAPPER + .convertValue( + java.util.Map.of("type", "permission.requested", "data", + java.util.Map.of("requestId", "permission-1", "permissionRequest", java.util.Map.of( + "kind", "url", "managedApprovalRequired", true, "url", "https://example.com"))), + PermissionRequestedEvent.class); + var request = PermissionRequest.fromJsonValue(event.getData().permissionRequest()); + + assertTrue(request.getManagedApprovalRequired()); + assertEquals("https://example.com", request.getExtensionData().get("url")); + } + + @Test + void testApproveAllLeavesManagedRequestPending() { + var request = new PermissionRequest(); + request.setKind("read"); + request.setManagedApprovalRequired(true); + + var result = PermissionHandler.APPROVE_ALL.handle(request, new PermissionInvocation()).join(); + + assertEquals("no-result", result.getKind()); + } + + @Test + void testApproveAllApprovesOrdinaryRequest() { + var request = new PermissionRequest(); + request.setKind("read"); + + var result = PermissionHandler.APPROVE_ALL.handle(request, new PermissionInvocation()).join(); + + assertEquals("approve-once", result.getKind()); + } } diff --git a/java/src/test/java/com/github/copilot/RpcHandlerDispatcherTest.java b/java/src/test/java/com/github/copilot/RpcHandlerDispatcherTest.java index 76c2d41b29..a5a1986df5 100644 --- a/java/src/test/java/com/github/copilot/RpcHandlerDispatcherTest.java +++ b/java/src/test/java/com/github/copilot/RpcHandlerDispatcherTest.java @@ -10,6 +10,7 @@ import java.lang.reflect.Field; import java.net.ServerSocket; import java.net.Socket; +import java.net.SocketTimeoutException; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -343,7 +344,7 @@ void permissionRequestHandlerFails() throws Exception { } @Test - void permissionRequestV2RejectsNoResult() throws Exception { + void permissionRequestNoResultRemainsPending() throws Exception { CopilotSession session = createSession("s1"); session.registerPermissionHandler((request, invocation) -> CompletableFuture .completedFuture(new PermissionRequestResult().setKind(PermissionRequestResultKind.NO_RESULT))); @@ -354,11 +355,8 @@ void permissionRequestV2RejectsNoResult() throws Exception { invokeHandler("permission.request", "13", params); - // V2 protocol does not support NO_RESULT — the handler should fall through - // to the exception path and respond with denied. - JsonNode response = readResponse(); - JsonNode result = response.get("result").get("result"); - assertEquals("user-not-available", result.get("kind").asText()); + serverSideSocket.setSoTimeout(100); + assertThrows(SocketTimeoutException.class, this::readResponse); } // ===== userInput.request tests ===== diff --git a/python/README.md b/python/README.md index f2b6ed6c99..a135be2ccf 100644 --- a/python/README.md +++ b/python/README.md @@ -121,7 +121,7 @@ async def main(): client = CopilotClient() await client.start() - # Create a session (on_permission_request is optional; approve_all allows every tool) + # approve_all approves ordinary requests; managed requests still require a human decision. session = await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", @@ -279,7 +279,7 @@ These are passed as keyword arguments to `create_session()`: - `streaming` (bool): Enable streaming delta events - `provider` (ProviderConfig): Custom API provider configuration (BYOK). See [Custom Providers](#custom-providers) section. - `infinite_sessions` (InfiniteSessionConfig): Automatic context compaction configuration -- `on_permission_request` (callable): Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. Use `PermissionHandler.approve_all` to allow everything, or provide a custom function for fine-grained control. See [Permission Handling](#permission-handling) section. +- `on_permission_request` (callable): Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. `PermissionHandler.approve_all` approves ordinary requests automatically; requests with `managed_approval_required is True` remain pending for explicit resolution through a human-facing host flow. See [Permission Handling](#permission-handling) section. - `on_user_input_request` (callable): Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section. - `hooks` (SessionHooks): Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. @@ -780,7 +780,7 @@ An `on_permission_request` handler is optional when you create or resume a sessi ### Approve All (simplest) -Use the built-in `PermissionHandler.approve_all` helper to allow every tool call without any checks: +Use the built-in `PermissionHandler.approve_all` helper to approve ordinary permission requests automatically: ```python from copilot import CopilotClient @@ -792,12 +792,14 @@ session = await client.create_session( ) ``` +For requests with `managed_approval_required is True`, `approve_all` returns `PermissionNoResult`. The request remains pending and the host must present a human-facing confirmation flow to resolve it explicitly. + ### Custom Permission Handler -Provide your own function to inspect each request and apply custom logic (sync or async): +Provide your own function to inspect each request and apply custom logic (sync or async). Check `managed_approval_required` before any automatic approval: ```python -from copilot import PermissionRequest, PermissionRequestResult +from copilot import PermissionNoResult, PermissionRequest, PermissionRequestResult from copilot.rpc import ( PermissionDecisionApproveOnce, PermissionDecisionReject, @@ -806,6 +808,9 @@ from copilot.session_events import PermissionRequestShell def on_permission_request(request: PermissionRequest, invocation: dict) -> PermissionRequestResult: + if request.managed_approval_required is True: + return PermissionNoResult() + # ``PermissionRequest`` is a discriminated union — pattern-match on # the variant class to access the per-kind fields. match request: @@ -828,6 +833,9 @@ Async handlers are also supported: async def on_permission_request( request: PermissionRequest, invocation: dict ) -> PermissionRequestResult: + if request.managed_approval_required is True: + return PermissionNoResult() + # Simulate an async approval check (e.g., prompting a user over a network) await asyncio.sleep(0) return PermissionDecisionApproveOnce() diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index fecd5839ed..1dc7c9e1c9 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -4849,6 +4849,7 @@ class PermissionRequestCustomTool: tool_description: str tool_name: str args: Any = None + managed_approval_required: bool | None = None tool_call_id: str | None = None @staticmethod @@ -4857,11 +4858,13 @@ def from_dict(obj: Any) -> "PermissionRequestCustomTool": tool_description = from_str(obj.get("toolDescription")) tool_name = from_str(obj.get("toolName")) args = obj.get("args") + managed_approval_required = from_union([from_none, from_bool], obj.get("managedApprovalRequired")) tool_call_id = from_union([from_none, from_str], obj.get("toolCallId")) return PermissionRequestCustomTool( tool_description=tool_description, tool_name=tool_name, args=args, + managed_approval_required=managed_approval_required, tool_call_id=tool_call_id, ) @@ -4872,6 +4875,8 @@ def to_dict(self) -> dict: result["toolName"] = from_str(self.tool_name) if self.args is not None: result["args"] = self.args + if self.managed_approval_required is not None: + result["managedApprovalRequired"] = from_union([from_none, from_bool], self.managed_approval_required) if self.tool_call_id is not None: result["toolCallId"] = from_union([from_none, from_str], self.tool_call_id) return result @@ -4883,6 +4888,7 @@ class PermissionRequestExtensionManagement: kind: ClassVar[str] = "extension-management" operation: str extension_name: str | None = None + managed_approval_required: bool | None = None tool_call_id: str | None = None @staticmethod @@ -4890,10 +4896,12 @@ def from_dict(obj: Any) -> "PermissionRequestExtensionManagement": assert isinstance(obj, dict) operation = from_str(obj.get("operation")) extension_name = from_union([from_none, from_str], obj.get("extensionName")) + managed_approval_required = from_union([from_none, from_bool], obj.get("managedApprovalRequired")) tool_call_id = from_union([from_none, from_str], obj.get("toolCallId")) return PermissionRequestExtensionManagement( operation=operation, extension_name=extension_name, + managed_approval_required=managed_approval_required, tool_call_id=tool_call_id, ) @@ -4903,6 +4911,8 @@ def to_dict(self) -> dict: result["operation"] = from_str(self.operation) if self.extension_name is not None: result["extensionName"] = from_union([from_none, from_str], self.extension_name) + if self.managed_approval_required is not None: + result["managedApprovalRequired"] = from_union([from_none, from_bool], self.managed_approval_required) if self.tool_call_id is not None: result["toolCallId"] = from_union([from_none, from_str], self.tool_call_id) return result @@ -4914,6 +4924,7 @@ class PermissionRequestExtensionPermissionAccess: capabilities: list[str] extension_name: str kind: ClassVar[str] = "extension-permission-access" + managed_approval_required: bool | None = None tool_call_id: str | None = None @staticmethod @@ -4921,10 +4932,12 @@ def from_dict(obj: Any) -> "PermissionRequestExtensionPermissionAccess": assert isinstance(obj, dict) capabilities = from_list(from_str, obj.get("capabilities")) extension_name = from_str(obj.get("extensionName")) + managed_approval_required = from_union([from_none, from_bool], obj.get("managedApprovalRequired")) tool_call_id = from_union([from_none, from_str], obj.get("toolCallId")) return PermissionRequestExtensionPermissionAccess( capabilities=capabilities, extension_name=extension_name, + managed_approval_required=managed_approval_required, tool_call_id=tool_call_id, ) @@ -4933,6 +4946,8 @@ def to_dict(self) -> dict: result["capabilities"] = from_list(from_str, self.capabilities) result["extensionName"] = from_str(self.extension_name) result["kind"] = self.kind + if self.managed_approval_required is not None: + result["managedApprovalRequired"] = from_union([from_none, from_bool], self.managed_approval_required) if self.tool_call_id is not None: result["toolCallId"] = from_union([from_none, from_str], self.tool_call_id) return result @@ -4944,6 +4959,7 @@ class PermissionRequestHook: kind: ClassVar[str] = "hook" tool_name: str hook_message: str | None = None + managed_approval_required: bool | None = None tool_args: Any = None tool_call_id: str | None = None @@ -4952,11 +4968,13 @@ def from_dict(obj: Any) -> "PermissionRequestHook": assert isinstance(obj, dict) tool_name = from_str(obj.get("toolName")) hook_message = from_union([from_none, from_str], obj.get("hookMessage")) + managed_approval_required = from_union([from_none, from_bool], obj.get("managedApprovalRequired")) tool_args = obj.get("toolArgs") tool_call_id = from_union([from_none, from_str], obj.get("toolCallId")) return PermissionRequestHook( tool_name=tool_name, hook_message=hook_message, + managed_approval_required=managed_approval_required, tool_args=tool_args, tool_call_id=tool_call_id, ) @@ -4967,6 +4985,8 @@ def to_dict(self) -> dict: result["toolName"] = from_str(self.tool_name) if self.hook_message is not None: result["hookMessage"] = from_union([from_none, from_str], self.hook_message) + if self.managed_approval_required is not None: + result["managedApprovalRequired"] = from_union([from_none, from_bool], self.managed_approval_required) if self.tool_args is not None: result["toolArgs"] = self.tool_args if self.tool_call_id is not None: @@ -4983,6 +5003,7 @@ class PermissionRequestMcp: tool_name: str tool_title: str args: Any = None + managed_approval_required: bool | None = None tool_call_id: str | None = None @staticmethod @@ -4993,6 +5014,7 @@ def from_dict(obj: Any) -> "PermissionRequestMcp": tool_name = from_str(obj.get("toolName")) tool_title = from_str(obj.get("toolTitle")) args = obj.get("args") + managed_approval_required = from_union([from_none, from_bool], obj.get("managedApprovalRequired")) tool_call_id = from_union([from_none, from_str], obj.get("toolCallId")) return PermissionRequestMcp( read_only=read_only, @@ -5000,6 +5022,7 @@ def from_dict(obj: Any) -> "PermissionRequestMcp": tool_name=tool_name, tool_title=tool_title, args=args, + managed_approval_required=managed_approval_required, tool_call_id=tool_call_id, ) @@ -5012,6 +5035,8 @@ def to_dict(self) -> dict: result["toolTitle"] = from_str(self.tool_title) if self.args is not None: result["args"] = self.args + if self.managed_approval_required is not None: + result["managedApprovalRequired"] = from_union([from_none, from_bool], self.managed_approval_required) if self.tool_call_id is not None: result["toolCallId"] = from_union([from_none, from_str], self.tool_call_id) return result @@ -5025,6 +5050,7 @@ class PermissionRequestMemory: action: PermissionRequestMemoryAction | None = None citations: str | None = None direction: PermissionRequestMemoryDirection | None = None + managed_approval_required: bool | None = None reason: str | None = None subject: str | None = None tool_call_id: str | None = None @@ -5036,6 +5062,7 @@ def from_dict(obj: Any) -> "PermissionRequestMemory": action = from_union([from_none, lambda x: parse_enum(PermissionRequestMemoryAction, x)], obj.get("action")) citations = from_union([from_none, from_str], obj.get("citations")) direction = from_union([from_none, lambda x: parse_enum(PermissionRequestMemoryDirection, x)], obj.get("direction")) + managed_approval_required = from_union([from_none, from_bool], obj.get("managedApprovalRequired")) reason = from_union([from_none, from_str], obj.get("reason")) subject = from_union([from_none, from_str], obj.get("subject")) tool_call_id = from_union([from_none, from_str], obj.get("toolCallId")) @@ -5044,6 +5071,7 @@ def from_dict(obj: Any) -> "PermissionRequestMemory": action=action, citations=citations, direction=direction, + managed_approval_required=managed_approval_required, reason=reason, subject=subject, tool_call_id=tool_call_id, @@ -5059,6 +5087,8 @@ def to_dict(self) -> dict: result["citations"] = from_union([from_none, from_str], self.citations) if self.direction is not None: result["direction"] = from_union([from_none, lambda x: to_enum(PermissionRequestMemoryDirection, x)], self.direction) + if self.managed_approval_required is not None: + result["managedApprovalRequired"] = from_union([from_none, from_bool], self.managed_approval_required) if self.reason is not None: result["reason"] = from_union([from_none, from_str], self.reason) if self.subject is not None: @@ -5074,6 +5104,7 @@ class PermissionRequestRead: intention: str kind: ClassVar[str] = "read" path: str + managed_approval_required: bool | None = None request_sandbox_bypass: bool | None = None request_sandbox_bypass_reason: str | None = None tool_call_id: str | None = None @@ -5083,12 +5114,14 @@ def from_dict(obj: Any) -> "PermissionRequestRead": assert isinstance(obj, dict) intention = from_str(obj.get("intention")) path = from_str(obj.get("path")) + managed_approval_required = from_union([from_none, from_bool], obj.get("managedApprovalRequired")) request_sandbox_bypass = from_union([from_none, from_bool], obj.get("requestSandboxBypass")) request_sandbox_bypass_reason = from_union([from_none, from_str], obj.get("requestSandboxBypassReason")) tool_call_id = from_union([from_none, from_str], obj.get("toolCallId")) return PermissionRequestRead( intention=intention, path=path, + managed_approval_required=managed_approval_required, request_sandbox_bypass=request_sandbox_bypass, request_sandbox_bypass_reason=request_sandbox_bypass_reason, tool_call_id=tool_call_id, @@ -5099,6 +5132,8 @@ def to_dict(self) -> dict: result["intention"] = from_str(self.intention) result["kind"] = self.kind result["path"] = from_str(self.path) + if self.managed_approval_required is not None: + result["managedApprovalRequired"] = from_union([from_none, from_bool], self.managed_approval_required) if self.request_sandbox_bypass is not None: result["requestSandboxBypass"] = from_union([from_none, from_bool], self.request_sandbox_bypass) if self.request_sandbox_bypass_reason is not None: @@ -5119,6 +5154,7 @@ class PermissionRequestShell: kind: ClassVar[str] = "shell" possible_paths: list[str] possible_urls: list[PermissionRequestShellPossibleUrl] + managed_approval_required: bool | None = None request_sandbox_bypass: bool | None = None request_sandbox_bypass_reason: str | None = None tool_call_id: str | None = None @@ -5134,6 +5170,7 @@ def from_dict(obj: Any) -> "PermissionRequestShell": intention = from_str(obj.get("intention")) possible_paths = from_list(from_str, obj.get("possiblePaths")) possible_urls = from_list(PermissionRequestShellPossibleUrl.from_dict, obj.get("possibleUrls")) + managed_approval_required = from_union([from_none, from_bool], obj.get("managedApprovalRequired")) request_sandbox_bypass = from_union([from_none, from_bool], obj.get("requestSandboxBypass")) request_sandbox_bypass_reason = from_union([from_none, from_str], obj.get("requestSandboxBypassReason")) tool_call_id = from_union([from_none, from_str], obj.get("toolCallId")) @@ -5146,6 +5183,7 @@ def from_dict(obj: Any) -> "PermissionRequestShell": intention=intention, possible_paths=possible_paths, possible_urls=possible_urls, + managed_approval_required=managed_approval_required, request_sandbox_bypass=request_sandbox_bypass, request_sandbox_bypass_reason=request_sandbox_bypass_reason, tool_call_id=tool_call_id, @@ -5162,6 +5200,8 @@ def to_dict(self) -> dict: result["kind"] = self.kind result["possiblePaths"] = from_list(from_str, self.possible_paths) result["possibleUrls"] = from_list(lambda x: to_class(PermissionRequestShellPossibleUrl, x), self.possible_urls) + if self.managed_approval_required is not None: + result["managedApprovalRequired"] = from_union([from_none, from_bool], self.managed_approval_required) if self.request_sandbox_bypass is not None: result["requestSandboxBypass"] = from_union([from_none, from_bool], self.request_sandbox_bypass) if self.request_sandbox_bypass_reason is not None: @@ -5221,6 +5261,7 @@ class PermissionRequestUrl: intention: str kind: ClassVar[str] = "url" url: str + managed_approval_required: bool | None = None request_sandbox_bypass: bool | None = None request_sandbox_bypass_reason: str | None = None tool_call_id: str | None = None @@ -5230,12 +5271,14 @@ def from_dict(obj: Any) -> "PermissionRequestUrl": assert isinstance(obj, dict) intention = from_str(obj.get("intention")) url = from_str(obj.get("url")) + managed_approval_required = from_union([from_none, from_bool], obj.get("managedApprovalRequired")) request_sandbox_bypass = from_union([from_none, from_bool], obj.get("requestSandboxBypass")) request_sandbox_bypass_reason = from_union([from_none, from_str], obj.get("requestSandboxBypassReason")) tool_call_id = from_union([from_none, from_str], obj.get("toolCallId")) return PermissionRequestUrl( intention=intention, url=url, + managed_approval_required=managed_approval_required, request_sandbox_bypass=request_sandbox_bypass, request_sandbox_bypass_reason=request_sandbox_bypass_reason, tool_call_id=tool_call_id, @@ -5246,6 +5289,8 @@ def to_dict(self) -> dict: result["intention"] = from_str(self.intention) result["kind"] = self.kind result["url"] = from_str(self.url) + if self.managed_approval_required is not None: + result["managedApprovalRequired"] = from_union([from_none, from_bool], self.managed_approval_required) if self.request_sandbox_bypass is not None: result["requestSandboxBypass"] = from_union([from_none, from_bool], self.request_sandbox_bypass) if self.request_sandbox_bypass_reason is not None: @@ -5263,6 +5308,7 @@ class PermissionRequestWrite: file_name: str intention: str kind: ClassVar[str] = "write" + managed_approval_required: bool | None = None new_file_contents: str | None = None request_sandbox_bypass: bool | None = None request_sandbox_bypass_reason: str | None = None @@ -5275,6 +5321,7 @@ def from_dict(obj: Any) -> "PermissionRequestWrite": diff = from_str(obj.get("diff")) file_name = from_str(obj.get("fileName")) intention = from_str(obj.get("intention")) + managed_approval_required = from_union([from_none, from_bool], obj.get("managedApprovalRequired")) new_file_contents = from_union([from_none, from_str], obj.get("newFileContents")) request_sandbox_bypass = from_union([from_none, from_bool], obj.get("requestSandboxBypass")) request_sandbox_bypass_reason = from_union([from_none, from_str], obj.get("requestSandboxBypassReason")) @@ -5284,6 +5331,7 @@ def from_dict(obj: Any) -> "PermissionRequestWrite": diff=diff, file_name=file_name, intention=intention, + managed_approval_required=managed_approval_required, new_file_contents=new_file_contents, request_sandbox_bypass=request_sandbox_bypass, request_sandbox_bypass_reason=request_sandbox_bypass_reason, @@ -5297,6 +5345,8 @@ def to_dict(self) -> dict: result["fileName"] = from_str(self.file_name) result["intention"] = from_str(self.intention) result["kind"] = self.kind + if self.managed_approval_required is not None: + result["managedApprovalRequired"] = from_union([from_none, from_bool], self.managed_approval_required) if self.new_file_contents is not None: result["newFileContents"] = from_union([from_none, from_str], self.new_file_contents) if self.request_sandbox_bypass is not None: diff --git a/python/copilot/session.py b/python/copilot/session.py index d9fc04dcef..d022e7a919 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -373,6 +373,8 @@ class PermissionHandler: def approve_all( request: PermissionRequest, invocation: dict[str, str] ) -> PermissionRequestResult: + if request.managed_approval_required is True: + return PermissionNoResult() return PermissionDecisionApproveOnce() diff --git a/python/test_managed_permissions.py b/python/test_managed_permissions.py new file mode 100644 index 0000000000..26575af0d3 --- /dev/null +++ b/python/test_managed_permissions.py @@ -0,0 +1,44 @@ +from copilot.rpc import PermissionDecisionApproveOnce +from copilot.session import PermissionHandler, PermissionNoResult +from copilot.session_events import PermissionRequestedData, PermissionRequestRead + + +def test_permission_event_exposes_managed_approval_required() -> None: + data = PermissionRequestedData.from_dict( + { + "permissionRequest": { + "kind": "read", + "intention": "Read managed content", + "path": "/workspace/file.txt", + "managedApprovalRequired": True, + }, + "requestId": "permission-1", + } + ) + + assert data.permission_request.managed_approval_required is True + assert data.to_dict()["permissionRequest"]["managedApprovalRequired"] is True + + +def test_approve_all_leaves_managed_request_pending() -> None: + request = PermissionRequestRead( + intention="Read managed content", + path="/workspace/file.txt", + managed_approval_required=True, + ) + + assert isinstance( + PermissionHandler.approve_all(request, {"sessionId": "session-1"}), PermissionNoResult + ) + + +def test_approve_all_approves_ordinary_request() -> None: + request = PermissionRequestRead( + intention="Read ordinary content", + path="/workspace/file.txt", + ) + + assert isinstance( + PermissionHandler.approve_all(request, {"sessionId": "session-1"}), + PermissionDecisionApproveOnce, + ) diff --git a/rust/README.md b/rust/README.md index e89ea55604..5772f5d54c 100644 --- a/rust/README.md +++ b/rust/README.md @@ -224,6 +224,10 @@ impl PermissionHandler for MyPermissions { _rid: RequestId, data: PermissionRequestData, ) -> PermissionResult { + if data.managed_approval_required == Some(true) { + return PermissionResult::no_result(); + } + if data.extra.get("tool").and_then(|v| v.as_str()) == Some("view") { PermissionResult::approve_once() } else { @@ -244,7 +248,7 @@ let config = SessionConfig::default() .with_user_input_handler(h); ``` -The built-in `ApproveAllHandler` and `DenyAllHandler` implement `PermissionHandler` for the common cases. To observe streamed session events (assistant messages, tool calls, etc.), call `session.subscribe()` — see [Streaming](#streaming) below. +The built-in `ApproveAllHandler` and `DenyAllHandler` implement `PermissionHandler` for the common cases. `ApproveAllHandler` leaves requests with `managed_approval_required == Some(true)` pending for an explicit human decision. To observe streamed session events (assistant messages, tool calls, etc.), call `session.subscribe()` — see [Streaming](#streaming) below. ### SessionConfig @@ -424,6 +428,8 @@ Reach for the `ToolHandler` trait directly when you need shared state across mul Set a permission policy directly on `SessionConfig` with the chainable builders. They install a synthesized `PermissionHandler` so only permission requests are intercepted; every other event flows through unchanged. +The approve-all policy leaves managed approval requests pending so a human-facing host flow can resolve them explicitly. + ```rust,ignore let session = client .create_session( diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index abc7dca626..efbe7fccef 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -2931,6 +2931,9 @@ pub struct PermissionRequestShell { pub intention: String, /// Permission kind discriminator pub kind: PermissionRequestShellKind, + /// When true, managed policy requires an explicit user decision and automatic approval must be bypassed. + #[serde(skip_serializing_if = "Option::is_none")] + pub managed_approval_required: Option, /// File paths that may be read or written by the command pub possible_paths: Vec, /// URLs that may be accessed by the command @@ -2963,6 +2966,9 @@ pub struct PermissionRequestWrite { pub intention: String, /// Permission kind discriminator pub kind: PermissionRequestWriteKind, + /// When true, managed policy requires an explicit user decision and automatic approval must be bypassed. + #[serde(skip_serializing_if = "Option::is_none")] + pub managed_approval_required: Option, /// Complete new file contents for newly created files #[serde(skip_serializing_if = "Option::is_none")] pub new_file_contents: Option, @@ -2985,6 +2991,9 @@ pub struct PermissionRequestRead { pub intention: String, /// Permission kind discriminator pub kind: PermissionRequestReadKind, + /// When true, managed policy requires an explicit user decision and automatic approval must be bypassed. + #[serde(skip_serializing_if = "Option::is_none")] + pub managed_approval_required: Option, /// Path of the file or directory being read pub path: String, /// True when the model has requested to run this search outside the sandbox (it set requestSandboxBypass: true and the host opted in via sandbox.allowBypass). This is a request, not a grant: the search runs unsandboxed only if the user approves this permission request. Hosts should highlight the elevated risk in the approval UI. @@ -3007,6 +3016,9 @@ pub struct PermissionRequestMcp { pub args: Option, /// Permission kind discriminator pub kind: PermissionRequestMcpKind, + /// When true, managed policy requires an explicit user decision and automatic approval must be bypassed. + #[serde(skip_serializing_if = "Option::is_none")] + pub managed_approval_required: Option, /// Whether this MCP tool is read-only (no side effects) pub read_only: bool, /// Name of the MCP server providing the tool @@ -3028,6 +3040,9 @@ pub struct PermissionRequestUrl { pub intention: String, /// Permission kind discriminator pub kind: PermissionRequestUrlKind, + /// When true, managed policy requires an explicit user decision and automatic approval must be bypassed. + #[serde(skip_serializing_if = "Option::is_none")] + pub managed_approval_required: Option, /// True when this URL fetch is requesting to bypass the sandbox network policy: either the model set requestSandboxBypass: true, or the tool re-issued the request as an interactive bypass after the network policy denied the approved URL (host opted in via sandbox.allowBypass). This is a request, not a grant: the fetch runs only if the user approves this permission request. Hosts should highlight the elevated risk in the approval UI. #[serde(skip_serializing_if = "Option::is_none")] pub request_sandbox_bypass: Option, @@ -3058,6 +3073,9 @@ pub struct PermissionRequestMemory { pub fact: String, /// Permission kind discriminator pub kind: PermissionRequestMemoryKind, + /// When true, managed policy requires an explicit user decision and automatic approval must be bypassed. + #[serde(skip_serializing_if = "Option::is_none")] + pub managed_approval_required: Option, /// Reason for the vote (vote only) #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, @@ -3078,6 +3096,9 @@ pub struct PermissionRequestCustomTool { pub args: Option, /// Permission kind discriminator pub kind: PermissionRequestCustomToolKind, + /// When true, managed policy requires an explicit user decision and automatic approval must be bypassed. + #[serde(skip_serializing_if = "Option::is_none")] + pub managed_approval_required: Option, /// Tool call ID that triggered this permission request #[serde(skip_serializing_if = "Option::is_none")] pub tool_call_id: Option, @@ -3096,6 +3117,9 @@ pub struct PermissionRequestHook { pub hook_message: Option, /// Permission kind discriminator pub kind: PermissionRequestHookKind, + /// When true, managed policy requires an explicit user decision and automatic approval must be bypassed. + #[serde(skip_serializing_if = "Option::is_none")] + pub managed_approval_required: Option, /// Arguments of the tool call being gated #[serde(skip_serializing_if = "Option::is_none")] pub tool_args: Option, @@ -3115,6 +3139,9 @@ pub struct PermissionRequestExtensionManagement { pub extension_name: Option, /// Permission kind discriminator pub kind: PermissionRequestExtensionManagementKind, + /// When true, managed policy requires an explicit user decision and automatic approval must be bypassed. + #[serde(skip_serializing_if = "Option::is_none")] + pub managed_approval_required: Option, /// The extension management operation (scaffold, reload) pub operation: String, /// Tool call ID that triggered this permission request @@ -3132,6 +3159,9 @@ pub struct PermissionRequestExtensionPermissionAccess { pub extension_name: String, /// Permission kind discriminator pub kind: PermissionRequestExtensionPermissionAccessKind, + /// When true, managed policy requires an explicit user decision and automatic approval must be bypassed. + #[serde(skip_serializing_if = "Option::is_none")] + pub managed_approval_required: Option, /// Tool call ID that triggered this permission request #[serde(skip_serializing_if = "Option::is_none")] pub tool_call_id: Option, diff --git a/rust/src/handler.rs b/rust/src/handler.rs index 3287a4f093..6e3bab0485 100644 --- a/rust/src/handler.rs +++ b/rust/src/handler.rs @@ -273,9 +273,8 @@ pub trait AutoModeSwitchHandler: Send + Sync + 'static { ) -> AutoModeSwitchResponse; } -/// A [`PermissionHandler`] that approves every request. Useful for CLI -/// tools, scripts, and tests that don't need interactive permission -/// prompts. +/// A [`PermissionHandler`] that approves ordinary requests. Requests that +/// require managed approval remain pending for an explicit human decision. #[derive(Debug, Clone)] pub struct ApproveAllHandler; @@ -285,9 +284,13 @@ impl PermissionHandler for ApproveAllHandler { &self, _session_id: SessionId, _request_id: RequestId, - _data: PermissionRequestData, + data: PermissionRequestData, ) -> PermissionResult { - PermissionResult::approve_once() + if data.managed_approval_required == Some(true) { + PermissionResult::no_result() + } else { + PermissionResult::approve_once() + } } } @@ -326,6 +329,21 @@ mod tests { )); } + #[tokio::test] + async fn approve_all_handler_leaves_managed_request_pending() { + let result = ApproveAllHandler + .handle( + SessionId::from("s1"), + RequestId::new("1"), + PermissionRequestData { + managed_approval_required: Some(true), + ..Default::default() + }, + ) + .await; + assert!(matches!(result, PermissionResult::NoResult)); + } + #[tokio::test] async fn deny_all_handler_returns_denied() { let result = DenyAllHandler diff --git a/rust/src/permission.rs b/rust/src/permission.rs index 2ddd773a30..ed3586c076 100644 --- a/rust/src/permission.rs +++ b/rust/src/permission.rs @@ -19,7 +19,10 @@ use async_trait::async_trait; use crate::handler::{PermissionHandler, PermissionResult}; use crate::types::{PermissionRequestData, RequestId, SessionId}; -/// Return a [`PermissionHandler`] that approves every request. +/// Return a [`PermissionHandler`] that approves ordinary requests. +/// +/// Requests that require managed approval remain pending for an explicit +/// human decision. pub fn approve_all() -> Arc { Arc::new(PolicyHandler { policy: Policy::ApproveAll, @@ -110,6 +113,12 @@ impl PermissionHandler for PolicyHandler { _request_id: RequestId, data: PermissionRequestData, ) -> PermissionResult { + if matches!(&self.policy, Policy::ApproveAll) + && data.managed_approval_required == Some(true) + { + return PermissionResult::no_result(); + } + let approved = match &self.policy { Policy::ApproveAll => true, Policy::DenyAll => false, @@ -144,6 +153,18 @@ mod tests { )); } + #[tokio::test] + async fn approve_all_leaves_managed_request_pending() { + let h = approve_all(); + let mut request = data(); + request.managed_approval_required = Some(true); + assert!(matches!( + h.handle(SessionId::from("s"), RequestId::new("1"), request) + .await, + PermissionResult::NoResult + )); + } + #[tokio::test] async fn deny_all_denies() { let h = deny_all(); diff --git a/rust/src/session.rs b/rust/src/session.rs index 89162346e4..5bbc142df0 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -1519,6 +1519,19 @@ fn extract_request_id(data: &Value) -> Option { .map(RequestId::new) } +fn permission_request_data(event_data: &Value) -> PermissionRequestData { + let request_data = event_data + .get("permissionRequest") + .cloned() + .unwrap_or_else(|| event_data.clone()); + serde_json::from_value(request_data.clone()).unwrap_or(PermissionRequestData { + kind: None, + tool_call_id: None, + managed_approval_required: None, + extra: request_data, + }) +} + /// Map a [`PermissionResult`] to the `result` payload sent back to the /// server via `session.permissions.handlePendingPermissionRequest`. /// @@ -1704,14 +1717,7 @@ async fn handle_notification( }; let client = client.clone(); let sid = session_id.clone(); - let data: PermissionRequestData = - serde_json::from_value(notification.event.data.clone()).unwrap_or_else(|_| { - PermissionRequestData { - kind: None, - tool_call_id: None, - extra: notification.event.data.clone(), - } - }); + let data = permission_request_data(¬ification.event.data); let span = tracing::error_span!( "permission_request_handler", session_id = %sid, @@ -2513,7 +2519,7 @@ fn inject_transform_sections_resume( mod tests { use serde_json::json; - use super::notification_permission_payload; + use super::{notification_permission_payload, permission_request_data}; use crate::handler::PermissionResult; #[test] @@ -2540,4 +2546,19 @@ mod tests { Some(json!({ "kind": "user-not-available" })) ); } + + #[test] + fn permission_request_data_reads_nested_managed_approval_metadata() { + let data = permission_request_data(&json!({ + "requestId": "permission-1", + "permissionRequest": { + "kind": "read", + "managedApprovalRequired": true, + "path": "/workspace/file.txt" + } + })); + + assert_eq!(data.managed_approval_required, Some(true)); + assert_eq!(data.extra["path"], "/workspace/file.txt"); + } } diff --git a/rust/src/types.rs b/rust/src/types.rs index 163e1d29c0..79988ee5cd 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -5440,6 +5440,9 @@ pub struct PermissionRequestData { /// to a specific tool invocation. #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_call_id: Option, + /// Whether managed policy requires an explicit human decision. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub managed_approval_required: Option, /// The full permission request params from the CLI. The shape varies by /// permission type and CLI version, so we preserve it as `Value`. #[serde(flatten)] diff --git a/rust/tests/api_types_test.rs b/rust/tests/api_types_test.rs index bcf2266916..9b86b1367a 100644 --- a/rust/tests/api_types_test.rs +++ b/rust/tests/api_types_test.rs @@ -7,6 +7,7 @@ use github_copilot_sdk::rpc::{ Extension, ExtensionList, ExtensionSource, ExtensionStatus, ExtensionsDisableRequest, ExtensionsEnableRequest, FleetStartRequest, FleetStartResult, TasksStartAgentRequest, }; +use github_copilot_sdk::session_events::{PermissionRequest, PermissionRequestedData}; #[test] fn extension_running_has_expected_status_and_source() { @@ -84,6 +85,25 @@ fn tasks_start_agent_request_fields_are_accessible() { assert_eq!(request.description.as_deref(), Some("SDK task agent")); } +#[test] +fn permission_event_exposes_managed_approval_required() { + let data: PermissionRequestedData = serde_json::from_value(serde_json::json!({ + "permissionRequest": { + "kind": "read", + "intention": "Read managed content", + "path": "/workspace/file.txt", + "managedApprovalRequired": true + }, + "requestId": "permission-1" + })) + .unwrap(); + + let PermissionRequest::Read(request) = data.permission_request else { + panic!("expected read permission request"); + }; + assert_eq!(request.managed_approval_required, Some(true)); +} + fn running_extension(id: &str, name: &str) -> Extension { Extension { id: id.to_string(), diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index b1d7474b98..d6eda7f99a 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -14,6 +14,7 @@ import { fileURLToPath } from "url"; import { promisify } from "util"; import wordwrap from "wordwrap"; import { + addManagedApprovalRequiredToPermissionRequests, cloneSchemaForCodegen, collectDefinitionCollections, collectExperimentalOnlyRpcReferencedDefinitionNames, @@ -1812,6 +1813,9 @@ function emitGoFlatDiscriminatedUnion( lines.push(`type ${typeName} interface {`); lines.push(`\t${markerName}()`); lines.push(`\t${discriminatorMethodName}() ${discGoType}`); + if (typeName === "PermissionRequest") { + lines.push(`\tRequiresManagedApproval() bool`); + } lines.push(`}`); lines.push(``); @@ -3679,7 +3683,9 @@ async function generateSessionEvents(schemaPath?: string, apiSchema?: ApiSchema) console.log("Go: generating session-events..."); const resolvedPath = schemaPath ?? (await getSessionEventsSchemaPath()); - const schema = cloneSchemaForCodegen((await loadSchemaJson(resolvedPath)) as JSONSchema7); + const schema = addManagedApprovalRequiredToPermissionRequests( + (await loadSchemaJson(resolvedPath)) as JSONSchema7 + ); const processed = propagateInternalVisibility(postProcessSchema(schema)); const processedApiSchema = apiSchema ? propagateInternalVisibility(postProcessSchema(cloneSchemaForCodegen(apiSchema as JSONSchema7)) as JSONSchema7) diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index 5b343122b6..7ef4bae9d8 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -11,6 +11,7 @@ import path from "path"; import type { JSONSchema7, JSONSchema7Definition } from "json-schema"; import { fileURLToPath } from "url"; import { + addManagedApprovalRequiredToPermissionRequests, cloneSchemaForCodegen, filterNodeByVisibility, fixNullableRequiredRefsInApiSchema, @@ -2808,7 +2809,9 @@ async function generateSessionEvents(schemaPath?: string): Promise { console.log("Python: generating session-events..."); const resolvedPath = schemaPath ?? (await getSessionEventsSchemaPath()); - const schema = (await loadSchemaJson(resolvedPath)) as JSONSchema7; + const schema = addManagedApprovalRequiredToPermissionRequests( + (await loadSchemaJson(resolvedPath)) as JSONSchema7 + ); const processed = propagateInternalVisibility(postProcessSchema(schema)); let code = generatePythonSessionEventsCode(processed); const { typeNames } = collectInternalSymbols(processed); diff --git a/scripts/codegen/rust.ts b/scripts/codegen/rust.ts index a04f5a21c5..a89e28d702 100644 --- a/scripts/codegen/rust.ts +++ b/scripts/codegen/rust.ts @@ -17,6 +17,7 @@ import { fileURLToPath } from "url"; import { promisify } from "util"; import type { JSONSchema7, JSONSchema7Definition } from "json-schema"; import { + addManagedApprovalRequiredToPermissionRequests, type ApiSchema, type DefinitionCollections, EXCLUDED_EVENT_TYPES, @@ -2168,7 +2169,9 @@ async function generate(): Promise { const sessionEventsSchema = propagateInternalVisibility( postProcessSchema( - stripBooleanLiterals(sessionEventsRaw) as JSONSchema7, + stripBooleanLiterals( + addManagedApprovalRequiredToPermissionRequests(sessionEventsRaw as JSONSchema7), + ) as JSONSchema7, ), ); const apiSchema = propagateInternalVisibility( diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts index 9ab335b05f..2138457dcd 100644 --- a/scripts/codegen/utils.ts +++ b/scripts/codegen/utils.ts @@ -453,6 +453,47 @@ export function cloneSchemaForCodegen(value: T): T { return value; } +const PERMISSION_REQUEST_DEFINITION_NAMES = [ + "PermissionRequestCustomTool", + "PermissionRequestExtensionManagement", + "PermissionRequestExtensionPermissionAccess", + "PermissionRequestHook", + "PermissionRequestMcp", + "PermissionRequestMemory", + "PermissionRequestRead", + "PermissionRequestShell", + "PermissionRequestUrl", + "PermissionRequestWrite", +] as const; + +/** + * Add managed approval metadata until the pinned CLI schema includes the field. + */ +export function addManagedApprovalRequiredToPermissionRequests(schema: T): T { + const cloned = cloneSchemaForCodegen(schema); + const property: JSONSchema7 = { + description: + "When true, managed policy requires an explicit user decision and automatic approval must be bypassed.", + type: ["boolean", "null"], + }; + + for (const definitions of [cloned.definitions, cloned.$defs]) { + if (!definitions) continue; + for (const name of PERMISSION_REQUEST_DEFINITION_NAMES) { + const definition = definitions[name]; + if (!definition || typeof definition !== "object") continue; + const objectDefinition = definition as JSONSchema7; + objectDefinition.properties = { + ...objectDefinition.properties, + managedApprovalRequired: + objectDefinition.properties?.managedApprovalRequired ?? cloneSchemaForCodegen(property), + }; + } + } + + return cloned; +} + export function getEnumValueDescriptions(schema: JSONSchema7 | null | undefined): EnumValueDescriptions | undefined { if (!schema || typeof schema !== "object") return undefined; From 4b42e4525175fc05274c4597fd4f4862d8766a53 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:49:40 -0700 Subject: [PATCH 07/31] Respect managed approval in permission policies Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../github/copilot/RpcHandlerDispatcher.java | 3 +- .../rpc/PermissionRequestResultKind.java | 6 ++-- .../copilot/RpcHandlerDispatcherTest.java | 8 ++--- python/copilot/session.py | 6 ++-- rust/src/permission.rs | 36 +++++++++++++++---- 5 files changed, 41 insertions(+), 18 deletions(-) diff --git a/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java b/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java index f19f14522d..1fa331a71d 100644 --- a/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java +++ b/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java @@ -220,7 +220,8 @@ private void handlePermissionRequest(JsonRpcClient rpc, String requestId, JsonNo session.handlePermissionRequest(permissionRequest).thenAccept(result -> { try { if (PermissionRequestResultKind.NO_RESULT.getValue().equalsIgnoreCase(result.getKind())) { - return; + throw new IllegalStateException( + "Permission handlers cannot return 'no-result' when connected to a protocol v2 server."); } rpc.sendResponse(requestIdLong, Map.of("result", result)); } catch (IOException e) { diff --git a/java/src/main/java/com/github/copilot/rpc/PermissionRequestResultKind.java b/java/src/main/java/com/github/copilot/rpc/PermissionRequestResultKind.java index b17bffdc89..14b6413e78 100644 --- a/java/src/main/java/com/github/copilot/rpc/PermissionRequestResultKind.java +++ b/java/src/main/java/com/github/copilot/rpc/PermissionRequestResultKind.java @@ -49,8 +49,10 @@ public final class PermissionRequestResultKind { * When the SDK is used as an extension and the extension's permission handler * cannot or chooses not to handle a given permission request, it can return * {@code NO_RESULT} to leave the request unanswered, allowing another client to - * handle it. The SDK suppresses its response for this result so the request - * remains pending. + * handle it. + *

+ * This kind is supported by the broadcast permission flow. Legacy protocol-v2 + * request callbacks require an immediate response and reject this result. */ public static final PermissionRequestResultKind NO_RESULT = new PermissionRequestResultKind("no-result"); diff --git a/java/src/test/java/com/github/copilot/RpcHandlerDispatcherTest.java b/java/src/test/java/com/github/copilot/RpcHandlerDispatcherTest.java index a5a1986df5..9b1d7c6795 100644 --- a/java/src/test/java/com/github/copilot/RpcHandlerDispatcherTest.java +++ b/java/src/test/java/com/github/copilot/RpcHandlerDispatcherTest.java @@ -10,7 +10,6 @@ import java.lang.reflect.Field; import java.net.ServerSocket; import java.net.Socket; -import java.net.SocketTimeoutException; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -344,7 +343,7 @@ void permissionRequestHandlerFails() throws Exception { } @Test - void permissionRequestNoResultRemainsPending() throws Exception { + void permissionRequestV2RejectsNoResult() throws Exception { CopilotSession session = createSession("s1"); session.registerPermissionHandler((request, invocation) -> CompletableFuture .completedFuture(new PermissionRequestResult().setKind(PermissionRequestResultKind.NO_RESULT))); @@ -355,8 +354,9 @@ void permissionRequestNoResultRemainsPending() throws Exception { invokeHandler("permission.request", "13", params); - serverSideSocket.setSoTimeout(100); - assertThrows(SocketTimeoutException.class, this::readResponse); + JsonNode response = readResponse(); + JsonNode result = response.get("result").get("result"); + assertEquals("user-not-available", result.get("kind").asText()); } // ===== userInput.request tests ===== diff --git a/python/copilot/session.py b/python/copilot/session.py index d022e7a919..4c80874ad1 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -344,10 +344,8 @@ class SystemMessageCustomizeConfig(TypedDict, total=False): class PermissionNoResult: """Sentinel returned by a permission handler to leave the request unanswered. - Only meaningful against protocol-v1 servers. v2 servers reject ``no-result`` - responses; the SDK raises :class:`ValueError` if a v2 server receives one. - Mirrors the ``{kind: "no-result"}`` extension TS adds to its ``PermissionDecision`` - union (see ``nodejs/src/types.ts:883``). + The SDK suppresses its response so another connected client, such as a + human-facing host, can answer the pending request. """ kind: Literal["no-result"] = "no-result" diff --git a/rust/src/permission.rs b/rust/src/permission.rs index ed3586c076..0114842a16 100644 --- a/rust/src/permission.rs +++ b/rust/src/permission.rs @@ -113,19 +113,17 @@ impl PermissionHandler for PolicyHandler { _request_id: RequestId, data: PermissionRequestData, ) -> PermissionResult { - if matches!(&self.policy, Policy::ApproveAll) - && data.managed_approval_required == Some(true) - { - return PermissionResult::no_result(); - } - let approved = match &self.policy { Policy::ApproveAll => true, Policy::DenyAll => false, Policy::Predicate(f) => f(&data), }; if approved { - PermissionResult::approve_once() + if data.managed_approval_required == Some(true) { + PermissionResult::no_result() + } else { + PermissionResult::approve_once() + } } else { PermissionResult::reject(None) } @@ -185,6 +183,30 @@ mod tests { )); } + #[tokio::test] + async fn approve_if_leaves_managed_approval_pending_when_predicate_approves() { + let h = approve_if(|_| true); + let mut request = data(); + request.managed_approval_required = Some(true); + assert!(matches!( + h.handle(SessionId::from("s"), RequestId::new("1"), request) + .await, + PermissionResult::NoResult + )); + } + + #[tokio::test] + async fn approve_if_still_rejects_managed_request_when_predicate_denies() { + let h = approve_if(|_| false); + let mut request = data(); + request.managed_approval_required = Some(true); + assert!(matches!( + h.handle(SessionId::from("s"), RequestId::new("1"), request) + .await, + PermissionResult::Decision(crate::types::PermissionDecision::Reject(_)) + )); + } + #[tokio::test] async fn resolve_handler_policy_wins() { struct AlwaysApprove; From 8bbf5b1d46e33f964ab919877cb9f21359024a58 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:20:24 -0700 Subject: [PATCH 08/31] Minimize Java permission overlay Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../github/copilot/RpcHandlerDispatcher.java | 3 +++ .../github/copilot/rpc/PermissionRequest.java | 22 ++----------------- .../rpc/PermissionRequestResultKind.java | 6 +++-- .../copilot/PermissionRequestResultTest.java | 2 -- .../copilot/RpcHandlerDispatcherTest.java | 2 ++ 5 files changed, 11 insertions(+), 24 deletions(-) diff --git a/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java b/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java index 1fa331a71d..d2dff958dc 100644 --- a/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java +++ b/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java @@ -220,6 +220,9 @@ private void handlePermissionRequest(JsonRpcClient rpc, String requestId, JsonNo session.handlePermissionRequest(permissionRequest).thenAccept(result -> { try { if (PermissionRequestResultKind.NO_RESULT.getValue().equalsIgnoreCase(result.getKind())) { + // Protocol v2 does not support NO_RESULT — the server + // expects exactly one response per request, so abstaining + // would leave it hanging. throw new IllegalStateException( "Permission handlers cannot return 'no-result' when connected to a protocol v2 server."); } diff --git a/java/src/main/java/com/github/copilot/rpc/PermissionRequest.java b/java/src/main/java/com/github/copilot/rpc/PermissionRequest.java index 2886491cd2..936c570899 100644 --- a/java/src/main/java/com/github/copilot/rpc/PermissionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/PermissionRequest.java @@ -5,10 +5,8 @@ package com.github.copilot.rpc; import java.util.Map; -import java.util.HashMap; -import com.fasterxml.jackson.annotation.JsonAnyGetter; -import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; @@ -24,6 +22,7 @@ * @since 1.0.0 */ @JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) public class PermissionRequest { private static final ObjectMapper MAPPER = new ObjectMapper(); @@ -119,7 +118,6 @@ public void setManagedApprovalRequired(Boolean managedApprovalRequired) { * * @return the extension data map */ - @JsonAnyGetter public Map getExtensionData() { return extensionData; } @@ -133,20 +131,4 @@ public Map getExtensionData() { public void setExtensionData(Map extensionData) { this.extensionData = extensionData; } - - /** - * Captures variant-specific permission request fields. - * - * @param name - * the JSON property name - * @param value - * the JSON property value - */ - @JsonAnySetter - public void setExtensionData(String name, Object value) { - if (extensionData == null) { - extensionData = new HashMap<>(); - } - extensionData.put(name, value); - } } diff --git a/java/src/main/java/com/github/copilot/rpc/PermissionRequestResultKind.java b/java/src/main/java/com/github/copilot/rpc/PermissionRequestResultKind.java index 14b6413e78..95476c36f6 100644 --- a/java/src/main/java/com/github/copilot/rpc/PermissionRequestResultKind.java +++ b/java/src/main/java/com/github/copilot/rpc/PermissionRequestResultKind.java @@ -51,8 +51,10 @@ public final class PermissionRequestResultKind { * {@code NO_RESULT} to leave the request unanswered, allowing another client to * handle it. *

- * This kind is supported by the broadcast permission flow. Legacy protocol-v2 - * request callbacks require an immediate response and reject this result. + * Warning: This kind is only valid with protocol v3 servers + * (broadcast permission model). When connected to a protocol v2 server, the SDK + * will throw {@link IllegalStateException} because v2 expects exactly one + * response per permission request. */ public static final PermissionRequestResultKind NO_RESULT = new PermissionRequestResultKind("no-result"); diff --git a/java/src/test/java/com/github/copilot/PermissionRequestResultTest.java b/java/src/test/java/com/github/copilot/PermissionRequestResultTest.java index ea80b0bed6..07c8b22d4c 100644 --- a/java/src/test/java/com/github/copilot/PermissionRequestResultTest.java +++ b/java/src/test/java/com/github/copilot/PermissionRequestResultTest.java @@ -86,7 +86,6 @@ void testPermissionRequestExposesManagedApprovalRequired() throws Exception { """, PermissionRequest.class); assertTrue(request.getManagedApprovalRequired()); - assertEquals("/workspace/file.txt", request.getExtensionData().get("path")); } @Test @@ -100,7 +99,6 @@ void testPermissionEventValueConvertsToTypedRequest() { var request = PermissionRequest.fromJsonValue(event.getData().permissionRequest()); assertTrue(request.getManagedApprovalRequired()); - assertEquals("https://example.com", request.getExtensionData().get("url")); } @Test diff --git a/java/src/test/java/com/github/copilot/RpcHandlerDispatcherTest.java b/java/src/test/java/com/github/copilot/RpcHandlerDispatcherTest.java index 9b1d7c6795..76c2d41b29 100644 --- a/java/src/test/java/com/github/copilot/RpcHandlerDispatcherTest.java +++ b/java/src/test/java/com/github/copilot/RpcHandlerDispatcherTest.java @@ -354,6 +354,8 @@ void permissionRequestV2RejectsNoResult() throws Exception { invokeHandler("permission.request", "13", params); + // V2 protocol does not support NO_RESULT — the handler should fall through + // to the exception path and respond with denied. JsonNode response = readResponse(); JsonNode result = response.get("result").get("result"); assertEquals("user-not-available", result.get("kind").asText()); From 2161da8e363c3633ababb64693bd69a152383240 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:51:34 -0700 Subject: [PATCH 09/31] Clarify Java managed permission deferral Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- java/README.md | 2 +- .../main/java/com/github/copilot/rpc/PermissionHandler.java | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/java/README.md b/java/README.md index c527c10028..0ff8f0f7ee 100644 --- a/java/README.md +++ b/java/README.md @@ -122,7 +122,7 @@ public class CopilotSDK { ## Permission Handling -`PermissionHandler.APPROVE_ALL` approves ordinary requests automatically. When `request.getManagedApprovalRequired()` is `true`, it returns `no-result`; the request remains pending and the host must present a human-facing confirmation flow to resolve it explicitly. +`PermissionHandler.APPROVE_ALL` approves ordinary requests automatically. When `request.getManagedApprovalRequired()` is `true`, it returns `no-result`. On the event-based permission path, this leaves the request unanswered so another client can present a human-facing confirmation flow. The legacy protocol v2 callback cannot defer a response, so the SDK fails closed with `user-not-available`; a custom v2 handler must complete its future with the human's explicit decision. When handling `PermissionRequestedEvent` directly, convert its generated event value with `PermissionRequest.fromJsonValue(event.getData().permissionRequest())` to access the typed metadata. diff --git a/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java b/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java index 49164afc6d..2e135fb71e 100644 --- a/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java +++ b/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java @@ -49,8 +49,10 @@ public interface PermissionHandler { /** * A pre-built handler that approves ordinary permission requests. *

- * Requests that require managed approval return {@code no-result} and remain - * pending for an explicit human decision. + * Requests that require managed approval return {@code no-result}. This leaves + * event-based requests unanswered so another client can handle them. Legacy + * protocol v2 callbacks cannot defer a response and fail closed; a custom v2 + * handler must complete its future with the human's explicit decision. * * @since 1.0.11 */ From 679c6a5a4f79838810a60e9650b1b14832e188ef Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:17:53 -0700 Subject: [PATCH 10/31] Preserve Rust permission event metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/src/session.rs | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/rust/src/session.rs b/rust/src/session.rs index 5bbc142df0..ac01034be3 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -1524,12 +1524,21 @@ fn permission_request_data(event_data: &Value) -> PermissionRequestData { .get("permissionRequest") .cloned() .unwrap_or_else(|| event_data.clone()); - serde_json::from_value(request_data.clone()).unwrap_or(PermissionRequestData { - kind: None, - tool_call_id: None, - managed_approval_required: None, - extra: request_data, - }) + let managed_approval_required = request_data + .get("managedApprovalRequired") + .and_then(Value::as_bool); + match serde_json::from_value::(request_data) { + Ok(mut data) => { + data.extra = event_data.clone(); + data + } + Err(_) => PermissionRequestData { + kind: None, + tool_call_id: None, + managed_approval_required, + extra: event_data.clone(), + }, + } } /// Map a [`PermissionResult`] to the `result` payload sent back to the @@ -2559,6 +2568,24 @@ mod tests { })); assert_eq!(data.managed_approval_required, Some(true)); - assert_eq!(data.extra["path"], "/workspace/file.txt"); + assert_eq!( + data.extra["permissionRequest"]["path"], + "/workspace/file.txt" + ); + } + + #[test] + fn permission_request_data_preserves_managed_flag_when_other_fields_are_malformed() { + let data = permission_request_data(&json!({ + "requestId": "permission-1", + "permissionRequest": { + "kind": "read", + "managedApprovalRequired": true, + "toolCallId": 42 + } + })); + + assert_eq!(data.managed_approval_required, Some(true)); + assert_eq!(data.extra["requestId"], "permission-1"); } } From d214c8bddbaccb43e2f596ced9867a8bd1b0f12e Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:53:44 -0700 Subject: [PATCH 11/31] Fix managed approval C# example Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dotnet/README.md b/dotnet/README.md index 8ba11eecd9..1b9cca5513 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -799,7 +799,10 @@ var session = await client.CreateSessionAsync(new SessionConfig Model = "gpt-5", OnPermissionRequest = async (request, invocation) => { - if (request.ManagedApprovalRequired == true) + if (request is PermissionRequestShell { ManagedApprovalRequired: true } + or PermissionRequestWrite { ManagedApprovalRequired: true } + or PermissionRequestRead { ManagedApprovalRequired: true } + or PermissionRequestUrl { ManagedApprovalRequired: true }) { return PermissionDecision.NoResult(); } From b70b4fee9e02362fe63263752a113616ffc430e5 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:59:26 -0700 Subject: [PATCH 12/31] Fix managed approval documentation guards Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- nodejs/README.md | 2 +- python/README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nodejs/README.md b/nodejs/README.md index b85c3f22b9..475d05d7e1 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -878,7 +878,7 @@ import type { PermissionRequest, PermissionRequestResult } from "@github/copilot const session = await client.createSession({ model: "gpt-5", onPermissionRequest: (request: PermissionRequest, invocation): PermissionRequestResult => { - if (request.managedApprovalRequired === true) { + if ("managedApprovalRequired" in request && request.managedApprovalRequired === true) { // Leave the request pending for the host's human-facing confirmation flow. return { kind: "no-result" }; } diff --git a/python/README.md b/python/README.md index a135be2ccf..78b01e334d 100644 --- a/python/README.md +++ b/python/README.md @@ -808,7 +808,7 @@ from copilot.session_events import PermissionRequestShell def on_permission_request(request: PermissionRequest, invocation: dict) -> PermissionRequestResult: - if request.managed_approval_required is True: + if getattr(request, "managed_approval_required", False) is True: return PermissionNoResult() # ``PermissionRequest`` is a discriminated union — pattern-match on @@ -833,7 +833,7 @@ Async handlers are also supported: async def on_permission_request( request: PermissionRequest, invocation: dict ) -> PermissionRequestResult: - if request.managed_approval_required is True: + if getattr(request, "managed_approval_required", False) is True: return PermissionNoResult() # Simulate an async approval check (e.g., prompting a user over a network) From 53561206d2d60e09e6f77b713931a2d3d60990e0 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:18:49 -0700 Subject: [PATCH 13/31] Fix .NET managed permission event test Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/test/Unit/PermissionHandlerTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotnet/test/Unit/PermissionHandlerTests.cs b/dotnet/test/Unit/PermissionHandlerTests.cs index 4a338478b0..06d3b2584b 100644 --- a/dotnet/test/Unit/PermissionHandlerTests.cs +++ b/dotnet/test/Unit/PermissionHandlerTests.cs @@ -36,7 +36,8 @@ public void PermissionEventExposesManagedApprovalRequired() SerializerOptions); Assert.NotNull(data); - Assert.True(data.PermissionRequest.ManagedApprovalRequired); + var request = Assert.IsType(data.PermissionRequest); + Assert.True(request.ManagedApprovalRequired); } [Fact] From 63c0e8fd514b39d221940b8203d09ffa6b495e90 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:22:17 +0000 Subject: [PATCH 14/31] Fix Java version references to align with main Picks up the maven-release-plugin bumps that landed on main after this branch's last merge: pom.xml snapshot version and the stable version cited in jbang-example.java and README.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- java/README.md | 6 +++--- java/jbang-example.java | 2 +- java/pom.xml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/java/README.md b/java/README.md index 0ff8f0f7ee..89f5aed644 100644 --- a/java/README.md +++ b/java/README.md @@ -39,7 +39,7 @@ Replace `${copilot.sdk.version}` with the latest release from Maven Central. ### Gradle ```groovy -implementation 'com.github:copilot-sdk-java:1.0.8-01' +implementation 'com.github:copilot-sdk-java:1.0.9-preview.0-01' ``` #### Snapshot Builds @@ -58,7 +58,7 @@ Snapshot builds of the next development version are published to Maven Central S com.github copilot-sdk-java - 1.0.9-SNAPSHOT + 1.0.10-preview.0-SNAPSHOT ``` @@ -67,7 +67,7 @@ Snapshot builds of the next development version are published to Maven Central S Replace `${copilot.sdk.version}` with the latest release from Maven Central. ```groovy -implementation 'com.github:copilot-sdk-java:1.0.8-01-SNAPSHOT' +implementation 'com.github:copilot-sdk-java:1.0.9-preview.0-01-SNAPSHOT' ``` ## Quick Start diff --git a/java/jbang-example.java b/java/jbang-example.java index 7288de3067..8bc28b9193 100644 --- a/java/jbang-example.java +++ b/java/jbang-example.java @@ -1,5 +1,5 @@ ///usr/bin/env jbang "$0" "$@" ; exit $? -//DEPS com.github:copilot-sdk-java:1.0.8-01 +//DEPS com.github:copilot-sdk-java:1.0.9-preview.0-01 import com.github.copilot.CopilotClient; import com.github.copilot.generated.AssistantMessageEvent; import com.github.copilot.generated.SessionUsageInfoEvent; diff --git a/java/pom.xml b/java/pom.xml index 4e491e10ac..a47cc1a8af 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ com.github copilot-sdk-java - 1.0.9-SNAPSHOT + 1.0.10-preview.0-SNAPSHOT jar GitHub Copilot SDK :: Java From 363ad3388f3769601c2e31f8644c39b78dbb1009 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 30 Jul 2026 06:19:33 -0700 Subject: [PATCH 15/31] Fail approve-all in managed sessions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/README.md | 4 +- dotnet/src/Client.cs | 4 +- dotnet/src/PermissionHandlers.cs | 21 ++----- dotnet/src/Session.cs | 13 ++++- dotnet/src/Types.cs | 3 + dotnet/test/Unit/PermissionHandlerTests.cs | 10 ++-- go/README.md | 4 +- go/client.go | 2 + go/permissions.go | 11 ++-- go/permissions_test.go | 15 +++-- go/session.go | 4 +- go/types.go | 3 +- java/README.md | 2 +- .../com/github/copilot/CopilotSession.java | 7 +++ .../github/copilot/SessionRequestBuilder.java | 2 + .../github/copilot/rpc/PermissionHandler.java | 16 ++---- .../copilot/rpc/PermissionInvocation.java | 22 +++++++ .../copilot/PermissionRequestResultTest.java | 8 ++- nodejs/README.md | 6 +- nodejs/src/client.ts | 10 +++- nodejs/src/session.ts | 5 +- nodejs/src/types.ts | 14 +++-- nodejs/test/client.test.ts | 13 +++-- python/README.md | 6 +- python/copilot/client.py | 14 ++++- python/copilot/session.py | 36 +++++++++--- python/test_managed_permissions.py | 19 +++++-- rust/README.md | 4 +- rust/src/handler.rs | 17 +++--- rust/src/permission.rs | 16 +++--- rust/src/session.rs | 57 +++++++++++++------ rust/src/types.rs | 3 + 32 files changed, 245 insertions(+), 126 deletions(-) diff --git a/dotnet/README.md b/dotnet/README.md index 1b9cca5513..a9170ea5f4 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -37,7 +37,7 @@ using GitHub.Copilot; await using var client = new CopilotClient(); await client.StartAsync(); -// ApproveAll approves ordinary requests; managed requests still require a human decision. +// ApproveAll is only valid when managed settings are disabled. await using var session = await client.CreateSessionAsync(new SessionConfig { Model = "gpt-5", @@ -125,7 +125,7 @@ Create a new conversation session. - `Provider` - Custom API provider configuration (BYOK) - `Streaming` - Enable streaming of response chunks (default: false) - `InfiniteSessions` - Configure automatic context compaction (see below) -- `OnPermissionRequest` - Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. `PermissionHandler.ApproveAll` approves ordinary requests automatically; requests with `ManagedApprovalRequired == true` remain pending for explicit resolution through a human-facing host flow. See [Permission Handling](#permission-handling) section. +- `OnPermissionRequest` - Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. `PermissionHandler.ApproveAll` approves requests when managed settings are disabled and throws when `EnableManagedSettings` is true. Custom handlers can inspect `ManagedApprovalRequired` for human-facing confirmation logic. See [Permission Handling](#permission-handling) section. - `OnUserInputRequest` - Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section. - `Hooks` - Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index c8d83dfee2..b84e01b726 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -783,7 +783,9 @@ private CopilotSession InitializeSession( _logger, this); session.RegisterTools(config.Tools ?? []); - session.RegisterPermissionHandler(config.OnPermissionRequest); + session.RegisterPermissionHandler( + config.OnPermissionRequest, + config.EnableManagedSettings is true); session.RegisterMcpAuthHandler(config.OnMcpAuthRequest); session.RegisterCommands(config.Commands); session.RegisterElicitationHandler(config.OnElicitationRequest); diff --git a/dotnet/src/PermissionHandlers.cs b/dotnet/src/PermissionHandlers.cs index bdfa102fae..6effeb063b 100644 --- a/dotnet/src/PermissionHandlers.cs +++ b/dotnet/src/PermissionHandlers.cs @@ -10,22 +10,11 @@ namespace GitHub.Copilot; public static class PermissionHandler { ///

- /// A permission handler that approves ordinary requests and leaves managed - /// requests pending for an explicit human decision. + /// A permission handler that approves requests when managed settings are disabled. /// public static Func> ApproveAll { get; } = - (request, _) => Task.FromResult( - RequiresManagedApproval(request) - ? PermissionDecision.NoResult() - : PermissionDecision.ApproveOnce()); - - private static bool RequiresManagedApproval(PermissionRequest request) => - request switch - { - PermissionRequestShell { ManagedApprovalRequired: true } => true, - PermissionRequestWrite { ManagedApprovalRequired: true } => true, - PermissionRequestRead { ManagedApprovalRequired: true } => true, - PermissionRequestUrl { ManagedApprovalRequired: true } => true, - _ => false, - }; + (_, invocation) => invocation.ManagedSettingsEnabled + ? Task.FromException( + new InvalidOperationException("ApproveAll cannot be used when managed settings are enabled")) + : Task.FromResult(PermissionDecision.ApproveOnce()); } diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 6db0744b48..b0c1dc9886 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -63,6 +63,7 @@ public sealed partial class CopilotSession : IAsyncDisposable private readonly CopilotClient _parentClient; private volatile Func>? _permissionHandler; + private bool _managedSettingsEnabled; private volatile Func>? _mcpAuthHandler; private volatile Func>? _userInputHandler; private volatile Func>? _elicitationHandler; @@ -557,13 +558,17 @@ internal void RegisterTools(ICollection tools) /// Registers a handler for permission requests. /// /// The permission handler function. + /// Whether managed settings are enabled for the session. /// /// 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(Func>? handler) + internal void RegisterPermissionHandler( + Func>? handler, + bool managedSettingsEnabled) { _permissionHandler = handler; + _managedSettingsEnabled = managedSettingsEnabled; } internal void RegisterMcpAuthHandler(Func>? handler) @@ -590,7 +595,8 @@ internal async Task HandlePermissionRequestAsync(JsonElement var invocation = new PermissionInvocation { - SessionId = SessionId + SessionId = SessionId, + ManagedSettingsEnabled = _managedSettingsEnabled }; var permissionTimestamp = Stopwatch.GetTimestamp(); @@ -932,7 +938,8 @@ private async Task ExecutePermissionAndRespondAsync(string requestId, Permission { var invocation = new PermissionInvocation { - SessionId = SessionId + SessionId = SessionId, + ManagedSettingsEnabled = _managedSettingsEnabled }; var permissionTimestamp = Stopwatch.GetTimestamp(); diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 85248707fd..2605ee431e 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -833,6 +833,9 @@ public sealed class PermissionInvocation /// Identifier of the session that triggered the permission request. /// public string SessionId { get; set; } = string.Empty; + + /// Whether managed settings are enabled for this session. + public bool ManagedSettingsEnabled { get; set; } } // ============================================================================ diff --git a/dotnet/test/Unit/PermissionHandlerTests.cs b/dotnet/test/Unit/PermissionHandlerTests.cs index 06d3b2584b..4a74b189b8 100644 --- a/dotnet/test/Unit/PermissionHandlerTests.cs +++ b/dotnet/test/Unit/PermissionHandlerTests.cs @@ -41,7 +41,7 @@ public void PermissionEventExposesManagedApprovalRequired() } [Fact] - public async Task ApproveAllLeavesManagedRequestPending() + public async Task ApproveAllThrowsWhenManagedSettingsEnabled() { var request = new PermissionRequestRead { @@ -50,9 +50,11 @@ public async Task ApproveAllLeavesManagedRequestPending() Path = "/workspace/file.txt", }; - var decision = await PermissionHandler.ApproveAll(request, new PermissionInvocation()); - - Assert.IsType(decision); + await Assert.ThrowsAsync(() => + PermissionHandler.ApproveAll(request, new PermissionInvocation + { + ManagedSettingsEnabled = true, + })); } [Fact] diff --git a/go/README.md b/go/README.md index 3929d2ac40..a6854055cc 100644 --- a/go/README.md +++ b/go/README.md @@ -55,7 +55,7 @@ func main() { } defer client.Stop() - // ApproveAll approves ordinary requests; managed requests still require a human decision. + // ApproveAll is only valid when managed settings are disabled. session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ Model: "gpt-5", OnPermissionRequest: copilot.PermissionHandler.ApproveAll, @@ -215,7 +215,7 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec - `Provider` (\*ProviderConfig): Custom API provider configuration (BYOK). See [Custom Providers](#custom-providers) section. - `Streaming` (*bool): Enable streaming delta events (nil = runtime default) - `InfiniteSessions` (\*InfiniteSessionConfig): Automatic context compaction configuration -- `OnPermissionRequest` (PermissionHandlerFunc): Optional handler called before each tool execution to approve or deny it. When nil, permission requests are emitted as events and left pending for manual resolution. `copilot.PermissionHandler.ApproveAll` approves ordinary requests automatically; requests where `RequiresManagedApproval()` is `true` remain pending for explicit resolution through a human-facing host flow. See [Permission Handling](#permission-handling) section. +- `OnPermissionRequest` (PermissionHandlerFunc): Optional handler called before each tool execution to approve or deny it. When nil, permission requests are emitted as events and left pending for manual resolution. `copilot.PermissionHandler.ApproveAll` approves requests when managed settings are disabled and returns an error when `EnableManagedSettings` is true. Custom handlers can inspect `RequiresManagedApproval()` for human-facing confirmation logic. See [Permission Handling](#permission-handling) section. - `OnUserInputRequest` (UserInputHandler): Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section. - `Hooks` (\*SessionHooks): Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. - `Commands` ([]CommandDefinition): Slash-commands registered for this session. See [Commands](#commands) section. diff --git a/go/client.go b/go/client.go index 0d07e21f54..bb87641460 100644 --- a/go/client.go +++ b/go/client.go @@ -907,6 +907,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses // routed to a registered session. initializeSession := func(sessionID string) (*Session, error) { s := newSession(sessionID, c.client, "") + s.managedSettings = config.EnableManagedSettings != nil && *config.EnableManagedSettings s.registerTools(config.Tools) s.registerPermissionHandler(config.OnPermissionRequest) @@ -1228,6 +1229,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, // Create and register the session before issuing the RPC so that // events emitted by the CLI (e.g. session.start) are not dropped. session := newSession(sessionID, c.client, "") + session.managedSettings = config.EnableManagedSettings != nil && *config.EnableManagedSettings session.registerTools(config.Tools) session.registerPermissionHandler(config.OnPermissionRequest) diff --git a/go/permissions.go b/go/permissions.go index dcbd5fb111..b21745bd3e 100644 --- a/go/permissions.go +++ b/go/permissions.go @@ -1,18 +1,19 @@ package copilot import ( + "errors" + "github.com/github/copilot-sdk/go/rpc" ) // PermissionHandler provides pre-built OnPermissionRequest implementations. var PermissionHandler = struct { - // ApproveAll approves ordinary permission requests. Requests that require - // managed approval remain pending for an explicit human decision. + // ApproveAll approves permission requests when managed settings are disabled. ApproveAll PermissionHandlerFunc }{ - ApproveAll: func(request PermissionRequest, _ PermissionInvocation) (rpc.PermissionDecision, error) { - if request.RequiresManagedApproval() { - return &rpc.PermissionDecisionNoResult{}, nil + ApproveAll: func(_ PermissionRequest, invocation PermissionInvocation) (rpc.PermissionDecision, error) { + if invocation.ManagedSettingsEnabled { + return nil, errors.New("ApproveAll cannot be used when managed settings are enabled") } return &rpc.PermissionDecisionApproveOnce{}, nil }, diff --git a/go/permissions_test.go b/go/permissions_test.go index 086060542c..7893faaa24 100644 --- a/go/permissions_test.go +++ b/go/permissions_test.go @@ -28,17 +28,16 @@ func TestPermissionEventExposesManagedApprovalRequired(t *testing.T) { } } -func TestApproveAllLeavesManagedRequestPending(t *testing.T) { - required := true +func TestApproveAllReturnsErrorWhenManagedSettingsEnabled(t *testing.T) { decision, err := copilot.PermissionHandler.ApproveAll( - &copilot.PermissionRequestRead{ManagedApprovalRequired: &required}, - copilot.PermissionInvocation{SessionID: "session-1"}, + &copilot.PermissionRequestRead{}, + copilot.PermissionInvocation{SessionID: "session-1", ManagedSettingsEnabled: true}, ) - if err != nil { - t.Fatal(err) + if err == nil { + t.Fatal("expected an error") } - if _, ok := decision.(*rpc.PermissionDecisionNoResult); !ok { - t.Fatalf("expected PermissionDecisionNoResult, got %T", decision) + if decision != nil { + t.Fatalf("expected no decision, got %T", decision) } } diff --git a/go/session.go b/go/session.go index 3059e3b3ae..9b37d42a4e 100644 --- a/go/session.go +++ b/go/session.go @@ -67,6 +67,7 @@ type Session struct { toolHandlersM sync.RWMutex permissionHandler PermissionHandlerFunc permissionMux sync.RWMutex + managedSettings bool mcpAuthHandler MCPAuthHandler mcpAuthMu sync.RWMutex userInputHandler UserInputHandler @@ -1593,7 +1594,8 @@ func (s *Session) executePermissionAndRespond(requestID string, permissionReques }() invocation := PermissionInvocation{ - SessionID: s.SessionID, + SessionID: s.SessionID, + ManagedSettingsEnabled: s.managedSettings, } decision, err := handler(permissionRequest, invocation) diff --git a/go/types.go b/go/types.go index b0fd9c06ef..fea6d081d1 100644 --- a/go/types.go +++ b/go/types.go @@ -375,7 +375,8 @@ type PermissionHandlerFunc func(request PermissionRequest, invocation Permission // PermissionInvocation provides context about a permission request type PermissionInvocation struct { - SessionID string + SessionID string + ManagedSettingsEnabled bool } // MCPAuthWwwAuthenticateParams contains parsed parameters from an MCP server's WWW-Authenticate response. diff --git a/java/README.md b/java/README.md index 89f5aed644..e6023fbb5a 100644 --- a/java/README.md +++ b/java/README.md @@ -122,7 +122,7 @@ public class CopilotSDK { ## Permission Handling -`PermissionHandler.APPROVE_ALL` approves ordinary requests automatically. When `request.getManagedApprovalRequired()` is `true`, it returns `no-result`. On the event-based permission path, this leaves the request unanswered so another client can present a human-facing confirmation flow. The legacy protocol v2 callback cannot defer a response, so the SDK fails closed with `user-not-available`; a custom v2 handler must complete its future with the human's explicit decision. +`PermissionHandler.APPROVE_ALL` approves requests when managed settings are disabled. When `enableManagedSettings` is true, it completes exceptionally. Custom handlers can inspect `request.getManagedApprovalRequired()` for human-facing confirmation logic. When handling `PermissionRequestedEvent` directly, convert its generated event value with `PermissionRequest.fromJsonValue(event.getData().permissionRequest())` to access the typed metadata. diff --git a/java/src/main/java/com/github/copilot/CopilotSession.java b/java/src/main/java/com/github/copilot/CopilotSession.java index c484fb1b16..721b021181 100644 --- a/java/src/main/java/com/github/copilot/CopilotSession.java +++ b/java/src/main/java/com/github/copilot/CopilotSession.java @@ -185,6 +185,7 @@ public final class CopilotSession implements AutoCloseable { private final Map commandHandlers = new ConcurrentHashMap<>(); private final Map bearerTokenProviders = new ConcurrentHashMap<>(); private final AtomicReference permissionHandler = new AtomicReference<>(); + private volatile boolean managedSettingsEnabled; private final AtomicReference mcpAuthHandler = new AtomicReference<>(); private final AtomicReference userInputHandler = new AtomicReference<>(); private final AtomicReference elicitationHandler = new AtomicReference<>(); @@ -1013,6 +1014,7 @@ private void executePermissionAndRespondAsync(String requestId, PermissionReques try { var invocation = new PermissionInvocation(); invocation.setSessionId(sessionId); + invocation.setManagedSettingsEnabled(managedSettingsEnabled); handler.handle(permissionRequest, invocation).thenAccept(result -> { try { PermissionRequestResultKind kind = new PermissionRequestResultKind(result.getKind()); @@ -1378,6 +1380,10 @@ void registerPermissionHandler(PermissionHandler handler) { permissionHandler.set(handler); } + void setManagedSettingsEnabled(boolean managedSettingsEnabled) { + this.managedSettingsEnabled = managedSettingsEnabled; + } + void registerMcpAuthHandler(McpAuthHandler handler) { mcpAuthHandler.set(handler); } @@ -1403,6 +1409,7 @@ CompletableFuture handlePermissionRequest(JsonNode perm PermissionRequest request = MAPPER.treeToValue(permissionRequestData, PermissionRequest.class); var invocation = new PermissionInvocation(); invocation.setSessionId(sessionId); + invocation.setManagedSettingsEnabled(managedSettingsEnabled); return handler.handle(request, invocation).exceptionally(ex -> { LOG.log(Level.SEVERE, "Permission handler threw an exception", ex); PermissionRequestResult result = new PermissionRequestResult(); diff --git a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java index 57d9a46a21..68fcaa9899 100644 --- a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -333,6 +333,7 @@ static void configureSession(CopilotSession session, SessionConfig config) { if (config.getOnPermissionRequest() != null) { session.registerPermissionHandler(config.getOnPermissionRequest()); } + session.setManagedSettingsEnabled(config.getEnableManagedSettings().orElse(false)); if (config.getOnMcpAuthRequest() != null) { session.registerMcpAuthHandler(config.getOnMcpAuthRequest()); } @@ -383,6 +384,7 @@ static void configureSession(CopilotSession session, ResumeSessionConfig config) if (config.getOnPermissionRequest() != null) { session.registerPermissionHandler(config.getOnPermissionRequest()); } + session.setManagedSettingsEnabled(config.getEnableManagedSettings().orElse(false)); if (config.getOnMcpAuthRequest() != null) { session.registerMcpAuthHandler(config.getOnMcpAuthRequest()); } diff --git a/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java b/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java index 2e135fb71e..f7985b7d30 100644 --- a/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java +++ b/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java @@ -47,19 +47,15 @@ public interface PermissionHandler { /** - * A pre-built handler that approves ordinary permission requests. - *

- * Requests that require managed approval return {@code no-result}. This leaves - * event-based requests unanswered so another client can handle them. Legacy - * protocol v2 callbacks cannot defer a response and fail closed; a custom v2 - * handler must complete its future with the human's explicit decision. + * A pre-built handler that approves permission requests when managed settings + * are disabled. * * @since 1.0.11 */ - PermissionHandler APPROVE_ALL = (request, - invocation) -> CompletableFuture.completedFuture(Boolean.TRUE.equals(request.getManagedApprovalRequired()) - ? PermissionRequestResult.noResult() - : PermissionRequestResult.approveOnce()); + PermissionHandler APPROVE_ALL = (request, invocation) -> invocation.isManagedSettingsEnabled() + ? CompletableFuture.failedFuture( + new IllegalStateException("APPROVE_ALL cannot be used when managed settings are enabled")) + : CompletableFuture.completedFuture(PermissionRequestResult.approveOnce()); /** * Handles a permission request from the assistant. diff --git a/java/src/main/java/com/github/copilot/rpc/PermissionInvocation.java b/java/src/main/java/com/github/copilot/rpc/PermissionInvocation.java index bda5bdde0a..10988cc1b7 100644 --- a/java/src/main/java/com/github/copilot/rpc/PermissionInvocation.java +++ b/java/src/main/java/com/github/copilot/rpc/PermissionInvocation.java @@ -16,6 +16,7 @@ public final class PermissionInvocation { private String sessionId; + private boolean managedSettingsEnabled; /** * Gets the session ID where the permission was requested. @@ -37,4 +38,25 @@ public PermissionInvocation setSessionId(String sessionId) { this.sessionId = sessionId; return this; } + + /** + * Gets whether managed settings are enabled for this session. + * + * @return whether managed settings are enabled + */ + public boolean isManagedSettingsEnabled() { + return managedSettingsEnabled; + } + + /** + * Sets whether managed settings are enabled for this session. + * + * @param managedSettingsEnabled + * whether managed settings are enabled + * @return this invocation for method chaining + */ + public PermissionInvocation setManagedSettingsEnabled(boolean managedSettingsEnabled) { + this.managedSettingsEnabled = managedSettingsEnabled; + return this; + } } diff --git a/java/src/test/java/com/github/copilot/PermissionRequestResultTest.java b/java/src/test/java/com/github/copilot/PermissionRequestResultTest.java index 07c8b22d4c..deca0f4d23 100644 --- a/java/src/test/java/com/github/copilot/PermissionRequestResultTest.java +++ b/java/src/test/java/com/github/copilot/PermissionRequestResultTest.java @@ -102,14 +102,16 @@ void testPermissionEventValueConvertsToTypedRequest() { } @Test - void testApproveAllLeavesManagedRequestPending() { + void testApproveAllFailsWhenManagedSettingsEnabled() { var request = new PermissionRequest(); request.setKind("read"); request.setManagedApprovalRequired(true); - var result = PermissionHandler.APPROVE_ALL.handle(request, new PermissionInvocation()).join(); + var invocation = new PermissionInvocation().setManagedSettingsEnabled(true); + var error = assertThrows(java.util.concurrent.CompletionException.class, + () -> PermissionHandler.APPROVE_ALL.handle(request, invocation).join()); - assertEquals("no-result", result.getKind()); + assertTrue(error.getCause() instanceof IllegalStateException); } @Test diff --git a/nodejs/README.md b/nodejs/README.md index 475d05d7e1..2ed9ba227a 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -36,7 +36,7 @@ import { CopilotClient, approveAll } from "@github/copilot-sdk"; const client = new CopilotClient(); await client.start(); -// approveAll approves ordinary requests; managed requests still require a human decision. +// approveAll is only valid when managed settings are disabled. const session = await client.createSession({ model: "gpt-5", onPermissionRequest: approveAll, @@ -855,7 +855,7 @@ An `onPermissionRequest` handler is optional when you create or resume a session ### Approve All (simplest) -Use the built-in `approveAll` helper to approve ordinary permission requests automatically: +Use the built-in `approveAll` helper when managed settings are disabled: ```typescript import { CopilotClient, approveAll } from "@github/copilot-sdk"; @@ -866,7 +866,7 @@ const session = await client.createSession({ }); ``` -For requests with `managedApprovalRequired: true`, `approveAll` returns `{ kind: "no-result" }`. The request remains pending and the host must present a human-facing confirmation flow to resolve it explicitly. +When `enableManagedSettings` is true for the session, `approveAll` throws. Use a custom handler for managed sessions; request-level `managedApprovalRequired` remains available for human-facing confirmation logic. ### Custom Permission Handler diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 61f4a99416..f30111e306 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1461,7 +1461,10 @@ export class CopilotClient { this.connection!, undefined, this.onGetTraceContext, - { mcpAuthHandler: config.onMcpAuthRequest } + { + mcpAuthHandler: config.onMcpAuthRequest, + managedSettingsEnabled: config.enableManagedSettings, + } ); s.registerTools(config.tools); s.registerCanvases(config.canvases); @@ -1674,7 +1677,10 @@ export class CopilotClient { this.connection!, undefined, this.onGetTraceContext, - { mcpAuthHandler: config.onMcpAuthRequest } + { + mcpAuthHandler: config.onMcpAuthRequest, + managedSettingsEnabled: config.enableManagedSettings, + } ); session.registerTools(config.tools); session.registerCanvases(config.canvases); diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 89403ade7b..ea7fb63ec6 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -157,6 +157,7 @@ export class CopilotSession { private transformCallbacks?: Map; private _rpc: ReturnType | null = null; private traceContextProvider?: TraceContextProvider; + private readonly managedSettingsEnabled: boolean; private _capabilities: SessionCapabilities = {}; private openCanvasInstances: OpenCanvasInstance[] = []; private disconnected = false; @@ -178,10 +179,11 @@ export class CopilotSession { private connection: MessageConnection, private _workspacePath?: string, traceContextProvider?: TraceContextProvider, - options?: { mcpAuthHandler?: McpAuthHandler } + options?: { mcpAuthHandler?: McpAuthHandler; managedSettingsEnabled?: boolean } ) { this.traceContextProvider = traceContextProvider; this.mcpAuthHandler = options?.mcpAuthHandler; + this.managedSettingsEnabled = options?.managedSettingsEnabled === true; } /** @@ -688,6 +690,7 @@ export class CopilotSession { try { const result = await this.permissionHandler!(permissionRequest, { sessionId: this.sessionId, + managedSettingsEnabled: this.managedSettingsEnabled, }); if (result.kind === "no-result") { return; diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index d00c521dd0..843fd134bb 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1121,16 +1121,18 @@ export type PermissionRequestResult = PermissionDecisionRequest["result"] | { ki export type PermissionHandler = ( request: PermissionRequest, - invocation: { sessionId: string } + invocation: { sessionId: string; managedSettingsEnabled: boolean } ) => Promise | PermissionRequestResult; /** - * Approves permission requests unless managed policy requires an explicit human decision. + * Approves permission requests for sessions without managed settings. */ -export const approveAll: PermissionHandler = (request) => - "managedApprovalRequired" in request && request.managedApprovalRequired - ? { kind: "no-result" } - : { kind: "approve-once" }; +export const approveAll: PermissionHandler = (_request, invocation) => { + if (invocation.managedSettingsEnabled) { + throw new Error("approveAll cannot be used when managed settings are enabled"); + } + return { kind: "approve-once" }; +}; export const defaultJoinSessionPermissionHandler: PermissionHandler = (): PermissionRequestResult => ({ diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 4805b1148a..85061d2d70 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -25,16 +25,19 @@ describe("approveAll", () => { url: "https://api.example.com/data", intention: "Fetch domain data", }; - const invocation = { sessionId: "session-1" }; + const invocation = { sessionId: "session-1", managedSettingsEnabled: false }; it("approves ordinary permission requests", () => { expect(approveAll(request, invocation)).toEqual({ kind: "approve-once" }); }); - it("leaves managed permission requests pending for human approval", () => { - expect(approveAll({ ...request, managedApprovalRequired: true }, invocation)).toEqual({ - kind: "no-result", - }); + it("rejects use when managed settings are enabled", () => { + expect(() => + approveAll( + { ...request, managedApprovalRequired: false }, + { ...invocation, managedSettingsEnabled: true } + ) + ).toThrow("approveAll cannot be used when managed settings are enabled"); }); }); diff --git a/python/README.md b/python/README.md index 78b01e334d..a9941a6038 100644 --- a/python/README.md +++ b/python/README.md @@ -121,7 +121,7 @@ async def main(): client = CopilotClient() await client.start() - # approve_all approves ordinary requests; managed requests still require a human decision. + # approve_all is only valid when managed settings are disabled. session = await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", @@ -279,7 +279,7 @@ These are passed as keyword arguments to `create_session()`: - `streaming` (bool): Enable streaming delta events - `provider` (ProviderConfig): Custom API provider configuration (BYOK). See [Custom Providers](#custom-providers) section. - `infinite_sessions` (InfiniteSessionConfig): Automatic context compaction configuration -- `on_permission_request` (callable): Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. `PermissionHandler.approve_all` approves ordinary requests automatically; requests with `managed_approval_required is True` remain pending for explicit resolution through a human-facing host flow. See [Permission Handling](#permission-handling) section. +- `on_permission_request` (callable): Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. `PermissionHandler.approve_all` approves requests when managed settings are disabled and raises an error when `enable_managed_settings` is true. Custom handlers can inspect `managed_approval_required` for human-facing confirmation logic. See [Permission Handling](#permission-handling) section. - `on_user_input_request` (callable): Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section. - `hooks` (SessionHooks): Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. @@ -792,7 +792,7 @@ session = await client.create_session( ) ``` -For requests with `managed_approval_required is True`, `approve_all` returns `PermissionNoResult`. The request remains pending and the host must present a human-facing confirmation flow to resolve it explicitly. +When `enable_managed_settings` is true for the session, `approve_all` raises an error. Use a custom handler for managed sessions; request-level `managed_approval_required` remains available for human-facing confirmation logic. ### Custom Permission Handler diff --git a/python/copilot/client.py b/python/copilot/client.py index a7706dfe57..440c74dea3 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -2521,7 +2521,12 @@ def _initialize_session(sid: str) -> CopilotSession: to a registered session. """ setup_start = time.perf_counter() - s = CopilotSession(sid, self._client, workspace_path=None) + s = CopilotSession( + sid, + self._client, + workspace_path=None, + managed_settings_enabled=enable_managed_settings is True, + ) if self._session_fs_config: if create_session_fs_handler is None: raise ValueError( @@ -3144,7 +3149,12 @@ async def resume_session( # Create and register the session before issuing the RPC so that # events emitted by the CLI (e.g. session.start) are not dropped. setup_start = time.perf_counter() - session = CopilotSession(session_id, self._client, workspace_path=None) + session = CopilotSession( + session_id, + self._client, + workspace_path=None, + managed_settings_enabled=enable_managed_settings is True, + ) if self._session_fs_config: if create_session_fs_handler is None: raise ValueError( diff --git a/python/copilot/session.py b/python/copilot/session.py index 152cad6cff..22d825bbd2 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -360,8 +360,13 @@ class PermissionNoResult: PermissionRequestResult = PermissionDecision | PermissionNoResult +class PermissionInvocation(TypedDict): + session_id: str + managed_settings_enabled: bool + + _PermissionHandlerFn = Callable[ - [PermissionRequest, dict[str, str]], + [PermissionRequest, PermissionInvocation], PermissionRequestResult | Awaitable[PermissionRequestResult], ] @@ -369,10 +374,10 @@ class PermissionNoResult: class PermissionHandler: @staticmethod def approve_all( - request: PermissionRequest, invocation: dict[str, str] + request: PermissionRequest, invocation: PermissionInvocation ) -> PermissionRequestResult: - if getattr(request, "managed_approval_required", False) is True: - return PermissionNoResult() + if invocation["managed_settings_enabled"]: + raise RuntimeError("approve_all cannot be used when managed settings are enabled") return PermissionDecisionApproveOnce() @@ -1449,7 +1454,11 @@ class CopilotSession: """ def __init__( - self, session_id: str, client: Any, workspace_path: os.PathLike[str] | str | None = None + self, + session_id: str, + client: Any, + workspace_path: os.PathLike[str] | str | None = None, + managed_settings_enabled: bool = False, ): """ Initialize a new CopilotSession. @@ -1465,6 +1474,7 @@ def __init__( (when infinite sessions enabled). """ self.session_id = session_id + self._managed_settings_enabled = managed_settings_enabled self._client = client self._workspace_path = os.fsdecode(workspace_path) if workspace_path is not None else None self._event_handlers: set[Callable[[SessionEvent], None]] = set() @@ -2102,7 +2112,13 @@ async def _execute_permission_and_respond( """Execute a permission handler and respond via RPC.""" try: handler_start = time.perf_counter() - result = handler(permission_request, {"session_id": self.session_id}) + result = handler( + permission_request, + { + "session_id": self.session_id, + "managed_settings_enabled": self._managed_settings_enabled, + }, + ) if inspect.isawaitable(result): result = await result log_timing( @@ -2528,7 +2544,13 @@ async def _handle_permission_request( try: handler_start = time.perf_counter() - result = handler(request, {"session_id": self.session_id}) + result = handler( + request, + { + "session_id": self.session_id, + "managed_settings_enabled": self._managed_settings_enabled, + }, + ) if inspect.isawaitable(result): result = await result log_timing( diff --git a/python/test_managed_permissions.py b/python/test_managed_permissions.py index 26575af0d3..4e23256c54 100644 --- a/python/test_managed_permissions.py +++ b/python/test_managed_permissions.py @@ -1,5 +1,7 @@ +import pytest + from copilot.rpc import PermissionDecisionApproveOnce -from copilot.session import PermissionHandler, PermissionNoResult +from copilot.session import PermissionHandler from copilot.session_events import PermissionRequestedData, PermissionRequestRead @@ -20,16 +22,18 @@ def test_permission_event_exposes_managed_approval_required() -> None: assert data.to_dict()["permissionRequest"]["managedApprovalRequired"] is True -def test_approve_all_leaves_managed_request_pending() -> None: +def test_approve_all_errors_when_managed_settings_enabled() -> None: request = PermissionRequestRead( intention="Read managed content", path="/workspace/file.txt", managed_approval_required=True, ) - assert isinstance( - PermissionHandler.approve_all(request, {"sessionId": "session-1"}), PermissionNoResult - ) + with pytest.raises(RuntimeError, match="managed settings are enabled"): + PermissionHandler.approve_all( + request, + {"session_id": "session-1", "managed_settings_enabled": True}, + ) def test_approve_all_approves_ordinary_request() -> None: @@ -39,6 +43,9 @@ def test_approve_all_approves_ordinary_request() -> None: ) assert isinstance( - PermissionHandler.approve_all(request, {"sessionId": "session-1"}), + PermissionHandler.approve_all( + request, + {"session_id": "session-1", "managed_settings_enabled": False}, + ), PermissionDecisionApproveOnce, ) diff --git a/rust/README.md b/rust/README.md index 5772f5d54c..96a03a42af 100644 --- a/rust/README.md +++ b/rust/README.md @@ -248,7 +248,7 @@ let config = SessionConfig::default() .with_user_input_handler(h); ``` -The built-in `ApproveAllHandler` and `DenyAllHandler` implement `PermissionHandler` for the common cases. `ApproveAllHandler` leaves requests with `managed_approval_required == Some(true)` pending for an explicit human decision. To observe streamed session events (assistant messages, tool calls, etc.), call `session.subscribe()` — see [Streaming](#streaming) below. +The built-in `ApproveAllHandler` and `DenyAllHandler` implement `PermissionHandler` for the common cases. `ApproveAllHandler` returns an error when `enable_managed_settings` is true; custom handlers can inspect `managed_approval_required` when implementing a human-facing confirmation flow. To observe streamed session events (assistant messages, tool calls, etc.), call `session.subscribe()` — see [Streaming](#streaming) below. ### SessionConfig @@ -428,7 +428,7 @@ Reach for the `ToolHandler` trait directly when you need shared state across mul Set a permission policy directly on `SessionConfig` with the chainable builders. They install a synthesized `PermissionHandler` so only permission requests are intercepted; every other event flows through unchanged. -The approve-all policy leaves managed approval requests pending so a human-facing host flow can resolve them explicitly. +The approve-all policy returns an error when `enable_managed_settings` is true. Custom handlers can inspect `managed_approval_required` for human-facing confirmation logic. ```rust,ignore let session = client diff --git a/rust/src/handler.rs b/rust/src/handler.rs index 6e3bab0485..c0e0ef0901 100644 --- a/rust/src/handler.rs +++ b/rust/src/handler.rs @@ -45,6 +45,8 @@ pub enum PermissionResult { /// Decline to respond to this request, allowing another connected /// client to answer instead. The SDK suppresses the response. NoResult, + /// The handler could not safely decide the request. + Error(String), } impl PermissionResult { @@ -273,8 +275,7 @@ pub trait AutoModeSwitchHandler: Send + Sync + 'static { ) -> AutoModeSwitchResponse; } -/// A [`PermissionHandler`] that approves ordinary requests. Requests that -/// require managed approval remain pending for an explicit human decision. +/// A [`PermissionHandler`] that approves requests when managed settings are disabled. #[derive(Debug, Clone)] pub struct ApproveAllHandler; @@ -286,8 +287,10 @@ impl PermissionHandler for ApproveAllHandler { _request_id: RequestId, data: PermissionRequestData, ) -> PermissionResult { - if data.managed_approval_required == Some(true) { - PermissionResult::no_result() + if data.managed_settings_enabled { + PermissionResult::Error( + "ApproveAllHandler cannot be used when managed settings are enabled".into(), + ) } else { PermissionResult::approve_once() } @@ -330,18 +333,18 @@ mod tests { } #[tokio::test] - async fn approve_all_handler_leaves_managed_request_pending() { + async fn approve_all_handler_errors_when_managed_settings_enabled() { let result = ApproveAllHandler .handle( SessionId::from("s1"), RequestId::new("1"), PermissionRequestData { - managed_approval_required: Some(true), + managed_settings_enabled: true, ..Default::default() }, ) .await; - assert!(matches!(result, PermissionResult::NoResult)); + assert!(matches!(result, PermissionResult::Error(_))); } #[tokio::test] diff --git a/rust/src/permission.rs b/rust/src/permission.rs index 0114842a16..56ea8c4b12 100644 --- a/rust/src/permission.rs +++ b/rust/src/permission.rs @@ -119,8 +119,10 @@ impl PermissionHandler for PolicyHandler { Policy::Predicate(f) => f(&data), }; if approved { - if data.managed_approval_required == Some(true) { - PermissionResult::no_result() + if matches!(self.policy, Policy::ApproveAll) && data.managed_settings_enabled { + PermissionResult::Error( + "approve-all policy cannot be used when managed settings are enabled".into(), + ) } else { PermissionResult::approve_once() } @@ -152,14 +154,14 @@ mod tests { } #[tokio::test] - async fn approve_all_leaves_managed_request_pending() { + async fn approve_all_errors_when_managed_settings_enabled() { let h = approve_all(); let mut request = data(); - request.managed_approval_required = Some(true); + request.managed_settings_enabled = true; assert!(matches!( h.handle(SessionId::from("s"), RequestId::new("1"), request) .await, - PermissionResult::NoResult + PermissionResult::Error(_) )); } @@ -184,14 +186,14 @@ mod tests { } #[tokio::test] - async fn approve_if_leaves_managed_approval_pending_when_predicate_approves() { + async fn approve_if_can_approve_managed_request() { let h = approve_if(|_| true); let mut request = data(); request.managed_approval_required = Some(true); assert!(matches!( h.handle(SessionId::from("s"), RequestId::new("1"), request) .await, - PermissionResult::NoResult + PermissionResult::Decision(crate::types::PermissionDecision::ApproveOnce(_)) )); } diff --git a/rust/src/session.rs b/rust/src/session.rs index 9465addaf2..c107a7df33 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -57,6 +57,7 @@ const TOOL_SEARCH_TOOL_NAME: &str = "tool_search_tool"; #[derive(Clone)] pub(crate) struct SessionHandlers { pub permission: Option>, + pub managed_settings_enabled: bool, pub elicitation: Option>, pub mcp_auth: Option>, pub user_input: Option>, @@ -894,6 +895,7 @@ impl Client { ); let handlers = SessionHandlers { permission: permission_handler, + managed_settings_enabled: wire.enable_managed_settings == Some(true), elicitation: runtime.elicitation_handler.take(), mcp_auth: runtime.mcp_auth_handler.take(), user_input: runtime.user_input_handler.take(), @@ -1159,6 +1161,7 @@ impl Client { ); let handlers = SessionHandlers { permission: permission_handler, + managed_settings_enabled: wire.enable_managed_settings == Some(true), elicitation: runtime.elicitation_handler.take(), mcp_auth: runtime.mcp_auth_handler.take(), user_input: runtime.user_input_handler.take(), @@ -1520,7 +1523,10 @@ fn extract_request_id(data: &Value) -> Option { .map(RequestId::new) } -fn permission_request_data(event_data: &Value) -> PermissionRequestData { +fn permission_request_data( + event_data: &Value, + managed_settings_enabled: bool, +) -> PermissionRequestData { let request_data = event_data .get("permissionRequest") .cloned() @@ -1531,12 +1537,14 @@ fn permission_request_data(event_data: &Value) -> PermissionRequestData { match serde_json::from_value::(request_data) { Ok(mut data) => { data.extra = event_data.clone(); + data.managed_settings_enabled = managed_settings_enabled; data } Err(_) => PermissionRequestData { kind: None, tool_call_id: None, managed_approval_required, + managed_settings_enabled, extra: event_data.clone(), }, } @@ -1549,6 +1557,10 @@ fn permission_request_data(event_data: &Value) -> PermissionRequestData { fn notification_permission_payload(result: &PermissionResult) -> Option { match result { PermissionResult::NoResult => None, + PermissionResult::Error(message) => { + tracing::error!(error = %message, "permission handler failed"); + Some(serde_json::json!({ "kind": "user-not-available" })) + } PermissionResult::Decision(decision) => Some( serde_json::to_value(decision).expect("serializing permission decision should succeed"), ), @@ -1727,7 +1739,10 @@ async fn handle_notification( }; let client = client.clone(); let sid = session_id.clone(); - let data = permission_request_data(¬ification.event.data); + let data = permission_request_data( + ¬ification.event.data, + handlers.managed_settings_enabled, + ); let span = tracing::error_span!( "permission_request_handler", session_id = %sid, @@ -2559,14 +2574,17 @@ mod tests { #[test] fn permission_request_data_reads_nested_managed_approval_metadata() { - let data = permission_request_data(&json!({ - "requestId": "permission-1", - "permissionRequest": { - "kind": "read", - "managedApprovalRequired": true, - "path": "/workspace/file.txt" - } - })); + let data = permission_request_data( + &json!({ + "requestId": "permission-1", + "permissionRequest": { + "kind": "read", + "managedApprovalRequired": true, + "path": "/workspace/file.txt" + } + }), + false, + ); assert_eq!(data.managed_approval_required, Some(true)); assert_eq!( @@ -2577,14 +2595,17 @@ mod tests { #[test] fn permission_request_data_preserves_managed_flag_when_other_fields_are_malformed() { - let data = permission_request_data(&json!({ - "requestId": "permission-1", - "permissionRequest": { - "kind": "read", - "managedApprovalRequired": true, - "toolCallId": 42 - } - })); + let data = permission_request_data( + &json!({ + "requestId": "permission-1", + "permissionRequest": { + "kind": "read", + "managedApprovalRequired": true, + "toolCallId": 42 + } + }), + false, + ); assert_eq!(data.managed_approval_required, Some(true)); assert_eq!(data.extra["requestId"], "permission-1"); diff --git a/rust/src/types.rs b/rust/src/types.rs index 6c766f425a..3469b5a1e4 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -5444,6 +5444,9 @@ pub struct PermissionRequestData { /// Whether managed policy requires an explicit human decision. #[serde(default, skip_serializing_if = "Option::is_none")] pub managed_approval_required: Option, + /// Whether managed settings are enabled for this session. + #[serde(default)] + pub managed_settings_enabled: bool, /// The full permission request params from the CLI. The shape varies by /// permission type and CLI version, so we preserve it as `Value`. #[serde(flatten)] From af2e19b229c2228672af4190607d5afe3bbdb042 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:14:45 -0700 Subject: [PATCH 16/31] Avoid expanding Rust permission result API Preserve approve-all fail-closed behavior by logging and returning the existing user-not-available decision instead of adding a public enum variant. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/src/handler.rs | 18 ++++++++++++------ rust/src/permission.rs | 10 +++++----- rust/src/session.rs | 4 ---- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/rust/src/handler.rs b/rust/src/handler.rs index c0e0ef0901..a5cfac3f99 100644 --- a/rust/src/handler.rs +++ b/rust/src/handler.rs @@ -45,8 +45,6 @@ pub enum PermissionResult { /// Decline to respond to this request, allowing another connected /// client to answer instead. The SDK suppresses the response. NoResult, - /// The handler could not safely decide the request. - Error(String), } impl PermissionResult { @@ -85,6 +83,11 @@ impl From for PermissionResult { } } +pub(crate) fn permission_handler_failure(message: &str) -> PermissionResult { + tracing::error!(error = message, "permission handler failed"); + PermissionResult::user_not_available() +} + /// Response to a user input request. #[derive(Debug, Clone)] pub struct UserInputResponse { @@ -288,8 +291,8 @@ impl PermissionHandler for ApproveAllHandler { data: PermissionRequestData, ) -> PermissionResult { if data.managed_settings_enabled { - PermissionResult::Error( - "ApproveAllHandler cannot be used when managed settings are enabled".into(), + permission_handler_failure( + "ApproveAllHandler cannot be used when managed settings are enabled", ) } else { PermissionResult::approve_once() @@ -333,7 +336,7 @@ mod tests { } #[tokio::test] - async fn approve_all_handler_errors_when_managed_settings_enabled() { + async fn approve_all_handler_fails_when_managed_settings_enabled() { let result = ApproveAllHandler .handle( SessionId::from("s1"), @@ -344,7 +347,10 @@ mod tests { }, ) .await; - assert!(matches!(result, PermissionResult::Error(_))); + assert!(matches!( + result, + PermissionResult::Decision(PermissionDecision::UserNotAvailable(_)) + )); } #[tokio::test] diff --git a/rust/src/permission.rs b/rust/src/permission.rs index 56ea8c4b12..049a51023b 100644 --- a/rust/src/permission.rs +++ b/rust/src/permission.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use async_trait::async_trait; -use crate::handler::{PermissionHandler, PermissionResult}; +use crate::handler::{PermissionHandler, PermissionResult, permission_handler_failure}; use crate::types::{PermissionRequestData, RequestId, SessionId}; /// Return a [`PermissionHandler`] that approves ordinary requests. @@ -120,8 +120,8 @@ impl PermissionHandler for PolicyHandler { }; if approved { if matches!(self.policy, Policy::ApproveAll) && data.managed_settings_enabled { - PermissionResult::Error( - "approve-all policy cannot be used when managed settings are enabled".into(), + permission_handler_failure( + "approve-all policy cannot be used when managed settings are enabled", ) } else { PermissionResult::approve_once() @@ -154,14 +154,14 @@ mod tests { } #[tokio::test] - async fn approve_all_errors_when_managed_settings_enabled() { + async fn approve_all_fails_when_managed_settings_enabled() { let h = approve_all(); let mut request = data(); request.managed_settings_enabled = true; assert!(matches!( h.handle(SessionId::from("s"), RequestId::new("1"), request) .await, - PermissionResult::Error(_) + PermissionResult::Decision(crate::types::PermissionDecision::UserNotAvailable(_)) )); } diff --git a/rust/src/session.rs b/rust/src/session.rs index c107a7df33..d31b4e0550 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -1557,10 +1557,6 @@ fn permission_request_data( fn notification_permission_payload(result: &PermissionResult) -> Option { match result { PermissionResult::NoResult => None, - PermissionResult::Error(message) => { - tracing::error!(error = %message, "permission handler failed"); - Some(serde_json::json!({ "kind": "user-not-available" })) - } PermissionResult::Decision(decision) => Some( serde_json::to_value(decision).expect("serializing permission decision should succeed"), ), From 347a1709cd6f82c147ccc37bf36611c090ff18c4 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:48:30 -0700 Subject: [PATCH 17/31] Keep managed permission helpers fail-closed Align Rust approve-all paths for request-level managed approval and correct cross-SDK documentation of session-level fail-fast behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/README.md | 2 +- go/README.md | 2 +- nodejs/README.md | 2 +- rust/README.md | 4 ++-- rust/src/handler.rs | 20 ++++++++++++++++++++ rust/src/permission.rs | 14 +++++++++----- 6 files changed, 34 insertions(+), 10 deletions(-) diff --git a/dotnet/README.md b/dotnet/README.md index a9170ea5f4..71be269470 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -787,7 +787,7 @@ var session = await client.CreateSessionAsync(new SessionConfig }); ``` -When `ManagedApprovalRequired` is `true`, `ApproveAll` returns `PermissionDecision.NoResult()`. The request remains pending and the host must present a human-facing confirmation flow to resolve it explicitly. +When `EnableManagedSettings` is true for the session, `ApproveAll` throws on the first permission request. Use a custom handler for managed sessions; request-level `ManagedApprovalRequired` remains available for human-facing confirmation logic. ### Custom Permission Handler diff --git a/go/README.md b/go/README.md index a6854055cc..ea474c53e6 100644 --- a/go/README.md +++ b/go/README.md @@ -683,7 +683,7 @@ session, err := client.CreateSession(context.Background(), &copilot.SessionConfi }) ``` -When `RequiresManagedApproval()` returns `true`, `ApproveAll` returns `PermissionDecisionNoResult`. The request remains pending and the host must present a human-facing confirmation flow to resolve it explicitly. +When `EnableManagedSettings` is true for the session, `ApproveAll` returns an error on the first permission request. Use a custom handler for managed sessions; request-level `RequiresManagedApproval()` remains available for human-facing confirmation logic. ### Custom Permission Handler diff --git a/nodejs/README.md b/nodejs/README.md index 2ed9ba227a..0b41dd8767 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -130,7 +130,7 @@ Create a new conversation session. - `systemMessage?: SystemMessageConfig` - System message customization (see below) - `infiniteSessions?: InfiniteSessionConfig` - Configure automatic context compaction (see below) - `provider?: ProviderConfig` - Custom API provider configuration (BYOK - Bring Your Own Key). See [Custom Providers](#custom-providers) section. -- `onPermissionRequest?: PermissionHandler` - Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. Use `approveAll` to approve ordinary requests automatically; requests with `managedApprovalRequired: true` remain pending for explicit resolution through a human-facing host flow. Provide a custom function for other fine-grained control. See [Permission Handling](#permission-handling) section. +- `onPermissionRequest?: PermissionHandler` - Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. `approveAll` approves requests when managed settings are disabled and throws when `enableManagedSettings` is true. Custom handlers can inspect `managedApprovalRequired` for human-facing confirmation logic. See [Permission Handling](#permission-handling) section. - `onUserInputRequest?: UserInputHandler` - Handler for user input requests from the agent. Enables the `ask_user` tool. See [User Input Requests](#user-input-requests) section. - `onElicitationRequest?: ElicitationHandler` - Handler for elicitation requests dispatched by the server. Enables this client to present form-based UI dialogs on behalf of the agent or other session participants. See [Elicitation Requests](#elicitation-requests) section. - `hooks?: SessionHooks` - Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. diff --git a/rust/README.md b/rust/README.md index 96a03a42af..d80b9033f8 100644 --- a/rust/README.md +++ b/rust/README.md @@ -248,7 +248,7 @@ let config = SessionConfig::default() .with_user_input_handler(h); ``` -The built-in `ApproveAllHandler` and `DenyAllHandler` implement `PermissionHandler` for the common cases. `ApproveAllHandler` returns an error when `enable_managed_settings` is true; custom handlers can inspect `managed_approval_required` when implementing a human-facing confirmation flow. To observe streamed session events (assistant messages, tool calls, etc.), call `session.subscribe()` — see [Streaming](#streaming) below. +The built-in `ApproveAllHandler` and `DenyAllHandler` implement `PermissionHandler` for the common cases. When `enable_managed_settings` is true, `ApproveAllHandler` logs an error and returns a user-not-available decision; custom handlers can inspect `managed_approval_required` when implementing a human-facing confirmation flow. To observe streamed session events (assistant messages, tool calls, etc.), call `session.subscribe()` — see [Streaming](#streaming) below. ### SessionConfig @@ -428,7 +428,7 @@ Reach for the `ToolHandler` trait directly when you need shared state across mul Set a permission policy directly on `SessionConfig` with the chainable builders. They install a synthesized `PermissionHandler` so only permission requests are intercepted; every other event flows through unchanged. -The approve-all policy returns an error when `enable_managed_settings` is true. Custom handlers can inspect `managed_approval_required` for human-facing confirmation logic. +When `enable_managed_settings` is true, the approve-all policy logs an error and returns a user-not-available decision. Custom handlers can inspect `managed_approval_required` for human-facing confirmation logic. ```rust,ignore let session = client diff --git a/rust/src/handler.rs b/rust/src/handler.rs index a5cfac3f99..c6d6f9875a 100644 --- a/rust/src/handler.rs +++ b/rust/src/handler.rs @@ -279,6 +279,9 @@ pub trait AutoModeSwitchHandler: Send + Sync + 'static { } /// A [`PermissionHandler`] that approves requests when managed settings are disabled. +/// +/// Requests that require managed approval remain pending for an explicit human +/// decision. #[derive(Debug, Clone)] pub struct ApproveAllHandler; @@ -294,6 +297,8 @@ impl PermissionHandler for ApproveAllHandler { permission_handler_failure( "ApproveAllHandler cannot be used when managed settings are enabled", ) + } else if data.managed_approval_required == Some(true) { + PermissionResult::no_result() } else { PermissionResult::approve_once() } @@ -353,6 +358,21 @@ mod tests { )); } + #[tokio::test] + async fn approve_all_handler_leaves_managed_approval_pending() { + let result = ApproveAllHandler + .handle( + SessionId::from("s1"), + RequestId::new("1"), + PermissionRequestData { + managed_approval_required: Some(true), + ..Default::default() + }, + ) + .await; + assert!(matches!(result, PermissionResult::NoResult)); + } + #[tokio::test] async fn deny_all_handler_returns_denied() { let result = DenyAllHandler diff --git a/rust/src/permission.rs b/rust/src/permission.rs index 049a51023b..50ae53517f 100644 --- a/rust/src/permission.rs +++ b/rust/src/permission.rs @@ -19,10 +19,12 @@ use async_trait::async_trait; use crate::handler::{PermissionHandler, PermissionResult, permission_handler_failure}; use crate::types::{PermissionRequestData, RequestId, SessionId}; -/// Return a [`PermissionHandler`] that approves ordinary requests. +/// Return a [`PermissionHandler`] that approves requests when managed settings +/// are disabled. /// -/// Requests that require managed approval remain pending for an explicit -/// human decision. +/// When managed settings are enabled, the handler logs an error and returns a +/// user-not-available decision. Requests that require managed approval remain +/// pending for an explicit human decision. pub fn approve_all() -> Arc { Arc::new(PolicyHandler { policy: Policy::ApproveAll, @@ -123,6 +125,8 @@ impl PermissionHandler for PolicyHandler { permission_handler_failure( "approve-all policy cannot be used when managed settings are enabled", ) + } else if data.managed_approval_required == Some(true) { + PermissionResult::no_result() } else { PermissionResult::approve_once() } @@ -186,14 +190,14 @@ mod tests { } #[tokio::test] - async fn approve_if_can_approve_managed_request() { + async fn approve_if_leaves_managed_approval_pending_when_predicate_approves() { let h = approve_if(|_| true); let mut request = data(); request.managed_approval_required = Some(true); assert!(matches!( h.handle(SessionId::from("s"), RequestId::new("1"), request) .await, - PermissionResult::Decision(crate::types::PermissionDecision::ApproveOnce(_)) + PermissionResult::NoResult )); } From 3c72a453db1413d9248712d33511e14f04d77275 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:07:44 -0700 Subject: [PATCH 18/31] Fix Java Gradle snapshot coordinate Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- java/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/java/README.md b/java/README.md index e6023fbb5a..0a170f2e73 100644 --- a/java/README.md +++ b/java/README.md @@ -67,7 +67,7 @@ Snapshot builds of the next development version are published to Maven Central S Replace `${copilot.sdk.version}` with the latest release from Maven Central. ```groovy -implementation 'com.github:copilot-sdk-java:1.0.9-preview.0-01-SNAPSHOT' +implementation 'com.github:copilot-sdk-java:1.0.10-preview.0-SNAPSHOT' ``` ## Quick Start From 2f6d52ec8aa881660725d3b1c442a004636a07b5 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:21:02 -0700 Subject: [PATCH 19/31] Align managed permission documentation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- nodejs/README.md | 2 +- python/README.md | 5 +++-- python/copilot/session.py | 3 ++- rust/src/handler.rs | 4 ++-- rust/src/permission.rs | 3 +-- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/nodejs/README.md b/nodejs/README.md index 0b41dd8767..1571f371d1 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -920,7 +920,7 @@ The handler must return one of the `PermissionDecision` shapes (or `{ kind: "no- | `"approve-permanently"` | Allow this request and persist the approval across sessions (currently used for URL domains) | `domain` (URL domain to approve) | | `"reject"` | Deny the request | `feedback?` (optional string surfaced to the agent) | | `"user-not-available"` | Deny the request because no user is available to confirm it | — | -| `"no-result"` | Leave the request unanswered (only valid with protocol v1; rejected by protocol v2 servers) | — | +| `"no-result"` | Suppress this SDK client's response so another connected client can answer the pending request | — | ### Resuming Sessions diff --git a/python/README.md b/python/README.md index a9941a6038..d3d4d4e098 100644 --- a/python/README.md +++ b/python/README.md @@ -845,7 +845,8 @@ async def on_permission_request( The handler returns a ``PermissionRequestResult``, which is an alias for ``PermissionDecision | PermissionNoResult`` (the generated wire-level -union of every decision variant, plus a small sentinel for v1 servers). +union of every decision variant, plus a sentinel that suppresses this SDK +client's response). Approval decisions are present-tense — they describe the decision to apply, not the past-tense outcome reported back on `permission.completed` session events. @@ -855,7 +856,7 @@ session events. | `PermissionDecisionApproveOnce()` | Allow this single request | | `PermissionDecisionReject(feedback="…")` | Deny the request (optional feedback string forwarded to the LLM) | | `PermissionDecisionUserNotAvailable()` | Deny the request because no user is available to confirm it (the default) | -| `PermissionNoResult()` | Leave the request unanswered (only valid with protocol v1; rejected by protocol v2 servers) | +| `PermissionNoResult()` | Suppress this SDK client's response so another connected client can answer the pending request | Several richer variants (``PermissionDecisionApproveForSession``, ``PermissionDecisionApproveForLocation``, ``PermissionDecisionApprovePermanently``, diff --git a/python/copilot/session.py b/python/copilot/session.py index 22d825bbd2..fee7f383f9 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -353,7 +353,8 @@ class PermissionNoResult: # The decision returned by a permission handler. Identical shape to the wire # ``PermissionDecision`` discriminated union, plus a :class:`PermissionNoResult` -# sentinel for v1 servers. Construct via the generated variant classes: +# sentinel that suppresses this SDK client's response. Construct via the +# generated variant classes: # ``PermissionDecisionApproveOnce()``, ``PermissionDecisionReject(feedback=...)``, # etc. The ``kind`` discriminator is baked in as a ``ClassVar`` default by # codegen, so callers must not pass it. diff --git a/rust/src/handler.rs b/rust/src/handler.rs index c6d6f9875a..7ec8919650 100644 --- a/rust/src/handler.rs +++ b/rust/src/handler.rs @@ -280,8 +280,8 @@ pub trait AutoModeSwitchHandler: Send + Sync + 'static { /// A [`PermissionHandler`] that approves requests when managed settings are disabled. /// -/// Requests that require managed approval remain pending for an explicit human -/// decision. +/// When managed settings are enabled, the handler logs an error and returns a +/// user-not-available decision. #[derive(Debug, Clone)] pub struct ApproveAllHandler; diff --git a/rust/src/permission.rs b/rust/src/permission.rs index 50ae53517f..e353ce3153 100644 --- a/rust/src/permission.rs +++ b/rust/src/permission.rs @@ -23,8 +23,7 @@ use crate::types::{PermissionRequestData, RequestId, SessionId}; /// are disabled. /// /// When managed settings are enabled, the handler logs an error and returns a -/// user-not-available decision. Requests that require managed approval remain -/// pending for an explicit human decision. +/// user-not-available decision. pub fn approve_all() -> Arc { Arc::new(PolicyHandler { policy: Policy::ApproveAll, From 6dba2c51165b8ef694b4a9c5bec86db6db0004d2 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:32:35 -0700 Subject: [PATCH 20/31] Clarify Java managed approval handling Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../java/com/github/copilot/rpc/PermissionHandler.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java b/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java index f7985b7d30..5d91e2c20a 100644 --- a/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java +++ b/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java @@ -18,7 +18,8 @@ *

{@code
  * PermissionHandler handler = (request, invocation) -> {
  * 	if (Boolean.TRUE.equals(request.getManagedApprovalRequired())) {
- * 		return CompletableFuture.completedFuture(PermissionRequestResult.noResult());
+ * 		// Obtain an explicit human decision before approving this request.
+ * 		return requestHumanApproval(request);
  * 	}
  *
  * 	// Check the permission kind
@@ -33,6 +34,11 @@
  * 			.completedFuture(new PermissionRequestResult().setKind(PermissionRequestResultKind.APPROVED));
  * };
  * }
+ *

+ * Event-based permission dispatch can use + * {@link PermissionRequestResult#noResult()} to let another connected client + * answer a pending request. Legacy protocol-v2 callbacks require a decision and + * cannot abstain. * *

* A pre-built handler that approves all requests is available as From b987639d137dd790184b0429975ab72ce4f28961 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:50:45 -0700 Subject: [PATCH 21/31] Surface permission handler failures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Session.cs | 3 ++- go/session.go | 1 + java/src/main/java/com/github/copilot/CopilotSession.java | 1 + nodejs/src/session.ts | 3 ++- python/copilot/session.py | 4 ++++ 5 files changed, 10 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index b0c1dc9886..8a46f9c3eb 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -961,8 +961,9 @@ private async Task ExecutePermissionAndRespondAsync(string requestId, Permission SessionId, requestId); } - catch (Exception) + catch (Exception ex) { + _logger.LogError(ex, "Permission handler failed. SessionId={SessionId}, RequestId={RequestId}", SessionId, requestId); try { await Rpc.Permissions.HandlePendingPermissionRequestAsync(requestId, PermissionDecision.UserNotAvailable()); diff --git a/go/session.go b/go/session.go index 9b37d42a4e..5232efb1e8 100644 --- a/go/session.go +++ b/go/session.go @@ -1600,6 +1600,7 @@ func (s *Session) executePermissionAndRespond(requestID string, permissionReques decision, err := handler(permissionRequest, invocation) if err != nil { + log.Printf("permission handler failed: session_id=%s request_id=%s error=%v", s.SessionID, requestID, err) s.RPC.Permissions.HandlePendingPermissionRequest(context.Background(), &rpc.PermissionDecisionRequest{ RequestID: requestID, Result: &rpc.PermissionDecisionUserNotAvailable{}, diff --git a/java/src/main/java/com/github/copilot/CopilotSession.java b/java/src/main/java/com/github/copilot/CopilotSession.java index 721b021181..30c9152777 100644 --- a/java/src/main/java/com/github/copilot/CopilotSession.java +++ b/java/src/main/java/com/github/copilot/CopilotSession.java @@ -964,6 +964,7 @@ private void executeToolAndRespondAsync(String requestId, String toolName, Strin LOG.log(Level.WARNING, "Error sending tool result for requestId=" + requestId, e); } }).exceptionally(ex -> { + LOG.log(Level.SEVERE, "Permission handler failed for requestId=" + requestId, ex); try { getRpc().tools.handlePendingToolCall(new SessionToolsHandlePendingToolCallParams(sessionId, requestId, null, ex.getMessage() != null ? ex.getMessage() : ex.toString())); diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index ea7fb63ec6..4e0e6e7c88 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -699,10 +699,11 @@ export class CopilotSession { return; } await this.rpc.permissions.handlePendingPermissionRequest({ requestId, result }); - } catch (_error) { + } catch (error) { if (this.disconnected) { return; } + console.error("Permission handler failed", error); try { await this.rpc.permissions.handlePendingPermissionRequest({ requestId, diff --git a/python/copilot/session.py b/python/copilot/session.py index fee7f383f9..58587201cf 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -2151,6 +2151,10 @@ async def _execute_permission_and_respond( request_id=request_id, ) except Exception: + logger.exception( + "Permission handler failed", + extra={"session_id": self.session_id, "request_id": request_id}, + ) try: await self.rpc.permissions.handle_pending_permission_request( PermissionDecisionRequest( From 0762739d4032719bb1ba3b6241e58e8cc3e7bab2 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:01:32 -0700 Subject: [PATCH 22/31] Log Java permission handler failures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- java/src/main/java/com/github/copilot/CopilotSession.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/java/src/main/java/com/github/copilot/CopilotSession.java b/java/src/main/java/com/github/copilot/CopilotSession.java index 30c9152777..aacd9ee481 100644 --- a/java/src/main/java/com/github/copilot/CopilotSession.java +++ b/java/src/main/java/com/github/copilot/CopilotSession.java @@ -964,7 +964,6 @@ private void executeToolAndRespondAsync(String requestId, String toolName, Strin LOG.log(Level.WARNING, "Error sending tool result for requestId=" + requestId, e); } }).exceptionally(ex -> { - LOG.log(Level.SEVERE, "Permission handler failed for requestId=" + requestId, ex); try { getRpc().tools.handlePendingToolCall(new SessionToolsHandlePendingToolCallParams(sessionId, requestId, null, ex.getMessage() != null ? ex.getMessage() : ex.toString())); @@ -1031,6 +1030,7 @@ private void executePermissionAndRespondAsync(String requestId, PermissionReques LOG.log(Level.WARNING, "Error sending permission result for requestId=" + requestId, e); } }).exceptionally(ex -> { + LOG.log(Level.SEVERE, "Permission handler failed for requestId=" + requestId, ex); try { PermissionRequestResult denied = new PermissionRequestResult(); denied.setKind(PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER); From c41594ba7aabb9be936bb4c4390fe3f70097576b Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:06:44 -0700 Subject: [PATCH 23/31] Clarify permission failure diagnostics Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- nodejs/src/session.ts | 6 +++++- python/README.md | 2 +- python/copilot/session.py | 7 ++++--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 4e0e6e7c88..3f1961f80d 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -703,7 +703,11 @@ export class CopilotSession { if (this.disconnected) { return; } - console.error("Permission handler failed", error); + console.error("Permission handler failed", { + sessionId: this.sessionId, + requestId, + error, + }); try { await this.rpc.permissions.handlePendingPermissionRequest({ requestId, diff --git a/python/README.md b/python/README.md index ac9fee45c5..e9c99ff422 100644 --- a/python/README.md +++ b/python/README.md @@ -857,7 +857,7 @@ session events. | `PermissionDecisionApproveOnce()` | Allow this single request | | `PermissionDecisionReject(feedback="…")` | Deny the request (optional feedback string forwarded to the LLM) | | `PermissionDecisionUserNotAvailable()` | Deny the request because no user is available to confirm it (the default) | -| `PermissionNoResult()` | Suppress this SDK client's response so another connected client can answer the pending request | +| `PermissionNoResult()` | During event-based dispatch, suppress this SDK client's response so another connected client can answer the pending request; legacy direct callbacks cannot abstain | Several richer variants (``PermissionDecisionApproveForSession``, ``PermissionDecisionApproveForLocation``, ``PermissionDecisionApprovePermanently``, diff --git a/python/copilot/session.py b/python/copilot/session.py index 58587201cf..f8a229ae75 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -342,10 +342,11 @@ class SystemMessageCustomizeConfig(TypedDict, total=False): @dataclass class PermissionNoResult: - """Sentinel returned by a permission handler to leave the request unanswered. + """Sentinel that leaves an event-dispatched permission request unanswered. - The SDK suppresses its response so another connected client, such as a - human-facing host, can answer the pending request. + During event-based permission dispatch, the SDK suppresses its response so + another connected client, such as a human-facing host, can answer the pending + request. Legacy direct callbacks require a concrete decision and cannot abstain. """ kind: Literal["no-result"] = "no-result" From f2e484a65e114574a85455eb3279a3c7557ad382 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:21:35 -0700 Subject: [PATCH 24/31] Clarify Rust managed approval fallback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/src/handler.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rust/src/handler.rs b/rust/src/handler.rs index 7ec8919650..77edf919c9 100644 --- a/rust/src/handler.rs +++ b/rust/src/handler.rs @@ -278,10 +278,12 @@ pub trait AutoModeSwitchHandler: Send + Sync + 'static { ) -> AutoModeSwitchResponse; } -/// A [`PermissionHandler`] that approves requests when managed settings are disabled. +/// A [`PermissionHandler`] that approves ordinary requests when managed settings are disabled. /// /// When managed settings are enabled, the handler logs an error and returns a -/// user-not-available decision. +/// user-not-available decision. As a defense-in-depth fallback, a request marked +/// as requiring managed approval is left unanswered even if the session flag is +/// absent. #[derive(Debug, Clone)] pub struct ApproveAllHandler; From c7eeb11b0c8297a7e4bd6a3d28667a378bd20444 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:48:36 -0700 Subject: [PATCH 25/31] Reject no-result in legacy Python callbacks Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- python/copilot/session.py | 5 ++++- python/test_managed_permissions.py | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/python/copilot/session.py b/python/copilot/session.py index f8a229ae75..87e86727de 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -2566,7 +2566,10 @@ async def _handle_permission_request( handler_start, session_id=self.session_id, ) - return cast(PermissionRequestResult, result) + result = cast(PermissionRequestResult, result) + if isinstance(result, PermissionNoResult): + return PermissionDecisionUserNotAvailable() + return result except Exception: # pylint: disable=broad-except # Handler failed, deny permission. logger.debug( diff --git a/python/test_managed_permissions.py b/python/test_managed_permissions.py index 4e23256c54..affe9c01f0 100644 --- a/python/test_managed_permissions.py +++ b/python/test_managed_permissions.py @@ -1,7 +1,7 @@ import pytest -from copilot.rpc import PermissionDecisionApproveOnce -from copilot.session import PermissionHandler +from copilot.rpc import PermissionDecisionApproveOnce, PermissionDecisionUserNotAvailable +from copilot.session import CopilotSession, PermissionHandler, PermissionNoResult from copilot.session_events import PermissionRequestedData, PermissionRequestRead @@ -49,3 +49,17 @@ def test_approve_all_approves_ordinary_request() -> None: ), PermissionDecisionApproveOnce, ) + + +async def test_legacy_permission_callback_rejects_no_result() -> None: + request = PermissionRequestRead( + intention="Read managed content", + path="/workspace/file.txt", + managed_approval_required=True, + ) + session = CopilotSession("session-1", client=None) + session._register_permission_handler(lambda _request, _invocation: PermissionNoResult()) + + result = await session._handle_permission_request(request) + + assert isinstance(result, PermissionDecisionUserNotAvailable) From 4157ef1d2acfef19a100622c77656d9b86b3e3e1 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:04:41 -0700 Subject: [PATCH 26/31] Fail closed on managed approval metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/src/PermissionHandlers.cs | 15 +++++++++++++-- dotnet/test/Unit/PermissionHandlerTests.cs | 15 +++++++++++++++ go/permissions.go | 7 +++++-- go/permissions_test.go | 17 +++++++++++++++++ .../github/copilot/rpc/PermissionHandler.java | 14 ++++++++++---- .../copilot/PermissionRequestResultTest.java | 11 +++++++++++ nodejs/src/types.ts | 5 ++++- nodejs/test/client.test.ts | 6 ++++++ python/copilot/session.py | 2 ++ python/test_managed_permissions.py | 16 ++++++++++++++++ 10 files changed, 99 insertions(+), 9 deletions(-) diff --git a/dotnet/src/PermissionHandlers.cs b/dotnet/src/PermissionHandlers.cs index 6effeb063b..acf82de115 100644 --- a/dotnet/src/PermissionHandlers.cs +++ b/dotnet/src/PermissionHandlers.cs @@ -13,8 +13,19 @@ public static class PermissionHandler /// A permission handler that approves requests when managed settings are disabled. /// public static Func> ApproveAll { get; } = - (_, invocation) => invocation.ManagedSettingsEnabled + (request, invocation) => invocation.ManagedSettingsEnabled ? Task.FromException( new InvalidOperationException("ApproveAll cannot be used when managed settings are enabled")) - : Task.FromResult(PermissionDecision.ApproveOnce()); + : RequiresManagedApproval(request) + ? Task.FromResult(PermissionDecision.NoResult()) + : Task.FromResult(PermissionDecision.ApproveOnce()); + + private static bool RequiresManagedApproval(PermissionRequest request) => request switch + { + PermissionRequestShell shell => shell.ManagedApprovalRequired is true, + PermissionRequestWrite write => write.ManagedApprovalRequired is true, + PermissionRequestRead read => read.ManagedApprovalRequired is true, + PermissionRequestUrl url => url.ManagedApprovalRequired is true, + _ => false, + }; } diff --git a/dotnet/test/Unit/PermissionHandlerTests.cs b/dotnet/test/Unit/PermissionHandlerTests.cs index 4a74b189b8..cf8cf6e52c 100644 --- a/dotnet/test/Unit/PermissionHandlerTests.cs +++ b/dotnet/test/Unit/PermissionHandlerTests.cs @@ -70,4 +70,19 @@ public async Task ApproveAllApprovesOrdinaryRequest() Assert.IsType(decision); } + + [Fact] + public async Task ApproveAllLeavesManagedRequestPendingWhenSessionFlagIsAbsent() + { + var request = new PermissionRequestRead + { + Intention = "Read managed content", + ManagedApprovalRequired = true, + Path = "/workspace/file.txt", + }; + + var decision = await PermissionHandler.ApproveAll(request, new PermissionInvocation()); + + Assert.IsType(decision); + } } diff --git a/go/permissions.go b/go/permissions.go index b21745bd3e..24b9cc7f13 100644 --- a/go/permissions.go +++ b/go/permissions.go @@ -11,9 +11,12 @@ var PermissionHandler = struct { // ApproveAll approves permission requests when managed settings are disabled. ApproveAll PermissionHandlerFunc }{ - ApproveAll: func(_ PermissionRequest, invocation PermissionInvocation) (rpc.PermissionDecision, error) { + ApproveAll: func(request PermissionRequest, invocation PermissionInvocation) (rpc.PermissionDecision, error) { if invocation.ManagedSettingsEnabled { - return nil, errors.New("ApproveAll cannot be used when managed settings are enabled") + return nil, errors.New("approveAll cannot be used when managed settings are enabled") + } + if request.RequiresManagedApproval() { + return &rpc.PermissionDecisionNoResult{}, nil } return &rpc.PermissionDecisionApproveOnce{}, nil }, diff --git a/go/permissions_test.go b/go/permissions_test.go index 7893faaa24..69919eb2d2 100644 --- a/go/permissions_test.go +++ b/go/permissions_test.go @@ -53,3 +53,20 @@ func TestApproveAllApprovesOrdinaryRequest(t *testing.T) { t.Fatalf("expected PermissionDecisionApproveOnce, got %T", decision) } } + +func TestApproveAllLeavesManagedRequestPendingWhenSessionFlagIsAbsent(t *testing.T) { + decision, err := copilot.PermissionHandler.ApproveAll( + &copilot.PermissionRequestRead{ManagedApprovalRequired: ptrTo(true)}, + copilot.PermissionInvocation{SessionID: "session-1"}, + ) + if err != nil { + t.Fatal(err) + } + if _, ok := decision.(*rpc.PermissionDecisionNoResult); !ok { + t.Fatalf("expected PermissionDecisionNoResult, got %T", decision) + } +} + +func ptrTo[T any](value T) *T { + return &value +} diff --git a/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java b/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java index 5d91e2c20a..58639beda2 100644 --- a/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java +++ b/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java @@ -58,10 +58,16 @@ public interface PermissionHandler { * * @since 1.0.11 */ - PermissionHandler APPROVE_ALL = (request, invocation) -> invocation.isManagedSettingsEnabled() - ? CompletableFuture.failedFuture( - new IllegalStateException("APPROVE_ALL cannot be used when managed settings are enabled")) - : CompletableFuture.completedFuture(PermissionRequestResult.approveOnce()); + PermissionHandler APPROVE_ALL = (request, invocation) -> { + if (invocation.isManagedSettingsEnabled()) { + return CompletableFuture.failedFuture( + new IllegalStateException("APPROVE_ALL cannot be used when managed settings are enabled")); + } + if (Boolean.TRUE.equals(request.getManagedApprovalRequired())) { + return CompletableFuture.completedFuture(PermissionRequestResult.noResult()); + } + return CompletableFuture.completedFuture(PermissionRequestResult.approveOnce()); + }; /** * Handles a permission request from the assistant. diff --git a/java/src/test/java/com/github/copilot/PermissionRequestResultTest.java b/java/src/test/java/com/github/copilot/PermissionRequestResultTest.java index deca0f4d23..d1cb6137ce 100644 --- a/java/src/test/java/com/github/copilot/PermissionRequestResultTest.java +++ b/java/src/test/java/com/github/copilot/PermissionRequestResultTest.java @@ -123,4 +123,15 @@ void testApproveAllApprovesOrdinaryRequest() { assertEquals("approve-once", result.getKind()); } + + @Test + void testApproveAllLeavesManagedRequestPendingWhenSessionFlagIsAbsent() { + var request = new PermissionRequest(); + request.setKind("read"); + request.setManagedApprovalRequired(true); + + var result = PermissionHandler.APPROVE_ALL.handle(request, new PermissionInvocation()).join(); + + assertEquals("no-result", result.getKind()); + } } diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 843fd134bb..b6576e05a8 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1127,10 +1127,13 @@ export type PermissionHandler = ( /** * Approves permission requests for sessions without managed settings. */ -export const approveAll: PermissionHandler = (_request, invocation) => { +export const approveAll: PermissionHandler = (request, invocation) => { if (invocation.managedSettingsEnabled) { throw new Error("approveAll cannot be used when managed settings are enabled"); } + if ("managedApprovalRequired" in request && request.managedApprovalRequired === true) { + return { kind: "no-result" }; + } return { kind: "approve-once" }; }; diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 85061d2d70..a88b48c948 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -39,6 +39,12 @@ describe("approveAll", () => { ) ).toThrow("approveAll cannot be used when managed settings are enabled"); }); + + it("does not approve managed requests when the session flag is absent", () => { + expect(approveAll({ ...request, managedApprovalRequired: true }, invocation)).toEqual({ + kind: "no-result", + }); + }); }); describe("CopilotClient", () => { diff --git a/python/copilot/session.py b/python/copilot/session.py index 87e86727de..1a93a17b3c 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -380,6 +380,8 @@ def approve_all( ) -> PermissionRequestResult: if invocation["managed_settings_enabled"]: raise RuntimeError("approve_all cannot be used when managed settings are enabled") + if getattr(request, "managed_approval_required", False) is True: + return PermissionNoResult() return PermissionDecisionApproveOnce() diff --git a/python/test_managed_permissions.py b/python/test_managed_permissions.py index affe9c01f0..4cf213f82f 100644 --- a/python/test_managed_permissions.py +++ b/python/test_managed_permissions.py @@ -51,6 +51,22 @@ def test_approve_all_approves_ordinary_request() -> None: ) +def test_approve_all_leaves_managed_request_pending_when_session_flag_is_absent() -> None: + request = PermissionRequestRead( + intention="Read managed content", + path="/workspace/file.txt", + managed_approval_required=True, + ) + + assert isinstance( + PermissionHandler.approve_all( + request, + {"session_id": "session-1", "managed_settings_enabled": False}, + ), + PermissionNoResult, + ) + + async def test_legacy_permission_callback_rejects_no_result() -> None: request = PermissionRequestRead( intention="Read managed content", From b1578a7e9944259c2965c92b249872cdcbd2e7b2 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:09:51 -0700 Subject: [PATCH 27/31] Initialize managed settings before Go events Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- go/client.go | 16 ++++++++++++---- go/session.go | 8 +++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/go/client.go b/go/client.go index bb87641460..2202e92a14 100644 --- a/go/client.go +++ b/go/client.go @@ -906,8 +906,12 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses // message is dispatched) so notifications for the new session id are // routed to a registered session. initializeSession := func(sessionID string) (*Session, error) { - s := newSession(sessionID, c.client, "") - s.managedSettings = config.EnableManagedSettings != nil && *config.EnableManagedSettings + s := newSession( + sessionID, + c.client, + "", + config.EnableManagedSettings != nil && *config.EnableManagedSettings, + ) s.registerTools(config.Tools) s.registerPermissionHandler(config.OnPermissionRequest) @@ -1228,8 +1232,12 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, // Create and register the session before issuing the RPC so that // events emitted by the CLI (e.g. session.start) are not dropped. - session := newSession(sessionID, c.client, "") - session.managedSettings = config.EnableManagedSettings != nil && *config.EnableManagedSettings + session := newSession( + sessionID, + c.client, + "", + config.EnableManagedSettings != nil && *config.EnableManagedSettings, + ) session.registerTools(config.Tools) session.registerPermissionHandler(config.OnPermissionRequest) diff --git a/go/session.go b/go/session.go index 5232efb1e8..74e68cc7f3 100644 --- a/go/session.go +++ b/go/session.go @@ -366,10 +366,16 @@ func canvasResultError(err error) error { } // newSession creates a new session wrapper with the given session ID and client. -func newSession(sessionID string, client *jsonrpc2.Client, workspacePath string) *Session { +func newSession( + sessionID string, + client *jsonrpc2.Client, + workspacePath string, + managedSettings bool, +) *Session { s := &Session{ SessionID: sessionID, workspacePath: workspacePath, + managedSettings: managedSettings, client: client, clientSessionAPIs: &rpc.ClientSessionAPIHandlers{}, handlers: make([]sessionHandler, 0), From 08fdb395603d14d4627833298d37e02c7fcb5f70 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:13:25 -0700 Subject: [PATCH 28/31] Fail closed on unknown permission requests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/src/PermissionHandlers.cs | 8 +++++++- dotnet/test/Unit/PermissionHandlerTests.cs | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/dotnet/src/PermissionHandlers.cs b/dotnet/src/PermissionHandlers.cs index acf82de115..f5e1dc0f8c 100644 --- a/dotnet/src/PermissionHandlers.cs +++ b/dotnet/src/PermissionHandlers.cs @@ -26,6 +26,12 @@ public static class PermissionHandler PermissionRequestWrite write => write.ManagedApprovalRequired is true, PermissionRequestRead read => read.ManagedApprovalRequired is true, PermissionRequestUrl url => url.ManagedApprovalRequired is true, - _ => false, + PermissionRequestMcp + or PermissionRequestMemory + or PermissionRequestCustomTool + or PermissionRequestHook + or PermissionRequestExtensionManagement + or PermissionRequestExtensionPermissionAccess => false, + _ => true, }; } diff --git a/dotnet/test/Unit/PermissionHandlerTests.cs b/dotnet/test/Unit/PermissionHandlerTests.cs index cf8cf6e52c..842a95a1e8 100644 --- a/dotnet/test/Unit/PermissionHandlerTests.cs +++ b/dotnet/test/Unit/PermissionHandlerTests.cs @@ -85,4 +85,14 @@ public async Task ApproveAllLeavesManagedRequestPendingWhenSessionFlagIsAbsent() Assert.IsType(decision); } + + [Fact] + public async Task ApproveAllLeavesUnknownRequestPending() + { + var request = new PermissionRequest { Kind = "future-managed-kind" }; + + var decision = await PermissionHandler.ApproveAll(request, new PermissionInvocation()); + + Assert.IsType(decision); + } } From 06920e40d07114bfffaedb823e7f5b75d7abec28 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:14:37 -0700 Subject: [PATCH 29/31] Preserve legacy Python approve-all calls Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- python/copilot/session.py | 2 +- python/test_managed_permissions.py | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/python/copilot/session.py b/python/copilot/session.py index 1a93a17b3c..b5be174ddb 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -378,7 +378,7 @@ class PermissionHandler: def approve_all( request: PermissionRequest, invocation: PermissionInvocation ) -> PermissionRequestResult: - if invocation["managed_settings_enabled"]: + if invocation.get("managed_settings_enabled", False): raise RuntimeError("approve_all cannot be used when managed settings are enabled") if getattr(request, "managed_approval_required", False) is True: return PermissionNoResult() diff --git a/python/test_managed_permissions.py b/python/test_managed_permissions.py index 4cf213f82f..16861a5112 100644 --- a/python/test_managed_permissions.py +++ b/python/test_managed_permissions.py @@ -1,3 +1,5 @@ +from typing import Any + import pytest from copilot.rpc import PermissionDecisionApproveOnce, PermissionDecisionUserNotAvailable @@ -58,13 +60,9 @@ def test_approve_all_leaves_managed_request_pending_when_session_flag_is_absent( managed_approval_required=True, ) - assert isinstance( - PermissionHandler.approve_all( - request, - {"session_id": "session-1", "managed_settings_enabled": False}, - ), - PermissionNoResult, - ) + legacy_invocation: Any = {"session_id": "session-1"} + + assert isinstance(PermissionHandler.approve_all(request, legacy_invocation), PermissionNoResult) async def test_legacy_permission_callback_rejects_no_result() -> None: From e740cf36b888d3cf4fd9c945620e76a2a0357ee4 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:20:39 -0700 Subject: [PATCH 30/31] Clarify Rust permission event payload Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/src/types.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rust/src/types.rs b/rust/src/types.rs index 3469b5a1e4..8e9a441b25 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -5447,8 +5447,9 @@ pub struct PermissionRequestData { /// Whether managed settings are enabled for this session. #[serde(default)] pub managed_settings_enabled: bool, - /// The full permission request params from the CLI. The shape varies by - /// permission type and CLI version, so we preserve it as `Value`. + /// The full permission event params from the CLI, including the request ID + /// and nested permission request. The shape varies by permission type and + /// CLI version, so we preserve it as `Value`. #[serde(flatten)] pub extra: Value, } From 8e7c16f16303c75aced82dafadcf3c2cfb55b594 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:24:44 -0700 Subject: [PATCH 31/31] Harden managed permission fallbacks Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- go/permissions_test.go | 7 +++++++ go/rpc/permission_request_managed_approval.go | 5 ++++- python/copilot/session.py | 2 ++ rust/src/types.rs | 2 +- 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/go/permissions_test.go b/go/permissions_test.go index 69919eb2d2..ed4c3a040b 100644 --- a/go/permissions_test.go +++ b/go/permissions_test.go @@ -67,6 +67,13 @@ func TestApproveAllLeavesManagedRequestPendingWhenSessionFlagIsAbsent(t *testing } } +func TestRawPermissionRequestWithMalformedJSONRequiresManagedApproval(t *testing.T) { + request := rpc.RawPermissionRequest{Raw: json.RawMessage(`{"managedApprovalRequired":`)} + if !request.RequiresManagedApproval() { + t.Fatal("expected malformed raw request to fail closed") + } +} + func ptrTo[T any](value T) *T { return &value } diff --git a/go/rpc/permission_request_managed_approval.go b/go/rpc/permission_request_managed_approval.go index 014d6b83db..d561cede2e 100644 --- a/go/rpc/permission_request_managed_approval.go +++ b/go/rpc/permission_request_managed_approval.go @@ -74,5 +74,8 @@ func (r RawPermissionRequest) RequiresManagedApproval() bool { var metadata struct { ManagedApprovalRequired *bool `json:"managedApprovalRequired"` } - return json.Unmarshal(r.Raw, &metadata) == nil && managedApprovalRequired(metadata.ManagedApprovalRequired) + if json.Unmarshal(r.Raw, &metadata) != nil { + return true + } + return managedApprovalRequired(metadata.ManagedApprovalRequired) } diff --git a/python/copilot/session.py b/python/copilot/session.py index b5be174ddb..3cb41553ff 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -1476,6 +1476,8 @@ def __init__( client: The internal client connection to the Copilot CLI. workspace_path: Path to the session workspace directory (when infinite sessions enabled). + managed_settings_enabled: Whether managed settings were enabled when + creating or resuming the session. """ self.session_id = session_id self._managed_settings_enabled = managed_settings_enabled diff --git a/rust/src/types.rs b/rust/src/types.rs index 8e9a441b25..ab3b906229 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -5445,7 +5445,7 @@ pub struct PermissionRequestData { #[serde(default, skip_serializing_if = "Option::is_none")] pub managed_approval_required: Option, /// Whether managed settings are enabled for this session. - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub managed_settings_enabled: bool, /// The full permission event params from the CLI, including the request ID /// and nested permission request. The shape varies by permission type and