diff --git a/dotnet/README.md b/dotnet/README.md index 796ef2254..8ba11eecd 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 4386e8ba6..e4e286534 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 000000000..3752bb3c6 --- /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 000000000..e33d4e0af --- /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 78350c86a..3929d2ac4 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 f86a72683..dcbd5fb11 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 000000000..086060542 --- /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 000000000..601c77f0e --- /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 44f49948f..431b5c01d 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 2a3178823..c527c1002 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 d2dff958d..1fa331a71 100644 --- a/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java +++ b/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java @@ -220,9 +220,6 @@ 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/PermissionHandler.java b/java/src/main/java/com/github/copilot/rpc/PermissionHandler.java index bd8e70b75..49164afc6 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 51a303feb..2886491cd 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 95476c36f..14b6413e7 100644 --- a/java/src/main/java/com/github/copilot/rpc/PermissionRequestResultKind.java +++ b/java/src/main/java/com/github/copilot/rpc/PermissionRequestResultKind.java @@ -51,10 +51,8 @@ public final class PermissionRequestResultKind { * {@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. + * 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/PermissionRequestResultTest.java b/java/src/test/java/com/github/copilot/PermissionRequestResultTest.java index 4a1ff0313..ea80b0bed 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 76c2d41b2..9b1d7c679 100644 --- a/java/src/test/java/com/github/copilot/RpcHandlerDispatcherTest.java +++ b/java/src/test/java/com/github/copilot/RpcHandlerDispatcherTest.java @@ -354,8 +354,6 @@ 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()); diff --git a/nodejs/README.md b/nodejs/README.md index e591c7dbc..b85c3f22b 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,9 +866,11 @@ 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: +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"; @@ -876,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 diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 352afc6a9..e9702148d 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 5f89ca3be..2a6d3949a 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,16 +1097,33 @@ 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"; +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}. @@ -1116,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 77149bc4b..4805b1148 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 37b49f8a4..c5e00833d 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, @@ -80,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", () => { @@ -103,6 +111,43 @@ 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; + expect(permissionEvent.data.permissionRequest.managedApprovalRequired).toBe(true); + }); + it("wraps ToolExecutionStartData inside the exported ToolExecutionStartEvent", () => { const event: ToolExecutionStartEvent = { id: "evt-1", @@ -160,6 +205,7 @@ describe("Session event type exports (#1156)", () => { assertImportable(); assertImportable(); assertImportable(); + assertImportable(); assertImportable(); assertImportable(); @@ -169,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 diff --git a/python/README.md b/python/README.md index f2b6ed6c9..a135be2cc 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 fecd5839e..1dc7c9e1c 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 d9fc04dce..4c80874ad 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" @@ -373,6 +371,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 000000000..26575af0d --- /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 e89ea5560..5772f5d54 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 abc7dca62..efbe7fcce 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 3287a4f09..6e3bab048 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 2ddd773a3..0114842a1 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, @@ -116,7 +119,11 @@ impl PermissionHandler for PolicyHandler { 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) } @@ -144,6 +151,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(); @@ -164,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; diff --git a/rust/src/session.rs b/rust/src/session.rs index 89162346e..5bbc142df 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 163e1d29c..79988ee5c 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 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..d6eda7f99 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 5b343122b..7ef4bae9d 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 a04f5a21c..a89e28d70 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 9ab335b05..2138457dc 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;