feat: add structured tool approval details to sidecar protocol

Expose SDK permission request details on approval-requested events via a
new permissionDetail payload. This preserves the existing title/detail
summary while carrying the command, URL, diff, args, and other
kind-specific data needed for richer approval UI in the renderer.

Also adds sidecar coverage for all supported permission request kinds and
verifies the new payload serializes over the protocol.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-26 16:40:41 +01:00
co-authored by Copilot
parent 69562c19f3
commit 13c543e2fd
4 changed files with 322 additions and 0 deletions
@@ -251,6 +251,31 @@ public sealed class AgentActivityEventDto : SidecarEventDto
public string? ToolName { get; init; }
}
public sealed class PermissionDetailDto
{
public string Kind { get; init; } = string.Empty;
public string? Intention { get; init; }
public string? Command { get; init; }
public string? Warning { get; init; }
public IReadOnlyList<string>? PossiblePaths { get; init; }
public IReadOnlyList<string>? PossibleUrls { get; init; }
public bool? HasWriteFileRedirection { get; init; }
public string? FileName { get; init; }
public string? Diff { get; init; }
public string? NewFileContents { get; init; }
public string? Path { get; init; }
public string? ServerName { get; init; }
public string? ToolTitle { get; init; }
public object? Args { get; init; }
public bool? ReadOnly { get; init; }
public string? Url { get; init; }
public string? Subject { get; init; }
public string? Fact { get; init; }
public string? Citations { get; init; }
public string? ToolDescription { get; init; }
public string? HookMessage { get; init; }
}
public sealed class ApprovalRequestedEventDto : SidecarEventDto
{
public string SessionId { get; init; } = string.Empty;
@@ -262,6 +287,7 @@ public sealed class ApprovalRequestedEventDto : SidecarEventDto
public string? PermissionKind { get; init; }
public string Title { get; init; } = string.Empty;
public string? Detail { get; init; }
public PermissionDetailDto? PermissionDetail { get; init; }
}
public sealed class CommandErrorEventDto : SidecarEventDto
@@ -10,6 +10,14 @@ internal sealed class CopilotApprovalCoordinator
private const string RejectedDecision = "rejected";
private const string ToolCallApprovalKind = "tool-call";
private const string WebFetchToolName = "web_fetch";
private const string ShellPermissionKind = "shell";
private const string WritePermissionKind = "write";
private const string ReadPermissionKind = "read";
private const string McpPermissionKind = "mcp";
private const string UrlPermissionKind = "url";
private const string MemoryPermissionKind = "memory";
private const string CustomToolPermissionKind = "custom-tool";
private const string HookPermissionKind = "hook";
private readonly ConcurrentDictionary<string, PendingApprovalRequest> _pendingApprovals = new(StringComparer.Ordinal);
@@ -131,6 +139,77 @@ internal sealed class CopilotApprovalCoordinator
PermissionKind = permissionKind,
Title = title,
Detail = detail,
PermissionDetail = BuildPermissionDetail(request),
};
}
internal static PermissionDetailDto BuildPermissionDetail(PermissionRequest request)
{
ArgumentNullException.ThrowIfNull(request);
return request switch
{
PermissionRequestShell shell => new PermissionDetailDto
{
Kind = ShellPermissionKind,
Intention = NormalizeOptionalString(shell.Intention),
Command = NormalizeOptionalString(shell.FullCommandText),
Warning = NormalizeOptionalString(shell.Warning),
PossiblePaths = NormalizeOptionalStringList(shell.PossiblePaths),
PossibleUrls = NormalizeOptionalStringList(shell.PossibleUrls.Select(static candidate => candidate.Url)),
HasWriteFileRedirection = shell.HasWriteFileRedirection,
},
PermissionRequestWrite write => new PermissionDetailDto
{
Kind = WritePermissionKind,
Intention = NormalizeOptionalString(write.Intention),
FileName = NormalizeOptionalString(write.FileName),
Diff = NormalizeOptionalString(write.Diff),
NewFileContents = NormalizeOptionalString(write.NewFileContents),
},
PermissionRequestRead read => new PermissionDetailDto
{
Kind = ReadPermissionKind,
Intention = NormalizeOptionalString(read.Intention),
Path = NormalizeOptionalString(read.Path),
},
PermissionRequestMcp mcp => new PermissionDetailDto
{
Kind = McpPermissionKind,
ServerName = NormalizeOptionalString(mcp.ServerName),
ToolTitle = NormalizeOptionalString(mcp.ToolTitle),
Args = mcp.Args,
ReadOnly = mcp.ReadOnly,
},
PermissionRequestUrl url => new PermissionDetailDto
{
Kind = UrlPermissionKind,
Intention = NormalizeOptionalString(url.Intention),
Url = NormalizeOptionalString(url.Url),
},
PermissionRequestMemory memory => new PermissionDetailDto
{
Kind = MemoryPermissionKind,
Subject = NormalizeOptionalString(memory.Subject),
Fact = NormalizeOptionalString(memory.Fact),
Citations = NormalizeOptionalString(memory.Citations),
},
PermissionRequestCustomTool customTool => new PermissionDetailDto
{
Kind = CustomToolPermissionKind,
ToolDescription = NormalizeOptionalString(customTool.ToolDescription),
Args = customTool.Args,
},
PermissionRequestHook hook => new PermissionDetailDto
{
Kind = HookPermissionKind,
Args = hook.ToolArgs,
HookMessage = NormalizeOptionalString(hook.HookMessage),
},
_ => new PermissionDetailDto
{
Kind = NormalizeOptionalString(request.Kind) ?? "unknown",
},
};
}
@@ -307,6 +386,17 @@ internal sealed class CopilotApprovalCoordinator
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private static IReadOnlyList<string>? NormalizeOptionalStringList(IEnumerable<string?> values)
{
List<string> normalized = values
.Select(NormalizeOptionalString)
.Where(static value => value is not null)
.Cast<string>()
.ToList();
return normalized.Count > 0 ? normalized : null;
}
private sealed record PendingApprovalRequest(
string RequestId,
string SessionId,