Skip to content

PermissionDecision polymorphic base instantiation in ExecutePermissionAndRespondAsync produces empty JSON, breaks built-in tool permission flow #1194

@AntonBaluev

Description

@AntonBaluev

Summary

CopilotSession.ExecutePermissionAndRespondAsync instantiates the abstract polymorphic base type Rpc.PermissionDecision directly instead of one of its registered derived types. STJ polymorphic serialization strips the discriminator from the base instance, the wire payload becomes effectively {}, and the CLI's permission receiver fails with Unhandled permission result kind: [object Object].

This affects every consumer that handles permission requests for built-in tools (view, bash, edit, create_file, etc.) — they all route through the v2 event-driven permission flow that hits this code path. Verified present in 0.3.0 and 1.0.0-beta.1.

Repro

Any session that:

  1. Provides a custom OnPermissionRequest (or uses PermissionHandler.ApproveAll)
  2. Lets the agent invoke a built-in SDK tool such as view
  3. Returns any PermissionRequestResult other than NoResult from the handler

Result: tool returns the error message Unhandled permission result kind: [object Object] and the session never reaches idle (so any SendAndWaitAsync-style call eventually times out).

Custom-defined tools registered with ToolOptions.ForReadOnlyTool() (auto-approved on the SDK side) are unaffected because they don't go through this path.

Root cause

CopilotSession.ExecutePermissionAndRespondAsync (decompiled from GitHub.Copilot.SDK 0.3.0/lib/net8.0/GitHub.Copilot.SDK.dll):

PermissionRequestResult result = await handler(permissionRequest, invocation);
if (!(result.Kind == new PermissionRequestResultKind("no-result")))
{
    await Rpc.Permissions.HandlePendingPermissionRequestAsync(requestId, new PermissionDecision
    {
        Kind = result.Kind.Value   // ← sets the discriminator on the abstract base
    });
}

PermissionDecision is configured for polymorphic serialization with derived-type discriminators:

[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind",
                 UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType)]
[JsonDerivedType(typeof(PermissionDecisionApproveOnce), "approve-once")]
[JsonDerivedType(typeof(PermissionDecisionApproveForSession), "approve-for-session")]
[JsonDerivedType(typeof(PermissionDecisionApproveForLocation), "approve-for-location")]
[JsonDerivedType(typeof(PermissionDecisionReject), "reject")]
[JsonDerivedType(typeof(PermissionDecisionUserNotAvailable), "user-not-available")]
public class PermissionDecision
{
    [JsonPropertyName("kind")]
    public virtual string Kind { get; set; } = string.Empty;
}

When STJ serializes a PermissionDecision whose runtime type is the base class:

  1. No registered [JsonDerivedType] matches the runtime type.
  2. UnknownDerivedTypeHandling.FallBackToBaseType writes the base type's properties.
  3. The Kind property is the polymorphic discriminator — STJ owns its emission and ignores user-set values for unrecognized derived types.

Net wire output: {}. CLI's JS layer then formats kind as [object Object].

Confirmed via unit test

Round-trip test against vanilla JsonSerializerOptions(JsonSerializerDefaults.Web):

var decision = new PermissionDecision { Kind = "approve-once" };
var json = JsonSerializer.Serialize(decision);
// Expected: {"kind":"approve-once"}
// Actual:   {}

(The legacy PermissionRequestResult { Kind = PermissionRequestResultKind.Approved } serializes correctly to {"kind":"approve-once"} — that path is fine. The bug is specifically in the SDK's conversion to the polymorphic PermissionDecision.)

Suggested fix

Map the legacy PermissionRequestResult.Kind to the appropriate registered derived type:

PermissionDecision decision = result.Kind.Value switch
{
    "approve-once"        => new PermissionDecisionApproveOnce(),
    "reject"              => new PermissionDecisionReject(),
    "user-not-available"  => new PermissionDecisionUserNotAvailable(),
    _                     => throw new InvalidOperationException($"Unmapped kind: {result.Kind.Value}")
};
await Rpc.Permissions.HandlePendingPermissionRequestAsync(requestId, decision);

(The full set may also need PermissionDecisionApproveForSession / PermissionDecisionApproveForLocation mappings if the legacy API is ever extended to express those.)

Same fix applies to the catch branch a few lines below that also constructs new PermissionDecision { Kind = ... }.

Workaround for consumers

Until fixed upstream, consumers can route approval through Hooks.OnPreToolUse returning PreToolUseHookOutput.PermissionDecision = "allow" — that path uses a plain string field (not the polymorphic class) and preempts the broken OnPermissionRequest flow entirely.

Environment

  • Package: GitHub.Copilot.SDK v0.3.0 (also reproduces in 1.0.0-beta.1)
  • Runtime: .NET 8 / net10.0
  • OS: Windows 10

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions