返回 DeepSeek-Reasonix
review_hooks_test.go
根目录 / internal / cli / review_hooks_test.go
1 package cli
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12 "sync"
13 "testing"
14
15 "reasonix/internal/config"
16 "reasonix/internal/hook"
17 "reasonix/internal/provider"
18 )
19
20 const reviewHookProbeKind = "cli-review-hook-probe"
21
22 var reviewHookProbe = &reviewHookProbeProvider{}
23
24 func init() {
25 provider.Register(reviewHookProbeKind, func(provider.Config) (provider.Provider, error) {
26 return reviewHookProbe, nil
27 })
28 }
29
30 // A review runs in a checkout under review, so only the user's own hooks may
31 // fire: the global deny governs the review subagent, the project hook never runs.
32 func TestReviewCommandRunsOnlyUserHooks(t *testing.T) {
33 isolateCLIConfigHome(t)
34 dir := setupReviewCheckout(t, nil)
35 scripts := t.TempDir()
36 globalLog := filepath.Join(scripts, "global.log")
37 projectLog := filepath.Join(scripts, "project.log")
38 globalHook := writeReviewHookScript(t, scripts, "global-deny.sh", globalLog, 2)
39 projectHook := writeReviewHookScript(t, scripts, "project-log.sh", projectLog, 0)
40 writeReviewHookSettings(t, hook.GlobalSettingsPath(""), globalHook)
41 writeReviewHookSettings(t, hook.ProjectSettingsPath(dir), projectHook)
42
43 var sessions []string
44 for run := range 2 {
45 reviewHookProbe.reset()
46 captureStdout(t, func() {
47 if rc := reviewCommand(nil); rc != 0 {
48 t.Fatalf("reviewCommand rc = %d, want 0", rc)
49 }
50 })
51 if !reviewHookProbe.ran() {
52 t.Fatal("review subagent never reached the provider")
53 }
54 if _, err := os.Stat(projectLog); !os.IsNotExist(err) {
55 t.Fatalf("the reviewed checkout's project hook ran (stat err = %v)", err)
56 }
57 if reviewHookProbe.readLeaked() {
58 t.Fatal("review subagent received marker.txt contents past the global PreToolUse deny")
59 }
60 if !reviewHookProbe.readBlocked() {
61 t.Fatal("review subagent's read_file result was not blocked by the global PreToolUse hook")
62 }
63 payloads := readReviewHookLog(t, globalLog)
64 if len(payloads) != run+1 {
65 t.Fatalf("global hook ran %d times after %d reviews, want one per review", len(payloads), run+1)
66 }
67 p := payloads[run]
68 if p.ToolName != "read_file" || !strings.HasPrefix(p.SessionID, "review:") || p.SessionID == "review:" {
69 t.Fatalf("hook payload = %+v, want read_file under a per-run review session", p)
70 }
71 sessions = append(sessions, p.SessionID)
72 }
73 if sessions[0] == sessions[1] {
74 t.Fatalf("two review runs shared hook session %q", sessions[0])
75 }
76 }
77
78 // The reviewed checkout's reasonix.toml must not choose the interpreter hooks
79 // run under: on Windows resolving a configured bash probes it, then spawns
80 // every bash hook with it, outside the sandbox.
81 func TestReviewHookRunnerTakesShellFromUserConfigOnly(t *testing.T) {
82 isolateCLIConfigHome(t)
83 userShell := filepath.Join(t.TempDir(), "user-shell")
84 writeReviewTestFile(t, filepath.Dir(config.UserConfigPath()), filepath.Base(config.UserConfigPath()),
85 fmt.Sprintf("[tools.shell]\nprefer = \"powershell\"\npath = %q\n", userShell))
86 var evilBash string
87 setupReviewCheckout(t, func(dir string) string {
88 evilBash = filepath.Join(dir, "evil-bash")
89 return fmt.Sprintf("\n[tools.shell]\nprefer = \"bash\"\npath = %q\n", evilBash)
90 })
91
92 var got []config.ShellConfig
93 var loads []hook.LoadOptions
94 prev := newCommandHookRunner
95 t.Cleanup(func() { newCommandHookRunner = prev })
96 newCommandHookRunner = func(shell config.ShellConfig, load hook.LoadOptions, warn io.Writer) *hook.Runner {
97 got = append(got, shell)
98 loads = append(loads, load)
99 return prev(shell, load, warn)
100 }
101
102 reviewHookProbe.reset()
103 captureStdout(t, func() {
104 if rc := reviewCommand(nil); rc != 0 {
105 t.Fatalf("reviewCommand rc = %d, want 0", rc)
106 }
107 })
108 if len(got) != 1 {
109 t.Fatalf("hook runner built %d times, want 1", len(got))
110 }
111 want := config.ShellConfig{Prefer: "powershell", Path: userShell}
112 if got[0] != want {
113 t.Fatalf("review hook runner shell = %+v, want the user's %+v (checkout configured %q)", got[0], want, evilBash)
114 }
115 if !loads[0].SkipProject {
116 t.Fatalf("review hook runner load = %+v, want SkipProject", loads[0])
117 }
118 }
119
120 // Review hooks run with the checkout as cwd, and cmd.exe resolves a bare
121 // `python` against the cwd first, so the checkout could ship the interpreter.
122 func TestReviewHooksNeverResolveCommandsAgainstTheCheckout(t *testing.T) {
123 isolateCLIConfigHome(t)
124 dir := setupReviewCheckout(t, nil)
125 writeReviewHookSettings(t, hook.GlobalSettingsPath(""), "python guard.py")
126
127 var mu sync.Mutex
128 var spawned []hook.SpawnInput
129 record := func(_ context.Context, in hook.SpawnInput) hook.SpawnResult {
130 mu.Lock()
131 defer mu.Unlock()
132 spawned = append(spawned, in)
133 return hook.SpawnResult{}
134 }
135 prev := newCommandHookRunner
136 t.Cleanup(func() { newCommandHookRunner = prev })
137 newCommandHookRunner = func(_ config.ShellConfig, load hook.LoadOptions, _ io.Writer) *hook.Runner {
138 return hook.NewRunner(hook.Load(load), load.ProjectRoot, record, nil)
139 }
140
141 reviewHookProbe.reset()
142 captureStdout(t, func() {
143 if rc := reviewCommand(nil); rc != 0 {
144 t.Fatalf("reviewCommand rc = %d, want 0", rc)
145 }
146 })
147 mu.Lock()
148 defer mu.Unlock()
149 if len(spawned) == 0 {
150 t.Fatal("the user's PreToolUse hook never spawned for the review subagent")
151 }
152 for _, in := range spawned {
153 if got := in.Env[hook.NoCwdCommandSearchEnv]; got != "1" {
154 t.Fatalf("review hook %q spawned in %s with %s=%q, want 1", in.Command, in.Cwd, hook.NoCwdCommandSearchEnv, got)
155 }
156 if !sameReviewPath(in.Cwd, dir) {
157 t.Fatalf("review hook cwd = %q, want the checkout %q", in.Cwd, dir)
158 }
159 }
160 }
161
162 func sameReviewPath(a, b string) bool {
163 ra, errA := filepath.EvalSymlinks(a)
164 rb, errB := filepath.EvalSymlinks(b)
165 return errA == nil && errB == nil && ra == rb
166 }
167
168 func setupReviewCheckout(t *testing.T, extraTOML func(dir string) string) string {
169 t.Helper()
170 dir := t.TempDir()
171 t.Chdir(dir)
172 for _, args := range [][]string{
173 {"init", "-q"},
174 {"config", "user.email", "test@example.com"},
175 {"config", "user.name", "test"},
176 } {
177 runReviewTestGit(t, dir, args...)
178 }
179 writeReviewTestFile(t, dir, "marker.txt", "review hook probe\n")
180 runReviewTestGit(t, dir, "add", "marker.txt")
181 runReviewTestGit(t, dir, "commit", "-q", "-m", "init")
182 writeReviewTestFile(t, dir, "marker.txt", "review hook probe\nchanged\n")
183 extra := ""
184 if extraTOML != nil {
185 extra = extraTOML(dir)
186 }
187 writeReviewTestFile(t, dir, "reasonix.toml", `
188 default_model = "reviewer"
189
190 [[providers]]
191 name = "reviewer"
192 kind = "`+reviewHookProbeKind+`"
193 model = "review-model"
194 base_url = "http://127.0.0.1:1"
195 `+extra)
196 approveWorkspace(t, dir)
197 return dir
198 }
199
200 type reviewHookPayload struct{ ToolName, SessionID string }
201
202 func writeReviewHookScript(t *testing.T, dir, name, logPath string, exit int) string {
203 t.Helper()
204 body := fmt.Sprintf("#!/bin/sh\ncat >> '%s'\nexit %d\n", strings.ReplaceAll(logPath, "'", `'\''`), exit)
205 writeReviewTestFile(t, dir, name, body)
206 path := filepath.Join(dir, name)
207 if err := os.Chmod(path, 0o755); err != nil {
208 t.Fatal(err)
209 }
210 return path
211 }
212
213 func writeReviewHookSettings(t *testing.T, path, command string) {
214 t.Helper()
215 settings, err := json.Marshal(map[string]any{"hooks": map[string]any{"PreToolUse": []any{map[string]string{"match": "read_file", "command": command}}}})
216 if err != nil {
217 t.Fatal(err)
218 }
219 writeReviewTestFile(t, filepath.Dir(path), filepath.Base(path), string(settings))
220 }
221
222 func readReviewHookLog(t *testing.T, path string) []reviewHookPayload {
223 t.Helper()
224 log, err := os.ReadFile(path)
225 if err != nil {
226 t.Fatalf("global PreToolUse hook never ran for the review subagent's read_file: %v", err)
227 }
228 var out []reviewHookPayload
229 for line := range strings.SplitSeq(strings.TrimSpace(string(log)), "\n") {
230 var p reviewHookPayload
231 if err := json.Unmarshal([]byte(line), &p); err != nil {
232 t.Fatalf("decode hook payload %q: %v", line, err)
233 }
234 out = append(out, p)
235 }
236 return out
237 }
238
239 func runReviewTestGit(t *testing.T, dir string, args ...string) {
240 t.Helper()
241 cmd := exec.Command("git", args...)
242 cmd.Dir = dir
243 if out, err := cmd.CombinedOutput(); err != nil {
244 t.Fatalf("git %v: %v\n%s", args, err, out)
245 }
246 }
247
248 func writeReviewTestFile(t *testing.T, dir, name, body string) {
249 t.Helper()
250 path := filepath.Join(dir, name)
251 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
252 t.Fatal(err)
253 }
254 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
255 t.Fatal(err)
256 }
257 }
258
259 type reviewHookProbeProvider struct {
260 mu sync.Mutex
261 calls int
262 blocked bool
263 leaked bool
264 }
265
266 func (p *reviewHookProbeProvider) reset() {
267 p.mu.Lock()
268 defer p.mu.Unlock()
269 p.calls, p.blocked, p.leaked = 0, false, false
270 }
271
272 func (p *reviewHookProbeProvider) ran() bool {
273 p.mu.Lock()
274 defer p.mu.Unlock()
275 return p.calls > 0
276 }
277
278 func (p *reviewHookProbeProvider) readBlocked() bool {
279 p.mu.Lock()
280 defer p.mu.Unlock()
281 return p.blocked
282 }
283
284 func (p *reviewHookProbeProvider) readLeaked() bool {
285 p.mu.Lock()
286 defer p.mu.Unlock()
287 return p.leaked
288 }
289
290 func (p *reviewHookProbeProvider) Name() string { return reviewHookProbeKind }
291
292 func (p *reviewHookProbeProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
293 p.mu.Lock()
294 call := p.calls
295 p.calls++
296 for _, msg := range req.Messages {
297 if msg.Role != provider.RoleTool || msg.Name != "read_file" {
298 continue
299 }
300 if strings.Contains(msg.Content, "blocked:") {
301 p.blocked = true
302 }
303 if strings.Contains(msg.Content, "review hook probe") {
304 p.leaked = true
305 }
306 }
307 p.mu.Unlock()
308
309 chunks := []provider.Chunk{{Type: provider.ChunkText, Text: "no findings"}, {Type: provider.ChunkDone}}
310 if call == 0 {
311 chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "review-read", Name: "read_file", Arguments: `{"path":"marker.txt"}`}}}
312 }
313 ch := make(chan provider.Chunk, len(chunks))
314 for _, chunk := range chunks {
315 ch <- chunk
316 }
317 close(ch)
318 return ch, nil
319 }
320
320 lines GO