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
+3 -1
View File
@@ -206,13 +206,15 @@ The protocol also carries **turn-scoped lifecycle events** alongside output delt
- **Sub-agent events**: started, completed, failed, selected, deselected — surfaced when custom agents are defined
- **Skill invocation events**: emitted when an agent-side skill is triggered
- **Hook lifecycle events**: start and end of registered hooks (e.g. pre-turn, post-turn)
- **Hook lifecycle events**: start and end of registered hooks, including Aryx-managed SDK hooks and project hook files discovered from `.github/hooks/*.json`
- **Session compaction events**: start and complete, with token-reduction metrics when infinite sessions trigger context trimming
- **Session usage events**: current token count and context-window limit for usage-bar rendering
- **Pending-messages-modified events**: emitted when mid-turn steering changes the pending message queue
These events flow through a single `onTurnScopedEvent` callback on the `runTurn` command, avoiding per-event-type callback proliferation. The main process maps each event to a `SessionEventRecord` and pushes it to the renderer, where lightweight state maps (activity, usage, turn-event log) consume them without touching the persisted workspace.
For project-backed sessions, the sidecar also discovers GitHub Copilot CLI hook definitions from `.github/hooks/*.json` under the repository root. Those files are parsed and merged once per run bundle, then projected onto the SDK session hook delegates. Hook commands run synchronously in the sidecar through the platform shell, with stdin JSON payloads shaped to match Copilot CLI hook expectations as closely as the SDK allows. Hook failures are logged to stderr and treated as non-fatal diagnostics, while `preToolUse` hook outputs can still deny a tool call before Aryx falls back to its built-in approval policy.
## Security model
Security in this system is mostly about **desktop trust boundaries**.
+2
View File
@@ -51,6 +51,8 @@ This keeps machine-wide tooling reusable while still letting each session decide
Patterns now require tool-call approval by default. They can also store default auto-approval for known MCP and LSP tools, and each session can override those auto-approval defaults from the Activity panel before a run starts.
Project-backed sessions also honor GitHub Copilot CLI-style hook files from `.github/hooks/*.json`. Aryx discovers them automatically in the connected repository and runs the supported lifecycle hooks inside the sidecar, with `preToolUse` deny decisions applied before Aryx's own approval policy.
### Watch runs as they happen
You can follow agent activity while a session is running, which makes longer or more complex workflows easier to trust and understand. The activity panel shows sub-agent delegations, skill invocations, hook lifecycle events, and context compaction in real time. A context-usage bar below the composer shows how much of the model's context window the current session occupies.
@@ -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);
}
}
}
}