Files
kit/internal/hooks/executor_test.go
T
Ed Zynda d30f15269e feat: enhance hooks with LLM feedback capabilities (#112)
* feat: enhance hooks with LLM feedback capabilities

- Add new HookOutput fields for LLM interaction (feedback, context, systemPrompt, modifyInput/Output)
- Implement Continue functionality to gracefully stop sessions from hooks
- Implement SuppressOutput to hide tool results from user display
- Add UserPromptSubmit context injection to provide additional context to LLM
- Update mergeHookOutputs to handle new fields
- Add comprehensive unit tests for new hook output processing
- Create example Python hook demonstrating LLM feedback features

This enhancement allows hooks to:
- Provide feedback and context that reaches the LLM
- Modify tool inputs/outputs before processing
- Control session flow with Continue field
- Suppress output display while still sending to LLM
- Inject system prompts and context for better LLM responses

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>

* fix: make tool blocking visible to LLM

When a PreToolUse hook blocks a tool execution, the LLM now receives an error message
indicating the tool was blocked, allowing it to adapt its approach.

Changes:
- Track when tools are blocked by PreToolUse hooks
- Replace tool execution results with error messages when blocked
- Add test to verify blocking functionality
- Add ToolBlockChecker type for future enhancements

This ensures the LLM is aware when its tool calls are blocked by security policies
and can respond appropriately rather than being unaware of the block.

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>

* refactor: remove unimplemented LLM feedback fields

Removed the following unimplemented fields from HookOutput:
- Feedback
- Context
- SystemPrompt
- ModifyInput
- ModifyOutput

These fields were added speculatively but not fully implemented.
Keeping only the working functionality:
- Continue/StopReason for session control
- SuppressOutput for hiding tool results
- Decision/Reason for blocking tools

The critical tool blocking visibility feature remains intact.

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>

---------

Co-authored-by: opencode <noreply@opencode.ai>
2025-07-24 15:54:29 +03:00

249 lines
5.5 KiB
Go

package hooks
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
func TestExecuteHooks(t *testing.T) {
// Create test scripts
tmpDir := t.TempDir()
// Simple echo script
echoScript := filepath.Join(tmpDir, "echo.sh")
if err := os.WriteFile(echoScript, []byte(`#!/bin/bash
cat
`), 0755); err != nil {
t.Fatalf("failed to create echo script: %v", err)
}
// Blocking script (exit code 2)
blockScript := filepath.Join(tmpDir, "block.sh")
if err := os.WriteFile(blockScript, []byte(`#!/bin/bash
echo "Blocked by policy" >&2
exit 2
`), 0755); err != nil {
t.Fatalf("failed to create block script: %v", err)
}
// JSON output script
jsonScript := filepath.Join(tmpDir, "json.sh")
if err := os.WriteFile(jsonScript, []byte(`#!/bin/bash
echo '{"decision": "approve", "reason": "Approved by test"}'
`), 0755); err != nil {
t.Fatalf("failed to create json script: %v", err)
}
tests := []struct {
name string
config *HookConfig
event HookEvent
input interface{}
expected *HookOutput
wantErr bool
}{
{
name: "simple command execution",
config: &HookConfig{
Hooks: map[HookEvent][]HookMatcher{
PreToolUse: {{
Matcher: "bash",
Hooks: []HookEntry{{
Type: "command",
Command: echoScript,
}},
}},
},
},
event: PreToolUse,
input: &PreToolUseInput{
CommonInput: CommonInput{HookEventName: PreToolUse},
ToolName: "bash",
},
expected: &HookOutput{},
},
{
name: "blocking hook",
config: &HookConfig{
Hooks: map[HookEvent][]HookMatcher{
PreToolUse: {{
Matcher: "bash",
Hooks: []HookEntry{{
Type: "command",
Command: blockScript,
}},
}},
},
},
event: PreToolUse,
input: &PreToolUseInput{
CommonInput: CommonInput{HookEventName: PreToolUse},
ToolName: "bash",
},
expected: &HookOutput{
Decision: "block",
Reason: "Blocked by policy\n",
Continue: boolPtr(false),
},
},
{
name: "JSON output parsing",
config: &HookConfig{
Hooks: map[HookEvent][]HookMatcher{
PreToolUse: {{
Matcher: "bash",
Hooks: []HookEntry{{
Type: "command",
Command: jsonScript,
}},
}},
},
},
event: PreToolUse,
input: &PreToolUseInput{
CommonInput: CommonInput{HookEventName: PreToolUse},
ToolName: "bash",
},
expected: &HookOutput{
Decision: "approve",
Reason: "Approved by test",
},
},
{
name: "timeout handling",
config: &HookConfig{
Hooks: map[HookEvent][]HookMatcher{
PreToolUse: {{
Matcher: "bash",
Hooks: []HookEntry{{
Type: "command",
Command: "sleep 10",
Timeout: 1,
}},
}},
},
},
event: PreToolUse,
input: &PreToolUseInput{
CommonInput: CommonInput{HookEventName: PreToolUse},
ToolName: "bash",
},
expected: &HookOutput{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
executor := NewExecutor(tt.config, "test-session", "/tmp/test.jsonl")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
got, err := executor.ExecuteHooks(ctx, tt.event, tt.input)
if tt.wantErr {
if err == nil {
t.Errorf("expected error but got none")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Compare outputs
if !compareHookOutputs(got, tt.expected) {
gotJSON, _ := json.MarshalIndent(got, "", " ")
expectedJSON, _ := json.MarshalIndent(tt.expected, "", " ")
t.Errorf("ExecuteHooks() output mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, expectedJSON)
}
})
}
}
func boolPtr(b bool) *bool {
return &b
}
func compareHookOutputs(a, b *HookOutput) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
// Compare Continue pointers
if (a.Continue == nil) != (b.Continue == nil) {
return false
}
if a.Continue != nil && *a.Continue != *b.Continue {
return false
}
return a.StopReason == b.StopReason &&
a.SuppressOutput == b.SuppressOutput &&
a.Decision == b.Decision &&
a.Reason == b.Reason
}
func TestToolBlocking(t *testing.T) {
// Create test script that blocks bash tool
tmpDir := t.TempDir()
blockBashScript := filepath.Join(tmpDir, "block_bash.sh")
if err := os.WriteFile(blockBashScript, []byte(`#!/bin/bash
echo '{"decision": "block", "reason": "Bash commands are not allowed for security reasons"}'
`), 0755); err != nil {
t.Fatalf("failed to create block bash script: %v", err)
}
config := &HookConfig{
Hooks: map[HookEvent][]HookMatcher{
PreToolUse: {{
Matcher: "bash",
Hooks: []HookEntry{{
Type: "command",
Command: blockBashScript,
}},
}},
},
}
executor := NewExecutor(config, "test-session", "/tmp/test.jsonl")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
input := &PreToolUseInput{
CommonInput: CommonInput{HookEventName: PreToolUse},
ToolName: "bash",
ToolInput: json.RawMessage(`{"command": "ls -la"}`),
}
got, err := executor.ExecuteHooks(ctx, PreToolUse, input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the hook blocked the tool
if got == nil {
t.Fatal("expected hook output, got nil")
}
if got.Decision != "block" {
t.Errorf("expected decision 'block', got '%s'", got.Decision)
}
if got.Reason != "Bash commands are not allowed for security reasons" {
t.Errorf("unexpected reason: %s", got.Reason)
}
// Continue field is optional for JSON output (only set for exit code 2)
}