feat: support project hook commands

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-28 13:23:01 +01:00
co-authored by Copilot
parent f459cc7291
commit dff97efd58
10 changed files with 1428 additions and 13 deletions
@@ -0,0 +1,62 @@
using System.Text.Json.Serialization;
namespace Aryx.AgentHost.Contracts;
internal static class HookTypeNames
{
public const string SessionStart = "sessionStart";
public const string SessionEnd = "sessionEnd";
public const string UserPromptSubmitted = "userPromptSubmitted";
public const string PreToolUse = "preToolUse";
public const string PostToolUse = "postToolUse";
public const string ErrorOccurred = "errorOccurred";
}
internal sealed class HookConfigFile
{
public int Version { get; init; }
public HookConfigHooks Hooks { get; init; } = new();
}
internal sealed class HookConfigHooks
{
public IReadOnlyList<HookCommandDefinition>? SessionStart { get; init; }
public IReadOnlyList<HookCommandDefinition>? SessionEnd { get; init; }
public IReadOnlyList<HookCommandDefinition>? UserPromptSubmitted { get; init; }
public IReadOnlyList<HookCommandDefinition>? PreToolUse { get; init; }
public IReadOnlyList<HookCommandDefinition>? PostToolUse { get; init; }
public IReadOnlyList<HookCommandDefinition>? ErrorOccurred { get; init; }
}
internal sealed class HookCommandDefinition
{
public string Type { get; init; } = string.Empty;
public string? Bash { get; init; }
[JsonPropertyName("powershell")]
public string? PowerShell { get; init; }
public string? Cwd { get; init; }
public IReadOnlyDictionary<string, string>? Env { get; init; }
public int? TimeoutSec { get; init; }
}
internal sealed class ResolvedHookSet
{
public static ResolvedHookSet Empty { get; } = new();
public IReadOnlyList<HookCommandDefinition> SessionStart { get; init; } = [];
public IReadOnlyList<HookCommandDefinition> SessionEnd { get; init; } = [];
public IReadOnlyList<HookCommandDefinition> UserPromptSubmitted { get; init; } = [];
public IReadOnlyList<HookCommandDefinition> PreToolUse { get; init; } = [];
public IReadOnlyList<HookCommandDefinition> PostToolUse { get; init; } = [];
public IReadOnlyList<HookCommandDefinition> ErrorOccurred { get; init; } = [];
public bool IsEmpty =>
SessionStart.Count == 0
&& SessionEnd.Count == 0
&& UserPromptSubmitted.Count == 0
&& PreToolUse.Count == 0
&& PostToolUse.Count == 0
&& ErrorOccurred.Count == 0;
}
@@ -30,6 +30,9 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
List<IAsyncDisposable> disposables = [];
List<AIAgent> agents = [];
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
ResolvedHookSet configuredHooks = await HookConfigLoader.LoadAsync(command.ProjectPath, cancellationToken)
.ConfigureAwait(false);
IHookCommandRunner hookCommandRunner = HookCommandRunner.Instance;
SessionToolingBundle? toolingBundle = command.Tooling is null
? null
: await SessionToolingBundle.CreateAsync(command.Tooling, command.ProjectPath, cancellationToken)
@@ -51,7 +54,9 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
agentIndex,
(request, invocation) => onPermissionRequest(definition, request, invocation),
(request, invocation) => onUserInputRequest(definition, request, invocation),
evt => onSessionEvent?.Invoke(definition, evt));
evt => onSessionEvent?.Invoke(definition, evt),
configuredHooks,
hookCommandRunner);
ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools);
@@ -78,7 +83,9 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
int agentIndex,
PermissionRequestHandler? onPermissionRequest = null,
UserInputHandler? onUserInputRequest = null,
SessionEventHandler? onSessionEvent = null)
SessionEventHandler? onSessionEvent = null,
ResolvedHookSet? configuredHooks = null,
IHookCommandRunner? hookCommandRunner = null)
{
// Let the Copilot SDK allocate session IDs. Explicit custom SessionId values currently
// cause turns to complete without assistant output, even for simple single-agent prompts.
@@ -98,7 +105,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
WorkingDirectory = command.ProjectPath,
OnPermissionRequest = onPermissionRequest,
OnUserInputRequest = onUserInputRequest,
Hooks = CopilotSessionHooks.Create(command, definition),
Hooks = CopilotSessionHooks.Create(command, definition, configuredHooks, hookCommandRunner),
OnEvent = onSessionEvent,
Streaming = true,
CustomAgents = CreateCustomAgents(definition.Copilot?.CustomAgents),
@@ -1,3 +1,5 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
@@ -7,25 +9,209 @@ internal static class CopilotSessionHooks
{
private const string AllowDecision = "allow";
private const string AskDecision = "ask";
private const string DenyDecision = "deny";
private static readonly JsonSerializerOptions HookJsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public static SessionHooks Create(RunTurnCommandDto command, PatternAgentDefinitionDto agentDefinition)
public static SessionHooks Create(
RunTurnCommandDto command,
PatternAgentDefinitionDto agentDefinition,
ResolvedHookSet? configuredHooks = null,
IHookCommandRunner? hookCommandRunner = null)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(agentDefinition);
ResolvedHookSet hooks = configuredHooks ?? ResolvedHookSet.Empty;
IHookCommandRunner runner = hookCommandRunner ?? HookCommandRunner.Instance;
return new SessionHooks
{
OnPreToolUse = (input, _) => Task.FromResult<PreToolUseHookOutput?>(
CreatePreToolUseOutput(command, agentDefinition, input)),
OnPostToolUse = static (_, _) => Task.FromResult<PostToolUseHookOutput?>(null),
OnUserPromptSubmitted = static (_, _) => Task.FromResult<UserPromptSubmittedHookOutput?>(null),
OnSessionStart = static (_, _) => Task.FromResult<SessionStartHookOutput?>(null),
OnSessionEnd = static (_, _) => Task.FromResult<SessionEndHookOutput?>(null),
OnErrorOccurred = static (_, _) => Task.FromResult<ErrorOccurredHookOutput?>(null),
OnPreToolUse = (input, _) => CreatePreToolUseOutputAsync(command, agentDefinition, hooks, runner, input),
OnPostToolUse = (input, _) => RunPostToolUseHooksAsync(command, hooks, runner, input),
OnUserPromptSubmitted = (input, _) => RunUserPromptSubmittedHooksAsync(command, hooks, runner, input),
OnSessionStart = (input, _) => RunSessionStartHooksAsync(command, hooks, runner, input),
OnSessionEnd = (input, _) => RunSessionEndHooksAsync(command, hooks, runner, input),
OnErrorOccurred = (input, _) => RunErrorOccurredHooksAsync(command, hooks, runner, input),
};
}
private static PreToolUseHookOutput CreatePreToolUseOutput(
private static async Task<PreToolUseHookOutput?> CreatePreToolUseOutputAsync(
RunTurnCommandDto command,
PatternAgentDefinitionDto agentDefinition,
ResolvedHookSet configuredHooks,
IHookCommandRunner hookCommandRunner,
PreToolUseHookInput input)
{
if (configuredHooks.PreToolUse.Count > 0)
{
string payload = SerializeHookInput(new FilePreToolUseHookInput
{
Timestamp = input.Timestamp,
Cwd = input.Cwd,
ToolName = input.ToolName,
ToolArgs = SerializeHookValue(input.ToolArgs),
});
foreach (HookCommandDefinition hook in configuredHooks.PreToolUse)
{
string? hookOutput = await hookCommandRunner.RunAsync(
hook,
payload,
command.ProjectPath,
CancellationToken.None)
.ConfigureAwait(false);
PreToolUseHookOutput? decision = ParsePreToolUseDecision(hookOutput);
if (string.Equals(decision?.PermissionDecision, DenyDecision, StringComparison.OrdinalIgnoreCase))
{
return decision;
}
}
}
return CreateApprovalPolicyOutput(command, agentDefinition, input);
}
private static async Task<PostToolUseHookOutput?> RunPostToolUseHooksAsync(
RunTurnCommandDto command,
ResolvedHookSet configuredHooks,
IHookCommandRunner hookCommandRunner,
PostToolUseHookInput input)
{
await RunConfiguredHooksAsync(
configuredHooks.PostToolUse,
hookCommandRunner,
command.ProjectPath,
SerializeHookInput(new FilePostToolUseHookInput
{
Timestamp = input.Timestamp,
Cwd = input.Cwd,
ToolName = input.ToolName,
ToolArgs = SerializeHookValue(input.ToolArgs),
ToolResult = input.ToolResult,
}))
.ConfigureAwait(false);
return null;
}
private static async Task<UserPromptSubmittedHookOutput?> RunUserPromptSubmittedHooksAsync(
RunTurnCommandDto command,
ResolvedHookSet configuredHooks,
IHookCommandRunner hookCommandRunner,
UserPromptSubmittedHookInput input)
{
await RunConfiguredHooksAsync(
configuredHooks.UserPromptSubmitted,
hookCommandRunner,
command.ProjectPath,
SerializeHookInput(new FileUserPromptSubmittedHookInput
{
Timestamp = input.Timestamp,
Cwd = input.Cwd,
Prompt = input.Prompt,
}))
.ConfigureAwait(false);
return null;
}
private static async Task<SessionStartHookOutput?> RunSessionStartHooksAsync(
RunTurnCommandDto command,
ResolvedHookSet configuredHooks,
IHookCommandRunner hookCommandRunner,
SessionStartHookInput input)
{
await RunConfiguredHooksAsync(
configuredHooks.SessionStart,
hookCommandRunner,
command.ProjectPath,
SerializeHookInput(new FileSessionStartHookInput
{
Timestamp = input.Timestamp,
Cwd = input.Cwd,
Source = input.Source,
InitialPrompt = input.InitialPrompt,
}))
.ConfigureAwait(false);
return null;
}
private static async Task<SessionEndHookOutput?> RunSessionEndHooksAsync(
RunTurnCommandDto command,
ResolvedHookSet configuredHooks,
IHookCommandRunner hookCommandRunner,
SessionEndHookInput input)
{
await RunConfiguredHooksAsync(
configuredHooks.SessionEnd,
hookCommandRunner,
command.ProjectPath,
SerializeHookInput(new FileSessionEndHookInput
{
Timestamp = input.Timestamp,
Cwd = input.Cwd,
Reason = input.Reason,
FinalMessage = input.FinalMessage,
Error = input.Error,
}))
.ConfigureAwait(false);
return null;
}
private static async Task<ErrorOccurredHookOutput?> RunErrorOccurredHooksAsync(
RunTurnCommandDto command,
ResolvedHookSet configuredHooks,
IHookCommandRunner hookCommandRunner,
ErrorOccurredHookInput input)
{
await RunConfiguredHooksAsync(
configuredHooks.ErrorOccurred,
hookCommandRunner,
command.ProjectPath,
SerializeHookInput(new FileErrorOccurredHookInput
{
Timestamp = input.Timestamp,
Cwd = input.Cwd,
Error = new FileHookError
{
Message = input.Error,
Context = input.ErrorContext,
Recoverable = input.Recoverable,
},
}))
.ConfigureAwait(false);
return null;
}
private static async Task RunConfiguredHooksAsync(
IReadOnlyList<HookCommandDefinition> hooks,
IHookCommandRunner hookCommandRunner,
string projectPath,
string payload)
{
if (hooks.Count == 0)
{
return;
}
foreach (HookCommandDefinition hook in hooks)
{
await hookCommandRunner.RunAsync(
hook,
payload,
projectPath,
CancellationToken.None)
.ConfigureAwait(false);
}
}
private static PreToolUseHookOutput CreateApprovalPolicyOutput(
RunTurnCommandDto command,
PatternAgentDefinitionDto agentDefinition,
PreToolUseHookInput input)
@@ -42,6 +228,101 @@ internal static class CopilotSessionHooks
};
}
private static PreToolUseHookOutput? ParsePreToolUseDecision(string? hookOutput)
{
if (string.IsNullOrWhiteSpace(hookOutput))
{
return null;
}
try
{
FilePreToolUseHookOutput? parsed = JsonSerializer.Deserialize<FilePreToolUseHookOutput>(hookOutput, HookJsonOptions);
if (!string.Equals(parsed?.PermissionDecision, DenyDecision, StringComparison.OrdinalIgnoreCase))
{
return null;
}
return new PreToolUseHookOutput
{
PermissionDecision = DenyDecision,
PermissionDecisionReason = Normalize(parsed?.PermissionDecisionReason),
};
}
catch (JsonException exception)
{
Console.Error.WriteLine($"[aryx hooks] Ignoring invalid preToolUse hook output: {exception.Message}");
return null;
}
}
private static string SerializeHookInput<T>(T input)
=> JsonSerializer.Serialize(input, HookJsonOptions);
private static string SerializeHookValue(object? value)
=> JsonSerializer.Serialize(value, HookJsonOptions);
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private sealed class FileSessionStartHookInput
{
public long Timestamp { get; init; }
public string Cwd { get; init; } = string.Empty;
public string Source { get; init; } = string.Empty;
public string? InitialPrompt { get; init; }
}
private sealed class FileSessionEndHookInput
{
public long Timestamp { get; init; }
public string Cwd { get; init; } = string.Empty;
public string Reason { get; init; } = string.Empty;
public string? FinalMessage { get; init; }
public string? Error { get; init; }
}
private sealed class FileUserPromptSubmittedHookInput
{
public long Timestamp { get; init; }
public string Cwd { get; init; } = string.Empty;
public string Prompt { get; init; } = string.Empty;
}
private sealed class FilePreToolUseHookInput
{
public long Timestamp { get; init; }
public string Cwd { get; init; } = string.Empty;
public string ToolName { get; init; } = string.Empty;
public string ToolArgs { get; init; } = "null";
}
private sealed class FilePostToolUseHookInput
{
public long Timestamp { get; init; }
public string Cwd { get; init; } = string.Empty;
public string ToolName { get; init; } = string.Empty;
public string ToolArgs { get; init; } = "null";
public object? ToolResult { get; init; }
}
private sealed class FileErrorOccurredHookInput
{
public long Timestamp { get; init; }
public string Cwd { get; init; } = string.Empty;
public FileHookError Error { get; init; } = new();
}
private sealed class FileHookError
{
public string Message { get; init; } = string.Empty;
public string Context { get; init; } = string.Empty;
public bool Recoverable { get; init; }
}
private sealed class FilePreToolUseHookOutput
{
public string? PermissionDecision { get; init; }
public string? PermissionDecisionReason { get; init; }
}
}
@@ -0,0 +1,220 @@
using System.ComponentModel;
using System.Diagnostics;
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
internal interface IHookCommandRunner
{
Task<string?> RunAsync(
HookCommandDefinition hook,
string inputJson,
string projectPath,
CancellationToken cancellationToken);
}
internal sealed class HookCommandRunner : IHookCommandRunner
{
private const int DefaultTimeoutSeconds = 30;
public static HookCommandRunner Instance { get; } = new();
public async Task<string?> RunAsync(
HookCommandDefinition hook,
string inputJson,
string projectPath,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(hook);
ArgumentNullException.ThrowIfNull(inputJson);
ArgumentException.ThrowIfNullOrWhiteSpace(projectPath);
string? commandText = SelectCommandText(hook);
if (commandText is null)
{
Console.Error.WriteLine("[aryx hooks] Skipping hook because no compatible shell command is configured for this platform.");
return null;
}
string workingDirectory = ResolveWorkingDirectory(projectPath, hook.Cwd);
ProcessStartInfo startInfo = CreateStartInfo(commandText, workingDirectory);
ApplyEnvironment(startInfo, hook.Env);
using Process process = new()
{
StartInfo = startInfo,
};
try
{
if (!process.Start())
{
Console.Error.WriteLine($"[aryx hooks] Failed to start hook command '{commandText}'.");
return null;
}
}
catch (Win32Exception exception)
{
Console.Error.WriteLine($"[aryx hooks] Failed to start hook command '{commandText}': {exception.Message}");
return null;
}
catch (InvalidOperationException exception)
{
Console.Error.WriteLine($"[aryx hooks] Failed to start hook command '{commandText}': {exception.Message}");
return null;
}
Task<string> stdoutTask = process.StandardOutput.ReadToEndAsync();
Task<string> stderrTask = process.StandardError.ReadToEndAsync();
try
{
await process.StandardInput.WriteAsync(inputJson).ConfigureAwait(false);
await process.StandardInput.FlushAsync().ConfigureAwait(false);
process.StandardInput.Close();
}
catch (IOException exception)
{
TryKillProcess(process);
Console.Error.WriteLine($"[aryx hooks] Failed to write hook input for '{commandText}': {exception.Message}");
return null;
}
catch (ObjectDisposedException exception)
{
TryKillProcess(process);
Console.Error.WriteLine($"[aryx hooks] Failed to write hook input for '{commandText}': {exception.Message}");
return null;
}
TimeSpan timeout = TimeSpan.FromSeconds(hook.TimeoutSec ?? DefaultTimeoutSeconds);
using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
try
{
await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
TryKillProcess(process);
await DrainOutputAsync(process, stdoutTask, stderrTask).ConfigureAwait(false);
Console.Error.WriteLine($"[aryx hooks] Hook command timed out after {(int)timeout.TotalSeconds} seconds: '{commandText}'.");
return null;
}
string stdout = await stdoutTask.ConfigureAwait(false);
string stderr = await stderrTask.ConfigureAwait(false);
if (process.ExitCode != 0)
{
string detail = string.IsNullOrWhiteSpace(stderr) ? $"exit code {process.ExitCode}" : stderr.Trim();
Console.Error.WriteLine($"[aryx hooks] Hook command failed for '{commandText}': {detail}");
return null;
}
return stdout;
}
private static async Task DrainOutputAsync(Process process, Task<string> stdoutTask, Task<string> stderrTask)
{
try
{
await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
}
catch (InvalidOperationException)
{
// Process already exited or could not be waited on.
}
await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
}
private static ProcessStartInfo CreateStartInfo(string commandText, string workingDirectory)
{
ProcessStartInfo startInfo = new()
{
WorkingDirectory = workingDirectory,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
if (OperatingSystem.IsWindows())
{
startInfo.FileName = "powershell.exe";
startInfo.ArgumentList.Add("-NoLogo");
startInfo.ArgumentList.Add("-NoProfile");
startInfo.ArgumentList.Add("-NonInteractive");
startInfo.ArgumentList.Add("-ExecutionPolicy");
startInfo.ArgumentList.Add("Bypass");
startInfo.ArgumentList.Add("-Command");
startInfo.ArgumentList.Add(commandText);
return startInfo;
}
startInfo.FileName = "bash";
startInfo.ArgumentList.Add("-lc");
startInfo.ArgumentList.Add(commandText);
return startInfo;
}
private static void ApplyEnvironment(ProcessStartInfo startInfo, IReadOnlyDictionary<string, string>? environment)
{
if (environment is not { Count: > 0 })
{
return;
}
foreach ((string key, string value) in environment)
{
startInfo.Environment[key] = value;
}
}
private static string ResolveWorkingDirectory(string projectPath, string? configuredCwd)
{
if (string.IsNullOrWhiteSpace(configuredCwd))
{
return Path.GetFullPath(projectPath);
}
string resolved = Path.IsPathRooted(configuredCwd)
? configuredCwd
: Path.Combine(projectPath, configuredCwd);
return Path.GetFullPath(resolved);
}
private static string? SelectCommandText(HookCommandDefinition hook)
{
if (OperatingSystem.IsWindows())
{
return NormalizeOptionalString(hook.PowerShell);
}
return NormalizeOptionalString(hook.Bash);
}
private static void TryKillProcess(Process process)
{
try
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
}
}
catch (InvalidOperationException)
{
// Process already exited.
}
catch (NotSupportedException)
{
// The platform does not support process tree termination.
}
}
private static string? NormalizeOptionalString(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
@@ -0,0 +1,207 @@
using System.Text.Json;
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
internal static class HookConfigLoader
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
AllowTrailingCommas = true,
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
};
public static async Task<ResolvedHookSet> LoadAsync(string projectPath, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(projectPath);
string hooksDirectory = Path.Combine(projectPath, ".github", "hooks");
if (!Directory.Exists(hooksDirectory))
{
return ResolvedHookSet.Empty;
}
string[] hookFiles;
try
{
hookFiles = Directory.GetFiles(hooksDirectory, "*.json", SearchOption.TopDirectoryOnly);
}
catch (IOException exception)
{
Console.Error.WriteLine($"[aryx hooks] Failed to enumerate hook files in '{hooksDirectory}': {exception.Message}");
return ResolvedHookSet.Empty;
}
catch (UnauthorizedAccessException exception)
{
Console.Error.WriteLine($"[aryx hooks] Failed to enumerate hook files in '{hooksDirectory}': {exception.Message}");
return ResolvedHookSet.Empty;
}
if (hookFiles.Length == 0)
{
return ResolvedHookSet.Empty;
}
Array.Sort(hookFiles, StringComparer.OrdinalIgnoreCase);
List<HookCommandDefinition> sessionStart = [];
List<HookCommandDefinition> sessionEnd = [];
List<HookCommandDefinition> userPromptSubmitted = [];
List<HookCommandDefinition> preToolUse = [];
List<HookCommandDefinition> postToolUse = [];
List<HookCommandDefinition> errorOccurred = [];
foreach (string hookFile in hookFiles)
{
HookConfigFile? config = await ReadHookConfigAsync(hookFile, cancellationToken).ConfigureAwait(false);
if (config is null)
{
continue;
}
if (config.Version != 1)
{
Console.Error.WriteLine($"[aryx hooks] Skipping '{hookFile}' because it declares unsupported version '{config.Version}'.");
continue;
}
AddHooks(sessionStart, config.Hooks.SessionStart, HookTypeNames.SessionStart, hookFile);
AddHooks(sessionEnd, config.Hooks.SessionEnd, HookTypeNames.SessionEnd, hookFile);
AddHooks(userPromptSubmitted, config.Hooks.UserPromptSubmitted, HookTypeNames.UserPromptSubmitted, hookFile);
AddHooks(preToolUse, config.Hooks.PreToolUse, HookTypeNames.PreToolUse, hookFile);
AddHooks(postToolUse, config.Hooks.PostToolUse, HookTypeNames.PostToolUse, hookFile);
AddHooks(errorOccurred, config.Hooks.ErrorOccurred, HookTypeNames.ErrorOccurred, hookFile);
}
if (
sessionStart.Count == 0
&& sessionEnd.Count == 0
&& userPromptSubmitted.Count == 0
&& preToolUse.Count == 0
&& postToolUse.Count == 0
&& errorOccurred.Count == 0)
{
return ResolvedHookSet.Empty;
}
return new ResolvedHookSet
{
SessionStart = [.. sessionStart],
SessionEnd = [.. sessionEnd],
UserPromptSubmitted = [.. userPromptSubmitted],
PreToolUse = [.. preToolUse],
PostToolUse = [.. postToolUse],
ErrorOccurred = [.. errorOccurred],
};
}
private static void AddHooks(
ICollection<HookCommandDefinition> target,
IReadOnlyList<HookCommandDefinition>? definitions,
string hookType,
string hookFile)
{
if (definitions is not { Count: > 0 })
{
return;
}
foreach (HookCommandDefinition definition in definitions)
{
HookCommandDefinition? normalized = NormalizeDefinition(definition, hookType, hookFile);
if (normalized is not null)
{
target.Add(normalized);
}
}
}
private static HookCommandDefinition? NormalizeDefinition(
HookCommandDefinition definition,
string hookType,
string hookFile)
{
string type = NormalizeOptionalString(definition.Type) ?? string.Empty;
if (!string.Equals(type, "command", StringComparison.OrdinalIgnoreCase))
{
Console.Error.WriteLine($"[aryx hooks] Skipping '{hookType}' entry in '{hookFile}' because type '{definition.Type}' is unsupported.");
return null;
}
string? bash = NormalizeOptionalString(definition.Bash);
string? powerShell = NormalizeOptionalString(definition.PowerShell);
if (bash is null && powerShell is null)
{
Console.Error.WriteLine($"[aryx hooks] Skipping '{hookType}' entry in '{hookFile}' because no shell command is configured.");
return null;
}
int? timeoutSec = definition.TimeoutSec;
if (timeoutSec is <= 0)
{
timeoutSec = null;
}
IReadOnlyDictionary<string, string>? env = NormalizeEnvironment(definition.Env);
return new HookCommandDefinition
{
Type = "command",
Bash = bash,
PowerShell = powerShell,
Cwd = NormalizeOptionalString(definition.Cwd),
Env = env,
TimeoutSec = timeoutSec,
};
}
private static async Task<HookConfigFile?> ReadHookConfigAsync(string hookFile, CancellationToken cancellationToken)
{
try
{
await using FileStream stream = File.OpenRead(hookFile);
return await JsonSerializer.DeserializeAsync<HookConfigFile>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException exception)
{
Console.Error.WriteLine($"[aryx hooks] Failed to parse '{hookFile}': {exception.Message}");
return null;
}
catch (IOException exception)
{
Console.Error.WriteLine($"[aryx hooks] Failed to read '{hookFile}': {exception.Message}");
return null;
}
catch (UnauthorizedAccessException exception)
{
Console.Error.WriteLine($"[aryx hooks] Failed to read '{hookFile}': {exception.Message}");
return null;
}
}
private static IReadOnlyDictionary<string, string>? NormalizeEnvironment(IReadOnlyDictionary<string, string>? environment)
{
if (environment is not { Count: > 0 })
{
return null;
}
Dictionary<string, string> normalized = new(StringComparer.Ordinal);
foreach ((string key, string value) in environment)
{
string? normalizedKey = NormalizeOptionalString(key);
if (normalizedKey is null)
{
continue;
}
normalized[normalizedKey] = value;
}
return normalized.Count == 0 ? null : normalized;
}
private static string? NormalizeOptionalString(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
@@ -0,0 +1,304 @@
using System.Text.Json;
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
using GitHub.Copilot.SDK;
namespace Aryx.AgentHost.Tests;
public sealed class CopilotSessionHooksTests
{
[Fact]
public async Task Create_FileBasedPreToolUseDenyOverridesApprovalPolicy()
{
RunTurnCommandDto command = CreateCommandWithToolApproval();
RecordingHookCommandRunner runner = new(
[
"""{"permissionDecision":"deny","permissionDecisionReason":"Blocked by repository hook"}""",
]);
ResolvedHookSet configuredHooks = new()
{
PreToolUse =
[
CreateHookCommand("deny-pre-tool"),
],
};
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
{
Timestamp = 1710000000000,
Cwd = command.ProjectPath,
ToolName = "view",
ToolArgs = JsonSerializer.SerializeToElement(new
{
path = "README.md",
}),
},
null!);
Assert.Equal("deny", decision?.PermissionDecision);
Assert.Equal("Blocked by repository hook", decision?.PermissionDecisionReason);
RecordedHookInvocation invocation = Assert.Single(runner.Invocations);
JsonDocument payload = JsonDocument.Parse(invocation.InputJson);
Assert.Equal("view", payload.RootElement.GetProperty("toolName").GetString());
Assert.Equal("{\"path\":\"README.md\"}", payload.RootElement.GetProperty("toolArgs").GetString());
Assert.Equal(command.ProjectPath, invocation.ProjectPath);
}
[Fact]
public async Task Create_PreToolUseFallsThroughWhenFileHooksDoNotDeny()
{
RunTurnCommandDto command = CreateCommandWithToolApproval();
RecordingHookCommandRunner runner = new(
[
"""{"permissionDecision":"allow"}""",
]);
ResolvedHookSet configuredHooks = new()
{
PreToolUse =
[
CreateHookCommand("allow-pre-tool"),
],
};
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
{
ToolName = "view",
},
null!);
Assert.Equal("ask", decision?.PermissionDecision);
Assert.Single(runner.Invocations);
}
[Fact]
public async Task Create_PreToolUseIgnoresInvalidHookOutputAndFallsThrough()
{
RunTurnCommandDto command = CreateCommandWithToolApproval();
RecordingHookCommandRunner runner = new(
[
"not-json",
]);
ResolvedHookSet configuredHooks = new()
{
PreToolUse =
[
CreateHookCommand("invalid-pre-tool"),
],
};
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
{
ToolName = "view",
},
null!);
Assert.Equal("ask", decision?.PermissionDecision);
Assert.Single(runner.Invocations);
}
[Fact]
public async Task Create_RunsConfiguredNonPreToolHooks()
{
RunTurnCommandDto command = CreateCommandWithoutApprovalRules();
RecordingHookCommandRunner runner = new();
ResolvedHookSet configuredHooks = new()
{
SessionStart = [CreateHookCommand("session-start-hook")],
UserPromptSubmitted = [CreateHookCommand("prompt-hook")],
PostToolUse = [CreateHookCommand("post-tool-hook")],
SessionEnd = [CreateHookCommand("session-end-hook")],
ErrorOccurred = [CreateHookCommand("error-hook")],
};
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
await hooks.OnSessionStart!(
new SessionStartHookInput
{
Timestamp = 1,
Cwd = command.ProjectPath,
Source = "new",
InitialPrompt = "Create the feature",
},
null!);
await hooks.OnUserPromptSubmitted!(
new UserPromptSubmittedHookInput
{
Timestamp = 2,
Cwd = command.ProjectPath,
Prompt = "Refactor the API",
},
null!);
await hooks.OnPostToolUse!(
new PostToolUseHookInput
{
Timestamp = 3,
Cwd = command.ProjectPath,
ToolName = "view",
ToolArgs = JsonSerializer.SerializeToElement(new
{
path = "README.md",
}),
ToolResult = JsonSerializer.SerializeToElement(new
{
resultType = "success",
textResultForLlm = "Read 1 file",
}),
},
null!);
await hooks.OnSessionEnd!(
new SessionEndHookInput
{
Timestamp = 4,
Cwd = command.ProjectPath,
Reason = "complete",
FinalMessage = "Done",
},
null!);
await hooks.OnErrorOccurred!(
new ErrorOccurredHookInput
{
Timestamp = 5,
Cwd = command.ProjectPath,
Error = "Network timeout",
ErrorContext = "tool_execution",
Recoverable = true,
},
null!);
Assert.Equal(
["session-start-hook", "prompt-hook", "post-tool-hook", "session-end-hook", "error-hook"],
runner.Invocations.Select(invocation => GetCommandText(invocation.Hook)).ToArray());
JsonDocument postToolPayload = JsonDocument.Parse(runner.Invocations[2].InputJson);
Assert.Equal("view", postToolPayload.RootElement.GetProperty("toolName").GetString());
Assert.Equal("success", postToolPayload.RootElement.GetProperty("toolResult").GetProperty("resultType").GetString());
JsonDocument errorPayload = JsonDocument.Parse(runner.Invocations[4].InputJson);
Assert.Equal("Network timeout", errorPayload.RootElement.GetProperty("error").GetProperty("message").GetString());
Assert.Equal("tool_execution", errorPayload.RootElement.GetProperty("error").GetProperty("context").GetString());
Assert.True(errorPayload.RootElement.GetProperty("error").GetProperty("recoverable").GetBoolean());
}
[Fact]
public async Task Create_WithoutConfiguredFileHooksPreservesExistingApprovalBehavior()
{
RunTurnCommandDto command = CreateCommandWithoutApprovalRules();
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
{
ToolName = "view",
},
null!);
Assert.Equal("allow", decision?.PermissionDecision);
}
private static RunTurnCommandDto CreateCommandWithToolApproval()
{
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
ProjectPath = @"C:\workspace\project",
Pattern = new PatternDefinitionDto
{
Id = "pattern-1",
Name = "Pattern",
Mode = "single",
Availability = "available",
ApprovalPolicy = new ApprovalPolicyDto
{
Rules =
[
new ApprovalCheckpointRuleDto
{
Kind = "tool-call",
AgentIds = ["agent-1"],
},
],
},
Agents =
[
new PatternAgentDefinitionDto
{
Id = "agent-1",
Name = "Primary",
Model = "gpt-5.4",
Instructions = "Help.",
},
],
},
};
}
private static RunTurnCommandDto CreateCommandWithoutApprovalRules()
{
RunTurnCommandDto command = CreateCommandWithToolApproval();
return new RunTurnCommandDto
{
RequestId = command.RequestId,
SessionId = command.SessionId,
ProjectPath = command.ProjectPath,
Pattern = new PatternDefinitionDto
{
Id = command.Pattern.Id,
Name = command.Pattern.Name,
Mode = command.Pattern.Mode,
Availability = command.Pattern.Availability,
ApprovalPolicy = new ApprovalPolicyDto(),
Agents = command.Pattern.Agents,
},
};
}
private static HookCommandDefinition CreateHookCommand(string name)
=> new()
{
Type = "command",
Bash = name,
PowerShell = name,
};
private static string GetCommandText(HookCommandDefinition hook)
=> hook.PowerShell ?? hook.Bash ?? string.Empty;
private sealed class RecordingHookCommandRunner : IHookCommandRunner
{
private readonly Queue<string?> _outputs;
public List<RecordedHookInvocation> Invocations { get; } = [];
public RecordingHookCommandRunner(IEnumerable<string?>? outputs = null)
{
_outputs = outputs is null ? new Queue<string?>() : new Queue<string?>(outputs);
}
public Task<string?> RunAsync(
HookCommandDefinition hook,
string inputJson,
string projectPath,
CancellationToken cancellationToken)
{
Invocations.Add(new RecordedHookInvocation(hook, inputJson, projectPath));
return Task.FromResult(_outputs.Count > 0 ? _outputs.Dequeue() : string.Empty);
}
}
private sealed record RecordedHookInvocation(
HookCommandDefinition Hook,
string InputJson,
string ProjectPath);
}
@@ -0,0 +1,126 @@
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
namespace Aryx.AgentHost.Tests;
public sealed class HookCommandRunnerTests
{
[Fact]
public async Task RunAsync_PipesJsonIntoHookStandardInput()
{
HookCommandRunner runner = new();
using TestDirectory project = new();
HookCommandDefinition hook = CreatePlatformHook(
OperatingSystem.IsWindows()
? "$payload = [Console]::In.ReadToEnd(); Write-Output $payload"
: "payload=$(cat); printf '%s' \"$payload\"");
string input = """{"toolName":"view","toolArgs":"{\"path\":\"README.md\"}"}""";
string? output = await runner.RunAsync(hook, input, project.Path, CancellationToken.None);
Assert.Equal(input, output?.Trim());
}
[Fact]
public async Task RunAsync_ReturnsNullWhenHookTimesOut()
{
HookCommandRunner runner = new();
using TestDirectory project = new();
HookCommandDefinition hook = CreatePlatformHook(
OperatingSystem.IsWindows() ? "Start-Sleep -Seconds 5" : "sleep 5",
timeoutSec: 1);
string? output = await runner.RunAsync(hook, "{}", project.Path, CancellationToken.None);
Assert.Null(output);
}
[Fact]
public async Task RunAsync_ReturnsNullWhenHookFails()
{
HookCommandRunner runner = new();
using TestDirectory project = new();
HookCommandDefinition hook = CreatePlatformHook(
OperatingSystem.IsWindows() ? "Write-Error 'boom'; exit 1" : "echo boom >&2; exit 1");
string? output = await runner.RunAsync(hook, "{}", project.Path, CancellationToken.None);
Assert.Null(output);
}
[Fact]
public async Task RunAsync_ReturnsNullWhenCurrentPlatformCommandIsMissing()
{
HookCommandRunner runner = new();
using TestDirectory project = new();
HookCommandDefinition hook = OperatingSystem.IsWindows()
? new HookCommandDefinition { Type = "command", Bash = "echo unsupported" }
: new HookCommandDefinition { Type = "command", PowerShell = "Write-Output unsupported" };
string? output = await runner.RunAsync(hook, "{}", project.Path, CancellationToken.None);
Assert.Null(output);
}
[Fact]
public async Task RunAsync_UsesConfiguredWorkingDirectoryAndEnvironment()
{
HookCommandRunner runner = new();
using TestDirectory project = new();
string hooksDirectory = Directory.CreateDirectory(Path.Combine(project.Path, "scripts")).FullName;
HookCommandDefinition hook = CreatePlatformHook(
OperatingSystem.IsWindows()
? "Write-Output ((Get-Location).Path + '|' + $env:HOOK_TEST_ENV)"
: "printf '%s|%s' \"$(pwd)\" \"$HOOK_TEST_ENV\"",
cwd: "scripts",
env: new Dictionary<string, string>
{
["HOOK_TEST_ENV"] = "configured",
});
string? output = await runner.RunAsync(hook, "{}", project.Path, CancellationToken.None);
Assert.Equal($"{hooksDirectory}|configured", output?.Trim());
}
private static HookCommandDefinition CreatePlatformHook(
string command,
int? timeoutSec = null,
string? cwd = null,
IReadOnlyDictionary<string, string>? env = null)
{
return OperatingSystem.IsWindows()
? new HookCommandDefinition
{
Type = "command",
PowerShell = command,
TimeoutSec = timeoutSec,
Cwd = cwd,
Env = env,
}
: new HookCommandDefinition
{
Type = "command",
Bash = command,
TimeoutSec = timeoutSec,
Cwd = cwd,
Env = env,
};
}
private sealed class TestDirectory : IDisposable
{
private readonly DirectoryInfo _directory = Directory.CreateTempSubdirectory("aryx-hooks-runner-");
public string Path => _directory.FullName;
public void Dispose()
{
if (_directory.Exists)
{
_directory.Delete(recursive: true);
}
}
}
}
@@ -0,0 +1,204 @@
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
namespace Aryx.AgentHost.Tests;
public sealed class HookConfigLoaderTests
{
[Fact]
public async Task LoadAsync_ParsesSupportedHookTypes()
{
using TestDirectory project = new();
string hooksDirectory = Directory.CreateDirectory(Path.Combine(project.Path, ".github", "hooks")).FullName;
await File.WriteAllTextAsync(
Path.Combine(hooksDirectory, "hooks.json"),
"""
{
"version": 1,
"hooks": {
"sessionStart": [
{
"type": "command",
"bash": "echo session-start",
"powershell": "Write-Output session-start",
"cwd": ".",
"env": { "HOOK_MODE": "audit" },
"timeoutSec": 15
}
],
"preToolUse": [
{
"type": "command",
"bash": "echo pre-tool",
"powershell": "Write-Output pre-tool"
}
],
"errorOccurred": [
{
"type": "command",
"bash": "echo error-hook",
"powershell": "Write-Output error-hook"
}
]
}
}
""");
ResolvedHookSet hooks = await HookConfigLoader.LoadAsync(project.Path, CancellationToken.None);
HookCommandDefinition sessionStart = Assert.Single(hooks.SessionStart);
Assert.Equal("command", sessionStart.Type);
Assert.Equal("echo session-start", sessionStart.Bash);
Assert.Equal("Write-Output session-start", sessionStart.PowerShell);
Assert.Equal(".", sessionStart.Cwd);
Assert.Equal(15, sessionStart.TimeoutSec);
Assert.NotNull(sessionStart.Env);
Assert.Equal("audit", sessionStart.Env["HOOK_MODE"]);
Assert.Single(hooks.PreToolUse);
Assert.Single(hooks.ErrorOccurred);
Assert.False(hooks.IsEmpty);
}
[Fact]
public async Task LoadAsync_MergesHookFilesInFileNameOrder()
{
using TestDirectory project = new();
string hooksDirectory = Directory.CreateDirectory(Path.Combine(project.Path, ".github", "hooks")).FullName;
await File.WriteAllTextAsync(
Path.Combine(hooksDirectory, "20-second.json"),
"""
{
"version": 1,
"hooks": {
"preToolUse": [
{
"type": "command",
"bash": "echo second",
"powershell": "Write-Output second"
}
]
}
}
""");
await File.WriteAllTextAsync(
Path.Combine(hooksDirectory, "10-first.json"),
"""
{
"version": 1,
"hooks": {
"preToolUse": [
{
"type": "command",
"bash": "echo first",
"powershell": "Write-Output first"
}
]
}
}
""");
ResolvedHookSet hooks = await HookConfigLoader.LoadAsync(project.Path, CancellationToken.None);
string[] commands = hooks.PreToolUse.Select(GetCommandText).ToArray();
Assert.Equal(2, commands.Length);
Assert.Contains("first", commands[0], StringComparison.Ordinal);
Assert.Contains("second", commands[1], StringComparison.Ordinal);
}
[Fact]
public async Task LoadAsync_ReturnsEmptyWhenHooksDirectoryIsMissing()
{
using TestDirectory project = new();
ResolvedHookSet hooks = await HookConfigLoader.LoadAsync(project.Path, CancellationToken.None);
Assert.Same(ResolvedHookSet.Empty, hooks);
}
[Fact]
public async Task LoadAsync_SkipsInvalidFilesAndUnsupportedVersions()
{
using TestDirectory project = new();
string hooksDirectory = Directory.CreateDirectory(Path.Combine(project.Path, ".github", "hooks")).FullName;
await File.WriteAllTextAsync(Path.Combine(hooksDirectory, "00-invalid.json"), "{ not-json");
await File.WriteAllTextAsync(
Path.Combine(hooksDirectory, "10-unsupported.json"),
"""
{
"version": 2,
"hooks": {
"sessionStart": [
{
"type": "command",
"bash": "echo unsupported",
"powershell": "Write-Output unsupported"
}
]
}
}
""");
await File.WriteAllTextAsync(
Path.Combine(hooksDirectory, "20-valid.json"),
"""
{
"version": 1,
"hooks": {
"sessionEnd": [
{
"type": "command",
"bash": "echo valid",
"powershell": "Write-Output valid"
}
]
}
}
""");
ResolvedHookSet hooks = await HookConfigLoader.LoadAsync(project.Path, CancellationToken.None);
HookCommandDefinition valid = Assert.Single(hooks.SessionEnd);
Assert.Contains("valid", GetCommandText(valid), StringComparison.Ordinal);
Assert.Empty(hooks.SessionStart);
}
[Fact]
public async Task LoadAsync_ReturnsEmptyForEmptyHooksObject()
{
using TestDirectory project = new();
string hooksDirectory = Directory.CreateDirectory(Path.Combine(project.Path, ".github", "hooks")).FullName;
await File.WriteAllTextAsync(
Path.Combine(hooksDirectory, "hooks.json"),
"""
{
"version": 1,
"hooks": {}
}
""");
ResolvedHookSet hooks = await HookConfigLoader.LoadAsync(project.Path, CancellationToken.None);
Assert.Same(ResolvedHookSet.Empty, hooks);
}
private static string GetCommandText(HookCommandDefinition hook)
=> hook.PowerShell ?? hook.Bash ?? string.Empty;
private sealed class TestDirectory : IDisposable
{
private readonly DirectoryInfo _directory = Directory.CreateTempSubdirectory("aryx-hooks-loader-");
public string Path => _directory.FullName;
public void Dispose()
{
if (_directory.Exists)
{
_directory.Delete(recursive: true);
}
}
}
}