input.go

  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}