diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotSessionHooks.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotSessionHooks.cs index 5750cde..91d3cbd 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotSessionHooks.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotSessionHooks.cs @@ -7,9 +7,19 @@ namespace Aryx.AgentHost.Services; internal static class CopilotSessionHooks { + private const string AskUserToolName = "ask_user"; private const string AllowDecision = "allow"; private const string AskDecision = "ask"; private const string DenyDecision = "deny"; + private const string HandoffToolPrefix = "handoff_to_"; + private const string ReportIntentToolName = "report_intent"; + private const string TaskCompleteToolName = "task_complete"; + private static readonly HashSet AlwaysAllowedToolNames = new(StringComparer.OrdinalIgnoreCase) + { + AskUserToolName, + ReportIntentToolName, + TaskCompleteToolName, + }; private static readonly JsonSerializerOptions HookJsonOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, @@ -216,11 +226,20 @@ internal static class CopilotSessionHooks PatternAgentDefinitionDto agentDefinition, PreToolUseHookInput input) { + string? toolName = Normalize(input.ToolName); + if (IsAlwaysAllowedTool(toolName)) + { + return new PreToolUseHookOutput + { + PermissionDecision = AllowDecision, + }; + } + bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval( command.Pattern.ApprovalPolicy, agentDefinition.Id, - Normalize(input.ToolName), - Normalize(input.ToolName)); + toolName, + toolName); return new PreToolUseHookOutput { @@ -262,6 +281,14 @@ internal static class CopilotSessionHooks private static string SerializeHookValue(object? value) => JsonSerializer.Serialize(value, HookJsonOptions); + private static bool IsAlwaysAllowedTool(string? toolName) + { + string? normalizedToolName = Normalize(toolName); + return normalizedToolName is not null + && (AlwaysAllowedToolNames.Contains(normalizedToolName) + || normalizedToolName.StartsWith(HandoffToolPrefix, StringComparison.OrdinalIgnoreCase)); + } + private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotSessionHooksTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotSessionHooksTests.cs index 2a7aa8b..2202396 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotSessionHooksTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotSessionHooksTests.cs @@ -106,6 +106,27 @@ public sealed class CopilotSessionHooksTests Assert.Single(runner.Invocations); } + [Theory] + [InlineData("ask_user")] + [InlineData("report_intent")] + [InlineData("task_complete")] + [InlineData("handoff_to_2")] + [InlineData("handoff_to_specialist")] + public async Task Create_PreToolUseAutoAllowsInfrastructureTools(string toolName) + { + RunTurnCommandDto command = CreateCommandWithToolApproval(); + SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner()); + + PreToolUseHookOutput? decision = await hooks.OnPreToolUse!( + new PreToolUseHookInput + { + ToolName = toolName, + }, + null!); + + Assert.Equal("allow", decision?.PermissionDecision); + } + [Fact] public async Task Create_RunsConfiguredNonPreToolHooks() {