1package hooks
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "testing"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11)
12
13func TestExecutor(t *testing.T) {
14 // Create temp directory for test hooks.
15 tempDir := t.TempDir()
16
17 t.Run("executes simple hook with env vars", func(t *testing.T) {
18 hookPath := filepath.Join(tempDir, "test-hook.sh")
19 hookScript := `#!/bin/bash
20export CRUSH_PERMISSION=approve
21export CRUSH_MESSAGE="test message"
22`
23 err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
24 require.NoError(t, err)
25
26 executor := NewExecutor(tempDir)
27 ctx := context.Background()
28 hookCtx := HookContext{
29 HookType: HookPreToolUse,
30 SessionID: "test-session",
31 WorkingDir: tempDir,
32 Data: map[string]any{
33 "tool_input": map[string]any{
34 "command": "ls",
35 },
36 },
37 }
38
39 result, err := executor.Execute(ctx, hookPath, hookCtx)
40
41 require.NoError(t, err)
42 assert.True(t, result.Continue)
43 assert.Equal(t, "approve", result.Permission)
44 assert.Equal(t, "test message", result.Message)
45 })
46
47 t.Run("helper functions are available", func(t *testing.T) {
48 hookPath := filepath.Join(tempDir, "helper-test.sh")
49 hookScript := `#!/bin/bash
50crush_approve "auto approved"
51`
52 err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
53 require.NoError(t, err)
54
55 executor := NewExecutor(tempDir)
56 ctx := context.Background()
57 hookCtx := HookContext{
58 HookType: HookPreToolUse,
59 SessionID: "test-session",
60 WorkingDir: tempDir,
61 Data: map[string]any{},
62 }
63
64 result, err := executor.Execute(ctx, hookPath, hookCtx)
65
66 require.NoError(t, err)
67 assert.Equal(t, "approve", result.Permission)
68 assert.Equal(t, "auto approved", result.Message)
69 })
70
71 t.Run("crush_deny sets continue=false and exits", func(t *testing.T) {
72 hookPath := filepath.Join(tempDir, "deny-test.sh")
73 hookScript := `#!/bin/bash
74crush_deny "blocked"
75`
76 err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
77 require.NoError(t, err)
78
79 executor := NewExecutor(tempDir)
80 ctx := context.Background()
81 hookCtx := HookContext{
82 HookType: HookPreToolUse,
83 SessionID: "test-session",
84 WorkingDir: tempDir,
85 Data: map[string]any{},
86 }
87
88 result, err := executor.Execute(ctx, hookPath, hookCtx)
89
90 require.NoError(t, err)
91 assert.False(t, result.Continue)
92 assert.Equal(t, "deny", result.Permission)
93 assert.Equal(t, "blocked", result.Message)
94 })
95
96 t.Run("reads JSON from stdin", func(t *testing.T) {
97 hookPath := filepath.Join(tempDir, "stdin-test.sh")
98 hookScript := `#!/bin/bash
99COMMAND=$(crush_get_tool_input command)
100if [ "$COMMAND" = "dangerous" ]; then
101 crush_deny "dangerous command"
102fi
103`
104 err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
105 require.NoError(t, err)
106
107 executor := NewExecutor(tempDir)
108 ctx := context.Background()
109 hookCtx := HookContext{
110 HookType: HookPreToolUse,
111 SessionID: "test-session",
112 WorkingDir: tempDir,
113 Data: map[string]any{
114 "tool_input": map[string]any{
115 "command": "dangerous",
116 },
117 },
118 }
119
120 result, err := executor.Execute(ctx, hookPath, hookCtx)
121
122 require.NoError(t, err)
123 assert.False(t, result.Continue)
124 assert.Equal(t, "deny", result.Permission)
125 })
126
127 t.Run("env variables are set correctly", func(t *testing.T) {
128 hookPath := filepath.Join(tempDir, "env-test.sh")
129 hookScript := `#!/bin/bash
130if [ "$CRUSH_HOOK_TYPE" = "pre-tool-use" ] && \
131 [ "$CRUSH_SESSION_ID" = "test-123" ] && \
132 [ "$CRUSH_TOOL_NAME" = "bash" ]; then
133 export CRUSH_MESSAGE="env vars correct"
134fi
135`
136 err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
137 require.NoError(t, err)
138
139 executor := NewExecutor(tempDir)
140 ctx := context.Background()
141 hookCtx := HookContext{
142 HookType: HookPreToolUse,
143 SessionID: "test-123",
144 WorkingDir: tempDir,
145 ToolName: "bash",
146 ToolCallID: "call-123",
147 Data: map[string]any{},
148 }
149
150 result, err := executor.Execute(ctx, hookPath, hookCtx)
151
152 require.NoError(t, err)
153 assert.Equal(t, "env vars correct", result.Message)
154 })
155
156 t.Run("supports JSON output for complex mutations", func(t *testing.T) {
157 hookPath := filepath.Join(tempDir, "json-test.sh")
158 hookScript := `#!/bin/bash
159cat <<EOF
160{
161 "permission": "approve",
162 "modified_input": {
163 "command": "ls -la",
164 "safe": true
165 }
166}
167EOF
168`
169 err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
170 require.NoError(t, err)
171
172 executor := NewExecutor(tempDir)
173 ctx := context.Background()
174 hookCtx := HookContext{
175 HookType: HookPreToolUse,
176 SessionID: "test-session",
177 WorkingDir: tempDir,
178 Data: map[string]any{},
179 }
180
181 result, err := executor.Execute(ctx, hookPath, hookCtx)
182
183 require.NoError(t, err)
184 assert.Equal(t, "approve", result.Permission)
185 assert.Equal(t, "ls -la", result.ModifiedInput["command"])
186 assert.Equal(t, true, result.ModifiedInput["safe"])
187 })
188
189 t.Run("handles exit code 1 as error", func(t *testing.T) {
190 hookPath := filepath.Join(tempDir, "error-test.sh")
191 hookScript := `#!/bin/bash
192echo "error occurred" >&2
193exit 1
194`
195 err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
196 require.NoError(t, err)
197
198 executor := NewExecutor(tempDir)
199 ctx := context.Background()
200 hookCtx := HookContext{
201 HookType: HookPreToolUse,
202 SessionID: "test-session",
203 WorkingDir: tempDir,
204 Data: map[string]any{},
205 }
206
207 _, err = executor.Execute(ctx, hookPath, hookCtx)
208
209 assert.Error(t, err)
210 assert.Contains(t, err.Error(), "hook failed with exit code 1")
211 })
212
213 t.Run("context files helper", func(t *testing.T) {
214 hookPath := filepath.Join(tempDir, "files-test.sh")
215 hookScript := `#!/bin/bash
216crush_add_context_file "file1.md"
217crush_add_context_file "file2.txt"
218`
219 err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
220 require.NoError(t, err)
221
222 executor := NewExecutor(tempDir)
223 ctx := context.Background()
224 hookCtx := HookContext{
225 HookType: HookUserPromptSubmit,
226 SessionID: "test-session",
227 WorkingDir: tempDir,
228 Data: map[string]any{},
229 }
230
231 result, err := executor.Execute(ctx, hookPath, hookCtx)
232
233 require.NoError(t, err)
234 assert.Equal(t, []string{"file1.md", "file2.txt"}, result.ContextFiles)
235 })
236
237 t.Run("context content helper", func(t *testing.T) {
238 hookPath := filepath.Join(tempDir, "content-test.sh")
239 hookScript := `#!/bin/bash
240crush_add_context "This is additional context"
241`
242 err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
243 require.NoError(t, err)
244
245 executor := NewExecutor(tempDir)
246 ctx := context.Background()
247 hookCtx := HookContext{
248 HookType: HookUserPromptSubmit,
249 SessionID: "test-session",
250 WorkingDir: tempDir,
251 Data: map[string]any{},
252 }
253
254 result, err := executor.Execute(ctx, hookPath, hookCtx)
255
256 require.NoError(t, err)
257 assert.Equal(t, "This is additional context", result.ContextContent)
258 })
259
260 t.Run("returns error if hook file doesn't exist", func(t *testing.T) {
261 executor := NewExecutor(tempDir)
262 ctx := context.Background()
263 hookCtx := HookContext{
264 HookType: HookPreToolUse,
265 SessionID: "test-session",
266 WorkingDir: tempDir,
267 Data: map[string]any{},
268 }
269
270 _, err := executor.Execute(ctx, "/nonexistent/hook.sh", hookCtx)
271
272 assert.Error(t, err)
273 assert.Contains(t, err.Error(), "failed to read hook")
274 })
275
276 t.Run("passes custom environment variables", func(t *testing.T) {
277 hookPath := filepath.Join(tempDir, "custom-env-test.sh")
278 hookScript := `#!/bin/bash
279if [ "$CUSTOM_API_KEY" = "secret123" ] && [ "$CUSTOM_REGION" = "us-west-2" ]; then
280 export CRUSH_MESSAGE="custom env vars set correctly"
281fi
282`
283 err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
284 require.NoError(t, err)
285
286 executor := NewExecutor(tempDir)
287 ctx := context.Background()
288 hookCtx := HookContext{
289 HookType: HookPreToolUse,
290 SessionID: "test-session",
291 WorkingDir: tempDir,
292 Data: map[string]any{},
293 Environment: map[string]string{
294 "CUSTOM_API_KEY": "secret123",
295 "CUSTOM_REGION": "us-west-2",
296 },
297 }
298
299 result, err := executor.Execute(ctx, hookPath, hookCtx)
300
301 require.NoError(t, err)
302 assert.Equal(t, "custom env vars set correctly", result.Message)
303 })
304
305 t.Run("modify input helper function", func(t *testing.T) {
306 hookPath := filepath.Join(tempDir, "modify-input-test.sh")
307 hookScript := `#!/bin/bash
308crush_modify_input "command" "ls -la"
309crush_modify_input "working_dir" "/tmp"
310`
311 err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
312 require.NoError(t, err)
313
314 executor := NewExecutor(tempDir)
315 ctx := context.Background()
316 hookCtx := HookContext{
317 HookType: HookPreToolUse,
318 SessionID: "test-session",
319 WorkingDir: tempDir,
320 Data: map[string]any{},
321 }
322
323 result, err := executor.Execute(ctx, hookPath, hookCtx)
324
325 require.NoError(t, err)
326 require.NotNil(t, result.ModifiedInput)
327 assert.Equal(t, "ls -la", result.ModifiedInput["command"])
328 assert.Equal(t, "/tmp", result.ModifiedInput["working_dir"])
329 })
330
331 t.Run("modify output helper function", func(t *testing.T) {
332 hookPath := filepath.Join(tempDir, "modify-output-test.sh")
333 hookScript := `#!/bin/bash
334crush_modify_output "status" "redacted"
335crush_modify_output "data" "[REDACTED]"
336`
337 err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
338 require.NoError(t, err)
339
340 executor := NewExecutor(tempDir)
341 ctx := context.Background()
342 hookCtx := HookContext{
343 HookType: HookPostToolUse,
344 SessionID: "test-session",
345 WorkingDir: tempDir,
346 Data: map[string]any{},
347 }
348
349 result, err := executor.Execute(ctx, hookPath, hookCtx)
350
351 require.NoError(t, err)
352 require.NotNil(t, result.ModifiedOutput)
353 assert.Equal(t, "redacted", result.ModifiedOutput["status"])
354 assert.Equal(t, "[REDACTED]", result.ModifiedOutput["data"])
355 })
356
357 t.Run("modify input with JSON types", func(t *testing.T) {
358 hookPath := filepath.Join(tempDir, "modify-input-json-test.sh")
359 hookScript := `#!/bin/bash
360crush_modify_input "offset" "100"
361crush_modify_input "limit" "50"
362crush_modify_input "run_in_background" "true"
363crush_modify_input "ignore" '["*.log","*.tmp"]'
364`
365 err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
366 require.NoError(t, err)
367
368 executor := NewExecutor(tempDir)
369 ctx := context.Background()
370 hookCtx := HookContext{
371 HookType: HookPreToolUse,
372 SessionID: "test-session",
373 WorkingDir: tempDir,
374 Data: map[string]any{},
375 }
376
377 result, err := executor.Execute(ctx, hookPath, hookCtx)
378
379 require.NoError(t, err)
380 require.NotNil(t, result.ModifiedInput)
381 assert.Equal(t, float64(100), result.ModifiedInput["offset"])
382 assert.Equal(t, float64(50), result.ModifiedInput["limit"])
383 assert.Equal(t, true, result.ModifiedInput["run_in_background"])
384 assert.Equal(t, []any{"*.log", "*.tmp"}, result.ModifiedInput["ignore"])
385 })
386}
387
388func TestGetHelpersScript(t *testing.T) {
389 script := GetHelpersScript()
390
391 assert.NotEmpty(t, script)
392 assert.Contains(t, script, "crush_approve")
393 assert.Contains(t, script, "crush_deny")
394 assert.Contains(t, script, "crush_add_context")
395}