1package agent
2
3import (
4 "context"
5 "testing"
6
7 "charm.land/fantasy"
8 "github.com/charmbracelet/crush/internal/config"
9 "github.com/charmbracelet/crush/internal/hooks"
10 "github.com/charmbracelet/crush/internal/permission"
11 "github.com/stretchr/testify/require"
12)
13
14// fakeTool records the context it was invoked with so tests can assert on
15// values stamped onto it by the hookedTool decorator.
16type fakeTool struct {
17 name string
18 called bool
19 gotCtx context.Context
20 resp fantasy.ToolResponse
21}
22
23func (f *fakeTool) Info() fantasy.ToolInfo {
24 return fantasy.ToolInfo{Name: f.name}
25}
26
27func (f *fakeTool) Run(ctx context.Context, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
28 f.called = true
29 f.gotCtx = ctx
30 return f.resp, nil
31}
32
33func (f *fakeTool) ProviderOptions() fantasy.ProviderOptions { return nil }
34func (f *fakeTool) SetProviderOptions(_ fantasy.ProviderOptions) {}
35
36// newRunner builds a hooks.Runner from a single HookConfig, running the
37// config-loader path that compiles the matcher regex.
38func newRunner(t *testing.T, cmd string) *hooks.Runner {
39 t.Helper()
40 cfg := &config.Config{
41 Hooks: map[string][]config.HookConfig{
42 hooks.EventPreToolUse: {{Command: cmd}},
43 },
44 }
45 require.NoError(t, cfg.ValidateHooks())
46 return hooks.NewRunner(cfg.Hooks[hooks.EventPreToolUse], t.TempDir(), t.TempDir())
47}
48
49func TestHookedTool_AllowStampsHookApproval(t *testing.T) {
50 t.Parallel()
51
52 inner := &fakeTool{name: "view", resp: fantasy.NewTextResponse("ok")}
53 runner := newRunner(t, `echo '{"decision":"allow"}'`)
54 tool := newHookedTool(inner, runner)
55
56 _, err := tool.Run(t.Context(), fantasy.ToolCall{ID: "call-1", Name: "view"})
57 require.NoError(t, err)
58 require.True(t, inner.called, "inner tool should have run")
59
60 // The inner tool's permission service can now treat call-1 as pre-approved.
61 svc := permission.NewPermissionService(t.TempDir(), false, nil)
62 granted, err := svc.Request(inner.gotCtx, permission.CreatePermissionRequest{
63 SessionID: "s1",
64 ToolCallID: "call-1",
65 ToolName: "view",
66 Action: "read",
67 Path: t.TempDir(),
68 })
69 require.NoError(t, err)
70 require.True(t, granted, "hook allow should bypass the permission prompt")
71}
72
73func TestHookedTool_SilentDoesNotStampApproval(t *testing.T) {
74 t.Parallel()
75
76 inner := &fakeTool{name: "view", resp: fantasy.NewTextResponse("ok")}
77 runner := newRunner(t, `exit 0`) // no stdout, no decision
78 tool := newHookedTool(inner, runner)
79
80 _, err := tool.Run(t.Context(), fantasy.ToolCall{ID: "call-2", Name: "view"})
81 require.NoError(t, err)
82 require.True(t, inner.called)
83
84 // With no hook opinion, a fresh permission request has nothing stamped
85 // and must fall through to the normal flow. We verify by checking that
86 // the context does not look pre-approved for this call ID: sending a
87 // request that no subscriber resolves will block until cancelled.
88 svc := permission.NewPermissionService(t.TempDir(), false, nil)
89 ctx, cancel := context.WithCancel(inner.gotCtx)
90 cancel()
91 granted, err := svc.Request(ctx, permission.CreatePermissionRequest{
92 SessionID: "s1",
93 ToolCallID: "call-2",
94 ToolName: "view",
95 Action: "read",
96 Path: t.TempDir(),
97 })
98 require.Error(t, err, "no approval stamped => request should reach the prompt path")
99 require.False(t, granted)
100}
101
102func TestHookedTool_DenySkipsInnerTool(t *testing.T) {
103 t.Parallel()
104
105 inner := &fakeTool{name: "bash"}
106 runner := newRunner(t, `echo "blocked" >&2; exit 2`)
107 tool := newHookedTool(inner, runner)
108
109 resp, err := tool.Run(t.Context(), fantasy.ToolCall{ID: "call-3", Name: "bash"})
110 require.NoError(t, err)
111 require.False(t, inner.called, "denied call must not reach the inner tool")
112 require.True(t, resp.IsError)
113 require.Contains(t, resp.Content, "blocked")
114}
115
116func TestWrapToolsWithHooks(t *testing.T) {
117 t.Parallel()
118
119 runner := newRunner(t, `exit 0`)
120 inputs := []fantasy.AgentTool{&fakeTool{name: "a"}, &fakeTool{name: "b"}}
121
122 t.Run("top-level agent wraps every tool", func(t *testing.T) {
123 t.Parallel()
124 out := wrapToolsWithHooks(inputs, runner, false)
125 require.Len(t, out, len(inputs))
126 for i, tool := range out {
127 _, ok := tool.(*hookedTool)
128 require.Truef(t, ok, "tool %d should be a *hookedTool", i)
129 }
130 })
131
132 t.Run("sub-agent skips the wrap", func(t *testing.T) {
133 t.Parallel()
134 out := wrapToolsWithHooks(inputs, runner, true)
135 require.Equal(t, inputs, out, "sub-agent tools should be returned unwrapped")
136 for _, tool := range out {
137 _, isHooked := tool.(*hookedTool)
138 require.False(t, isHooked, "sub-agent tool should not be wrapped")
139 }
140 })
141
142 t.Run("nil runner skips the wrap for both agent kinds", func(t *testing.T) {
143 t.Parallel()
144 require.Equal(t, inputs, wrapToolsWithHooks(inputs, nil, false))
145 require.Equal(t, inputs, wrapToolsWithHooks(inputs, nil, true))
146 })
147}