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,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);
}
}
}
}