Files

193 lines
5.4 KiB
Go

// Package hooks runs user-defined shell commands that fire on hook events
// (e.g. PreToolUse), returning decisions that control agent behavior.
package hooks
import (
"encoding/json"
"log/slog"
"strings"
"github.com/tidwall/sjson"
)
// Hook event name constants.
const (
EventPreToolUse = "PreToolUse"
)
// HaltExitCode is the exit code that halts the whole turn. 2 blocks the
// current tool call; 49 sits in the no-man's-land between the
// generic-error range (1-30), the sysexits range (64-78), and the
// killed-by-signal range (128+) so it can't be hit by accident.
const HaltExitCode = 49
// HookMetadata is embedded in tool response metadata so the UI can
// display a hook indicator.
type HookMetadata struct {
HookCount int `json:"hook_count"`
Decision string `json:"decision"`
Halt bool `json:"halt,omitempty"`
Reason string `json:"reason,omitempty"`
InputRewrite bool `json:"input_rewrite,omitempty"`
Hooks []HookInfo `json:"hooks,omitempty"`
}
// HookInfo identifies a single hook that ran and its individual result.
type HookInfo struct {
Name string `json:"name"`
Matcher string `json:"matcher,omitempty"`
Decision string `json:"decision"`
Halt bool `json:"halt,omitempty"`
Reason string `json:"reason,omitempty"`
InputRewrite bool `json:"input_rewrite,omitempty"`
}
// Decision represents the outcome of a single hook execution.
type Decision int
const (
// DecisionNone means the hook expressed no opinion.
DecisionNone Decision = iota
// DecisionAllow means the hook explicitly allowed the action.
DecisionAllow
// DecisionDeny means the hook blocked the action.
DecisionDeny
)
func (d Decision) String() string {
switch d {
case DecisionAllow:
return "allow"
case DecisionDeny:
return "deny"
default:
return "none"
}
}
// HookResult holds the parsed output of a single hook execution.
type HookResult struct {
Decision Decision
Halt bool // If true, halt the whole turn.
Reason string // Deny or halt reason (same field, different audience).
Context string
UpdatedInput string // Shallow-merge patch against tool_input (opaque JSON).
}
// AggregateResult holds the combined outcome of all hooks for an event.
type AggregateResult struct {
Decision Decision
Halt bool // Any hook requested halt.
HookCount int // Number of hooks that ran.
Hooks []HookInfo // Info about each hook that ran (config order).
Reason string // Concatenated deny/halt reasons (newline-separated).
Context string // Concatenated context from all hooks.
UpdatedInput string // Merged tool_input JSON (empty if no patches).
}
// aggregate merges multiple HookResults into a single AggregateResult.
// Results are processed in config order (the order of the slice). Deny
// wins over allow, allow wins over none. Halt is sticky. Reasons and
// context concatenate in order. updated_input patches shallow-merge in
// order against the original tool input; later patches override earlier
// ones on colliding keys.
func aggregate(results []HookResult, origToolInput string) AggregateResult {
var (
decision Decision
halt bool
reasons []string
contexts []string
merged = origToolInput
anyPatch = false
)
for _, r := range results {
switch r.Decision {
case DecisionDeny:
decision = DecisionDeny
if r.Reason != "" {
reasons = append(reasons, r.Reason)
}
case DecisionAllow:
if decision != DecisionDeny {
decision = DecisionAllow
}
case DecisionNone:
// No change.
}
if r.Halt {
halt = true
if r.Reason != "" && r.Decision != DecisionDeny {
// A halting hook that didn't also deny still contributes
// its reason so the user sees it.
reasons = append(reasons, r.Reason)
}
}
if r.Context != "" {
contexts = append(contexts, r.Context)
}
if r.UpdatedInput != "" {
next, err := shallowMerge(merged, r.UpdatedInput)
if err != nil {
slog.Warn("Hook updated_input patch rejected; ignoring",
"error", err,
"patch", r.UpdatedInput,
)
continue
}
merged = next
anyPatch = true
}
}
agg := AggregateResult{
Decision: decision,
Halt: halt,
HookCount: len(results),
}
if anyPatch {
agg.UpdatedInput = merged
}
if len(reasons) > 0 {
agg.Reason = strings.Join(reasons, "\n")
}
if len(contexts) > 0 {
agg.Context = strings.Join(contexts, "\n")
}
return agg
}
// shallowMerge applies a top-level-keys patch to base (both JSON
// objects). Keys in patch overwrite keys in base; keys absent from the
// patch are preserved. Returns an error if either value is not a valid
// JSON object.
func shallowMerge(base, patch string) (string, error) {
if base == "" {
base = "{}"
}
// Ensure base is an object so sjson has somewhere to write.
var baseAny any
if err := json.Unmarshal([]byte(base), &baseAny); err != nil {
return "", err
}
if _, ok := baseAny.(map[string]any); !ok {
return "", errNotObject("tool_input")
}
var patchMap map[string]json.RawMessage
if err := json.Unmarshal([]byte(patch), &patchMap); err != nil {
return "", errNotObject("updated_input")
}
out := base
for k, v := range patchMap {
next, err := sjson.SetRawBytes([]byte(out), k, v)
if err != nil {
return "", err
}
out = string(next)
}
return out, nil
}
type errNotObject string
func (e errNotObject) Error() string { return string(e) + " is not a JSON object" }