hooks.go

  1// Package hooks runs user-defined shell commands that fire on hook events
  2// (e.g. PreToolUse), returning decisions that control agent behavior.
  3package hooks
  4
  5import (
  6	"encoding/json"
  7	"log/slog"
  8	"strings"
  9
 10	"github.com/tidwall/sjson"
 11)
 12
 13// Hook event name constants.
 14const (
 15	EventPreToolUse = "PreToolUse"
 16)
 17
 18// HaltExitCode is the exit code that halts the whole turn. 2 blocks the
 19// current tool call; 49 sits in the no-man's-land between the
 20// generic-error range (1-30), the sysexits range (64-78), and the
 21// killed-by-signal range (128+) so it can't be hit by accident.
 22const HaltExitCode = 49
 23
 24// HookMetadata is embedded in tool response metadata so the UI can
 25// display a hook indicator.
 26type HookMetadata struct {
 27	HookCount    int        `json:"hook_count"`
 28	Decision     string     `json:"decision"`
 29	Halt         bool       `json:"halt,omitempty"`
 30	Reason       string     `json:"reason,omitempty"`
 31	InputRewrite bool       `json:"input_rewrite,omitempty"`
 32	Hooks        []HookInfo `json:"hooks,omitempty"`
 33}
 34
 35// HookInfo identifies a single hook that ran and its individual result.
 36type HookInfo struct {
 37	Name         string `json:"name"`
 38	Matcher      string `json:"matcher,omitempty"`
 39	Decision     string `json:"decision"`
 40	Halt         bool   `json:"halt,omitempty"`
 41	Reason       string `json:"reason,omitempty"`
 42	InputRewrite bool   `json:"input_rewrite,omitempty"`
 43}
 44
 45// Decision represents the outcome of a single hook execution.
 46type Decision int
 47
 48const (
 49	// DecisionNone means the hook expressed no opinion.
 50	DecisionNone Decision = iota
 51	// DecisionAllow means the hook explicitly allowed the action.
 52	DecisionAllow
 53	// DecisionDeny means the hook blocked the action.
 54	DecisionDeny
 55)
 56
 57func (d Decision) String() string {
 58	switch d {
 59	case DecisionAllow:
 60		return "allow"
 61	case DecisionDeny:
 62		return "deny"
 63	default:
 64		return "none"
 65	}
 66}
 67
 68// HookResult holds the parsed output of a single hook execution.
 69type HookResult struct {
 70	Decision     Decision
 71	Halt         bool   // If true, halt the whole turn.
 72	Reason       string // Deny or halt reason (same field, different audience).
 73	Context      string
 74	UpdatedInput string // Shallow-merge patch against tool_input (opaque JSON).
 75}
 76
 77// AggregateResult holds the combined outcome of all hooks for an event.
 78type AggregateResult struct {
 79	Decision     Decision
 80	Halt         bool       // Any hook requested halt.
 81	HookCount    int        // Number of hooks that ran.
 82	Hooks        []HookInfo // Info about each hook that ran (config order).
 83	Reason       string     // Concatenated deny/halt reasons (newline-separated).
 84	Context      string     // Concatenated context from all hooks.
 85	UpdatedInput string     // Merged tool_input JSON (empty if no patches).
 86}
 87
 88// aggregate merges multiple HookResults into a single AggregateResult.
 89// Results are processed in config order (the order of the slice). Deny
 90// wins over allow, allow wins over none. Halt is sticky. Reasons and
 91// context concatenate in order. updated_input patches shallow-merge in
 92// order against the original tool input; later patches override earlier
 93// ones on colliding keys.
 94func aggregate(results []HookResult, origToolInput string) AggregateResult {
 95	var (
 96		decision Decision
 97		halt     bool
 98		reasons  []string
 99		contexts []string
100		merged   = origToolInput
101		anyPatch = false
102	)
103	for _, r := range results {
104		switch r.Decision {
105		case DecisionDeny:
106			decision = DecisionDeny
107			if r.Reason != "" {
108				reasons = append(reasons, r.Reason)
109			}
110		case DecisionAllow:
111			if decision != DecisionDeny {
112				decision = DecisionAllow
113			}
114		case DecisionNone:
115			// No change.
116		}
117		if r.Halt {
118			halt = true
119			if r.Reason != "" && r.Decision != DecisionDeny {
120				// A halting hook that didn't also deny still contributes
121				// its reason so the user sees it.
122				reasons = append(reasons, r.Reason)
123			}
124		}
125		if r.Context != "" {
126			contexts = append(contexts, r.Context)
127		}
128		if r.UpdatedInput != "" {
129			next, err := shallowMerge(merged, r.UpdatedInput)
130			if err != nil {
131				slog.Warn("Hook updated_input patch rejected; ignoring",
132					"error", err,
133					"patch", r.UpdatedInput,
134				)
135				continue
136			}
137			merged = next
138			anyPatch = true
139		}
140	}
141
142	agg := AggregateResult{
143		Decision:  decision,
144		Halt:      halt,
145		HookCount: len(results),
146	}
147	if anyPatch {
148		agg.UpdatedInput = merged
149	}
150	if len(reasons) > 0 {
151		agg.Reason = strings.Join(reasons, "\n")
152	}
153	if len(contexts) > 0 {
154		agg.Context = strings.Join(contexts, "\n")
155	}
156	return agg
157}
158
159// shallowMerge applies a top-level-keys patch to base (both JSON
160// objects). Keys in patch overwrite keys in base; keys absent from the
161// patch are preserved. Returns an error if either value is not a valid
162// JSON object.
163func shallowMerge(base, patch string) (string, error) {
164	if base == "" {
165		base = "{}"
166	}
167	// Ensure base is an object so sjson has somewhere to write.
168	var baseAny any
169	if err := json.Unmarshal([]byte(base), &baseAny); err != nil {
170		return "", err
171	}
172	if _, ok := baseAny.(map[string]any); !ok {
173		return "", errNotObject("tool_input")
174	}
175	var patchMap map[string]json.RawMessage
176	if err := json.Unmarshal([]byte(patch), &patchMap); err != nil {
177		return "", errNotObject("updated_input")
178	}
179	out := base
180	for k, v := range patchMap {
181		next, err := sjson.SetRawBytes([]byte(out), k, v)
182		if err != nil {
183			return "", err
184		}
185		out = string(next)
186	}
187	return out, nil
188}
189
190type errNotObject string
191
192func (e errNotObject) Error() string { return string(e) + " is not a JSON object" }