mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
cdc4abfb36
* Add comprehensive hooks system for MCPHost lifecycle events Implements a flexible hooks system based on Anthropic Claude Code specification: - **Hook Events**: PreToolUse, PostToolUse, UserPromptSubmit, Stop - **Hook Types**: Command execution with JSON input/output - **Configuration**: XDG-compliant with layered config support - **Security**: Command validation, timeout controls, safe execution - **Common Fields**: Consistent session ID, timestamps, model info across all hooks Key features: - Hooks receive JSON via stdin and can control flow via stdout - Pattern matching for tool-specific hooks (regex support) - Enhanced Stop hook with agent response and metadata - Centralized session management with consistent IDs - Built-in examples for logging, validation, and monitoring This enables users to: - Log and audit all tool usage and prompts - Implement custom security policies - Track usage metrics and model performance - Integrate with external systems - Build custom workflows around MCPHost 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode <noreply@opencode.ai> * Enable hooks in script mode Previously, hooks were only initialized and executed in normal mode but not in script mode. This was because script mode had its own execution path that bypassed the hook initialization code. This fix: - Adds hook initialization to runScriptMode function - Creates hook executor with proper session ID and model info - Passes the hook executor to runAgenticLoop Now hooks work consistently across all execution modes (normal, script, and interactive), ensuring uniform behavior for logging, validation, and monitoring. 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode <noreply@opencode.ai> * Remove unnecessary hooks.local.yml pattern The .local.yml pattern adds unnecessary complexity. Users who want project-specific hooks that aren't committed to git can simply add .mcphost/ to their .gitignore. This simplifies the hooks configuration loading and makes it clearer that: - Global user hooks go in ~/.config/mcphost/hooks.yml - Project-specific hooks go in .mcphost/hooks.yml - Git ignore management is left to the user 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode <noreply@opencode.ai> * Fix hooks test isolation and add --no-hooks flag - Fix TestLoadHooksConfig by setting temporary XDG_CONFIG_HOME to prevent loading global hooks - Add --no-hooks flag to disable all hooks execution across all modes - Update README with documentation for the new flag - Add test to verify hooks loading behavior This allows users to temporarily disable hooks for security or debugging purposes. 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode <noreply@opencode.ai> --------- Co-authored-by: opencode <noreply@opencode.ai>
252 lines
4.7 KiB
Go
252 lines
4.7 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|