hooked_tool_test.go

  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}