Getting Started with the GitHub Copilot SDK — Part 5: Permission Request Handling

This is Part 5 of my hands-on series on the GitHub Copilot SDK for .NET. The
companion code lives at
egarim/GettingStartedWithGithubCopilotSDK
— each numbered folder runs on its own.
So far the model has talked. Now it wants to act — write a file, run a command. And
the moment it does, the SDK stops and asks you:
May I?
That gate is OnPermissionRequest, and it's the whole topic today.
What a permission actually is
A permission request is not a hook. Hooks fire on lots of lifecycle events.
Permissions fire for exactly one reason:
The model wants to do something with a side effect — modify your environment.
Write a file. Run a shell command. The kind of thing you don't want happening behind your
back. For a plain question — "what's 1+1?" — no permission is requested at all. You only
get asked when there's something to lose.
Approve it
You wire a handler into SessionConfig. Return "approved" and the action goes through:
var session = await client.CreateSessionAsync(new SessionConfig
{
OnPermissionRequest = (request, invocation) =>
{
Console.WriteLine(quot; [Permission] Kind: {request.Kind} -> APPROVED");
return Task.FromResult(new PermissionRequestResult { Kind = "approved" });
}
});
Ask the model to edit a file, and afterwards the file on disk has actually changed.
"original content" becomes "modified content". The model wrote it because you let it.
Deny it
Same shape, opposite answer. Return "denied-interactively-by-user":
OnPermissionRequest = (request, invocation) =>
{
Console.WriteLine(quot; [Permission] Kind: {request.Kind} -> DENIED");
return Task.FromResult(new PermissionRequestResult
{
Kind = "denied-interactively-by-user"
});
}
Now ask it to overwrite a protected file. It can't. The bytes on disk stay exactly as they
were. The model is simply told the user said no, and it carries on from there. This is
your guardrail for anything you don't want touched.
Two response kinds, that's the whole vocabulary:
"approved"— the model proceeds."denied-interactively-by-user"— the model is told you blocked it.
The handler can be async
Here's where it gets useful. The handler returns a Task, so it can be async — go
check a database, call an external authorization service, pop a dialog — before you decide:
OnPermissionRequest = async (request, invocation) =>
{
Console.WriteLine(quot; [Permission] Kind: {request.Kind} - checking...");
await Task.Delay(500); // stand-in for a DB / API / approval service call
Console.WriteLine(" [Permission] approved after wait");
return new PermissionRequestResult { Kind = "approved" };
}
In a real app that Task.Delay is a call to your approval system. The model just waits.
Know what you're approving
Every request carries a ToolCallId — a unique id that ties the permission to the
exact tool invocation that triggered it:
OnPermissionRequest = (request, invocation) =>
{
if (!string.IsNullOrEmpty(request.ToolCallId))
Console.WriteLine(quot; [Permission] ToolCallId: {request.ToolCallId}");
return Task.FromResult(new PermissionRequestResult { Kind = "approved" });
}
That id is your audit trail. Which tool asked for what, and when. If you're logging
agent activity for compliance, this is the thread you follow.
Fail safe, not fail open
What if your handler throws? Maybe your auth service is down and the code blows up. The
SDK does the safe thing:
OnPermissionRequest = (request, invocation) =>
{
throw new InvalidOperationException("Simulated handler crash");
}
The app doesn't crash. The SDK catches the exception and auto-denies the
permission. The session keeps running; the model reports it couldn't do the thing. The
default is the conservative one — when in doubt, the action does not happen.
Permissions survive a resume
Resumed sessions get their own handler too. ResumeSessionConfig accepts
OnPermissionRequest, so when you pick a conversation back up you can attach a fresh
decision policy:
var session2 = await client.ResumeSessionAsync(sessionId, new ResumeSessionConfig
{
OnPermissionRequest = (request, invocation) =>
{
Console.WriteLine(quot; [Resume] Kind: {request.Kind} -> APPROVED");
return Task.FromResult(new PermissionRequestResult { Kind = "approved" });
}
});
Worth noting: permissions are not inherited from the original session. A resumed
session with no handler is back to default behavior. If you want control after a resume,
wire it up again.
Takeaways
- Permissions fire only for side-effecting actions — file writes, command runs. Plain
questions never ask. OnPermissionRequestreturns"approved"or"denied-interactively-by-user". That's the
whole vocabulary.- The handler can be
async— defer to a DB, an API, or a human before deciding. request.ToolCallIdis your audit trail: which tool asked for what.- If your handler throws, the SDK auto-denies and keeps going. Fail safe by default.
- Resumed sessions take their own handler via
ResumeSessionConfig— permissions aren't
inherited.
That's your safety gate. In Part 6 — Asking the User for Input
we flip the direction: instead of the model asking permission to act, it asks you a
question and waits for your answer.
Want to follow along? You'll need the .NET 10 SDK and GitHub Copilot access. Then
dotnet run --project 05.PermissionsDemofrom the
course repo.