diff --git a/internal/agenthooks/command.go b/internal/agenthooks/command.go new file mode 100644 index 0000000..94aca7e --- /dev/null +++ b/internal/agenthooks/command.go @@ -0,0 +1,116 @@ +package agenthooks + +import "strings" + +// CommandHandler is the command-bearing portion of an agent hook handler. +type CommandHandler struct { + Command string + Args []string +} + +// CommandPredicate reports whether a hook command handler belongs to a +// particular installer or product. +type CommandPredicate func(handler CommandHandler) bool + +// SplitCommand splits a shell-like command string enough for hook ownership +// detection. It supports quotes and backslash escapes, and reports false for +// unterminated quotes or trailing escapes. +func SplitCommand(command string) ([]string, bool) { + var fields []string + var builder strings.Builder + var quote rune + inField := false + + runes := []rune(command) + for i := 0; i < len(runes); i++ { + char := runes[i] + switch { + case quote != 0: + if char == quote { + quote = 0 + continue + } + if quote == '"' && char == '\\' && i+1 < len(runes) && isDoubleQuoteEscape(runes[i+1]) { + i++ + if runes[i] != '\n' { + builder.WriteRune(runes[i]) + } + inField = true + continue + } + builder.WriteRune(char) + inField = true + case char == '\\': + if i+1 >= len(runes) { + return nil, false + } + i++ + builder.WriteRune(runes[i]) + inField = true + case char == '\'' || char == '"': + quote = char + inField = true + case char == ' ' || char == '\t' || char == '\n' || char == '\r': + if inField { + fields = append(fields, builder.String()) + builder.Reset() + inField = false + } + default: + builder.WriteRune(char) + inField = true + } + } + if quote != 0 { + return nil, false + } + if inField { + fields = append(fields, builder.String()) + } + return fields, true +} + +func isDoubleQuoteEscape(char rune) bool { + switch char { + case '$', '`', '"', '\\', '\n': + return true + default: + return false + } +} + +func commandHandlerFromMap(handler map[string]any) (CommandHandler, bool) { + command, ok := handler["command"].(string) + if !ok { + return CommandHandler{}, false + } + args, ok := stringSlice(handler["args"]) + if !ok { + return CommandHandler{}, false + } + return CommandHandler{ + Command: command, + Args: args, + }, true +} + +func stringSlice(value any) ([]string, bool) { + switch args := value.(type) { + case nil: + return nil, true + case []string: + return append([]string(nil), args...), true + case []any: + out := make([]string, 0, len(args)) + for _, arg := range args { + text, ok := arg.(string) + if !ok { + return nil, false + } + out = append(out, text) + } + return out, true + default: + return nil, false + } +} diff --git a/internal/agenthooks/command_test.go b/internal/agenthooks/command_test.go new file mode 100644 index 0000000..2505bcf --- /dev/null +++ b/internal/agenthooks/command_test.go @@ -0,0 +1,31 @@ +package agenthooks + +import "testing" + +func TestSplitCommand(t *testing.T) { + fields, ok := SplitCommand(`'/Users/o'\''brien/bin/kontext' hook 'pre-tool-use'`) + if !ok { + t.Fatal("SplitCommand() ok = false, want true") + } + want := []string{"/Users/o'brien/bin/kontext", "hook", "pre-tool-use"} + if len(fields) != len(want) { + t.Fatalf("fields = %v, want %v", fields, want) + } + for i := range want { + if fields[i] != want[i] { + t.Fatalf("fields = %v, want %v", fields, want) + } + } + + if _, ok := SplitCommand(`'/usr/local/bin/kontext hook`); ok { + t.Fatal("SplitCommand(unterminated) ok = true, want false") + } + + fields, ok = SplitCommand(`"/tmp/kon\text" hook pre-tool-use`) + if !ok { + t.Fatal("SplitCommand(backslash) ok = false, want true") + } + if fields[0] != `/tmp/kon\text` { + t.Fatalf("first field = %q, want preserved backslash", fields[0]) + } +} diff --git a/internal/agenthooks/config.go b/internal/agenthooks/config.go new file mode 100644 index 0000000..52be9af --- /dev/null +++ b/internal/agenthooks/config.go @@ -0,0 +1,194 @@ +package agenthooks + +import ( + "errors" + "maps" +) + +const defaultHooksKey = "hooks" + +// Config wraps a generic agent hook settings map. The expected JSON shape is: +// +// { +// "hooks": { +// "": [ +// {"matcher": "...", "hooks": [{"command": "..."}]} +// ] +// } +// } +type Config struct { + Settings map[string]any + HooksKey string + HooksDescription string +} + +// HooksMap returns the hooks object from the config. A missing hooks key is an +// empty map; a non-object hooks value is left untouched and reported as an +// error because the file belongs to the user. +func (c Config) HooksMap() (map[string]any, error) { + if c.Settings == nil { + return nil, errors.New("settings must be a JSON object") + } + key := c.hooksKey() + switch value := c.Settings[key].(type) { + case nil: + return map[string]any{}, nil + case map[string]any: + return value, nil + default: + return nil, errors.New(c.hooksDescription() + " must be a JSON object") + } +} + +// Merge removes existing owned handlers for each event in plan, then inserts +// that event's canonical group. Foreign content is preserved verbatim. +func (c Config) Merge(plan Plan, isOwned CommandPredicate) error { + if err := plan.Validate(); err != nil { + return err + } + hooks, err := c.HooksMap() + if err != nil { + return err + } + + nextHooks := maps.Clone(hooks) + for _, event := range plan.sortedEvents() { + name := event.String() + groups := c.withoutOwnedHandlers(nextHooks[name], isOwned) + group, err := plan.Events[event].nativeGroup() + if err != nil { + return err + } + switch plan.Events[event].normalizedPlacement() { + case PlacementAppend: + nextHooks[name] = append(groups, group) + default: + return errUnsupportedPlacement(plan.Events[event].Placement) + } + } + c.Settings[c.hooksKey()] = nextHooks + return nil +} + +// Remove strips owned handlers from the selected events and prunes event keys +// or the top-level hooks key when they become empty. Foreign hooks survive. +func (c Config) Remove(plan Plan, isOwned CommandPredicate) error { + if err := plan.Validate(); err != nil { + return err + } + hooks, err := c.HooksMap() + if err != nil { + return err + } + + nextHooks := maps.Clone(hooks) + for _, event := range plan.sortedEvents() { + name := event.String() + if _, present := nextHooks[name]; !present { + continue + } + groups := c.withoutOwnedHandlers(nextHooks[name], isOwned) + if len(groups) == 0 { + delete(nextHooks, name) + continue + } + nextHooks[name] = groups + } + if len(nextHooks) == 0 { + delete(c.Settings, c.hooksKey()) + } else { + c.Settings[c.hooksKey()] = nextHooks + } + return nil +} + +// HasCommand reports whether any command handler in any event matches the +// predicate. Unparseable entries are ignored. +func HasCommand(hooks map[string]any, match CommandPredicate) bool { + if match == nil { + return false + } + for _, raw := range hooks { + list, ok := raw.([]any) + if !ok { + continue + } + for _, entry := range list { + group, ok := entry.(map[string]any) + if !ok { + continue + } + handlers, _ := group["hooks"].([]any) + for _, handler := range handlers { + handlerMap, ok := handler.(map[string]any) + if !ok { + continue + } + if handler, ok := commandHandlerFromMap(handlerMap); ok && match(handler) { + return true + } + } + } + } + return false +} + +// WithoutOwnedHandlers filters owned handlers out of every matcher group in an +// event's group list, dropping groups left without handlers. Unparseable +// entries are kept verbatim. +func WithoutOwnedHandlers(raw any, isOwned CommandPredicate) []any { + return Config{}.withoutOwnedHandlers(raw, isOwned) +} + +func (c Config) withoutOwnedHandlers(raw any, isOwned CommandPredicate) []any { + list, ok := raw.([]any) + if !ok { + if raw == nil { + return nil + } + return []any{raw} + } + filtered := make([]any, 0, len(list)) + for _, entry := range list { + group, ok := entry.(map[string]any) + if !ok { + filtered = append(filtered, entry) + continue + } + handlers, ok := group["hooks"].([]any) + if !ok { + filtered = append(filtered, entry) + continue + } + kept := make([]any, 0, len(handlers)) + for _, handler := range handlers { + if handlerMap, ok := handler.(map[string]any); ok { + if handler, ok := commandHandlerFromMap(handlerMap); ok && isOwned != nil && isOwned(handler) { + continue + } + } + kept = append(kept, handler) + } + if len(kept) == 0 { + continue + } + nextGroup := maps.Clone(group) + nextGroup["hooks"] = kept + filtered = append(filtered, nextGroup) + } + return filtered +} + +func (c Config) hooksKey() string { + if c.HooksKey != "" { + return c.HooksKey + } + return defaultHooksKey +} + +func (c Config) hooksDescription() string { + if c.HooksDescription != "" { + return c.HooksDescription + } + return c.hooksKey() +} diff --git a/internal/agenthooks/config_test.go b/internal/agenthooks/config_test.go new file mode 100644 index 0000000..69125a7 --- /dev/null +++ b/internal/agenthooks/config_test.go @@ -0,0 +1,248 @@ +package agenthooks + +import "testing" + +func TestConfigMergePreservesForeignHandlers(t *testing.T) { + settings := map[string]any{ + "hooks": map[string]any{ + "PreToolUse": []any{ + map[string]any{ + "matcher": "", + "hooks": []any{ + map[string]any{"type": "command", "command": "owned old"}, + map[string]any{"type": "command", "command": "foreign"}, + }, + }, + }, + "PostToolUse": []any{ + map[string]any{"matcher": "", "hooks": []any{map[string]any{"type": "command", "command": "foreign post"}}}, + }, + }, + "other": "value", + } + + config := Config{Settings: settings} + err := config.Merge(testPlan(map[string]string{"PreToolUse": "owned new"}), func(handler CommandHandler) bool { + return handler.Command == "owned old" || handler.Command == "owned new" + }) + if err != nil { + t.Fatalf("Merge() error = %v", err) + } + + if settings["other"] != "value" { + t.Fatalf("foreign top-level key changed: %v", settings["other"]) + } + hooks := settings["hooks"].(map[string]any) + pre := hooks["PreToolUse"].([]any) + if len(pre) != 2 { + t.Fatalf("PreToolUse groups = %d, want foreign group plus canonical group", len(pre)) + } + firstHandlers := pre[0].(map[string]any)["hooks"].([]any) + if len(firstHandlers) != 1 || firstHandlers[0].(map[string]any)["command"] != "foreign" { + t.Fatalf("foreign handler not preserved: %v", firstHandlers) + } + secondHandlers := pre[1].(map[string]any)["hooks"].([]any) + if len(secondHandlers) != 1 || secondHandlers[0].(map[string]any)["command"] != "owned new" { + t.Fatalf("canonical handler not appended: %v", secondHandlers) + } + if _, ok := hooks["PostToolUse"]; !ok { + t.Fatal("unrelated event was removed") + } +} + +func TestConfigMergeUsesCustomHooksKey(t *testing.T) { + settings := map[string]any{} + config := Config{Settings: settings, HooksKey: "customHooks"} + + if err := config.Merge(testPlan(map[string]string{"PreToolUse": "owned"}), func(handler CommandHandler) bool { + return handler.Command == "owned" + }); err != nil { + t.Fatalf("Merge() error = %v", err) + } + + if _, ok := settings["hooks"]; ok { + t.Fatalf("default hooks key was written: %v", settings) + } + if _, ok := settings["customHooks"].(map[string]any)["PreToolUse"]; !ok { + t.Fatalf("custom hooks key missing PreToolUse: %v", settings) + } +} + +func TestConfigRemovePrunesOwnedHandlersOnly(t *testing.T) { + settings := map[string]any{ + "hooks": map[string]any{ + "PreToolUse": []any{ + map[string]any{ + "matcher": "", + "hooks": []any{ + map[string]any{"type": "command", "command": "owned"}, + map[string]any{"type": "command", "command": "foreign"}, + }, + }, + }, + "PostToolUse": []any{ + map[string]any{"matcher": "", "hooks": []any{map[string]any{"type": "command", "command": "owned"}}}, + }, + }, + } + + config := Config{Settings: settings} + if err := config.Remove(testPlan(map[string]string{ + "PreToolUse": "owned", + "PostToolUse": "owned", + }), func(handler CommandHandler) bool { + return handler.Command == "owned" + }); err != nil { + t.Fatalf("Remove() error = %v", err) + } + + hooks := settings["hooks"].(map[string]any) + pre := hooks["PreToolUse"].([]any) + handlers := pre[0].(map[string]any)["hooks"].([]any) + if len(handlers) != 1 || handlers[0].(map[string]any)["command"] != "foreign" { + t.Fatalf("foreign handler not preserved: %v", handlers) + } + if _, present := hooks["PostToolUse"]; present { + t.Fatalf("empty event was not pruned: %v", hooks["PostToolUse"]) + } +} + +func TestConfigRemoveDropsEmptyHooksKey(t *testing.T) { + settings := map[string]any{ + "hooks": map[string]any{ + "PreToolUse": []any{ + map[string]any{"matcher": "", "hooks": []any{map[string]any{"type": "command", "command": "owned"}}}, + }, + }, + } + + config := Config{Settings: settings} + if err := config.Remove(testPlan(map[string]string{"PreToolUse": "owned"}), func(handler CommandHandler) bool { + return handler.Command == "owned" + }); err != nil { + t.Fatalf("Remove() error = %v", err) + } + if _, present := settings["hooks"]; present { + t.Fatalf("hooks key remains after removing the last handler: %v", settings["hooks"]) + } +} + +func TestConfigPreservesMalformedEventValues(t *testing.T) { + settings := map[string]any{ + "hooks": map[string]any{ + "PreToolUse": "invalid", + }, + } + config := Config{Settings: settings, HooksDescription: "test hooks"} + + if err := config.Merge(testPlan(map[string]string{"PreToolUse": "owned"}), func(handler CommandHandler) bool { + return handler.Command == "owned" + }); err != nil { + t.Fatalf("Merge() error = %v", err) + } + hooks := settings["hooks"].(map[string]any) + pre := hooks["PreToolUse"].([]any) + if len(pre) != 2 || pre[0] != "invalid" { + t.Fatalf("malformed event not preserved during merge: %v", pre) + } + + if err := config.Remove(testPlan(map[string]string{"PreToolUse": "owned"}), func(handler CommandHandler) bool { + return handler.Command == "owned" + }); err != nil { + t.Fatalf("Remove() error = %v", err) + } + pre = settings["hooks"].(map[string]any)["PreToolUse"].([]any) + if len(pre) != 1 || pre[0] != "invalid" { + t.Fatalf("malformed event not preserved during remove: %v", pre) + } +} + +func TestConfigRejectsNonObjectHooks(t *testing.T) { + settings := map[string]any{"hooks": []any{"invalid"}} + config := Config{Settings: settings, HooksDescription: "test hooks"} + + if _, err := config.HooksMap(); err == nil { + t.Fatal("HooksMap() error = nil, want non-object hooks error") + } + if err := config.Merge(testPlan(map[string]string{"PreToolUse": "owned"}), nil); err == nil { + t.Fatal("Merge() error = nil, want non-object hooks error") + } + if err := config.Remove(testPlan(map[string]string{"PreToolUse": "owned"}), nil); err == nil { + t.Fatal("Remove() error = nil, want non-object hooks error") + } +} + +func TestConfigRejectsNilSettings(t *testing.T) { + config := Config{} + + if _, err := config.HooksMap(); err == nil { + t.Fatal("HooksMap() error = nil, want nil settings error") + } + if err := config.Merge(testPlan(map[string]string{"PreToolUse": "owned"}), nil); err == nil { + t.Fatal("Merge() error = nil, want nil settings error") + } + if err := config.Remove(testPlan(map[string]string{"PreToolUse": "owned"}), nil); err == nil { + t.Fatal("Remove() error = nil, want nil settings error") + } +} + +func TestHasCommand(t *testing.T) { + hooks := map[string]any{ + "PreToolUse": []any{ + "unparseable", + map[string]any{"matcher": "", "hooks": []any{ + map[string]any{"type": "command", "command": "owned"}, + }}, + }, + } + + if !HasCommand(hooks, func(handler CommandHandler) bool { return handler.Command == "owned" }) { + t.Fatal("HasCommand() = false, want true") + } + if HasCommand(hooks, func(handler CommandHandler) bool { return handler.Command == "missing" }) { + t.Fatal("HasCommand(missing) = true, want false") + } +} + +func TestCommandPredicateReceivesArgs(t *testing.T) { + hooks := map[string]any{ + "PreToolUse": []any{ + map[string]any{"matcher": "", "hooks": []any{ + map[string]any{ + "type": "command", + "command": "kontext", + "args": []any{"hook", "pre-tool-use"}, + }, + map[string]any{ + "type": "command", + "command": "kontext", + "args": []any{"other"}, + }, + }}, + }, + } + matchHook := func(handler CommandHandler) bool { + return handler.Command == "kontext" && + len(handler.Args) == 2 && + handler.Args[0] == "hook" && + handler.Args[1] == "pre-tool-use" + } + + if !HasCommand(hooks, matchHook) { + t.Fatal("HasCommand(args) = false, want true") + } + + config := Config{Settings: map[string]any{"hooks": hooks}} + if err := config.Remove(testPlan(map[string]string{"PreToolUse": "unused"}), matchHook); err != nil { + t.Fatalf("Remove() error = %v", err) + } + pre := config.Settings["hooks"].(map[string]any)["PreToolUse"].([]any) + handlers := pre[0].(map[string]any)["hooks"].([]any) + if len(handlers) != 1 { + t.Fatalf("handlers = %d, want only non-matching args handler", len(handlers)) + } + kept := handlers[0].(map[string]any) + if got := kept["args"].([]any)[0]; got != "other" { + t.Fatalf("kept args = %v, want other handler", kept["args"]) + } +} diff --git a/internal/agenthooks/plan.go b/internal/agenthooks/plan.go new file mode 100644 index 0000000..ea7ec6d --- /dev/null +++ b/internal/agenthooks/plan.go @@ -0,0 +1,134 @@ +package agenthooks + +import ( + "errors" + "fmt" + "sort" + + "github.com/kontext-security/kontext-cli/internal/hook" +) + +type SchemaVersion string + +const SchemaVersionV1 SchemaVersion = "kontext.agenthooks/v1" + +type ProviderID string + +const ( + ProviderClaudeCode ProviderID = "claude-code" + ProviderCodex ProviderID = "codex" +) + +type OwnerID string + +const OwnerKontextManagedObserve OwnerID = "kontext/managed-observe" + +type Placement string + +const PlacementAppend Placement = "append" + +type MatchSpec struct { + Pattern string +} + +type CommandHook struct { + Command string + Args []string + Timeout int + Async *bool +} + +type EventPlan struct { + Match MatchSpec + Command CommandHook + Placement Placement +} + +type Plan struct { + Version SchemaVersion + Provider ProviderID + Owner OwnerID + Events map[hook.HookName]EventPlan +} + +func (p Plan) Validate() error { + if p.Version != SchemaVersionV1 { + return fmt.Errorf("hook plan version must be %q", SchemaVersionV1) + } + if p.Provider == "" { + return errors.New("hook plan provider is required") + } + if p.Owner == "" { + return errors.New("hook plan owner is required") + } + for event, plan := range p.Events { + if !event.IsKnown() { + return fmt.Errorf("hook event %q is not recognized", event) + } + if err := plan.Validate(); err != nil { + return fmt.Errorf("%s hook plan: %w", event, err) + } + } + return nil +} + +func (p Plan) sortedEvents() []hook.HookName { + events := make([]hook.HookName, 0, len(p.Events)) + for event := range p.Events { + events = append(events, event) + } + sort.Slice(events, func(i, j int) bool { + return events[i].String() < events[j].String() + }) + return events +} + +func (p EventPlan) Validate() error { + if p.Command.Command == "" { + return errors.New("command is required") + } + switch p.normalizedPlacement() { + case PlacementAppend: + return nil + default: + return errUnsupportedPlacement(p.Placement) + } +} + +func (p EventPlan) normalizedPlacement() Placement { + if p.Placement != "" { + return p.Placement + } + return PlacementAppend +} + +func (p EventPlan) nativeGroup() (map[string]any, error) { + if err := p.Validate(); err != nil { + return nil, err + } + handler := map[string]any{ + "type": "command", + "command": p.Command.Command, + } + if len(p.Command.Args) > 0 { + args := make([]any, 0, len(p.Command.Args)) + for _, arg := range p.Command.Args { + args = append(args, arg) + } + handler["args"] = args + } + if p.Command.Timeout != 0 { + handler["timeout"] = float64(p.Command.Timeout) + } + if p.Command.Async != nil { + handler["async"] = *p.Command.Async + } + return map[string]any{ + "matcher": p.Match.Pattern, + "hooks": []any{handler}, + }, nil +} + +func errUnsupportedPlacement(placement Placement) error { + return fmt.Errorf("placement must be %q, got %q", PlacementAppend, placement) +} diff --git a/internal/agenthooks/plan_test.go b/internal/agenthooks/plan_test.go new file mode 100644 index 0000000..11cdffe --- /dev/null +++ b/internal/agenthooks/plan_test.go @@ -0,0 +1,34 @@ +package agenthooks + +import ( + "testing" + + "github.com/kontext-security/kontext-cli/internal/hook" +) + +func TestPlanValidation(t *testing.T) { + plan := testPlan(map[string]string{"PreToolUse": "owned"}) + plan.Version = "future" + if err := plan.Validate(); err == nil { + t.Fatal("Validate() error = nil, want bad version error") + } +} + +func testPlan(commands map[string]string) Plan { + events := make(map[hook.HookName]EventPlan, len(commands)) + for name, command := range commands { + events[hook.HookName(name)] = EventPlan{ + Match: MatchSpec{Pattern: ""}, + Command: CommandHook{ + Command: command, + }, + Placement: PlacementAppend, + } + } + return Plan{ + Version: SchemaVersionV1, + Provider: ProviderClaudeCode, + Owner: OwnerKontextManagedObserve, + Events: events, + } +} diff --git a/internal/claudemanaged/settings.go b/internal/claudemanaged/settings.go index 3f64fd7..b00a0ba 100644 --- a/internal/claudemanaged/settings.go +++ b/internal/claudemanaged/settings.go @@ -8,6 +8,7 @@ import ( "runtime" "strings" + "github.com/kontext-security/kontext-cli/internal/agenthooks" "github.com/kontext-security/kontext-cli/internal/hook" ) @@ -213,7 +214,7 @@ func hasManagedObserveHook(groups []MatcherGroup, event Event) bool { if err := validateAsync(event, handler.Async); err != nil { continue } - fields, ok := splitHookCommand(handler.Command) + fields, ok := agenthooks.SplitCommand(handler.Command) if ok && len(fields) == 3 && filepath.Base(fields[0]) == "kontext" && fields[1] == "hook" && fields[2] == event.Alias { return true } @@ -233,7 +234,7 @@ func isCanonicalManagedDropInHandler(handler Handler, event Event) bool { } else if handler.Async != nil { return false } - fields, ok := splitHookCommand(handler.Command) + fields, ok := agenthooks.SplitCommand(handler.Command) return ok && len(fields) == 3 && filepath.Base(fields[0]) == "kontext" && fields[1] == "hook" && diff --git a/internal/claudemanaged/usersettings.go b/internal/claudemanaged/usersettings.go index c65f4ce..71b144c 100644 --- a/internal/claudemanaged/usersettings.go +++ b/internal/claudemanaged/usersettings.go @@ -9,13 +9,16 @@ import ( "path/filepath" "strings" "time" + + "github.com/kontext-security/kontext-cli/internal/agenthooks" + "github.com/kontext-security/kontext-cli/internal/hook" ) // User-level Claude Code settings (~/.claude/settings.json) integration for // self-serve installs. Unlike the MDM managed-settings drop-in (root-owned, // tamper-resistant), this file belongs to the user: the merge must preserve -// every byte of foreign content (their hooks, permissions, env, unknown -// keys), be idempotent, and be cleanly reversible. +// foreign JSON content (their hooks, permissions, env, unknown keys), be +// idempotent, and be cleanly reversible. // UserSettingsPath returns ~/.claude/settings.json, creating ~/.claude. func UserSettingsPath() (string, error) { @@ -154,8 +157,69 @@ func IsGuardHookCommand(command string) bool { // entries. Matching on the alias rather than the exact binary path means a // re-run after the binary moved (brew prefix change) replaces stale entries. func IsManagedHookCommand(command string) bool { - fields, ok := splitHookCommand(command) - if !ok || len(fields) != 3 { + fields, ok := agenthooks.SplitCommand(command) + if !ok { + return false + } + return isManagedHookFields(fields) +} + +func isGuardHookHandler(handler agenthooks.CommandHandler) bool { + if len(handler.Args) == 0 { + return IsGuardHookCommand(handler.Command) + } + fields, ok := commandHandlerFields(handler) + if !ok { + return false + } + return isGuardHookFields(fields) +} + +func isManagedHookHandler(handler agenthooks.CommandHandler) bool { + if len(handler.Args) == 0 { + return IsManagedHookCommand(handler.Command) + } + fields, ok := commandHandlerFields(handler) + if !ok { + return false + } + return isManagedHookFields(fields) +} + +func commandHandlerFields(handler agenthooks.CommandHandler) ([]string, bool) { + command := strings.TrimSpace(handler.Command) + if command == "" { + return nil, false + } + if fields, ok := agenthooks.SplitCommand(command); ok && len(fields) == 1 { + command = fields[0] + } + fields := make([]string, 0, 1+len(handler.Args)) + fields = append(fields, command) + fields = append(fields, handler.Args...) + return fields, true +} + +func isGuardHookFields(fields []string) bool { + if len(fields) < 4 || filepath.Base(fields[0]) != "kontext" { + return false + } + if fields[1] == "guard" && fields[2] == "hook" && fields[3] == "claude-code" { + return true + } + if fields[1] != "hook" || fields[2] != "--agent" || fields[3] != "claude" { + return false + } + for _, field := range fields[4:] { + if field == "--mode" { + return true + } + } + return false +} + +func isManagedHookFields(fields []string) bool { + if len(fields) != 3 { return false } for _, field := range fields { @@ -174,134 +238,85 @@ func IsManagedHookCommand(command string) bool { return false } -func splitHookCommand(command string) ([]string, bool) { - var fields []string - var builder strings.Builder - var quote rune - inField := false +// MergeManagedHooks installs/refreshes the five managed-observe hooks in the +// settings map: for each supported event it removes any existing Kontext +// managed handlers (stale binary paths included) and appends the canonical +// group from Template. Everything else in the map is untouched. Idempotent: +// merging twice is a fixpoint. Returns non-fatal warnings for conditions the +// user should know about. +func MergeManagedHooks(settings map[string]any, kontextBinary string) ([]string, error) { + var warnings []string - runes := []rune(command) - for i := 0; i < len(runes); i++ { - char := runes[i] - switch { - case quote != 0: - if char == quote { - quote = 0 - continue - } - if quote == '"' && char == '\\' && i+1 < len(runes) { - i++ - builder.WriteRune(runes[i]) - inField = true - continue - } - builder.WriteRune(char) - inField = true - case char == '\\': - if i+1 >= len(runes) { - return nil, false - } - i++ - builder.WriteRune(runes[i]) - inField = true - case char == '\'' || char == '"': - quote = char - inField = true - case char == ' ' || char == '\t' || char == '\n' || char == '\r': - if inField { - fields = append(fields, builder.String()) - builder.Reset() - inField = false - } - default: - builder.WriteRune(char) - inField = true - } - } - if quote != 0 { - return nil, false + if disabled, ok := settings["disableAllHooks"].(bool); ok && disabled { + warnings = append(warnings, "Claude Code hooks are globally disabled (disableAllHooks) in settings.json; Kontext hooks will not fire until you remove that setting") } - if inField { - fields = append(fields, builder.String()) - } - return fields, true -} -// RemoveManagedHooks strips OUR handlers (and only ours) from the settings -// map, pruning groups and event keys that end up empty. Foreign hooks, -// including Guard's, survive untouched. Idempotent. -func RemoveManagedHooks(settings map[string]any) error { - hooks, err := hooksMap(settings) + config := agenthooks.Config{ + Settings: settings, + HooksDescription: "settings.json hooks", + } + hooks, err := config.HooksMap() if err != nil { - return err + return nil, err } - for _, event := range SupportedEvents { - name := event.Name.String() - if _, present := hooks[name]; !present { - continue - } - groups := withoutManagedHandlers(hooks[name]) - if len(groups) == 0 { - delete(hooks, name) - continue - } - hooks[name] = groups + + if agenthooks.HasCommand(hooks, isGuardHookHandler) { + warnings = append(warnings, "Kontext Guard hooks are also installed; consider `kontext guard hooks uninstall claude-code` to avoid duplicate processing") } - if len(hooks) == 0 { - delete(settings, "hooks") + + // Build the typed plan before touching the map, so an error can + // never leave the caller's settings half-merged. + plan := managedHookPlan(kontextBinary) + if err := plan.Validate(); err != nil { + return nil, err } - return nil -} -func hooksMap(settings map[string]any) (map[string]any, error) { - switch value := settings["hooks"].(type) { - case nil: - return map[string]any{}, nil - case map[string]any: - return value, nil - default: - // Never clobber a shape we don't understand — the file is the user's. - return nil, errors.New("settings.json hooks must be a JSON object") + if err := config.Merge(plan, isManagedHookHandler); err != nil { + return nil, err } + return warnings, nil } -// withoutManagedHandlers filters our handlers out of every matcher group of -// an event's group list, dropping groups left without handlers. Unparseable -// entries are kept verbatim (they are the user's data, not ours to judge). -func withoutManagedHandlers(raw any) []any { - list, ok := raw.([]any) - if !ok { - if raw == nil { - return nil - } - return []any{raw} +func managedHookPlan(kontextBinary string) agenthooks.Plan { + kontextBinary = strings.TrimSpace(kontextBinary) + if kontextBinary == "" { + kontextBinary = DefaultKontextBinary } - filtered := make([]any, 0, len(list)) - for _, entry := range list { - group, ok := entry.(map[string]any) - if !ok { - filtered = append(filtered, entry) - continue - } - handlers, ok := group["hooks"].([]any) - if !ok { - filtered = append(filtered, entry) - continue + + events := make(map[hook.HookName]agenthooks.EventPlan, len(SupportedEvents)) + for _, event := range SupportedEvents { + handler := agenthooks.CommandHook{ + Command: hookCommand(kontextBinary, event.Alias), + Timeout: DefaultHookTimeout, } - kept := make([]any, 0, len(handlers)) - for _, handler := range handlers { - if handlerMap, ok := handler.(map[string]any); ok { - if command, ok := handlerMap["command"].(string); ok && IsManagedHookCommand(command) { - continue - } - } - kept = append(kept, handler) + if event.Async { + value := true + handler.Async = &value } - if len(kept) == 0 { - continue + events[event.Name] = agenthooks.EventPlan{ + Match: agenthooks.MatchSpec{ + Pattern: "", + }, + Command: handler, + Placement: agenthooks.PlacementAppend, } - group["hooks"] = kept - filtered = append(filtered, group) } - return filtered + + return agenthooks.Plan{ + Version: agenthooks.SchemaVersionV1, + Provider: agenthooks.ProviderClaudeCode, + Owner: agenthooks.OwnerKontextManagedObserve, + Events: events, + } +} + +// RemoveManagedHooks strips OUR handlers (and only ours) from the settings +// map, pruning groups and event keys that end up empty. Foreign hooks, +// including Guard's, survive untouched. Idempotent. +func RemoveManagedHooks(settings map[string]any) error { + config := agenthooks.Config{ + Settings: settings, + HooksDescription: "settings.json hooks", + } + return config.Remove(managedHookPlan(DefaultKontextBinary), isManagedHookHandler) } diff --git a/internal/claudemanaged/usersettings_test.go b/internal/claudemanaged/usersettings_test.go index 7625bb6..b7567ad 100644 --- a/internal/claudemanaged/usersettings_test.go +++ b/internal/claudemanaged/usersettings_test.go @@ -9,6 +9,8 @@ import ( "testing" ) +const testBinary = "/opt/homebrew/bin/kontext" + func decode(t *testing.T, raw string) map[string]any { t.Helper() var out map[string]any @@ -31,33 +33,285 @@ func eventGroups(t *testing.T, settings map[string]any, event string) []any { return groups } -func TestRemoveManagedHooksLeavesForeignContent(t *testing.T) { +func TestMergeManagedHooksIntoEmptySettings(t *testing.T) { + settings := map[string]any{} + warnings, err := MergeManagedHooks(settings, testBinary) + if err != nil { + t.Fatalf("MergeManagedHooks() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("warnings = %v, want none", warnings) + } + + for _, event := range SupportedEvents { + groups := eventGroups(t, settings, event.Name.String()) + if len(groups) != 1 { + t.Fatalf("%s groups = %d, want 1", event.Name, len(groups)) + } + } + + data, err := json.Marshal(map[string]any{"hooks": settings["hooks"]}) + if err != nil { + t.Fatal(err) + } + if err := Validate(data, testBinary); err != nil { + t.Fatalf("merged settings fail Validate: %v", err) + } +} + +func TestMergeManagedHooksMatchesTemplateShape(t *testing.T) { + settings := map[string]any{} + if _, err := MergeManagedHooks(settings, testBinary); err != nil { + t.Fatal(err) + } + + data, err := json.Marshal(Template(testBinary).Hooks) + if err != nil { + t.Fatal(err) + } + var want map[string]any + if err := json.Unmarshal(data, &want); err != nil { + t.Fatal(err) + } + + for _, event := range SupportedEvents { + name := event.Name.String() + got := eventGroups(t, settings, name) + if !reflect.DeepEqual(got, want[name]) { + t.Fatalf("%s merged group = %#v, want %#v", name, got, want[name]) + } + } +} + +func TestMergeManagedHooksPreservesForeignContent(t *testing.T) { settings := decode(t, `{ "permissions": {"allow": ["Bash(ls:*)"]}, + "env": {"FOO": "bar"}, "hooks": { - "SessionStart": [{"matcher": "", "hooks": [{"type": "command", "command": "'/usr/local/bin/kontext' hook 'session-start'"}]}], "PreToolUse": [ - {"matcher": "", "hooks": [{"type": "command", "command": "'/usr/local/bin/kontext' hook 'pre-tool-use'"}]}, - {"matcher": "Edit", "hooks": [{"type": "command", "command": "lint-check"}]} + {"matcher": "Bash", "hooks": [{"type": "command", "command": "/usr/local/bin/other-tool check"}]} ], - "PostToolUse": [{"matcher": "", "hooks": [{"type": "command", "command": "'/usr/local/bin/kontext' hook 'post-tool-use'"}]}], - "SessionEnd": [{"matcher": "", "hooks": [{"type": "command", "command": "'/usr/local/bin/kontext' hook 'session-end'"}]}] + "Notification": [ + {"matcher": "", "hooks": [{"type": "command", "command": "say done"}]} + ] + }, + "unknownKey": 42 + }`) + + if _, err := MergeManagedHooks(settings, testBinary); err != nil { + t.Fatal(err) + } + + if settings["unknownKey"] != float64(42) { + t.Fatalf("unknownKey clobbered: %v", settings["unknownKey"]) + } + if _, ok := settings["permissions"].(map[string]any); !ok { + t.Fatal("permissions clobbered") + } + + groups := eventGroups(t, settings, "PreToolUse") + if len(groups) != 2 { + t.Fatalf("PreToolUse groups = %d, want foreign + ours", len(groups)) + } + foreign := groups[0].(map[string]any) + if foreign["matcher"] != "Bash" { + t.Fatalf("foreign group reordered or modified: %v", foreign) + } + + notification := eventGroups(t, settings, "Notification") + if len(notification) != 1 { + t.Fatalf("Notification groups = %d, want 1", len(notification)) + } +} + +func TestMergeManagedHooksPreservesMalformedEventValues(t *testing.T) { + settings := decode(t, `{ + "hooks": { + "PreToolUse": "invalid" } }`) + if _, err := MergeManagedHooks(settings, testBinary); err != nil { + t.Fatal(err) + } + + groups := eventGroups(t, settings, "PreToolUse") + if len(groups) != 2 || groups[0] != "invalid" { + t.Fatalf("PreToolUse groups = %#v, want malformed value plus managed group", groups) + } + if err := RemoveManagedHooks(settings); err != nil { t.Fatal(err) } + groups = eventGroups(t, settings, "PreToolUse") + if len(groups) != 1 || groups[0] != "invalid" { + t.Fatalf("PreToolUse groups after remove = %#v, want malformed value preserved", groups) + } +} + +func TestMergeManagedHooksReplacesStaleBinaryPath(t *testing.T) { + settings := decode(t, `{ + "hooks": { + "PreToolUse": [ + {"matcher": "", "hooks": [{"type": "command", "command": "'/Applications/Kontext CLI/kontext' hook 'pre-tool-use'", "timeout": 20}]} + ] + } + }`) + + if _, err := MergeManagedHooks(settings, testBinary); err != nil { + t.Fatal(err) + } + + groups := eventGroups(t, settings, "PreToolUse") + if len(groups) != 1 { + t.Fatalf("PreToolUse groups = %d, want stale entry replaced, not duplicated", len(groups)) + } + handler := groups[0].(map[string]any)["hooks"].([]any)[0].(map[string]any) + if !strings.Contains(handler["command"].(string), testBinary) { + t.Fatalf("stale binary path not replaced: %v", handler["command"]) + } +} + +func TestMergeManagedHooksReplacesSplitCommandArgs(t *testing.T) { + settings := decode(t, `{ + "hooks": { + "PreToolUse": [ + {"matcher": "", "hooks": [{"type": "command", "command": "/Applications/Kontext CLI/kontext", "args": ["hook", "pre-tool-use"], "timeout": 20}]} + ] + } + }`) + + if _, err := MergeManagedHooks(settings, testBinary); err != nil { + t.Fatal(err) + } + + groups := eventGroups(t, settings, "PreToolUse") + if len(groups) != 1 { + t.Fatalf("PreToolUse groups = %d, want split command entry replaced, not duplicated", len(groups)) + } + handler := groups[0].(map[string]any)["hooks"].([]any)[0].(map[string]any) + if _, present := handler["args"]; present { + t.Fatalf("split command args were preserved on canonical handler: %v", handler) + } + if !strings.Contains(handler["command"].(string), testBinary) { + t.Fatalf("split command path not replaced: %v", handler["command"]) + } +} + +func TestMergeManagedHooksIsIdempotent(t *testing.T) { + settings := map[string]any{} + if _, err := MergeManagedHooks(settings, testBinary); err != nil { + t.Fatal(err) + } + first, err := json.Marshal(settings) + if err != nil { + t.Fatal(err) + } + if _, err := MergeManagedHooks(settings, testBinary); err != nil { + t.Fatal(err) + } + second, err := json.Marshal(settings) + if err != nil { + t.Fatal(err) + } + if string(first) != string(second) { + t.Fatalf("double merge is not a fixpoint:\n%s\n%s", first, second) + } +} + +func TestMergeManagedHooksWarnsOnDisableAllHooks(t *testing.T) { + settings := decode(t, `{"disableAllHooks": true}`) + warnings, err := MergeManagedHooks(settings, testBinary) + if err != nil { + t.Fatal(err) + } + if len(warnings) != 1 || !strings.Contains(warnings[0], "disableAllHooks") { + t.Fatalf("warnings = %v, want disableAllHooks warning", warnings) + } + if settings["disableAllHooks"] != true { + t.Fatal("disableAllHooks was mutated") + } +} + +func TestMergeManagedHooksWarnsOnGuardHooksAndLeavesThem(t *testing.T) { + settings := decode(t, `{ + "hooks": { + "PreToolUse": [ + {"matcher": "", "hooks": [{"type": "command", "command": "'/usr/local/bin/kontext' hook --agent claude --event pre-tool-use --mode observe"}]} + ] + } + }`) + + warnings, err := MergeManagedHooks(settings, testBinary) + if err != nil { + t.Fatal(err) + } + if len(warnings) != 1 || !strings.Contains(warnings[0], "Guard") { + t.Fatalf("warnings = %v, want guard-conflict warning", warnings) + } + + groups := eventGroups(t, settings, "PreToolUse") + if len(groups) != 2 { + t.Fatalf("PreToolUse groups = %d, want guard hook + ours", len(groups)) + } +} + +func TestMergeManagedHooksWarnsOnSplitArgsGuardHooks(t *testing.T) { + settings := decode(t, `{ + "hooks": { + "PreToolUse": [ + {"matcher": "", "hooks": [{"type": "command", "command": "kontext", "args": ["hook", "--agent", "claude", "--event", "pre-tool-use", "--mode", "observe"]}]} + ] + } + }`) + + warnings, err := MergeManagedHooks(settings, testBinary) + if err != nil { + t.Fatal(err) + } + if len(warnings) != 1 || !strings.Contains(warnings[0], "Guard") { + t.Fatalf("warnings = %v, want guard-conflict warning", warnings) + } + + groups := eventGroups(t, settings, "PreToolUse") + if len(groups) != 2 { + t.Fatalf("PreToolUse groups = %d, want guard hook + ours", len(groups)) + } +} + +func TestMergeManagedHooksRejectsNonObjectHooks(t *testing.T) { + settings := decode(t, `{"hooks": ["weird"]}`) + if _, err := MergeManagedHooks(settings, testBinary); err == nil { + t.Fatal("expected error for non-object hooks, got nil") + } +} + +func TestRemoveManagedHooksLeavesForeignContent(t *testing.T) { + settings := map[string]any{ + "permissions": map[string]any{"allow": []any{"Bash(ls:*)"}}, + } + if _, err := MergeManagedHooks(settings, testBinary); err != nil { + t.Fatal(err) + } hooks := settings["hooks"].(map[string]any) - // Events that only held our hooks are pruned entirely. + pre := hooks["PreToolUse"].([]any) + hooks["PreToolUse"] = append(pre, map[string]any{ + "matcher": "Edit", + "hooks": []any{map[string]any{"type": "command", "command": "lint-check"}}, + }) + + if err := RemoveManagedHooks(settings); err != nil { + t.Fatal(err) + } + + hooks = settings["hooks"].(map[string]any) for _, event := range []string{"SessionStart", "PostToolUse", "SessionEnd"} { if _, present := hooks[event]; present { t.Fatalf("%s not pruned after removal", event) } } - // The foreign PreToolUse hook survives alone. - pre := hooks["PreToolUse"].([]any) + pre = hooks["PreToolUse"].([]any) if len(pre) != 1 || pre[0].(map[string]any)["matcher"] != "Edit" { t.Fatalf("foreign PreToolUse hook lost: %v", pre) } @@ -66,8 +320,37 @@ func TestRemoveManagedHooksLeavesForeignContent(t *testing.T) { } } +func TestRemoveManagedHooksRemovesSplitCommandArgs(t *testing.T) { + settings := decode(t, `{ + "hooks": { + "PreToolUse": [ + {"matcher": "", "hooks": [ + {"type": "command", "command": "kontext", "args": ["hook", "pre-tool-use"]}, + {"type": "command", "command": "lint-check"} + ]} + ] + } + }`) + + if err := RemoveManagedHooks(settings); err != nil { + t.Fatal(err) + } + + groups := eventGroups(t, settings, "PreToolUse") + if len(groups) != 1 { + t.Fatalf("PreToolUse groups = %d, want one foreign group", len(groups)) + } + handlers := groups[0].(map[string]any)["hooks"].([]any) + if len(handlers) != 1 || handlers[0].(map[string]any)["command"] != "lint-check" { + t.Fatalf("remaining handlers = %v, want only foreign handler", handlers) + } +} + func TestRemoveManagedHooksDropsEmptyHooksKey(t *testing.T) { - settings := decode(t, `{"hooks": {"PreToolUse": [{"matcher": "", "hooks": [{"type": "command", "command": "'/usr/local/bin/kontext' hook 'pre-tool-use'"}]}]}}`) + settings := map[string]any{} + if _, err := MergeManagedHooks(settings, testBinary); err != nil { + t.Fatal(err) + } if err := RemoveManagedHooks(settings); err != nil { t.Fatal(err) } @@ -110,6 +393,7 @@ func TestIsManagedHookCommand(t *testing.T) { {"'/opt/homebrew/bin/kontext' hook 'session-start'", true}, {"/usr/local/bin/kontext hook session-end", true}, {"'/usr/local/bin/kontext' hook 'unterminated", false}, + {`"/tmp/kon\text" hook pre-tool-use`, false}, // Guard hooks must never match. {"'/usr/local/bin/kontext' hook --agent claude --event pre-tool-use --mode observe", false}, {"kontext guard hook claude-code", false},