remove legacy hooks system, superseded by extensions

This commit is contained in:
Ed Zynda
2026-02-27 18:17:32 +03:00
parent bc57364017
commit d6f8020554
14 changed files with 5 additions and 1703 deletions
-181
View File
@@ -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)
}
+3 -3
View File
@@ -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,
-111
View File
@@ -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
}
-141
View File
@@ -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)
}
}
}
}
}
-227
View File
@@ -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
}
-36
View File
@@ -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
}
-265
View File
@@ -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
}
-244
View File
@@ -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)
}
-68
View File
@@ -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"`
}
-10
View File
@@ -1,10 +0,0 @@
hooks:
InvalidEvent:
- hooks:
- type: command
command: "echo test"
PreToolUse:
- matcher: "[invalid regex"
hooks:
- type: command
command: "echo test"
-21
View File
@@ -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'"
-133
View File
@@ -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
}
-251
View File
@@ -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)
}
}
})
}
}
+2 -12
View File
@@ -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
}