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" }