mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
remove legacy hooks system, superseded by extensions
This commit is contained in:
-181
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -1,10 +0,0 @@
|
||||
hooks:
|
||||
InvalidEvent:
|
||||
- hooks:
|
||||
- type: command
|
||||
command: "echo test"
|
||||
PreToolUse:
|
||||
- matcher: "[invalid regex"
|
||||
hooks:
|
||||
- type: command
|
||||
command: "echo test"
|
||||
-21
@@ -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'"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user