| 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 |