Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions internal/agenthooks/command.go
Original file line number Diff line number Diff line change
@@ -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
}
}
31 changes: 31 additions & 0 deletions internal/agenthooks/command_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
194 changes: 194 additions & 0 deletions internal/agenthooks/config.go
Original file line number Diff line number Diff line change
@@ -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": {
// "<EventName>": [
// {"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()
}
Loading
Loading