diff --git a/dotnet/README.md b/dotnet/README.md index 83398aff2..a7d72e777 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 is only valid when managed settings are disabled. await using var session = await client.CreateSessionAsync(new SessionConfig { Model = "gpt-5", @@ -131,7 +131,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 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. @@ -781,7 +781,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; @@ -793,9 +793,11 @@ var session = await client.CreateSessionAsync(new SessionConfig }); ``` +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 -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 @@ -803,6 +805,14 @@ var session = await client.CreateSessionAsync(new SessionConfig Model = "gpt-5", OnPermissionRequest = async (request, invocation) => { + if (request is PermissionRequestShell { ManagedApprovalRequired: true } + or PermissionRequestWrite { ManagedApprovalRequired: true } + or PermissionRequestRead { ManagedApprovalRequired: true } + or PermissionRequestUrl { 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/Client.cs b/dotnet/src/Client.cs index c8d83dfee..b84e01b72 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 4386e8ba6..f5e1dc0f8 100644 --- a/dotnet/src/PermissionHandlers.cs +++ b/dotnet/src/PermissionHandlers.cs @@ -9,7 +9,29 @@ 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 requests when managed settings are disabled. + /// public static Func> ApproveAll { get; } = - (_, _) => Task.FromResult(PermissionDecision.ApproveOnce()); + (request, invocation) => invocation.ManagedSettingsEnabled + ? Task.FromException( + new InvalidOperationException("ApproveAll cannot be used when managed settings are enabled")) + : 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, + PermissionRequestMcp + or PermissionRequestMemory + or PermissionRequestCustomTool + or PermissionRequestHook + or PermissionRequestExtensionManagement + or PermissionRequestExtensionPermissionAccess => false, + _ => true, + }; } diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 6db0744b4..8a46f9c3e 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(); @@ -954,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/dotnet/src/Types.cs b/dotnet/src/Types.cs index 565204d39..a5b620702 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 new file mode 100644 index 000000000..842a95a1e --- /dev/null +++ b/dotnet/test/Unit/PermissionHandlerTests.cs @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * 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); + var request = Assert.IsType(data.PermissionRequest); + Assert.True(request.ManagedApprovalRequired); + } + + [Fact] + public async Task ApproveAllThrowsWhenManagedSettingsEnabled() + { + var request = new PermissionRequestRead + { + Intention = "Read managed content", + ManagedApprovalRequired = true, + Path = "/workspace/file.txt", + }; + + await Assert.ThrowsAsync(() => + PermissionHandler.ApproveAll(request, new PermissionInvocation + { + ManagedSettingsEnabled = true, + })); + } + + [Fact] + public async Task ApproveAllApprovesOrdinaryRequest() + { + var request = new PermissionRequestRead + { + Intention = "Read ordinary content", + Path = "/workspace/file.txt", + }; + + var decision = await PermissionHandler.ApproveAll(request, new PermissionInvocation()); + + 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); + } + + [Fact] + public async Task ApproveAllLeavesUnknownRequestPending() + { + var request = new PermissionRequest { Kind = "future-managed-kind" }; + + var decision = await PermissionHandler.ApproveAll(request, new PermissionInvocation()); + + Assert.IsType(decision); + } +} diff --git a/go/README.md b/go/README.md index 8e881e0c7..aa49bbe5b 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 is only valid when managed settings are disabled. session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ Model: "gpt-5", OnPermissionRequest: copilot.PermissionHandler.ApproveAll, @@ -221,7 +221,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 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. @@ -680,7 +680,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{ @@ -689,9 +689,11 @@ session, err := client.CreateSession(context.Background(), &copilot.SessionConfi }) ``` +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 -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 ( @@ -704,6 +706,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/client.go b/go/client.go index 0d07e21f5..2202e92a1 100644 --- a/go/client.go +++ b/go/client.go @@ -906,7 +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 := newSession( + sessionID, + c.client, + "", + config.EnableManagedSettings != nil && *config.EnableManagedSettings, + ) s.registerTools(config.Tools) s.registerPermissionHandler(config.OnPermissionRequest) @@ -1227,7 +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 := newSession( + sessionID, + c.client, + "", + config.EnableManagedSettings != nil && *config.EnableManagedSettings, + ) session.registerTools(config.Tools) session.registerPermissionHandler(config.OnPermissionRequest) diff --git a/go/permissions.go b/go/permissions.go index f86a72683..24b9cc7f1 100644 --- a/go/permissions.go +++ b/go/permissions.go @@ -1,15 +1,23 @@ package copilot import ( + "errors" + "github.com/github/copilot-sdk/go/rpc" ) // PermissionHandler provides pre-built OnPermissionRequest implementations. var PermissionHandler = struct { - // ApproveAll approves all permission requests. + // ApproveAll approves permission requests when managed settings are disabled. ApproveAll PermissionHandlerFunc }{ - ApproveAll: func(_ PermissionRequest, _ 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") + } + 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 000000000..ed4c3a040 --- /dev/null +++ b/go/permissions_test.go @@ -0,0 +1,79 @@ +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 TestApproveAllReturnsErrorWhenManagedSettingsEnabled(t *testing.T) { + decision, err := copilot.PermissionHandler.ApproveAll( + &copilot.PermissionRequestRead{}, + copilot.PermissionInvocation{SessionID: "session-1", ManagedSettingsEnabled: true}, + ) + if err == nil { + t.Fatal("expected an error") + } + if decision != nil { + t.Fatalf("expected no decision, 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) + } +} + +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 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 new file mode 100644 index 000000000..d561cede2 --- /dev/null +++ b/go/rpc/permission_request_managed_approval.go @@ -0,0 +1,81 @@ +// 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 false +} + +// RequiresManagedApproval reports whether managed policy requires an explicit +// human decision for this request. +func (r PermissionRequestExtensionManagement) RequiresManagedApproval() bool { + return false +} + +// RequiresManagedApproval reports whether managed policy requires an explicit +// human decision for this request. +func (r PermissionRequestExtensionPermissionAccess) RequiresManagedApproval() bool { + return false +} + +// RequiresManagedApproval reports whether managed policy requires an explicit +// human decision for this request. +func (r PermissionRequestHook) RequiresManagedApproval() bool { + return false +} + +// RequiresManagedApproval reports whether managed policy requires an explicit +// human decision for this request. +func (r PermissionRequestMCP) RequiresManagedApproval() bool { + return false +} + +// RequiresManagedApproval reports whether managed policy requires an explicit +// human decision for this request. +func (r PermissionRequestMemory) RequiresManagedApproval() bool { + return false +} + +// 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"` + } + if json.Unmarshal(r.Raw, &metadata) != nil { + return true + } + return managedApprovalRequired(metadata.ManagedApprovalRequired) +} diff --git a/go/rpc/zsession_events.go b/go/rpc/zsession_events.go index f12b3071a..4861034b2 100644 --- a/go/rpc/zsession_events.go +++ b/go/rpc/zsession_events.go @@ -2905,6 +2905,7 @@ func (PermissionPromptRequestWrite) Kind() PermissionPromptRequestKind { type PermissionRequest interface { permissionRequest() Kind() PermissionRequestKind + RequiresManagedApproval() bool } type RawPermissionRequest struct { diff --git a/go/session.go b/go/session.go index 3059e3b3a..74e68cc7f 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 @@ -365,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), @@ -1593,11 +1600,13 @@ func (s *Session) executePermissionAndRespond(requestID string, permissionReques }() invocation := PermissionInvocation{ - SessionID: s.SessionID, + SessionID: s.SessionID, + ManagedSettingsEnabled: s.managedSettings, } 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/go/types.go b/go/types.go index d1fc34ecd..d64aa7786 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 676c0184a..5ace4cb93 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.1-01-SNAPSHOT' +implementation 'com.github:copilot-sdk-java:1.0.10-preview.1-SNAPSHOT' ``` ## Quick Start @@ -120,6 +120,31 @@ public class CopilotSDK { } ``` +## Permission Handling + +`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. + +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 requestHumanApproval(request); + } + + return CompletableFuture.completedFuture(PermissionRequestResult.approveOnce()); +}; +``` + +Event-based permission dispatch can use `PermissionRequestResult.noResult()` to let another connected client answer a pending request. Legacy protocol-v2 callbacks require a decision and cannot abstain. + When targeting MCP tools configured through `setMcpServers(...)`, remember the runtime tool name is `-`. For `setAvailableTools(...)` and `setExcludedTools(...)`, prefer the source-qualified filter form diff --git a/java/src/main/java/com/github/copilot/CopilotSession.java b/java/src/main/java/com/github/copilot/CopilotSession.java index c484fb1b1..aacd9ee48 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()); @@ -1028,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); @@ -1378,6 +1381,10 @@ void registerPermissionHandler(PermissionHandler handler) { permissionHandler.set(handler); } + void setManagedSettingsEnabled(boolean managedSettingsEnabled) { + this.managedSettingsEnabled = managedSettingsEnabled; + } + void registerMcpAuthHandler(McpAuthHandler handler) { mcpAuthHandler.set(handler); } @@ -1403,6 +1410,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 57d9a46a2..68fcaa989 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 bd8e70b75..58639beda 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,11 @@ * *
{@code
  * PermissionHandler handler = (request, invocation) -> {
+ * 	if (Boolean.TRUE.equals(request.getManagedApprovalRequired())) {
+ * 		// Obtain an explicit human decision before approving this request.
+ * 		return requestHumanApproval(request);
+ * 	}
+ *
  * 	// Check the permission kind
  * 	if ("dangerous-action".equals(request.getKind())) {
  * 		// Deny dangerous actions
@@ -29,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 @@ -43,12 +53,21 @@ public interface PermissionHandler { /** - * A pre-built handler that approves all permission requests. + * A pre-built handler that approves permission requests when managed settings + * are disabled. * * @since 1.0.11 */ - PermissionHandler APPROVE_ALL = (request, invocation) -> CompletableFuture - .completedFuture(new PermissionRequestResult().setKind(PermissionRequestResultKind.APPROVED)); + 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/main/java/com/github/copilot/rpc/PermissionInvocation.java b/java/src/main/java/com/github/copilot/rpc/PermissionInvocation.java index bda5bdde0..10988cc1b 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/main/java/com/github/copilot/rpc/PermissionRequest.java b/java/src/main/java/com/github/copilot/rpc/PermissionRequest.java index 51a303feb..936c57089 100644 --- a/java/src/main/java/com/github/copilot/rpc/PermissionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/PermissionRequest.java @@ -6,8 +6,10 @@ import java.util.Map; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 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. @@ -20,16 +22,39 @@ * @since 1.0.0 */ @JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) 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,6 +93,26 @@ 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. * diff --git a/java/src/test/java/com/github/copilot/PermissionRequestResultTest.java b/java/src/test/java/com/github/copilot/PermissionRequestResultTest.java index 4a1ff0313..d1cb6137c 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,64 @@ 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()); + } + + @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()); + } + + @Test + void testApproveAllFailsWhenManagedSettingsEnabled() { + var request = new PermissionRequest(); + request.setKind("read"); + request.setManagedApprovalRequired(true); + + var invocation = new PermissionInvocation().setManagedSettingsEnabled(true); + var error = assertThrows(java.util.concurrent.CompletionException.class, + () -> PermissionHandler.APPROVE_ALL.handle(request, invocation).join()); + + assertTrue(error.getCause() instanceof IllegalStateException); + } + + @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()); + } + + @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/README.md b/nodejs/README.md index c8bc95082..f736d48fd 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 is only valid when managed settings are disabled. const session = await client.createSession({ model: "gpt-5", onPermissionRequest: approveAll, @@ -136,7 +136,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. `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. @@ -861,7 +861,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 when managed settings are disabled: ```typescript import { CopilotClient, approveAll } from "@github/copilot-sdk"; @@ -872,9 +872,11 @@ const session = await client.createSession({ }); ``` +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 -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"; @@ -882,6 +884,11 @@ import type { PermissionRequest, PermissionRequestResult } from "@github/copilot const session = await client.createSession({ model: "gpt-5", onPermissionRequest: (request: PermissionRequest, invocation): PermissionRequestResult => { + if ("managedApprovalRequired" in request && 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 @@ -919,7 +926,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/nodejs/src/client.ts b/nodejs/src/client.ts index 6d99ce49e..fd3b06f87 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1462,7 +1462,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); @@ -1692,7 +1695,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 8b946c786..2a4513d47 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -423,6 +423,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; @@ -591,10 +592,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; } /** @@ -1108,6 +1110,7 @@ export class CopilotSession { try { const result = await this.permissionHandler!(permissionRequest, { sessionId: this.sessionId, + managedSettingsEnabled: this.managedSettingsEnabled, }); if (result.kind === "no-result") { return; @@ -1116,10 +1119,15 @@ export class CopilotSession { return; } await this.rpc.permissions.handlePendingPermissionRequest({ requestId, result }); - } catch (_error) { + } catch (error) { if (this.disconnected) { return; } + console.error("Permission handler failed", { + sessionId: this.sessionId, + requestId, + error, + }); try { await this.rpc.permissions.handlePendingPermissionRequest({ requestId, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 3da5e3bc4..d02446da8 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -11,6 +11,7 @@ import type { Canvas } from "./canvas.js"; import type { SessionFsProvider } from "./sessionFsProvider.js"; import type { CopilotRequestHandler } from "./copilotRequestHandler.js"; import type { + PermissionRequest as GeneratedPermissionRequest, ReasoningSummary, SessionLimitsConfig, SessionEvent as GeneratedSessionEvent, @@ -1095,16 +1096,20 @@ 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 * 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. The runtime currently emits it for managed Shell, + * Read, Edit, and Domain selector asks. */ -export type { PermissionRequest } from "./generated/session-events.js"; -import type { PermissionRequest } from "./generated/session-events.js"; - -import type { PermissionDecisionRequest } from "./generated/rpc.js"; +export type PermissionRequest = GeneratedPermissionRequest; /** * Permission decision result returned from a {@link PermissionHandler}. @@ -1116,10 +1121,21 @@ export type PermissionRequestResult = PermissionDecisionRequest["result"] | { ki export type PermissionHandler = ( request: PermissionRequest, - invocation: { sessionId: string } + invocation: { sessionId: string; managedSettingsEnabled: boolean } ) => Promise | PermissionRequestResult; -export const approveAll: PermissionHandler = () => ({ kind: "approve-once" }); +/** + * Approves permission requests for sessions without managed settings. + */ +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" }; +}; export const defaultJoinSessionPermissionHandler: PermissionHandler = (): PermissionRequestResult => ({ diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 77149bc4b..a88b48c94 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -19,6 +19,34 @@ 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", managedSettingsEnabled: false }; + + it("approves ordinary permission requests", () => { + expect(approveAll(request, invocation)).toEqual({ kind: "approve-once" }); + }); + + 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"); + }); + + it("does not approve managed requests when the session flag is absent", () => { + 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 f20c2db33..e706ed610 100644 --- a/nodejs/test/session-event-types.test.ts +++ b/nodejs/test/session-event-types.test.ts @@ -18,6 +18,9 @@ import { describe, expect, it } from "vitest"; 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, @@ -83,6 +86,11 @@ type _AssistantMessageEventStaysAlignedWithSessionEventUnion = _AssertEqual< Extract >; const _assistantMessageEventAlignmentCheck: _AssistantMessageEventStaysAlignedWithSessionEventUnion = true; +type _PermissionRequestedEventStaysAlignedWithSessionEventUnion = _AssertEqual< + PermissionRequestedEvent, + Extract +>; +const _permissionRequestedEventAlignmentCheck: _PermissionRequestedEventStaysAlignedWithSessionEventUnion = true; type _DefaultFactoryArgsAreJsonValue = _AssertEqual; const _defaultFactoryArgsCheck: _DefaultFactoryArgsAreJsonValue = true; type _DefaultFactoryResultIsJsonValueOrVoid = _AssertEqual< @@ -117,6 +125,46 @@ describe("Session event type exports (#1156)", () => { expect(data.turnId).toBe("turn-1"); }); + it("exposes explicit user approval metadata for managed Domain requests", () => { + const request: PermissionRequest = { + kind: "url", + url: "https://api.example.com/data", + intention: "Fetch domain data", + managedApprovalRequired: true, + }; + + 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; + if (permissionEvent.data.permissionRequest.kind !== "url") { + throw new Error("expected URL permission request"); + } + expect(permissionEvent.data.permissionRequest.managedApprovalRequired).toBe(true); + }); + it("wraps ToolExecutionStartData inside the exported ToolExecutionStartEvent", () => { const event: ToolExecutionStartEvent = { id: "evt-1", @@ -174,6 +222,7 @@ describe("Session event type exports (#1156)", () => { assertImportable(); assertImportable(); assertImportable(); + assertImportable(); assertImportable(); assertImportable(); @@ -183,6 +232,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 diff --git a/python/README.md b/python/README.md index bd268727e..e9c99ff42 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 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. 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 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. - `available_tools` / `excluded_tools` / `default_agent.excluded_tools` / custom-agent `tools`: MCP tools registered from `mcp_servers` are exposed to the runtime as `-`. For `available_tools` and `excluded_tools`, prefer `ToolSet().add_mcp("-")` or the raw `mcp:-` form. For custom-agent `tools` and `default_agent.excluded_tools`, use `-` directly. @@ -781,7 +781,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 @@ -793,12 +793,14 @@ session = await client.create_session( ) ``` +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 -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, @@ -807,6 +809,9 @@ from copilot.session_events import PermissionRequestShell def on_permission_request(request: PermissionRequest, invocation: dict) -> PermissionRequestResult: + if getattr(request, "managed_approval_required", False) is True: + return PermissionNoResult() + # ``PermissionRequest`` is a discriminated union — pattern-match on # the variant class to access the per-kind fields. match request: @@ -829,6 +834,9 @@ Async handlers are also supported: async def on_permission_request( request: PermissionRequest, invocation: dict ) -> PermissionRequestResult: + if getattr(request, "managed_approval_required", False) is True: + return PermissionNoResult() + # Simulate an async approval check (e.g., prompting a user over a network) await asyncio.sleep(0) return PermissionDecisionApproveOnce() @@ -838,7 +846,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. @@ -848,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()` | Leave the request unanswered (only valid with protocol v1; rejected by protocol v2 servers) | +| `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/client.py b/python/copilot/client.py index 6f29e9659..62732f607 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -2517,7 +2517,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( @@ -3136,7 +3141,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 d9fc04dce..3cb41553f 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -342,12 +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. - 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``). + 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" @@ -355,15 +354,21 @@ 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. PermissionRequestResult = PermissionDecision | PermissionNoResult +class PermissionInvocation(TypedDict): + session_id: str + managed_settings_enabled: bool + + _PermissionHandlerFn = Callable[ - [PermissionRequest, dict[str, str]], + [PermissionRequest, PermissionInvocation], PermissionRequestResult | Awaitable[PermissionRequestResult], ] @@ -371,8 +376,12 @@ class PermissionNoResult: class PermissionHandler: @staticmethod def approve_all( - request: PermissionRequest, invocation: dict[str, str] + request: PermissionRequest, invocation: PermissionInvocation ) -> PermissionRequestResult: + 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() return PermissionDecisionApproveOnce() @@ -1449,7 +1458,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. @@ -1463,8 +1476,11 @@ 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 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 +2118,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( @@ -2134,6 +2156,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( @@ -2528,7 +2554,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( @@ -2538,7 +2570,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 new file mode 100644 index 000000000..16861a511 --- /dev/null +++ b/python/test_managed_permissions.py @@ -0,0 +1,79 @@ +from typing import Any + +import pytest + +from copilot.rpc import PermissionDecisionApproveOnce, PermissionDecisionUserNotAvailable +from copilot.session import CopilotSession, 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_errors_when_managed_settings_enabled() -> None: + request = PermissionRequestRead( + intention="Read managed content", + path="/workspace/file.txt", + managed_approval_required=True, + ) + + 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: + request = PermissionRequestRead( + intention="Read ordinary content", + path="/workspace/file.txt", + ) + + assert isinstance( + PermissionHandler.approve_all( + request, + {"session_id": "session-1", "managed_settings_enabled": False}, + ), + PermissionDecisionApproveOnce, + ) + + +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, + ) + + 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: + 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) diff --git a/rust/README.md b/rust/README.md index d1b141290..2aa8f6b65 100644 --- a/rust/README.md +++ b/rust/README.md @@ -230,6 +230,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 { @@ -250,7 +254,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. 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 @@ -430,6 +434,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. +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 .create_session( diff --git a/rust/src/handler.rs b/rust/src/handler.rs index 3287a4f09..77edf919c 100644 --- a/rust/src/handler.rs +++ b/rust/src/handler.rs @@ -83,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 { @@ -273,9 +278,12 @@ 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 when managed settings are disabled. +/// +/// When managed settings are enabled, the handler logs an error and returns a +/// 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; @@ -285,9 +293,17 @@ impl PermissionHandler for ApproveAllHandler { &self, _session_id: SessionId, _request_id: RequestId, - _data: PermissionRequestData, + data: PermissionRequestData, ) -> PermissionResult { - PermissionResult::approve_once() + if data.managed_settings_enabled { + 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() + } } } @@ -326,6 +342,39 @@ mod tests { )); } + #[tokio::test] + async fn approve_all_handler_fails_when_managed_settings_enabled() { + let result = ApproveAllHandler + .handle( + SessionId::from("s1"), + RequestId::new("1"), + PermissionRequestData { + managed_settings_enabled: true, + ..Default::default() + }, + ) + .await; + assert!(matches!( + result, + PermissionResult::Decision(PermissionDecision::UserNotAvailable(_)) + )); + } + + #[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 2ddd773a3..e353ce315 100644 --- a/rust/src/permission.rs +++ b/rust/src/permission.rs @@ -16,10 +16,14 @@ 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 every request. +/// Return a [`PermissionHandler`] that approves requests when managed settings +/// are disabled. +/// +/// When managed settings are enabled, the handler logs an error and returns a +/// user-not-available decision. pub fn approve_all() -> Arc { Arc::new(PolicyHandler { policy: Policy::ApproveAll, @@ -116,7 +120,15 @@ impl PermissionHandler for PolicyHandler { Policy::Predicate(f) => f(&data), }; if approved { - PermissionResult::approve_once() + if matches!(self.policy, Policy::ApproveAll) && data.managed_settings_enabled { + 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() + } } else { PermissionResult::reject(None) } @@ -144,6 +156,18 @@ mod tests { )); } + #[tokio::test] + 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::Decision(crate::types::PermissionDecision::UserNotAvailable(_)) + )); + } + #[tokio::test] async fn deny_all_denies() { let h = deny_all(); @@ -164,6 +188,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; diff --git a/rust/src/session.rs b/rust/src/session.rs index 165dc2e17..d31b4e055 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,6 +1523,33 @@ fn extract_request_id(data: &Value) -> Option { .map(RequestId::new) } +fn permission_request_data( + event_data: &Value, + managed_settings_enabled: bool, +) -> PermissionRequestData { + let request_data = event_data + .get("permissionRequest") + .cloned() + .unwrap_or_else(|| event_data.clone()); + 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.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(), + }, + } +} + /// Map a [`PermissionResult`] to the `result` payload sent back to the /// server via `session.permissions.handlePendingPermissionRequest`. /// @@ -1705,14 +1735,10 @@ 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, + handlers.managed_settings_enabled, + ); let span = tracing::error_span!( "permission_request_handler", session_id = %sid, @@ -2514,7 +2540,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] @@ -2541,4 +2567,43 @@ 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" + } + }), + false, + ); + + assert_eq!(data.managed_approval_required, Some(true)); + 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 + } + }), + 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 11a92ad51..e9f077a23 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -5445,8 +5445,15 @@ pub struct PermissionRequestData { /// to a specific tool invocation. #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_call_id: Option, - /// The full permission request params from the CLI. The shape varies by - /// permission type and CLI version, so we preserve it as `Value`. + /// 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, 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 + /// CLI version, so we preserve it as `Value`. #[serde(flatten)] pub extra: Value, } diff --git a/rust/tests/api_types_test.rs b/rust/tests/api_types_test.rs index bcf226691..9b86b1367 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 b1d7474b9..10b48a246 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -1812,6 +1812,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(``);