mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
d30f15269e
* 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>
249 lines
5.5 KiB
Go
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)
|
|
}
|