Compare commits

...
6 Commits
Author SHA1 Message Date
David Kaya 0eac1da70c chore: release v0.0.30 2026-04-20 09:04:25 +02:00
David Kaya b8553c6593 Merge branch 'agents/approval-mapping-issue-analysis' into main 2026-04-17 20:47:23 +02:00
David KayaandCopilot 26847cf00d fix: consume canonical approvalToolKey from sidecar
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 14:21:53 +02:00
David KayaandCopilot fce0430091 fix: align backend approval tool keys
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 14:05:00 +02:00
David Kaya fa355197cb chore: release v0.0.29 2026-04-16 14:48:09 +02:00
David KayaandCopilot 5f263002f0 fix: pass mainWindow argument to showAndFocusWindow in second-instance handler
The showAndFocusWindow function signature was updated to require a
mainWindow parameter, but the call site in the second-instance handler
was not updated, causing a TypeScript compilation error that broke CI.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 14:43:41 +02:00
13 changed files with 399 additions and 73 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aryx", "name": "aryx",
"version": "0.0.28", "version": "0.0.30",
"description": "Orchestrator for Copilot-powered agent workflows across multiple projects.", "description": "Orchestrator for Copilot-powered agent workflows across multiple projects.",
"private": true, "private": true,
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",
@@ -678,6 +678,7 @@ public sealed class ApprovalRequestedEventDto : SidecarEventDto
public string? AgentName { get; init; } public string? AgentName { get; init; }
public string? ToolName { get; init; } public string? ToolName { get; init; }
public string? PermissionKind { get; init; } public string? PermissionKind { get; init; }
public string? ApprovalToolKey { get; init; }
public string Title { get; init; } = string.Empty; public string Title { get; init; } = string.Empty;
public string? Detail { get; init; } public string? Detail { get; init; }
public PermissionDetailDto? PermissionDetail { get; init; } public PermissionDetailDto? PermissionDetail { get; init; }
@@ -21,22 +21,33 @@ internal sealed class CopilotApprovalCoordinator
private const string HookPermissionKind = "hook"; private const string HookPermissionKind = "hook";
private const string ToolCallingActivityType = "tool-calling"; private const string ToolCallingActivityType = "tool-calling";
private static readonly Dictionary<string, string> HookToolCategories = new(StringComparer.OrdinalIgnoreCase) private static readonly Dictionary<string, HookToolApprovalMapping> HookToolApprovals = new(StringComparer.OrdinalIgnoreCase)
{ {
["view"] = ReadPermissionKind, ["view"] = new(ReadPermissionKind, ReadPermissionKind),
["glob"] = ReadPermissionKind, ["show_file"] = new(ReadPermissionKind, ReadPermissionKind),
["grep"] = ReadPermissionKind, ["read_file"] = new(ReadPermissionKind, ReadPermissionKind),
["lsp"] = ReadPermissionKind, ["glob"] = new(ReadPermissionKind, ReadPermissionKind),
["edit"] = WritePermissionKind, ["grep"] = new(ReadPermissionKind, ReadPermissionKind),
["create"] = WritePermissionKind, ["rg"] = new(ReadPermissionKind, ReadPermissionKind),
["powershell"] = ShellPermissionKind, ["lsp"] = new(ReadPermissionKind, ReadPermissionKind),
["read_powershell"] = ShellPermissionKind, ["edit"] = new(WritePermissionKind, WritePermissionKind),
["write_powershell"] = ShellPermissionKind, ["create"] = new(WritePermissionKind, WritePermissionKind),
["stop_powershell"] = ShellPermissionKind, ["write_file"] = new(WritePermissionKind, WritePermissionKind),
["list_powershell"] = ShellPermissionKind, ["apply_patch"] = new(WritePermissionKind, WritePermissionKind),
["web_fetch"] = UrlPermissionKind, ["bash"] = new(ShellPermissionKind, ShellPermissionKind),
["web_search"] = UrlPermissionKind, ["read_bash"] = new(ShellPermissionKind, ShellPermissionKind),
["store_memory"] = MemoryPermissionKind, ["write_bash"] = new(ShellPermissionKind, ShellPermissionKind),
["stop_bash"] = new(ShellPermissionKind, ShellPermissionKind),
["list_bash"] = new(ShellPermissionKind, ShellPermissionKind),
["powershell"] = new(ShellPermissionKind, ShellPermissionKind),
["read_powershell"] = new(ShellPermissionKind, ShellPermissionKind),
["write_powershell"] = new(ShellPermissionKind, ShellPermissionKind),
["stop_powershell"] = new(ShellPermissionKind, ShellPermissionKind),
["list_powershell"] = new(ShellPermissionKind, ShellPermissionKind),
["web_fetch"] = new(UrlPermissionKind, WebFetchToolName),
["web_search"] = new(UrlPermissionKind, WebFetchToolName),
["store_memory"] = new(MemoryPermissionKind, StoreMemoryToolName),
["remember_fact"] = new(MemoryPermissionKind, StoreMemoryToolName),
}; };
private readonly ConcurrentDictionary<string, PendingApprovalRequest> _pendingApprovals = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, PendingApprovalRequest> _pendingApprovals = new(StringComparer.Ordinal);
@@ -96,19 +107,22 @@ internal sealed class CopilotApprovalCoordinator
Func<ApprovalRequestedEventDto, Task> onApproval, Func<ApprovalRequestedEventDto, Task> onApproval,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
string? toolName = ResolveApprovalToolName(request, toolCalls); ResolvedApprovalContext approval = ResolveApprovalContext(request, toolCalls, command.Tooling?.McpServers);
string? autoApprovedToolName = ResolveAutoApprovedToolName(request); string? approvalCacheKey = ResolveApprovalCacheKey(approval.ToolName, approval.ApprovalToolKey);
string? mcpServerApprovalKey = ResolveMcpServerApprovalKey(request, command.Tooling?.McpServers);
string? approvalCacheKey = ResolveApprovalCacheKey(toolName, autoApprovedToolName);
AgentActivityEventDto? fileChangeActivity = BuildToolCallFileChangeActivity(command, agent, request, toolName); AgentActivityEventDto? fileChangeActivity = BuildToolCallFileChangeActivity(command, agent, request, approval.ToolName);
if (fileChangeActivity is not null && onActivity is not null) if (fileChangeActivity is not null && onActivity is not null)
{ {
await onActivity(fileChangeActivity).ConfigureAwait(false); await onActivity(fileChangeActivity).ConfigureAwait(false);
} }
if (IsToolApprovedForRequest(command.RequestId, approvalCacheKey) if (IsToolApprovedForRequest(command.RequestId, approvalCacheKey)
|| !RequiresToolCallApproval(command.Workflow.Settings.ApprovalPolicy, agent.GetAgentId(), toolName, autoApprovedToolName, mcpServerApprovalKey)) || !RequiresToolCallApproval(
command.Workflow.Settings.ApprovalPolicy,
agent.GetAgentId(),
approval.ToolName,
approval.ApprovalToolKey,
approval.McpServerApprovalKey))
{ {
return CreateApprovalResult(PermissionRequestResultKind.Approved); return CreateApprovalResult(PermissionRequestResultKind.Approved);
} }
@@ -127,7 +141,7 @@ internal sealed class CopilotApprovalCoordinator
request, request,
invocation, invocation,
pending.ApprovalId, pending.ApprovalId,
toolName)) approval.ToolName))
.ConfigureAwait(false); .ConfigureAwait(false);
using CancellationTokenRegistration registration = cancellationToken.Register( using CancellationTokenRegistration registration = cancellationToken.Register(
@@ -155,21 +169,22 @@ internal sealed class CopilotApprovalCoordinator
string approvalId, string approvalId,
string? toolName) string? toolName)
{ {
string permissionKind = ResolvePermissionKind(request, command.Tooling?.McpServers); ResolvedApprovalContext approval = ResolveApprovalContext(request, toolName, command.Tooling?.McpServers);
string agentId = agent.GetAgentId(); string agentId = agent.GetAgentId();
string agentName = agent.GetAgentName(); string agentName = agent.GetAgentName();
string? sessionId = NormalizeOptionalString(invocation.SessionId); string? sessionId = NormalizeOptionalString(invocation.SessionId);
string? normalizedToolName = NormalizeOptionalString(toolName); string? normalizedToolName = approval.ToolName;
string? displayToolName = normalizedToolName ?? NormalizeOptionalString(approval.ApprovalToolKey);
string? requestedUrl = request is PermissionRequestUrl urlRequest string? requestedUrl = request is PermissionRequestUrl urlRequest
? NormalizeOptionalString(urlRequest.Url) ? NormalizeOptionalString(urlRequest.Url)
: null; : null;
string title = normalizedToolName is null string title = displayToolName is null
? $"Approve {permissionKind}" ? $"Approve {approval.PermissionKind}"
: $"Approve {normalizedToolName}"; : $"Approve {displayToolName}";
string detail = normalizedToolName is null string detail = displayToolName is null
? $"{agentName} requested {permissionKind} permission" ? $"{agentName} requested {approval.PermissionKind} permission"
: $"{agentName} requested {permissionKind} permission for tool \"{normalizedToolName}\""; : $"{agentName} requested {approval.PermissionKind} permission for tool \"{displayToolName}\"";
if (requestedUrl is not null) if (requestedUrl is not null)
{ {
@@ -195,7 +210,8 @@ internal sealed class CopilotApprovalCoordinator
AgentId = NormalizeOptionalString(agentId), AgentId = NormalizeOptionalString(agentId),
AgentName = NormalizeOptionalString(agentName), AgentName = NormalizeOptionalString(agentName),
ToolName = normalizedToolName, ToolName = normalizedToolName,
PermissionKind = permissionKind, PermissionKind = approval.PermissionKind,
ApprovalToolKey = NormalizeOptionalString(approval.ApprovalToolKey),
Title = title, Title = title,
Detail = detail, Detail = detail,
PermissionDetail = BuildPermissionDetail(request, command.Tooling?.McpServers), PermissionDetail = BuildPermissionDetail(request, command.Tooling?.McpServers),
@@ -314,7 +330,7 @@ internal sealed class CopilotApprovalCoordinator
ApprovalPolicyDto? approvalPolicy, ApprovalPolicyDto? approvalPolicy,
string agentId, string agentId,
string? toolName, string? toolName,
string? autoApprovedToolName = null, string? approvalToolKey = null,
string? mcpServerApprovalKey = null) string? mcpServerApprovalKey = null)
{ {
if (approvalPolicy?.Rules is null || approvalPolicy.Rules.Count == 0) if (approvalPolicy?.Rules is null || approvalPolicy.Rules.Count == 0)
@@ -333,7 +349,7 @@ internal sealed class CopilotApprovalCoordinator
return true; return true;
} }
return !MatchesAutoApprovedTool(autoApprovedToolNames, toolName, autoApprovedToolName) return !MatchesAutoApprovedTool(autoApprovedToolNames, toolName, approvalToolKey)
&& !MatchesAutoApprovedToolName(autoApprovedToolNames, mcpServerApprovalKey); && !MatchesAutoApprovedToolName(autoApprovedToolNames, mcpServerApprovalKey);
} }
@@ -411,9 +427,67 @@ internal sealed class CopilotApprovalCoordinator
?? GetFallbackToolName(request); ?? GetFallbackToolName(request);
} }
private static string? ResolveAutoApprovedToolName(PermissionRequest request) private static ResolvedApprovalContext ResolveApprovalContext(
PermissionRequest request,
ToolCallRegistry? toolCalls,
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
{ {
return GetFallbackToolName(request); return ResolveApprovalContext(
request,
ResolveApprovalToolName(request, toolCalls),
configuredMcpServers);
}
private static ResolvedApprovalContext ResolveApprovalContext(
PermissionRequest request,
string? toolName,
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
{
string? normalizedToolName = NormalizeOptionalString(toolName);
return request switch
{
PermissionRequestShell => new(
normalizedToolName,
ShellPermissionKind,
ShellPermissionKind,
null),
PermissionRequestWrite => new(
normalizedToolName,
WritePermissionKind,
WritePermissionKind,
null),
PermissionRequestRead => new(
normalizedToolName,
ReadPermissionKind,
ReadPermissionKind,
null),
PermissionRequestUrl => new(
normalizedToolName,
UrlPermissionKind,
WebFetchToolName,
null),
PermissionRequestMemory => new(
normalizedToolName,
MemoryPermissionKind,
StoreMemoryToolName,
null),
PermissionRequestMcp => new(
normalizedToolName,
McpPermissionKind,
normalizedToolName,
ResolveMcpServerApprovalKey(request, configuredMcpServers)),
PermissionRequestCustomTool => new(
normalizedToolName,
CustomToolPermissionKind,
normalizedToolName,
null),
PermissionRequestHook => ResolveHookApprovalContext(normalizedToolName, configuredMcpServers),
_ => new(
normalizedToolName,
NormalizeOptionalString(request.Kind) ?? "tool access",
normalizedToolName,
ResolveMcpServerApprovalKey(request, configuredMcpServers)),
};
} }
private const string McpServerApprovalPrefix = "mcp_server:"; private const string McpServerApprovalPrefix = "mcp_server:";
@@ -522,44 +596,56 @@ internal sealed class CopilotApprovalCoordinator
PermissionRequestWrite => WritePermissionKind, PermissionRequestWrite => WritePermissionKind,
PermissionRequestRead => ReadPermissionKind, PermissionRequestRead => ReadPermissionKind,
PermissionRequestMemory => StoreMemoryToolName, PermissionRequestMemory => StoreMemoryToolName,
PermissionRequestHook hook => ResolveHookToolCategory(hook.ToolName), PermissionRequestHook hook => ResolveHookApprovalToolKey(hook.ToolName),
_ => null, _ => null,
}; };
} }
internal static string? ResolveHookToolCategory(string? toolName) internal static string? ResolveHookToolCategory(string? toolName)
{ {
string? normalized = NormalizeOptionalString(toolName); return TryResolveHookToolApproval(toolName, out HookToolApprovalMapping? mapping) && mapping is not null
if (normalized is null) ? mapping.PermissionKind
{ : null;
return null;
}
return HookToolCategories.TryGetValue(normalized, out string? category) ? category : null;
} }
private static string ResolvePermissionKind( internal static string? ResolveHookApprovalToolKey(string? toolName)
PermissionRequest request, {
return TryResolveHookToolApproval(toolName, out HookToolApprovalMapping? mapping) && mapping is not null
? mapping.ApprovalToolKey
: null;
}
internal static ResolvedApprovalContext ResolveHookApprovalContext(
string? toolName,
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers) IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
{ {
string permissionKind = string.IsNullOrWhiteSpace(request.Kind) string? normalizedToolName = NormalizeOptionalString(toolName);
? "tool access" if (normalizedToolName is null)
: request.Kind.Trim();
if (request is not PermissionRequestHook hook)
{ {
return permissionKind; return new ResolvedApprovalContext(null, HookPermissionKind, null, null);
} }
string? resolvedCategory = ResolveHookToolCategory(hook.ToolName); if (TryResolveHookToolApproval(normalizedToolName, out HookToolApprovalMapping? mapping) && mapping is not null)
if (resolvedCategory is not null)
{ {
return resolvedCategory; return new ResolvedApprovalContext(
normalizedToolName,
mapping.PermissionKind,
mapping.ApprovalToolKey,
null);
} }
return ResolveHookMcpServerName(hook.ToolName, configuredMcpServers) is not null string? mcpServerApprovalKey = ResolveHookMcpServerApprovalKey(normalizedToolName, configuredMcpServers);
? McpPermissionKind return mcpServerApprovalKey is not null
: permissionKind; ? new ResolvedApprovalContext(
normalizedToolName,
McpPermissionKind,
normalizedToolName,
mcpServerApprovalKey)
: new ResolvedApprovalContext(
normalizedToolName,
HookPermissionKind,
normalizedToolName,
null);
} }
private static PermissionDetailDto BuildHookPermissionDetail( private static PermissionDetailDto BuildHookPermissionDetail(
@@ -619,13 +705,29 @@ internal sealed class CopilotApprovalCoordinator
: strippedToolName; : strippedToolName;
} }
private static bool TryResolveHookToolApproval(
string? toolName,
out HookToolApprovalMapping? mapping)
{
string? normalizedToolName = NormalizeOptionalString(toolName);
if (normalizedToolName is not null
&& HookToolApprovals.TryGetValue(normalizedToolName, out HookToolApprovalMapping? resolvedMapping))
{
mapping = resolvedMapping;
return true;
}
mapping = null;
return false;
}
private static bool MatchesAutoApprovedTool( private static bool MatchesAutoApprovedTool(
IReadOnlyList<string> autoApprovedToolNames, IReadOnlyList<string> autoApprovedToolNames,
string? toolName, string? toolName,
string? autoApprovedToolName) string? approvalToolKey)
{ {
return MatchesAutoApprovedToolName(autoApprovedToolNames, toolName) return MatchesAutoApprovedToolName(autoApprovedToolNames, toolName)
|| MatchesAutoApprovedToolName(autoApprovedToolNames, autoApprovedToolName); || MatchesAutoApprovedToolName(autoApprovedToolNames, approvalToolKey);
} }
private static bool MatchesAutoApprovedToolName( private static bool MatchesAutoApprovedToolName(
@@ -720,6 +822,16 @@ internal sealed class CopilotApprovalCoordinator
return normalized.Count > 0 ? normalized : null; return normalized.Count > 0 ? normalized : null;
} }
internal sealed record ResolvedApprovalContext(
string? ToolName,
string PermissionKind,
string? ApprovalToolKey,
string? McpServerApprovalKey);
private sealed record HookToolApprovalMapping(
string PermissionKind,
string ApprovalToolKey);
private sealed record PendingApprovalRequest( private sealed record PendingApprovalRequest(
string RequestId, string RequestId,
string SessionId, string SessionId,
@@ -248,17 +248,16 @@ internal static class CopilotSessionHooks
}; };
} }
string? autoApprovedToolName = CopilotApprovalCoordinator.ResolveHookToolCategory(toolName) ?? toolName; CopilotApprovalCoordinator.ResolvedApprovalContext approval = CopilotApprovalCoordinator.ResolveHookApprovalContext(
string? mcpServerApprovalKey = CopilotApprovalCoordinator.ResolveHookMcpServerApprovalKey(
toolName, toolName,
command.Tooling?.McpServers); command.Tooling?.McpServers);
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval( bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
command.Workflow.Settings.ApprovalPolicy, command.Workflow.Settings.ApprovalPolicy,
agentDefinition.GetAgentId(), agentDefinition.GetAgentId(),
toolName, approval.ToolName,
autoApprovedToolName, approval.ApprovalToolKey,
mcpServerApprovalKey); approval.McpServerApprovalKey);
return new PreToolUseHookOutput return new PreToolUseHookOutput
{ {
@@ -154,11 +154,15 @@ public sealed class CopilotSessionHooksTests
[Theory] [Theory]
[InlineData("view", "read")] [InlineData("view", "read")]
[InlineData("grep", "read")] [InlineData("grep", "read")]
[InlineData("rg", "read")]
[InlineData("edit", "write")] [InlineData("edit", "write")]
[InlineData("apply_patch", "write")]
[InlineData("powershell", "shell")] [InlineData("powershell", "shell")]
public async Task Create_PreToolUseAutoAllowsWhenCategoryIsApproved(string toolName, string category) [InlineData("web_search", "web_fetch")]
[InlineData("store_memory", "store_memory")]
public async Task Create_PreToolUseAutoAllowsWhenApprovalToolKeyIsApproved(string toolName, string approvalToolKey)
{ {
RunTurnCommandDto command = CreateCommandWithAutoApprovedCategory(category); RunTurnCommandDto command = CreateCommandWithAutoApprovedCategory(approvalToolKey);
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner()); SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!( PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
@@ -1487,6 +1487,28 @@ public sealed class CopilotWorkflowRunnerTests
Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "git.status")); Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "git.status"));
} }
[Fact]
public void RequiresToolCallApproval_HonorsCanonicalHookApprovalKeysForModernToolNames()
{
ApprovalPolicyDto policy = new()
{
Rules =
[
new ApprovalCheckpointRuleDto
{
Kind = "tool-call",
AgentIds = ["agent-1"],
},
],
AutoApprovedToolNames = ["read", "write", "web_fetch"],
};
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "rg", "read"));
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "apply_patch", "write"));
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "web_search", "web_fetch"));
Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "powershell", "shell"));
}
[Fact] [Fact]
public void RequiresToolCallApproval_HonorsMcpServerLevelApprovalKey() public void RequiresToolCallApproval_HonorsMcpServerLevelApprovalKey()
{ {
@@ -1738,6 +1760,7 @@ public sealed class CopilotWorkflowRunnerTests
"lsp_ts_hover"); "lsp_ts_hover");
Assert.Equal("lsp_ts_hover", approvalEvent.ToolName); Assert.Equal("lsp_ts_hover", approvalEvent.ToolName);
Assert.Equal("lsp_ts_hover", approvalEvent.ApprovalToolKey);
Assert.Equal("Approve lsp_ts_hover", approvalEvent.Title); Assert.Equal("Approve lsp_ts_hover", approvalEvent.Title);
Assert.Contains("tool \"lsp_ts_hover\"", approvalEvent.Detail); Assert.Contains("tool \"lsp_ts_hover\"", approvalEvent.Detail);
Assert.NotNull(approvalEvent.PermissionDetail); Assert.NotNull(approvalEvent.PermissionDetail);
@@ -1770,6 +1793,7 @@ public sealed class CopilotWorkflowRunnerTests
"web_fetch"); "web_fetch");
Assert.Equal("web_fetch", approvalEvent.ToolName); Assert.Equal("web_fetch", approvalEvent.ToolName);
Assert.Equal("web_fetch", approvalEvent.ApprovalToolKey);
Assert.Equal("Approve web_fetch", approvalEvent.Title); Assert.Equal("Approve web_fetch", approvalEvent.Title);
Assert.Contains("url permission", approvalEvent.Detail); Assert.Contains("url permission", approvalEvent.Detail);
Assert.Contains("tool \"web_fetch\"", approvalEvent.Detail); Assert.Contains("tool \"web_fetch\"", approvalEvent.Detail);
@@ -1992,11 +2016,21 @@ public sealed class CopilotWorkflowRunnerTests
[Theory] [Theory]
[InlineData("view", "read")] [InlineData("view", "read")]
[InlineData("show_file", "read")]
[InlineData("read_file", "read")]
[InlineData("glob", "read")] [InlineData("glob", "read")]
[InlineData("grep", "read")] [InlineData("grep", "read")]
[InlineData("rg", "read")]
[InlineData("lsp", "read")] [InlineData("lsp", "read")]
[InlineData("edit", "write")] [InlineData("edit", "write")]
[InlineData("create", "write")] [InlineData("create", "write")]
[InlineData("write_file", "write")]
[InlineData("apply_patch", "write")]
[InlineData("bash", "shell")]
[InlineData("read_bash", "shell")]
[InlineData("write_bash", "shell")]
[InlineData("stop_bash", "shell")]
[InlineData("list_bash", "shell")]
[InlineData("powershell", "shell")] [InlineData("powershell", "shell")]
[InlineData("read_powershell", "shell")] [InlineData("read_powershell", "shell")]
[InlineData("write_powershell", "shell")] [InlineData("write_powershell", "shell")]
@@ -2005,6 +2039,7 @@ public sealed class CopilotWorkflowRunnerTests
[InlineData("web_fetch", "url")] [InlineData("web_fetch", "url")]
[InlineData("web_search", "url")] [InlineData("web_search", "url")]
[InlineData("store_memory", "memory")] [InlineData("store_memory", "memory")]
[InlineData("remember_fact", "memory")]
public void ResolveHookToolCategory_ReturnsExpectedCategoryForKnownTools(string toolName, string expectedCategory) public void ResolveHookToolCategory_ReturnsExpectedCategoryForKnownTools(string toolName, string expectedCategory)
{ {
Assert.Equal(expectedCategory, CopilotApprovalCoordinator.ResolveHookToolCategory(toolName)); Assert.Equal(expectedCategory, CopilotApprovalCoordinator.ResolveHookToolCategory(toolName));
@@ -2027,6 +2062,29 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(" ")); Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(" "));
} }
[Theory]
[InlineData("view", "read")]
[InlineData("rg", "read")]
[InlineData("apply_patch", "write")]
[InlineData("bash", "shell")]
[InlineData("web_fetch", "web_fetch")]
[InlineData("web_search", "web_fetch")]
[InlineData("store_memory", "store_memory")]
[InlineData("remember_fact", "store_memory")]
public void ResolveHookApprovalToolKey_ReturnsExpectedKeyForKnownTools(string toolName, string expectedKey)
{
Assert.Equal(expectedKey, CopilotApprovalCoordinator.ResolveHookApprovalToolKey(toolName));
}
[Fact]
public void ResolveHookApprovalToolKey_ReturnsNullForUnknownTools()
{
Assert.Null(CopilotApprovalCoordinator.ResolveHookApprovalToolKey("custom_tool"));
Assert.Null(CopilotApprovalCoordinator.ResolveHookApprovalToolKey(null));
Assert.Null(CopilotApprovalCoordinator.ResolveHookApprovalToolKey(""));
Assert.Null(CopilotApprovalCoordinator.ResolveHookApprovalToolKey(" "));
}
[Fact] [Fact]
public void ResolveHookMcpServerApprovalKey_PrefersLongestConfiguredServerName() public void ResolveHookMcpServerApprovalKey_PrefersLongestConfiguredServerName()
{ {
@@ -2119,6 +2177,7 @@ public sealed class CopilotWorkflowRunnerTests
"view"); "view");
Assert.Equal("view", approvalEvent.ToolName); Assert.Equal("view", approvalEvent.ToolName);
Assert.Equal("read", approvalEvent.ApprovalToolKey);
Assert.Equal("read", approvalEvent.PermissionKind); Assert.Equal("read", approvalEvent.PermissionKind);
Assert.Contains("read permission", approvalEvent.Detail); Assert.Contains("read permission", approvalEvent.Detail);
} }
@@ -2151,6 +2210,7 @@ public sealed class CopilotWorkflowRunnerTests
"icm-mcp-get_schedule"); "icm-mcp-get_schedule");
Assert.Equal("mcp", approvalEvent.PermissionKind); Assert.Equal("mcp", approvalEvent.PermissionKind);
Assert.Equal("icm-mcp-get_schedule", approvalEvent.ApprovalToolKey);
Assert.Contains("mcp permission", approvalEvent.Detail); Assert.Contains("mcp permission", approvalEvent.Detail);
Assert.NotNull(approvalEvent.PermissionDetail); Assert.NotNull(approvalEvent.PermissionDetail);
Assert.Equal("mcp", approvalEvent.PermissionDetail!.Kind); Assert.Equal("mcp", approvalEvent.PermissionDetail!.Kind);
@@ -2182,6 +2242,7 @@ public sealed class CopilotWorkflowRunnerTests
"icm-mcp-get_schedule"); "icm-mcp-get_schedule");
Assert.Equal("hook", approvalEvent.PermissionKind); Assert.Equal("hook", approvalEvent.PermissionKind);
Assert.Equal("icm-mcp-get_schedule", approvalEvent.ApprovalToolKey);
} }
[Fact] [Fact]
@@ -2214,6 +2275,7 @@ public sealed class CopilotWorkflowRunnerTests
Assert.False(pending.IsCompleted); Assert.False(pending.IsCompleted);
Assert.NotNull(observedApproval); Assert.NotNull(observedApproval);
Assert.Equal("lsp_ts_definition", observedApproval!.ApprovalToolKey);
await coordinator.ResolveApprovalAsync( await coordinator.ResolveApprovalAsync(
new ResolveApprovalCommandDto new ResolveApprovalCommandDto
@@ -2270,6 +2332,8 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("tool-calling", observedActivity!.ActivityType); Assert.Equal("tool-calling", observedActivity!.ActivityType);
Assert.Equal("apply_patch", observedActivity.ToolName); Assert.Equal("apply_patch", observedActivity.ToolName);
Assert.Equal("tool-call-write-1", observedActivity.ToolCallId); Assert.Equal("tool-call-write-1", observedActivity.ToolCallId);
Assert.Equal("apply_patch", observedApproval!.ToolName);
Assert.Equal("write", observedApproval.ApprovalToolKey);
ToolCallFileChangeDto preview = Assert.Single(observedActivity.FileChanges!); ToolCallFileChangeDto preview = Assert.Single(observedActivity.FileChanges!);
Assert.Equal("README.md", preview.Path); Assert.Equal("README.md", preview.Path);
@@ -2388,6 +2452,7 @@ public sealed class CopilotWorkflowRunnerTests
Assert.False(firstPending.IsCompleted); Assert.False(firstPending.IsCompleted);
Assert.NotNull(firstApproval); Assert.NotNull(firstApproval);
Assert.Equal("read", firstApproval!.ApprovalToolKey);
await coordinator.ResolveApprovalAsync( await coordinator.ResolveApprovalAsync(
new ResolveApprovalCommandDto new ResolveApprovalCommandDto
@@ -2428,6 +2493,82 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal(PermissionRequestResultKind.Approved, secondResult.Kind); Assert.Equal(PermissionRequestResultKind.Approved, secondResult.Kind);
} }
[Fact]
public async Task RequestApprovalAsync_AlwaysApproveCachesCanonicalWriteApprovalForModernHookTools()
{
CopilotApprovalCoordinator coordinator = new();
ApprovalRequestedEventDto? firstApproval = null;
RunTurnCommandDto command = CreateApprovalCommand();
Task<PermissionRequestResult> firstPending = coordinator.RequestApprovalAsync(
command,
command.Workflow.GetAgentNodes()[0],
new PermissionRequestWrite
{
Kind = "write",
ToolCallId = "tool-call-write-1",
Intention = "Update the README",
FileName = "README.md",
Diff = "@@ -1 +1 @@",
NewFileContents = "# Aryx\n",
},
new PermissionInvocation
{
SessionId = "copilot-session-1",
},
CreateToolCallRegistry(("tool-call-write-1", "apply_patch")),
approval =>
{
firstApproval = approval;
return Task.CompletedTask;
},
CancellationToken.None);
Assert.False(firstPending.IsCompleted);
Assert.NotNull(firstApproval);
Assert.Equal("write", firstApproval!.ApprovalToolKey);
await coordinator.ResolveApprovalAsync(
new ResolveApprovalCommandDto
{
ApprovalId = firstApproval.ApprovalId,
Decision = "approved",
AlwaysApprove = true,
},
CancellationToken.None);
PermissionRequestResult firstResult = await firstPending;
Assert.Equal(PermissionRequestResultKind.Approved, firstResult.Kind);
bool sawSecondApproval = false;
PermissionRequestResult secondResult = await coordinator.RequestApprovalAsync(
command,
command.Workflow.GetAgentNodes()[0],
new PermissionRequestWrite
{
Kind = "write",
ToolCallId = "tool-call-write-2",
Intention = "Create docs draft",
FileName = "docs\\guide.md",
Diff = "@@ -0,0 +1 @@",
NewFileContents = "# Guide\n",
},
new PermissionInvocation
{
SessionId = "copilot-session-1",
},
CreateToolCallRegistry(("tool-call-write-2", "write_file")),
approval =>
{
sawSecondApproval = true;
return Task.CompletedTask;
},
CancellationToken.None);
Assert.False(sawSecondApproval);
Assert.Equal(PermissionRequestResultKind.Approved, secondResult.Kind);
}
[Fact] [Fact]
public async Task RequestApprovalAsync_AlwaysApproveCacheDoesNotCarryAcrossTurnRequests() public async Task RequestApprovalAsync_AlwaysApproveCacheDoesNotCarryAcrossTurnRequests()
{ {
@@ -412,6 +412,7 @@ public sealed class SidecarProtocolHostTests
AgentId = "agent-1", AgentId = "agent-1",
AgentName = "Primary", AgentName = "Primary",
PermissionKind = "tool access", PermissionKind = "tool access",
ApprovalToolKey = "shell",
Title = "Approve tool access", Title = "Approve tool access",
PermissionDetail = new PermissionDetailDto PermissionDetail = new PermissionDetailDto
{ {
@@ -437,6 +438,7 @@ public sealed class SidecarProtocolHostTests
Assert.Equal("turn-approval", approvalEvent.GetProperty("requestId").GetString()); Assert.Equal("turn-approval", approvalEvent.GetProperty("requestId").GetString());
Assert.Equal("approval-1", approvalEvent.GetProperty("approvalId").GetString()); Assert.Equal("approval-1", approvalEvent.GetProperty("approvalId").GetString());
Assert.Equal("tool-call", approvalEvent.GetProperty("approvalKind").GetString()); Assert.Equal("tool-call", approvalEvent.GetProperty("approvalKind").GetString());
Assert.Equal("shell", approvalEvent.GetProperty("approvalToolKey").GetString());
Assert.Equal("Approve tool access", approvalEvent.GetProperty("title").GetString()); Assert.Equal("Approve tool access", approvalEvent.GetProperty("title").GetString());
JsonElement permissionDetail = approvalEvent.GetProperty("permissionDetail"); JsonElement permissionDetail = approvalEvent.GetProperty("permissionDetail");
Assert.Equal("shell", permissionDetail.GetProperty("kind").GetString()); Assert.Equal("shell", permissionDetail.GetProperty("kind").GetString());
+3 -1
View File
@@ -116,7 +116,9 @@ async function bootstrap(): Promise<void> {
} }
app.on('second-instance', () => { app.on('second-instance', () => {
showAndFocusWindow(); if (mainWindow && !mainWindow.isDestroyed()) {
showAndFocusWindow(mainWindow);
}
}); });
app.whenReady().then(bootstrap); app.whenReady().then(bootstrap);
+4 -3
View File
@@ -7,9 +7,9 @@ import type {
import { import {
dequeuePendingApprovalState, dequeuePendingApprovalState,
enqueuePendingApprovalState, enqueuePendingApprovalState,
getPendingApprovalToolKey,
listPendingApprovals, listPendingApprovals,
resolvePendingApproval, resolvePendingApproval,
resolveApprovalToolKey,
type ApprovalDecision, type ApprovalDecision,
type PendingApprovalRecord, type PendingApprovalRecord,
} from '@shared/domain/approval'; } from '@shared/domain/approval';
@@ -109,7 +109,7 @@ export class ApprovalCoordinator {
this.setSessionPendingApprovalState(session, dequeuePendingApprovalState(session, approvalId)); this.setSessionPendingApprovalState(session, dequeuePendingApprovalState(session, approvalId));
session.updatedAt = resolvedAt; session.updatedAt = resolvedAt;
const approvalKey = resolveApprovalToolKey(approval.toolName, approval.permissionKind); const approvalKey = getPendingApprovalToolKey(approval);
if (decision === 'approved' && alwaysApprove && approvalKey) { if (decision === 'approved' && alwaysApprove && approvalKey) {
const existing = session.approvalSettings?.autoApprovedToolNames ?? []; const existing = session.approvalSettings?.autoApprovedToolNames ?? [];
if (!existing.includes(approvalKey)) { if (!existing.includes(approvalKey)) {
@@ -127,7 +127,7 @@ export class ApprovalCoordinator {
continue; continue;
} }
const queuedKey = resolveApprovalToolKey(queued.toolName, queued.permissionKind); const queuedKey = getPendingApprovalToolKey(queued);
if (queuedKey !== approvalKey) { if (queuedKey !== approvalKey) {
continue; continue;
} }
@@ -361,6 +361,7 @@ export class ApprovalCoordinator {
agentName: event.agentName, agentName: event.agentName,
toolName: event.toolName, toolName: event.toolName,
permissionKind: event.permissionKind, permissionKind: event.permissionKind,
approvalToolKey: event.approvalToolKey,
title: event.title, title: event.title,
detail: event.detail, detail: event.detail,
permissionDetail: event.permissionDetail, permissionDetail: event.permissionDetail,
@@ -3,7 +3,7 @@ import { Bot, Check, ChevronDown, Loader2, ShieldAlert, ShieldBan, ShieldCheck,
import { MarkdownContent } from '@renderer/components/MarkdownContent'; import { MarkdownContent } from '@renderer/components/MarkdownContent';
import { permissionDetailSummary, PermissionDetailView } from '@renderer/components/chat/PermissionDetailView'; import { permissionDetailSummary, PermissionDetailView } from '@renderer/components/chat/PermissionDetailView';
import { resolveApprovalToolKey } from '@shared/domain/approval'; import { getPendingApprovalToolKey } from '@shared/domain/approval';
import type { ApprovalDecision, PendingApprovalRecord } from '@shared/domain/approval'; import type { ApprovalDecision, PendingApprovalRecord } from '@shared/domain/approval';
import { resolveToolLabel } from '@shared/domain/tooling'; import { resolveToolLabel } from '@shared/domain/tooling';
@@ -25,7 +25,7 @@ export function ApprovalBanner({
const kindLabel = approval.kind === 'final-response' ? 'Final response review' : 'Tool call approval'; const kindLabel = approval.kind === 'final-response' ? 'Final response review' : 'Tool call approval';
const hasMessages = approval.messages && approval.messages.length > 0; const hasMessages = approval.messages && approval.messages.length > 0;
const showPosition = position !== undefined && total !== undefined && total > 1; const showPosition = position !== undefined && total !== undefined && total > 1;
const approvalToolKey = resolveApprovalToolKey(approval.toolName, approval.permissionKind); const approvalToolKey = getPendingApprovalToolKey(approval);
const canAlwaysApprove = approval.kind === 'tool-call' && !!approvalToolKey; const canAlwaysApprove = approval.kind === 'tool-call' && !!approvalToolKey;
const approvalToolLabel = approvalToolKey ? resolveToolLabel(approvalToolKey) : undefined; const approvalToolLabel = approvalToolKey ? resolveToolLabel(approvalToolKey) : undefined;
+1
View File
@@ -518,6 +518,7 @@ export interface ApprovalRequestedEvent {
agentName?: string; agentName?: string;
toolName?: string; toolName?: string;
permissionKind?: string; permissionKind?: string;
approvalToolKey?: string;
title: string; title: string;
detail?: string; detail?: string;
permissionDetail?: PermissionDetail; permissionDetail?: PermissionDetail;
+18
View File
@@ -34,6 +34,7 @@ export interface PendingApprovalRecord {
agentName?: string; agentName?: string;
toolName?: string; toolName?: string;
permissionKind?: string; permissionKind?: string;
approvalToolKey?: string;
title: string; title: string;
detail?: string; detail?: string;
messages?: PendingApprovalMessageRecord[]; messages?: PendingApprovalMessageRecord[];
@@ -215,6 +216,22 @@ export function resolveApprovalToolKey(
return toolName; return toolName;
} }
/**
* Returns the canonical approval key for a pending approval.
*
* Prefers the explicit `approvalToolKey` supplied by the sidecar. Falls back to
* re-deriving the key from `toolName` and `permissionKind` to support legacy
* records persisted before the sidecar contract change.
*/
export function getPendingApprovalToolKey(
record: Pick<PendingApprovalRecord, 'approvalToolKey' | 'toolName' | 'permissionKind'>,
): string | undefined {
return (
normalizeOptionalString(record.approvalToolKey)
?? resolveApprovalToolKey(record.toolName, record.permissionKind)
);
}
export function approvalPolicyAutoApprovesTool( export function approvalPolicyAutoApprovesTool(
policy: ApprovalPolicy | undefined, policy: ApprovalPolicy | undefined,
toolName?: string, toolName?: string,
@@ -312,6 +329,7 @@ export function normalizePendingApproval(
agentName: normalizeOptionalString(approval?.agentName), agentName: normalizeOptionalString(approval?.agentName),
toolName: normalizeOptionalString(approval?.toolName), toolName: normalizeOptionalString(approval?.toolName),
permissionKind: normalizeOptionalString(approval?.permissionKind), permissionKind: normalizeOptionalString(approval?.permissionKind),
approvalToolKey: normalizeOptionalString(approval?.approvalToolKey),
title, title,
detail: normalizeOptionalString(approval?.detail), detail: normalizeOptionalString(approval?.detail),
messages: normalizePendingApprovalMessages(approval?.messages), messages: normalizePendingApprovalMessages(approval?.messages),
+45
View File
@@ -10,6 +10,7 @@ import {
normalizeSessionApprovalSettings, normalizeSessionApprovalSettings,
pruneSessionApprovalSettings, pruneSessionApprovalSettings,
resolveApprovalToolKey, resolveApprovalToolKey,
getPendingApprovalToolKey,
dequeuePendingApprovalState, dequeuePendingApprovalState,
enqueuePendingApprovalState, enqueuePendingApprovalState,
listPendingApprovals, listPendingApprovals,
@@ -261,4 +262,48 @@ describe('approval helpers', () => {
expect(resolveApprovalToolKey(undefined, undefined)).toBeUndefined(); expect(resolveApprovalToolKey(undefined, undefined)).toBeUndefined();
expect(resolveApprovalToolKey(undefined, 'mcp')).toBeUndefined(); expect(resolveApprovalToolKey(undefined, 'mcp')).toBeUndefined();
}); });
test('getPendingApprovalToolKey prefers the explicit approval tool key from the sidecar', () => {
expect(
getPendingApprovalToolKey({
approvalToolKey: 'write',
toolName: 'apply_patch',
permissionKind: 'hook',
}),
).toBe('write');
expect(
getPendingApprovalToolKey({
approvalToolKey: 'web_fetch',
toolName: 'web_search',
permissionKind: 'hook',
}),
).toBe('web_fetch');
expect(
getPendingApprovalToolKey({
approvalToolKey: 'mcp_server:icm-mcp',
toolName: 'icm-mcp-get_schedule',
permissionKind: 'mcp',
}),
).toBe('mcp_server:icm-mcp');
});
test('getPendingApprovalToolKey falls back to derivation for legacy approvals without the explicit key', () => {
expect(
getPendingApprovalToolKey({
toolName: 'view',
permissionKind: 'read',
}),
).toBe('read');
expect(
getPendingApprovalToolKey({
toolName: 'git.status',
permissionKind: 'mcp',
}),
).toBe('git.status');
expect(getPendingApprovalToolKey({})).toBeUndefined();
});
}); });