executor_test.go

  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}