1package hooks
2
3import (
4 "encoding/json"
5 "fmt"
6 "log/slog"
7 "os"
8 "strings"
9
10 "github.com/tidwall/gjson"
11)
12
13// SupportedOutputVersion is the highest envelope version this build
14// understands. Hooks may omit `version` entirely (treated as 1) or pin
15// an older version. Unknown higher versions are still parsed but logged.
16const SupportedOutputVersion = 1
17
18// Payload is the JSON structure piped to hook commands via stdin.
19// ToolInput is emitted as a parsed JSON object for compatibility with
20// Claude Code hooks (which expect tool_input to be an object, not a
21// string).
22type Payload struct {
23 Event string `json:"event"`
24 SessionID string `json:"session_id"`
25 CWD string `json:"cwd"`
26 ToolName string `json:"tool_name"`
27 ToolInput json.RawMessage `json:"tool_input"`
28}
29
30// BuildPayload constructs the JSON stdin payload for a hook command.
31func BuildPayload(eventName, sessionID, cwd, toolName, toolInputJSON string) []byte {
32 toolInput := json.RawMessage(toolInputJSON)
33 if !json.Valid(toolInput) {
34 toolInput = json.RawMessage("{}")
35 }
36 p := Payload{
37 Event: eventName,
38 SessionID: sessionID,
39 CWD: cwd,
40 ToolName: toolName,
41 ToolInput: toolInput,
42 }
43 data, err := json.Marshal(p)
44 if err != nil {
45 return []byte("{}")
46 }
47 return data
48}
49
50// BuildEnv constructs the environment variable slice for a hook command.
51// It includes all current process env vars plus hook-specific ones.
52func BuildEnv(eventName, toolName, sessionID, cwd, projectDir, toolInputJSON string) []string {
53 env := os.Environ()
54 env = append(env,
55 fmt.Sprintf("CRUSH_EVENT=%s", eventName),
56 fmt.Sprintf("CRUSH_TOOL_NAME=%s", toolName),
57 fmt.Sprintf("CRUSH_SESSION_ID=%s", sessionID),
58 fmt.Sprintf("CRUSH_CWD=%s", cwd),
59 fmt.Sprintf("CRUSH_PROJECT_DIR=%s", projectDir),
60 )
61
62 // Extract tool-specific env vars from the JSON input.
63 if toolInputJSON != "" {
64 if cmd := gjson.Get(toolInputJSON, "command"); cmd.Exists() {
65 env = append(env, fmt.Sprintf("CRUSH_TOOL_INPUT_COMMAND=%s", cmd.String()))
66 }
67 if fp := gjson.Get(toolInputJSON, "file_path"); fp.Exists() {
68 env = append(env, fmt.Sprintf("CRUSH_TOOL_INPUT_FILE_PATH=%s", fp.String()))
69 }
70 }
71
72 return env
73}
74
75// parseStdout parses the JSON output from a hook command's stdout.
76// Supports both Crush format and Claude Code format (hookSpecificOutput).
77func parseStdout(stdout string) HookResult {
78 stdout = strings.TrimSpace(stdout)
79 if stdout == "" {
80 return HookResult{Decision: DecisionNone}
81 }
82
83 var raw map[string]json.RawMessage
84 if err := json.Unmarshal([]byte(stdout), &raw); err != nil {
85 return HookResult{Decision: DecisionNone}
86 }
87
88 // Claude Code compat: if hookSpecificOutput is present, parse that.
89 if hso, ok := raw["hookSpecificOutput"]; ok {
90 return parseClaudeCodeOutput(hso)
91 }
92
93 var parsed struct {
94 Version int `json:"version"`
95 Decision string `json:"decision"`
96 Halt bool `json:"halt"`
97 Reason string `json:"reason"`
98 Context json.RawMessage `json:"context"`
99 UpdatedInput json.RawMessage `json:"updated_input"`
100 }
101 if err := json.Unmarshal([]byte(stdout), &parsed); err != nil {
102 return HookResult{Decision: DecisionNone}
103 }
104
105 if parsed.Version > SupportedOutputVersion {
106 slog.Debug("Hook output declared a newer envelope version than this build supports",
107 "version", parsed.Version,
108 "supported", SupportedOutputVersion,
109 )
110 }
111
112 result := HookResult{
113 Halt: parsed.Halt,
114 Reason: parsed.Reason,
115 Context: parseContext(parsed.Context),
116 }
117 result.Decision = parseDecision(parsed.Decision)
118 result.UpdatedInput = rawToString(parsed.UpdatedInput)
119 return result
120}
121
122// parseContext accepts either a single string or an array of strings and
123// returns a newline-joined value with empty entries dropped.
124func parseContext(raw json.RawMessage) string {
125 if len(raw) == 0 || string(raw) == "null" {
126 return ""
127 }
128 // String form.
129 if raw[0] == '"' {
130 var s string
131 if err := json.Unmarshal(raw, &s); err == nil {
132 return s
133 }
134 return ""
135 }
136 // Array form.
137 if raw[0] == '[' {
138 var items []string
139 if err := json.Unmarshal(raw, &items); err != nil {
140 return ""
141 }
142 out := items[:0]
143 for _, s := range items {
144 if s != "" {
145 out = append(out, s)
146 }
147 }
148 return strings.Join(out, "\n")
149 }
150 return ""
151}
152
153// parseClaudeCodeOutput handles the Claude Code hook output format:
154// {"hookSpecificOutput": {"permissionDecision": "allow", ...}}
155func parseClaudeCodeOutput(data json.RawMessage) HookResult {
156 var hso struct {
157 PermissionDecision string `json:"permissionDecision"`
158 PermissionDecisionReason string `json:"permissionDecisionReason"`
159 UpdatedInput json.RawMessage `json:"updatedInput"`
160 }
161 if err := json.Unmarshal(data, &hso); err != nil {
162 return HookResult{Decision: DecisionNone}
163 }
164
165 result := HookResult{
166 Decision: parseDecision(hso.PermissionDecision),
167 Reason: hso.PermissionDecisionReason,
168 }
169
170 // Marshal updatedInput back to a string for our opaque format.
171 if len(hso.UpdatedInput) > 0 && string(hso.UpdatedInput) != "null" {
172 result.UpdatedInput = string(hso.UpdatedInput)
173 }
174
175 return result
176}
177
178// rawToString converts a json.RawMessage to a string suitable for use
179// as opaque tool input. It accepts both a JSON object (nested) and a
180// JSON string (stringified, for backward compatibility).
181func rawToString(raw json.RawMessage) string {
182 if len(raw) == 0 || string(raw) == "null" {
183 return ""
184 }
185 // If it's a JSON string, unwrap it.
186 if raw[0] == '"' {
187 var s string
188 if err := json.Unmarshal(raw, &s); err == nil {
189 return s
190 }
191 }
192 // Otherwise it's an object/array — use as-is.
193 return string(raw)
194}
195
196func parseDecision(s string) Decision {
197 switch strings.ToLower(s) {
198 case "allow":
199 return DecisionAllow
200 case "deny":
201 return DecisionDeny
202 default:
203 return DecisionNone
204 }
205}