From d6f8020554c7c1957c8fc7242e4fe0c477329824 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 27 Feb 2026 18:17:32 +0300 Subject: [PATCH] remove legacy hooks system, superseded by extensions --- cmd/hooks.go | 181 --------------- cmd/root.go | 6 +- internal/extensions/compat.go | 111 --------- internal/hooks/config.go | 141 ------------ internal/hooks/config_test.go | 227 ------------------ internal/hooks/events.go | 36 --- internal/hooks/executor.go | 265 ---------------------- internal/hooks/executor_test.go | 244 -------------------- internal/hooks/schemas.go | 68 ------ internal/hooks/testdata/invalid-hooks.yml | 10 - internal/hooks/testdata/valid-hooks.yml | 21 -- internal/hooks/validator.go | 133 ----------- internal/hooks/validator_test.go | 251 -------------------- pkg/kit/setup.go | 14 +- 14 files changed, 5 insertions(+), 1703 deletions(-) delete mode 100644 cmd/hooks.go delete mode 100644 internal/extensions/compat.go delete mode 100644 internal/hooks/config.go delete mode 100644 internal/hooks/config_test.go delete mode 100644 internal/hooks/events.go delete mode 100644 internal/hooks/executor.go delete mode 100644 internal/hooks/executor_test.go delete mode 100644 internal/hooks/schemas.go delete mode 100644 internal/hooks/testdata/invalid-hooks.yml delete mode 100644 internal/hooks/testdata/valid-hooks.yml delete mode 100644 internal/hooks/validator.go delete mode 100644 internal/hooks/validator_test.go diff --git a/cmd/hooks.go b/cmd/hooks.go deleted file mode 100644 index 5c1caf4e..00000000 --- a/cmd/hooks.go +++ /dev/null @@ -1,181 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "text/tabwriter" - - "github.com/mark3labs/kit/internal/hooks" - "github.com/spf13/cobra" - "gopkg.in/yaml.v3" -) - -// hooksCmd represents the hooks command for managing KIT hook configurations. -// Hooks allow users to execute custom scripts or commands at various points -// during KIT execution, such as before/after tool use or when prompts are submitted. -var hooksCmd = &cobra.Command{ - Use: "hooks", - Short: "Manage KIT hooks", - Long: "Commands for managing and testing KIT hooks configuration", -} - -// hooksListCmd represents the list subcommand for displaying all configured hooks. -// It shows a formatted table of hook events, matchers, commands, and timeouts -// to help users understand their current hook configuration. -var hooksListCmd = &cobra.Command{ - Use: "list", - Short: "List all configured hooks", - RunE: func(cmd *cobra.Command, args []string) error { - config, err := hooks.LoadHooksConfig() - if err != nil { - return fmt.Errorf("loading hooks config: %w", err) - } - - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintln(w, "EVENT\tMATCHER\tCOMMAND\tTIMEOUT") - - for event, matchers := range config.Hooks { - for _, matcher := range matchers { - for _, hook := range matcher.Hooks { - timeout := "60s" - if hook.Timeout > 0 { - timeout = fmt.Sprintf("%ds", hook.Timeout) - } - _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", - event, matcher.Matcher, hook.Command, timeout) - } - } - } - - return w.Flush() - }, -} - -// hooksValidateCmd represents the validate subcommand for checking hook configuration validity. -// It loads and validates the hooks configuration file, ensuring proper syntax, -// valid event types, and correct matcher patterns before use. -var hooksValidateCmd = &cobra.Command{ - Use: "validate", - Short: "Validate hooks configuration", - RunE: func(cmd *cobra.Command, args []string) error { - config, err := hooks.LoadHooksConfig() - if err != nil { - return fmt.Errorf("validation failed: %w", err) - } - - // Additional validation - if err := hooks.ValidateHookConfig(config); err != nil { - return fmt.Errorf("validation failed: %w", err) - } - - fmt.Println("✓ Hooks configuration is valid") - return nil - }, -} - -// hooksInitCmd represents the init subcommand for generating an example hooks configuration. -// It creates a .kit/hooks.yml file with sample hook configurations demonstrating -// various hook events and common use cases like logging commands and tool usage. -var hooksInitCmd = &cobra.Command{ - Use: "init", - Short: "Generate example hooks configuration", - RunE: func(cmd *cobra.Command, args []string) error { - example := &hooks.HookConfig{ - Hooks: map[hooks.HookEvent][]hooks.HookMatcher{ - // PreToolUse - runs before any tool execution - hooks.PreToolUse: { - { - Matcher: "bash.*", - Hooks: []hooks.HookEntry{ - { - Type: "command", - Command: `mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/kit/logs" && jq -r '"[" + (now | strftime("%Y-%m-%d %H:%M:%S")) + "] $ " + .tool_input.command' >> "${XDG_CONFIG_HOME:-$HOME/.config}/kit/logs/bash-commands.log"`, - Timeout: 5, - }, - }, - }, - { - Matcher: ".*", // Log all tool usage - Hooks: []hooks.HookEntry{ - { - Type: "command", - Command: `jq -c '{time: now | strftime("%Y-%m-%d %H:%M:%S"), event: "pre", tool: .tool_name, input: .tool_input}' >> "${XDG_CONFIG_HOME:-$HOME/.config}/kit/logs/all-tools.jsonl"`, - Timeout: 5, - }, - }, - }, - }, - // PostToolUse - runs after tool execution completes - hooks.PostToolUse: { - { - Matcher: "bash.*", - Hooks: []hooks.HookEntry{ - { - Type: "command", - Command: `jq -c '{time: now | strftime("%Y-%m-%d %H:%M:%S"), cmd: .tool_input.command, exit: .tool_response._meta.exit, stdout: (.tool_response._meta.stdout | rtrimstr("\n") | .[0:100]), stderr: (.tool_response._meta.stderr | rtrimstr("\n"))}' >> "${XDG_CONFIG_HOME:-$HOME/.config}/kit/logs/bash-audit.jsonl"`, - Timeout: 5, - }, - }, - }, - { - Matcher: "mcp__.*", // Log MCP tool responses - Hooks: []hooks.HookEntry{ - { - Type: "command", - Command: `jq -c '{time: now | strftime("%Y-%m-%d %H:%M:%S"), tool: .tool_name, response_preview: (.tool_response | tostring | .[0:200])}' >> "${XDG_CONFIG_HOME:-$HOME/.config}/kit/logs/mcp-tools.jsonl"`, - Timeout: 5, - }, - }, - }, - }, - // UserPromptSubmit - runs when user submits a prompt - hooks.UserPromptSubmit: { - { - Hooks: []hooks.HookEntry{ - { - Type: "command", - Command: `mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/kit/logs" && jq -r '"[" + (now | strftime("%Y-%m-%d %H:%M:%S")) + "] " + .prompt' >> "${XDG_CONFIG_HOME:-$HOME/.config}/kit/logs/prompts.log"`, - }, - }, - }, - }, - // Stop - runs when the main agent finishes responding - hooks.Stop: { - { - Hooks: []hooks.HookEntry{ - { - Type: "command", - Command: `jq -r '"[" + (now | strftime("%Y-%m-%d %H:%M:%S")) + "] Session " + .session_id + " stopped"' >> "${XDG_CONFIG_HOME:-$HOME/.config}/kit/logs/sessions.log"`, - }, - }, - }, - }, - }, - } - - // Create .kit directory if it doesn't exist - if err := os.MkdirAll(".kit", 0755); err != nil { - return fmt.Errorf("creating .kit directory: %w", err) - } - - // Write example configuration - data, err := yaml.Marshal(example) - if err != nil { - return fmt.Errorf("marshaling example: %w", err) - } - - if err := os.WriteFile(".kit/hooks.yml", data, 0644); err != nil { - return fmt.Errorf("writing example: %w", err) - } - - fmt.Println("Created .kit/hooks.yml with example configuration") - return nil - }, -} - -func init() { - rootCmd.AddCommand(hooksCmd) - hooksCmd.AddCommand(hooksListCmd) - hooksCmd.AddCommand(hooksValidateCmd) - hooksCmd.AddCommand(hooksInitCmd) -} diff --git a/cmd/root.go b/cmd/root.go index 1d832702..4f05d61a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -115,7 +115,7 @@ func GetRootCommand(v string) *cobra.Command { } // InitConfig initializes the configuration for KIT by loading config files, -// environment variables, and hooks configuration. It delegates to the SDK's +// environment variables. It delegates to the SDK's // InitConfig, injecting the CLI-specific configFile flag and debug mode. // This function is automatically called by cobra before command execution. func InitConfig() { @@ -222,7 +222,7 @@ func init() { rootCmd.PersistentFlags(). BoolVar(&noSessionFlag, "no-session", false, "ephemeral mode — no session persistence") rootCmd.PersistentFlags(). - BoolVar(&noExtensionsFlag, "no-extensions", false, "disable all extensions and hooks") + BoolVar(&noExtensionsFlag, "no-extensions", false, "disable all extensions") rootCmd.PersistentFlags(). StringSliceVarP(&extensionPaths, "extension", "e", nil, "load additional extension file(s)") @@ -344,7 +344,7 @@ func runNormalMode(ctx context.Context) error { } // Build Kit options from CLI flags and create the SDK instance. - // kit.New() handles: config → skills → agent → session → hooks → extension bridge. + // kit.New() handles: config → skills → agent → session → extension bridge. kitOpts := &kit.Options{ MCPConfig: mcpConfig, ShowSpinner: true, diff --git a/internal/extensions/compat.go b/internal/extensions/compat.go deleted file mode 100644 index 40e3be4a..00000000 --- a/internal/extensions/compat.go +++ /dev/null @@ -1,111 +0,0 @@ -package extensions - -import ( - "context" - "encoding/json" - - "github.com/mark3labs/kit/internal/hooks" -) - -// HooksAsExtension wraps an existing hooks.HookConfig as a LoadedExtension -// so that legacy .kit/hooks.yml configurations continue to work alongside -// the new Yaegi extension system. The adapter translates the old event names -// and shell-command execution model into extension HandlerFunc handlers. -func HooksAsExtension(config *hooks.HookConfig) *LoadedExtension { - if config == nil || len(config.Hooks) == 0 { - return nil - } - - ext := &LoadedExtension{ - Path: "hooks.yml (compat)", - Handlers: make(map[EventType][]HandlerFunc), - } - - executor := hooks.NewExecutor(config, "", "") - - // Map PreToolUse → ToolCall - if matchers, ok := config.Hooks[hooks.PreToolUse]; ok && len(matchers) > 0 { - ext.Handlers[ToolCall] = []HandlerFunc{ - func(event Event, _ Context) Result { - tc, ok := event.(ToolCallEvent) - if !ok { - return nil - } - input := &hooks.PreToolUseInput{ - ToolName: tc.ToolName, - ToolInput: json.RawMessage(tc.Input), - } - output, err := executor.ExecuteHooks(context.Background(), hooks.PreToolUse, input) - if err != nil || output == nil { - return nil - } - if output.Decision == "block" { - return ToolCallResult{Block: true, Reason: output.Reason} - } - return nil - }, - } - } - - // Map PostToolUse → ToolResult - if matchers, ok := config.Hooks[hooks.PostToolUse]; ok && len(matchers) > 0 { - ext.Handlers[ToolResult] = []HandlerFunc{ - func(event Event, _ Context) Result { - tr, ok := event.(ToolResultEvent) - if !ok { - return nil - } - input := &hooks.PostToolUseInput{ - ToolName: tr.ToolName, - ToolInput: json.RawMessage(tr.Input), - ToolResponse: json.RawMessage(tr.Content), - } - _, _ = executor.ExecuteHooks(context.Background(), hooks.PostToolUse, input) - return nil // legacy hooks don't modify results - }, - } - } - - // Map UserPromptSubmit → Input - if matchers, ok := config.Hooks[hooks.UserPromptSubmit]; ok && len(matchers) > 0 { - ext.Handlers[Input] = []HandlerFunc{ - func(event Event, _ Context) Result { - ie, ok := event.(InputEvent) - if !ok { - return nil - } - input := &hooks.UserPromptSubmitInput{ - Prompt: ie.Text, - } - output, err := executor.ExecuteHooks(context.Background(), hooks.UserPromptSubmit, input) - if err != nil || output == nil { - return nil - } - if output.Decision == "block" { - return InputResult{Action: "handled"} - } - return nil - }, - } - } - - // Map Stop → AgentEnd - if matchers, ok := config.Hooks[hooks.Stop]; ok && len(matchers) > 0 { - ext.Handlers[AgentEnd] = []HandlerFunc{ - func(event Event, _ Context) Result { - ae, ok := event.(AgentEndEvent) - if !ok { - return nil - } - input := &hooks.StopInput{ - Response: ae.Response, - StopReason: ae.StopReason, - } - _, _ = executor.ExecuteHooks(context.Background(), hooks.Stop, input) - return nil - }, - } - } - - return ext -} diff --git a/internal/hooks/config.go b/internal/hooks/config.go deleted file mode 100644 index 94a8c98c..00000000 --- a/internal/hooks/config.go +++ /dev/null @@ -1,141 +0,0 @@ -package hooks - -import ( - "encoding/json" - "fmt" - "github.com/mark3labs/kit/internal/config" - "gopkg.in/yaml.v3" - "os" - "path/filepath" -) - -// HookConfig represents the complete hooks configuration containing event-triggered -// hooks for tool execution lifecycle events. -type HookConfig struct { - Hooks map[HookEvent][]HookMatcher `yaml:"hooks" json:"hooks"` -} - -// HookMatcher matches specific tools and defines hooks to execute. The Matcher field -// contains a pattern to match tool names, and Hooks contains the commands to run -// when a match occurs. The Merge field controls how this matcher combines with others. -type HookMatcher struct { - Matcher string `yaml:"matcher,omitempty" json:"matcher,omitempty"` - Merge string `yaml:"_merge,omitempty" json:"_merge,omitempty"` - Hooks []HookEntry `yaml:"hooks" json:"hooks"` -} - -// HookEntry defines a single hook command to execute. Type specifies the command -// type (e.g., "bash"), Command contains the actual command to run, and Timeout -// optionally specifies the maximum execution time in seconds. -type HookEntry struct { - Type string `yaml:"type" json:"type"` - Command string `yaml:"command" json:"command"` - Timeout int `yaml:"timeout,omitempty" json:"timeout,omitempty"` -} - -// LoadHooksConfig loads and merges hook configurations from multiple sources. -// It searches for hooks.{json,yml} files in standard locations (XDG config directory, -// local .kit directory) and any custom paths provided. Configurations are merged -// with later sources taking precedence. Environment variable substitution is applied -// to all loaded configurations. -func LoadHooksConfig(customPaths ...string) (*HookConfig, error) { - // Get config directory following XDG Base Directory specification - configDir := getConfigDir() - - // Define search paths in order of precedence (lowest to highest) - searchPaths := []string{ - filepath.Join(configDir, "kit", "hooks.json"), - filepath.Join(configDir, "kit", "hooks.yml"), - ".kit/hooks.json", - ".kit/hooks.yml", - } - - // Add custom paths with highest precedence - searchPaths = append(searchPaths, customPaths...) - - merged := &HookConfig{ - Hooks: make(map[HookEvent][]HookMatcher), - } - - for _, path := range searchPaths { - if _, err := os.Stat(path); os.IsNotExist(err) { - continue - } - - // Read file content - content, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("reading %s: %w", path, err) - } - - // Apply environment substitution - envSubstituter := &config.EnvSubstituter{} - substituted, err := envSubstituter.SubstituteEnvVars(string(content)) - if err != nil { - return nil, fmt.Errorf("substituting env vars in %s: %w", path, err) - } - - // Parse configuration - var cfg HookConfig - if filepath.Ext(path) == ".json" { - err = json.Unmarshal([]byte(substituted), &cfg) - } else { - err = yaml.Unmarshal([]byte(substituted), &cfg) - } - if err != nil { - return nil, fmt.Errorf("parsing %s: %w", path, err) - } - - // Merge configurations - mergeHookConfigs(merged, &cfg) - } - - return merged, nil -} - -// getConfigDir returns the configuration directory following XDG Base Directory specification -func getConfigDir() string { - // Try XDG_CONFIG_HOME first - if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" { - return xdgConfig - } - - // Fall back to ~/.config - if home := os.Getenv("HOME"); home != "" { - return filepath.Join(home, ".config") - } - - // Last resort: current directory - return "." -} - -// mergeHookConfigs merges source hooks into destination -func mergeHookConfigs(dst, src *HookConfig) { - for event, matchers := range src.Hooks { - if dst.Hooks[event] == nil { - dst.Hooks[event] = matchers - continue - } - - // Handle merge strategies - for _, srcMatcher := range matchers { - if srcMatcher.Merge == "replace" { - // Replace all matchers for this event - dst.Hooks[event] = []HookMatcher{srcMatcher} - } else { - // Append or update existing matcher - found := false - for i, dstMatcher := range dst.Hooks[event] { - if dstMatcher.Matcher == srcMatcher.Matcher { - dst.Hooks[event][i] = srcMatcher - found = true - break - } - } - if !found { - dst.Hooks[event] = append(dst.Hooks[event], srcMatcher) - } - } - } - } -} diff --git a/internal/hooks/config_test.go b/internal/hooks/config_test.go deleted file mode 100644 index 4edd1be0..00000000 --- a/internal/hooks/config_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package hooks - -import ( - "os" - "path/filepath" - "reflect" - "sort" - "testing" -) - -func TestLoadHooksConfig(t *testing.T) { - // Save original XDG_CONFIG_HOME - originalXDG := os.Getenv("XDG_CONFIG_HOME") - defer func() { - if originalXDG != "" { - _ = os.Setenv("XDG_CONFIG_HOME", originalXDG) - } else { - _ = os.Unsetenv("XDG_CONFIG_HOME") - } - }() - - tests := []struct { - name string - files map[string]string - expected *HookConfig - wantErr bool - }{ - { - name: "single yaml file", - files: map[string]string{ - "hooks.yml": ` -hooks: - PreToolUse: - - matcher: "bash" - hooks: - - type: command - command: "echo test" - timeout: 5 -`, - }, - expected: &HookConfig{ - Hooks: map[HookEvent][]HookMatcher{ - PreToolUse: { - { - Matcher: "bash", - Hooks: []HookEntry{ - {Type: "command", Command: "echo test", Timeout: 5}, - }, - }, - }, - }, - }, - }, - { - name: "environment substitution", - files: map[string]string{ - "hooks.yml": ` -hooks: - PreToolUse: - - matcher: "bash" - hooks: - - type: command - command: "${env://TEST_HOOK_CMD:-echo default}" -`, - }, - expected: &HookConfig{ - Hooks: map[HookEvent][]HookMatcher{ - PreToolUse: { - { - Matcher: "bash", - Hooks: []HookEntry{ - {Type: "command", Command: "echo default"}, - }, - }, - }, - }, - }, - }, - { - name: "merge multiple files", - files: map[string]string{ - "global.yml": ` -hooks: - PreToolUse: - - matcher: "bash" - hooks: - - type: command - command: "global-hook" -`, - "local.yml": ` -hooks: - PreToolUse: - - matcher: "fetch" - hooks: - - type: command - command: "local-hook" -`, - }, - expected: &HookConfig{ - Hooks: map[HookEvent][]HookMatcher{ - PreToolUse: { - { - Matcher: "bash", - Hooks: []HookEntry{{Type: "command", Command: "global-hook"}}, - }, - { - Matcher: "fetch", - Hooks: []HookEntry{{Type: "command", Command: "local-hook"}}, - }, - }, - }, - }, - }, - { - name: "invalid yaml", - files: map[string]string{ - "hooks.yml": `invalid yaml content`, - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create temporary directory for test files - tmpDir := t.TempDir() - - // Set XDG_CONFIG_HOME to a temp directory to avoid loading global hooks - testConfigDir := filepath.Join(tmpDir, "config") - _ = os.Setenv("XDG_CONFIG_HOME", testConfigDir) - - // Write test files - var paths []string - for filename, content := range tt.files { - path := filepath.Join(tmpDir, filename) - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - t.Fatalf("failed to write test file: %v", err) - } - paths = append(paths, path) - } - // Sort paths to ensure deterministic order - sort.Strings(paths) - - // Load configuration - got, err := LoadHooksConfig(paths...) - - if tt.wantErr { - if err == nil { - t.Errorf("expected error but got none") - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if !reflect.DeepEqual(got, tt.expected) { - t.Errorf("LoadHooksConfig() = %+v, want %+v", got, tt.expected) - } - }) - } -} - -func TestMatchesPattern(t *testing.T) { - tests := []struct { - pattern string - toolName string - want bool - }{ - {"", "bash", true}, // Empty pattern matches all - {"bash", "bash", true}, // Exact match - {"bash", "Bash", false}, // Case sensitive - {"bash|fetch", "bash", true}, // Regex OR - {"bash|fetch", "fetch", true}, // Regex OR - {"bash|fetch", "todo", false}, // Regex OR no match - {"mcp__.*", "mcp__filesystem__read", true}, // MCP pattern - {".*write.*", "mcp__fs__write_file", true}, // Contains pattern - {"^bash$", "bash", true}, // Anchored regex - {"^bash$", "bash2", false}, // Anchored regex no match - } - - for _, tt := range tests { - t.Run(tt.pattern+"_"+tt.toolName, func(t *testing.T) { - got := matchesPattern(tt.pattern, tt.toolName) - if got != tt.want { - t.Errorf("matchesPattern(%q, %q) = %v, want %v", tt.pattern, tt.toolName, got, tt.want) - } - }) - } -} - -func TestNoHooksFlag(t *testing.T) { - // This test verifies that when hooks are disabled via configuration, - // the LoadHooksConfig function is not called. The actual implementation - // of this is in cmd/root.go where viper.GetBool("no-hooks") is checked. - // This test documents the expected behavior. - - // Create a test hooks file - tmpDir := t.TempDir() - hooksFile := filepath.Join(tmpDir, "hooks.yml") - content := ` -hooks: - PreToolUse: - - matcher: "bash" - hooks: - - type: command - command: "echo 'This should not run'" -` - if err := os.WriteFile(hooksFile, []byte(content), 0644); err != nil { - t.Fatalf("failed to write test file: %v", err) - } - - // Load the hooks config normally - config, err := LoadHooksConfig(hooksFile) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Verify hooks are loaded - if len(config.Hooks) == 0 { - t.Error("expected hooks to be loaded") - } - - // The actual --no-hooks flag implementation is in cmd/root.go - // where it checks viper.GetBool("no-hooks") before calling LoadHooksConfig -} diff --git a/internal/hooks/events.go b/internal/hooks/events.go deleted file mode 100644 index 63eacfe1..00000000 --- a/internal/hooks/events.go +++ /dev/null @@ -1,36 +0,0 @@ -package hooks - -// HookEvent represents a point in KIT's lifecycle where hooks can be executed. -// Events can be tool-related (requiring matchers) or lifecycle-related. -type HookEvent string - -const ( - // PreToolUse fires before any tool execution, allowing pre-processing or validation - PreToolUse HookEvent = "PreToolUse" - - // PostToolUse fires after tool execution completes, allowing post-processing or logging - PostToolUse HookEvent = "PostToolUse" - - // UserPromptSubmit fires when user submits a prompt, before agent processing - UserPromptSubmit HookEvent = "UserPromptSubmit" - - // Stop fires when the main agent finishes responding to a user prompt - Stop HookEvent = "Stop" -) - -// IsValid returns true if the event is a valid hook event. -// Valid events are PreToolUse, PostToolUse, UserPromptSubmit, and Stop. -func (e HookEvent) IsValid() bool { - switch e { - case PreToolUse, PostToolUse, UserPromptSubmit, Stop: - return true - } - return false -} - -// RequiresMatcher returns true if the event uses tool matchers. -// PreToolUse and PostToolUse events require matchers to determine which -// tools trigger the hooks. Other events apply globally without matchers. -func (e HookEvent) RequiresMatcher() bool { - return e == PreToolUse || e == PostToolUse -} diff --git a/internal/hooks/executor.go b/internal/hooks/executor.go deleted file mode 100644 index 30d30d56..00000000 --- a/internal/hooks/executor.go +++ /dev/null @@ -1,265 +0,0 @@ -package hooks - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "regexp" - "sync" - "time" -) - -// Executor handles hook execution for KIT lifecycle events. It manages -// hook configuration, executes matching hooks in parallel, and processes -// their outputs to determine application behavior. -type Executor struct { - config *HookConfig - sessionID string - transcript string - model string - interactive bool - mu sync.RWMutex -} - -// NewExecutor creates a new hook executor with the given configuration, -// session ID, and transcript path. The executor manages hook execution -// throughout the application lifecycle. -func NewExecutor(config *HookConfig, sessionID, transcriptPath string) *Executor { - return &Executor{ - config: config, - sessionID: sessionID, - transcript: transcriptPath, - } -} - -// SetModel sets the model name for hook context. This information is passed -// to hooks as part of their input data for context-aware processing. -func (e *Executor) SetModel(model string) { - e.mu.Lock() - defer e.mu.Unlock() - e.model = model -} - -// SetInteractive sets whether the application is running in interactive mode. -// This information is passed to hooks for mode-specific behavior. -func (e *Executor) SetInteractive(interactive bool) { - e.mu.Lock() - defer e.mu.Unlock() - e.interactive = interactive -} - -// PopulateCommonFields fills in the common fields for any hook input, including -// session ID, transcript path, working directory, event name, timestamp, model, -// and interactive mode. These fields provide context to hooks regardless of event type. -func (e *Executor) PopulateCommonFields(event HookEvent) CommonInput { - e.mu.RLock() - defer e.mu.RUnlock() - - cwd, _ := os.Getwd() - return CommonInput{ - SessionID: e.sessionID, - TranscriptPath: e.transcript, - CWD: cwd, - HookEventName: event, - Timestamp: time.Now().Unix(), - Model: e.model, - Interactive: e.interactive, - } -} - -// ExecuteHooks runs all matching hooks for an event. For tool-related events, -// it matches hooks based on tool name patterns. Hooks are executed in parallel -// with configurable timeouts. Returns a combined HookOutput from all executed -// hooks, with blocking decisions taking precedence. -func (e *Executor) ExecuteHooks(ctx context.Context, event HookEvent, input any) (*HookOutput, error) { - matchers, ok := e.config.Hooks[event] - if !ok || len(matchers) == 0 { - return nil, nil - } - - // Get tool name if applicable - toolName := "" - if event.RequiresMatcher() { - toolName = extractToolName(input) - } - - // Find matching hooks - var hooksToRun []HookEntry - for _, matcher := range matchers { - if matchesPattern(matcher.Matcher, toolName) { - hooksToRun = append(hooksToRun, matcher.Hooks...) - } - } - - if len(hooksToRun) == 0 { - return nil, nil - } - - // Execute hooks in parallel - results := make(chan *hookResult, len(hooksToRun)) - var wg sync.WaitGroup - - for _, hook := range hooksToRun { - wg.Add(1) - go func(h HookEntry) { - defer wg.Done() - result := e.executeHook(ctx, h, input) - results <- result - }(hook) - } - - wg.Wait() - close(results) - - // Process results - return e.processResults(results) -} - -// executeHook runs a single hook command -func (e *Executor) executeHook(ctx context.Context, hook HookEntry, input any) *hookResult { - // Prepare input JSON - inputJSON, err := json.Marshal(input) - if err != nil { - return &hookResult{err: fmt.Errorf("marshaling input: %w", err)} - } - - // Set timeout - timeout := time.Duration(hook.Timeout) * time.Second - if timeout == 0 { - timeout = 60 * time.Second - } - - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - // Create command - cmd := exec.CommandContext(ctx, "sh", "-c", hook.Command) - cmd.Stdin = bytes.NewReader(inputJSON) - cmd.Dir = getCurrentWorkingDir() - - // Capture output - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - // Execute - err = cmd.Run() - - exitCode := 0 - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - exitCode = exitErr.ExitCode() - } else { - exitCode = -1 - } - } - - return &hookResult{ - exitCode: exitCode, - stdout: stdout.String(), - stderr: stderr.String(), - err: err, - } -} - -// matchesPattern checks if a tool name matches a pattern -func matchesPattern(pattern, toolName string) bool { - if pattern == "" { - return true // Empty pattern matches all - } - - // Try exact match first - if pattern == toolName { - return true - } - - // Try regex match - matched, err := regexp.MatchString(pattern, toolName) - if err != nil { - // Invalid regex pattern, return false - return false - } - - return matched -} - -// extractToolName gets the tool name from various input types -func extractToolName(input any) string { - switch v := input.(type) { - case *PreToolUseInput: - return v.ToolName - case *PostToolUseInput: - return v.ToolName - default: - return "" - } -} - -type hookResult struct { - exitCode int - stdout string - stderr string - err error -} - -// processResults combines results from multiple hooks -func (e *Executor) processResults(results <-chan *hookResult) (*HookOutput, error) { - var finalOutput HookOutput - - for result := range results { - if result.err != nil && result.exitCode != 2 { - // Hook execution failed, skip this result - continue - } - - // Handle exit code 2 (blocking error) - if result.exitCode == 2 { - finalOutput.Decision = "block" - finalOutput.Reason = result.stderr - continueVal := false - finalOutput.Continue = &continueVal - return &finalOutput, nil - } - - // Try to parse JSON output - if result.stdout != "" { - var output HookOutput - if err := json.Unmarshal([]byte(result.stdout), &output); err == nil { - // Merge outputs (later hooks can override) - mergeHookOutputs(&finalOutput, &output) - } - } - } - - return &finalOutput, nil -} - -// mergeHookOutputs combines two hook outputs -func mergeHookOutputs(dst, src *HookOutput) { - if src.Continue != nil { - dst.Continue = src.Continue - } - if src.StopReason != "" { - dst.StopReason = src.StopReason - } - if src.Decision != "" { - dst.Decision = src.Decision - } - if src.Reason != "" { - dst.Reason = src.Reason - } - if src.SuppressOutput { - dst.SuppressOutput = true - } -} - -func getCurrentWorkingDir() string { - cwd, err := os.Getwd() - if err != nil { - return "/" - } - return cwd -} diff --git a/internal/hooks/executor_test.go b/internal/hooks/executor_test.go deleted file mode 100644 index 8f8ecadd..00000000 --- a/internal/hooks/executor_test.go +++ /dev/null @@ -1,244 +0,0 @@ -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 any - 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: new(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 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) -} diff --git a/internal/hooks/schemas.go b/internal/hooks/schemas.go deleted file mode 100644 index 4762d63e..00000000 --- a/internal/hooks/schemas.go +++ /dev/null @@ -1,68 +0,0 @@ -package hooks - -import ( - "encoding/json" -) - -// CommonInput contains fields common to all hook inputs, providing context -// information that is available to every hook regardless of the event type. -// These fields help hooks understand the execution environment and session state. -type CommonInput struct { - SessionID string `json:"session_id"` // Unique session identifier - TranscriptPath string `json:"transcript_path"` // Path to transcript file (if enabled) - CWD string `json:"cwd"` // Current working directory - HookEventName HookEvent `json:"hook_event_name"` // The hook event type - Timestamp int64 `json:"timestamp"` // Unix timestamp when hook fired - Model string `json:"model"` // AI model being used - Interactive bool `json:"interactive"` // Whether in interactive mode -} - -// PreToolUseInput is passed to PreToolUse hooks before a tool is executed. -// It contains the tool name and input parameters, allowing hooks to validate, -// modify, or block tool execution. -type PreToolUseInput struct { - CommonInput - ToolName string `json:"tool_name"` - ToolInput json.RawMessage `json:"tool_input"` -} - -// PostToolUseInput is passed to PostToolUse hooks after a tool has been executed. -// It contains the tool name, input parameters, and the tool's response, allowing -// hooks to log, analyze, or react to tool execution results. -type PostToolUseInput struct { - CommonInput - ToolName string `json:"tool_name"` - ToolInput json.RawMessage `json:"tool_input"` - ToolResponse json.RawMessage `json:"tool_response"` -} - -// UserPromptSubmitInput is passed to UserPromptSubmit hooks when a user submits -// a prompt. It contains the user's input text, allowing hooks to validate, -// modify, or log user interactions before processing. -type UserPromptSubmitInput struct { - CommonInput - Prompt string `json:"prompt"` -} - -// StopInput is passed to Stop hooks when the agent finishes responding to a prompt. -// It contains the final response, completion reason, and optional metadata about -// the interaction, allowing hooks to perform cleanup or logging operations. -type StopInput struct { - CommonInput - StopHookActive bool `json:"stop_hook_active"` - Response string `json:"response"` // The agent's final response - StopReason string `json:"stop_reason"` // "completed", "cancelled", "error" - Meta json.RawMessage `json:"meta,omitempty"` // Additional metadata (e.g., token usage, model info) -} - -// HookOutput represents the JSON output from a hook that controls KIT behavior. -// Hooks can decide whether to continue execution, provide reasons for stopping, -// suppress output, or block tool execution. The Decision field can be "approve", -// "block", or empty (default behavior). -type HookOutput struct { - Continue *bool `json:"continue,omitempty"` - StopReason string `json:"stopReason,omitempty"` - SuppressOutput bool `json:"suppressOutput,omitempty"` - Decision string `json:"decision,omitempty"` // "approve", "block", or "" - Reason string `json:"reason,omitempty"` -} diff --git a/internal/hooks/testdata/invalid-hooks.yml b/internal/hooks/testdata/invalid-hooks.yml deleted file mode 100644 index 293188bc..00000000 --- a/internal/hooks/testdata/invalid-hooks.yml +++ /dev/null @@ -1,10 +0,0 @@ -hooks: - InvalidEvent: - - hooks: - - type: command - command: "echo test" - PreToolUse: - - matcher: "[invalid regex" - hooks: - - type: command - command: "echo test" \ No newline at end of file diff --git a/internal/hooks/testdata/valid-hooks.yml b/internal/hooks/testdata/valid-hooks.yml deleted file mode 100644 index 3a8eeca8..00000000 --- a/internal/hooks/testdata/valid-hooks.yml +++ /dev/null @@ -1,21 +0,0 @@ -hooks: - PreToolUse: - - matcher: "bash" - hooks: - - type: command - command: "echo 'Executing bash command'" - timeout: 5 - - matcher: "fetch" - hooks: - - type: command - command: "echo 'Fetching URL'" - timeout: 10 - UserPromptSubmit: - - hooks: - - type: command - command: "date >> /tmp/kit-prompts.log" - PostToolUse: - - matcher: ".*" - hooks: - - type: command - command: "echo 'Tool execution completed'" \ No newline at end of file diff --git a/internal/hooks/validator.go b/internal/hooks/validator.go deleted file mode 100644 index bf0d6c44..00000000 --- a/internal/hooks/validator.go +++ /dev/null @@ -1,133 +0,0 @@ -package hooks - -import ( - "fmt" - "regexp" - "strings" -) - -// Security patterns to detect potentially dangerous commands -var ( - commandInjectionPattern = regexp.MustCompile(`[;&|]|\$\(|` + "`") - pathTraversalPattern = regexp.MustCompile(`\.\.\/`) - commandSubstitutionPattern = regexp.MustCompile(`\$\([^)]+\)|` + "`" + `[^` + "`" + `]+` + "`") -) - -// validateHookCommand validates a hook command for security issues -func validateHookCommand(command string) error { - if command == "" { - return fmt.Errorf("empty command") - } - - // Check for command injection attempts - if commandInjectionPattern.MatchString(command) { - // Allow simple pipes and redirects, but check for dangerous patterns - if containsDangerousPattern(command) { - return fmt.Errorf("potential command injection detected") - } - } - - // Check for path traversal - if pathTraversalPattern.MatchString(command) { - return fmt.Errorf("path traversal detected") - } - - // Check for command substitution - if commandSubstitutionPattern.MatchString(command) { - return fmt.Errorf("command substitution detected") - } - - return nil -} - -// containsDangerousPattern checks for specific dangerous command patterns -func containsDangerousPattern(command string) bool { - dangerousPatterns := []string{ - "; rm ", - "&& rm ", - "| rm ", - "; dd ", - "&& dd ", - "| dd ", - "/dev/null 2>&1", - } - - for _, pattern := range dangerousPatterns { - if strings.Contains(command, pattern) { - return true - } - } - - // Check for multiple command separators which might indicate injection - separatorCount := 0 - for _, sep := range []string{";", "&&", "||", "|"} { - separatorCount += strings.Count(command, sep) - } - - // Allow up to 2 separators for reasonable command chaining - return separatorCount > 2 -} - -// ValidateHookConfig validates the entire hook configuration for correctness -// and security. It checks event validity, regex patterns, hook definitions, -// and performs security validation on all commands. Returns an error describing -// any validation failures. -func ValidateHookConfig(config *HookConfig) error { - if config == nil { - return fmt.Errorf("nil configuration") - } - - for event, matchers := range config.Hooks { - if !event.IsValid() { - return fmt.Errorf("invalid event: %s", event) - } - - for i, matcher := range matchers { - // Validate regex pattern if provided - if matcher.Matcher != "" { - if _, err := regexp.Compile(matcher.Matcher); err != nil { - return fmt.Errorf("invalid regex pattern in matcher %d for event %s: %w", i, event, err) - } - } - - // Validate hooks - if len(matcher.Hooks) == 0 { - return fmt.Errorf("no hooks defined for matcher %d in event %s", i, event) - } - - for j, hook := range matcher.Hooks { - if err := validateHookEntry(hook); err != nil { - return fmt.Errorf("invalid hook %d in matcher %d for event %s: %w", j, i, event, err) - } - } - } - } - - return nil -} - -// validateHookEntry validates a single hook entry -func validateHookEntry(hook HookEntry) error { - if hook.Type != "command" { - return fmt.Errorf("invalid hook type: %s (only 'command' is supported)", hook.Type) - } - - if hook.Command == "" { - return fmt.Errorf("empty command") - } - - // Basic security validation - if err := validateHookCommand(hook.Command); err != nil { - return fmt.Errorf("command validation failed: %w", err) - } - - if hook.Timeout < 0 { - return fmt.Errorf("negative timeout: %d", hook.Timeout) - } - - if hook.Timeout > 600 { // 10 minutes max - return fmt.Errorf("timeout too large: %d (max 600 seconds)", hook.Timeout) - } - - return nil -} diff --git a/internal/hooks/validator_test.go b/internal/hooks/validator_test.go deleted file mode 100644 index 5b62dbfd..00000000 --- a/internal/hooks/validator_test.go +++ /dev/null @@ -1,251 +0,0 @@ -package hooks - -import ( - "strings" - "testing" -) - -func TestValidateHookCommand(t *testing.T) { - tests := []struct { - name string - command string - wantErr bool - errMsg string - }{ - { - name: "simple command", - command: "echo hello", - wantErr: false, - }, - { - name: "absolute path", - command: "/usr/local/bin/validator.py", - wantErr: false, - }, - { - name: "command injection attempt", - command: "echo test; rm -rf /", - wantErr: true, - errMsg: "potential command injection", - }, - { - name: "path traversal", - command: "cat ../../../etc/passwd", - wantErr: true, - errMsg: "path traversal detected", - }, - { - name: "command substitution", - command: "echo $(/bin/sh -c 'malicious')", - wantErr: true, - errMsg: "command substitution detected", - }, - { - name: "backtick substitution", - command: "echo `whoami`", - wantErr: true, - errMsg: "command substitution detected", - }, - { - name: "empty command", - command: "", - wantErr: true, - errMsg: "empty command", - }, - { - name: "simple pipe allowed", - command: "ps aux | grep process", - wantErr: false, - }, - { - name: "too many command separators", - command: "cmd1 | cmd2 && cmd3 ; cmd4", - wantErr: true, - errMsg: "potential command injection", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateHookCommand(tt.command) - - if tt.wantErr { - if err == nil { - t.Errorf("expected error but got none") - return - } - if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("error message %q does not contain %q", err.Error(), tt.errMsg) - } - } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - } - }) - } -} - -func TestValidateHookConfig(t *testing.T) { - tests := []struct { - name string - config *HookConfig - wantErr bool - errMsg string - }{ - { - name: "valid config", - config: &HookConfig{ - Hooks: map[HookEvent][]HookMatcher{ - PreToolUse: { - { - Matcher: "bash", - Hooks: []HookEntry{ - {Type: "command", Command: "echo test"}, - }, - }, - }, - }, - }, - wantErr: false, - }, - { - name: "nil config", - config: nil, - wantErr: true, - errMsg: "nil configuration", - }, - { - name: "invalid event", - config: &HookConfig{ - Hooks: map[HookEvent][]HookMatcher{ - "InvalidEvent": { - { - Hooks: []HookEntry{ - {Type: "command", Command: "echo test"}, - }, - }, - }, - }, - }, - wantErr: true, - errMsg: "invalid event", - }, - { - name: "invalid regex pattern", - config: &HookConfig{ - Hooks: map[HookEvent][]HookMatcher{ - PreToolUse: { - { - Matcher: "[invalid", - Hooks: []HookEntry{ - {Type: "command", Command: "echo test"}, - }, - }, - }, - }, - }, - wantErr: true, - errMsg: "invalid regex pattern", - }, - { - name: "no hooks defined", - config: &HookConfig{ - Hooks: map[HookEvent][]HookMatcher{ - PreToolUse: { - { - Matcher: "bash", - Hooks: []HookEntry{}, - }, - }, - }, - }, - wantErr: true, - errMsg: "no hooks defined", - }, - { - name: "invalid hook type", - config: &HookConfig{ - Hooks: map[HookEvent][]HookMatcher{ - PreToolUse: { - { - Hooks: []HookEntry{ - {Type: "invalid", Command: "echo test"}, - }, - }, - }, - }, - }, - wantErr: true, - errMsg: "invalid hook type", - }, - { - name: "empty command", - config: &HookConfig{ - Hooks: map[HookEvent][]HookMatcher{ - PreToolUse: { - { - Hooks: []HookEntry{ - {Type: "command", Command: ""}, - }, - }, - }, - }, - }, - wantErr: true, - errMsg: "empty command", - }, - { - name: "negative timeout", - config: &HookConfig{ - Hooks: map[HookEvent][]HookMatcher{ - PreToolUse: { - { - Hooks: []HookEntry{ - {Type: "command", Command: "echo test", Timeout: -1}, - }, - }, - }, - }, - }, - wantErr: true, - errMsg: "negative timeout", - }, - { - name: "timeout too large", - config: &HookConfig{ - Hooks: map[HookEvent][]HookMatcher{ - PreToolUse: { - { - Hooks: []HookEntry{ - {Type: "command", Command: "echo test", Timeout: 700}, - }, - }, - }, - }, - }, - wantErr: true, - errMsg: "timeout too large", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateHookConfig(tt.config) - - if tt.wantErr { - if err == nil { - t.Errorf("expected error but got none") - return - } - if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("error message %q does not contain %q", err.Error(), tt.errMsg) - } - } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - } - }) - } -} diff --git a/pkg/kit/setup.go b/pkg/kit/setup.go index 17e9ad8a..bba88f10 100644 --- a/pkg/kit/setup.go +++ b/pkg/kit/setup.go @@ -9,7 +9,6 @@ import ( "github.com/mark3labs/kit/internal/agent" "github.com/mark3labs/kit/internal/config" "github.com/mark3labs/kit/internal/extensions" - "github.com/mark3labs/kit/internal/hooks" "github.com/mark3labs/kit/internal/models" "github.com/mark3labs/kit/internal/tools" "github.com/spf13/viper" @@ -165,8 +164,8 @@ type extensionCreationOpts struct { extraTools []fantasy.AgentTool } -// loadExtensions discovers and loads Yaegi extensions plus legacy hooks.yml, -// builds the runner, and returns the tool wrapper/extra tools. +// loadExtensions discovers and loads Yaegi extensions, builds the runner, +// and returns the tool wrapper/extra tools. func loadExtensions() (*extensions.Runner, extensionCreationOpts, error) { extraPaths := viper.GetStringSlice("extension") loaded, err := extensions.LoadExtensions(extraPaths) @@ -174,15 +173,6 @@ func loadExtensions() (*extensions.Runner, extensionCreationOpts, error) { return nil, extensionCreationOpts{}, err } - // Also load legacy hooks.yml as a compat extension. - hooksCfg, _ := hooks.LoadHooksConfig() - if hooksCfg != nil && len(hooksCfg.Hooks) > 0 { - compat := extensions.HooksAsExtension(hooksCfg) - if compat != nil { - loaded = append([]extensions.LoadedExtension{*compat}, loaded...) - } - } - if len(loaded) == 0 { return nil, extensionCreationOpts{}, nil }