| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "reasonix/internal/agent" |
| 11 | "reasonix/internal/event" |
| 12 | "reasonix/internal/memory" |
| 13 | "reasonix/internal/permission" |
| 14 | "reasonix/internal/provider" |
| 15 | "reasonix/internal/tool" |
| 16 | ) |
| 17 | |
| 18 | // runHeadlessWriteOnce drives one write_file tool call through a headless gate in |
| 19 | // the given mode, with the given explicit ask rules, and reports how many |
| 20 | // approval prompts were emitted (must always be 0 headless) and which paths were |
| 21 | // actually written. It fails the test if the turn blocks (a wrongful prompt would |
| 22 | // hang forever under the zero approval timeout). |
| 23 | func runHeadlessWriteOnce(t *testing.T, mode string, askRules []string) (prompts int, written []string) { |
| 24 | t.Helper() |
| 25 | writer := &recordingWriter{} |
| 26 | reg := tool.NewRegistry() |
| 27 | reg.Add(writer) |
| 28 | |
| 29 | prov := &scriptedTurns{turns: [][]provider.Chunk{ |
| 30 | toolCallTurn("c1", "write_file", `{"path":"a.txt"}`), |
| 31 | textTurn("Done."), |
| 32 | }} |
| 33 | ag := agent.New(prov, reg, agent.NewSession(""), agent.Options{}, event.Discard) |
| 34 | |
| 35 | c := newOwnedTestController(t, Options{ |
| 36 | Runner: ag, |
| 37 | Executor: ag, |
| 38 | Policy: permission.New("ask", nil, askRules, nil), |
| 39 | Sink: event.FuncSink(func(e event.Event) { |
| 40 | if e.Kind == event.ApprovalRequest { |
| 41 | prompts++ |
| 42 | } |
| 43 | }), |
| 44 | // ApprovalTimeout intentionally zero: a wrongful prompt would block forever. |
| 45 | }) |
| 46 | c.ApplyHeadlessApprovalMode(mode) |
| 47 | |
| 48 | done := make(chan error, 1) |
| 49 | go func() { done <- c.runTurnWithRaw(context.Background(), "edit", "edit") }() |
| 50 | select { |
| 51 | case err := <-done: |
| 52 | if err != nil { |
| 53 | t.Fatalf("runTurnWithRaw: %v", err) |
| 54 | } |
| 55 | case <-time.After(5 * time.Second): |
| 56 | t.Fatalf("headless %s must not block on a write", mode) |
| 57 | } |
| 58 | writer.mu.Lock() |
| 59 | defer writer.mu.Unlock() |
| 60 | return prompts, append([]string(nil), writer.paths...) |
| 61 | } |
| 62 | |
| 63 | // TestApplyHeadlessApprovalModeAutoDeniesExplicitAskRule pins the corrected auto |
| 64 | // contract: a command the config explicitly marked "ask" must NOT run silently |
| 65 | // under headless auto (there is no one to approve it), yet must not prompt or |
| 66 | // hang either. auto preserves explicit ask rules by failing closed. |
| 67 | func TestApplyHeadlessApprovalModeAutoDeniesExplicitAskRule(t *testing.T) { |
| 68 | prompts, written := runHeadlessWriteOnce(t, ToolApprovalAuto, []string{"write_file"}) |
| 69 | if prompts != 0 { |
| 70 | t.Fatalf("approval prompts = %d, want 0 (headless run has no UI to answer)", prompts) |
| 71 | } |
| 72 | if len(written) != 0 { |
| 73 | t.Fatalf("executed writes = %v, want none (auto must not silently run an explicit ask rule)", written) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | // TestApplyHeadlessApprovalModeAutoAllowsWriterFallback confirms auto still |
| 78 | // auto-approves the ordinary writer fallback (no explicit rule): that is the |
| 79 | // permissiveness auto is meant to add over the default headless gate. |
| 80 | func TestApplyHeadlessApprovalModeAutoAllowsWriterFallback(t *testing.T) { |
| 81 | prompts, written := runHeadlessWriteOnce(t, ToolApprovalAuto, nil) |
| 82 | if prompts != 0 { |
| 83 | t.Fatalf("approval prompts = %d, want 0", prompts) |
| 84 | } |
| 85 | if len(written) != 1 || written[0] != "a.txt" { |
| 86 | t.Fatalf("executed writes = %v, want a.txt (auto auto-approves the writer fallback)", written) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func TestApplyHeadlessApprovalModeAskDeniesWriterFallback(t *testing.T) { |
| 91 | prompts, written := runHeadlessWriteOnce(t, ToolApprovalAsk, nil) |
| 92 | if prompts != 0 { |
| 93 | t.Fatalf("approval prompts = %d, want 0 (headless run has no UI to answer)", prompts) |
| 94 | } |
| 95 | if len(written) != 0 { |
| 96 | t.Fatalf("executed writes = %v, want none (default headless ask must fail closed)", written) |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | // TestApplyHeadlessApprovalModeYoloBypassesAskRule confirms only bypass runs an |
| 101 | // explicitly ask-gated command unattended. |
| 102 | func TestApplyHeadlessApprovalModeYoloBypassesAskRule(t *testing.T) { |
| 103 | prompts, written := runHeadlessWriteOnce(t, ToolApprovalYolo, []string{"write_file"}) |
| 104 | if prompts != 0 { |
| 105 | t.Fatalf("approval prompts = %d, want 0", prompts) |
| 106 | } |
| 107 | if len(written) != 1 || written[0] != "a.txt" { |
| 108 | t.Fatalf("executed writes = %v, want a.txt (bypass runs even explicit ask rules)", written) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | // TestApplyHeadlessApprovalModeDontAskDeniesWithoutPrompting checks that dontAsk |
| 113 | // denies a would-ask tool through the non-blocking deny approver rather than |
| 114 | // emitting a prompt or hanging. |
| 115 | func TestApplyHeadlessApprovalModeDontAskDeniesWithoutPrompting(t *testing.T) { |
| 116 | writer := &recordingWriter{} |
| 117 | reg := tool.NewRegistry() |
| 118 | reg.Add(writer) |
| 119 | |
| 120 | prov := &scriptedTurns{turns: [][]provider.Chunk{ |
| 121 | toolCallTurn("c1", "write_file", `{"path":"a.txt"}`), |
| 122 | textTurn("Done."), |
| 123 | }} |
| 124 | ag := agent.New(prov, reg, agent.NewSession(""), agent.Options{}, event.Discard) |
| 125 | |
| 126 | prompts := 0 |
| 127 | c := newOwnedTestController(t, Options{ |
| 128 | Runner: ag, |
| 129 | Executor: ag, |
| 130 | Policy: permission.New("ask", nil, []string{"write_file"}, nil), |
| 131 | Sink: event.FuncSink(func(e event.Event) { |
| 132 | if e.Kind == event.ApprovalRequest { |
| 133 | prompts++ |
| 134 | } |
| 135 | }), |
| 136 | }) |
| 137 | c.ApplyHeadlessApprovalMode(ToolApprovalDontAsk) |
| 138 | |
| 139 | done := make(chan error, 1) |
| 140 | go func() { done <- c.runTurnWithRaw(context.Background(), "edit", "edit") }() |
| 141 | select { |
| 142 | case err := <-done: |
| 143 | if err != nil { |
| 144 | t.Fatalf("runTurnWithRaw: %v", err) |
| 145 | } |
| 146 | case <-time.After(5 * time.Second): |
| 147 | t.Fatal("headless dontAsk must not block") |
| 148 | } |
| 149 | if prompts != 0 { |
| 150 | t.Fatalf("approval prompts = %d, want 0 (dontAsk denies silently)", prompts) |
| 151 | } |
| 152 | writer.mu.Lock() |
| 153 | defer writer.mu.Unlock() |
| 154 | if len(writer.paths) != 0 { |
| 155 | t.Fatalf("executed writes = %v, want none (dontAsk must deny)", writer.paths) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | func TestApplyHeadlessApprovalModeAllowsOnlyLowRiskProjectMemoryCreate(t *testing.T) { |
| 160 | safeArgs := `{"name":"release-target","description":"Project release target","type":"reference","scope":"project","body":"Release from main-v2."}` |
| 161 | for _, tc := range []struct { |
| 162 | name string |
| 163 | args string |
| 164 | policy permission.Policy |
| 165 | wantMemory bool |
| 166 | }{ |
| 167 | {name: "safe project create", args: safeArgs, policy: permission.New("ask", nil, nil, nil), wantMemory: true}, |
| 168 | {name: "explicit deny wins", args: safeArgs, policy: permission.New("ask", nil, nil, []string{"remember"})}, |
| 169 | {name: "global create", args: `{"name":"release-target","description":"Release target","type":"reference","scope":"global","body":"Release from main-v2."}`, policy: permission.New("ask", nil, nil, nil)}, |
| 170 | {name: "user preference", args: `{"name":"preferred-editor","description":"Preferred editor","type":"user","scope":"project","body":"Use Vim."}`, policy: permission.New("ask", nil, nil, nil)}, |
| 171 | } { |
| 172 | t.Run(tc.name, func(t *testing.T) { |
| 173 | store := memory.Store{Dir: t.TempDir(), GlobalDir: t.TempDir()} |
| 174 | reg := tool.NewRegistry() |
| 175 | reg.Add(memory.NewRememberTool(store)) |
| 176 | prov := &scriptedTurns{turns: [][]provider.Chunk{ |
| 177 | toolCallTurn("c1", "remember", tc.args), |
| 178 | textTurn("Done."), |
| 179 | }} |
| 180 | ag := agent.New(prov, reg, agent.NewSession(""), agent.Options{}, event.Discard) |
| 181 | prompts := 0 |
| 182 | c := newOwnedTestController(t, Options{ |
| 183 | Runner: ag, |
| 184 | Executor: ag, |
| 185 | Memory: &memory.Set{Store: store}, |
| 186 | Policy: tc.policy, |
| 187 | Sink: event.FuncSink(func(e event.Event) { |
| 188 | if e.Kind == event.ApprovalRequest { |
| 189 | prompts++ |
| 190 | } |
| 191 | }), |
| 192 | }) |
| 193 | c.ApplyHeadlessApprovalMode(ToolApprovalAsk) |
| 194 | if err := c.runTurnWithRaw(context.Background(), "remember", "remember"); err != nil { |
| 195 | t.Fatalf("runTurnWithRaw: %v", err) |
| 196 | } |
| 197 | if prompts != 0 { |
| 198 | t.Fatalf("approval prompts = %d, want 0 for headless run", prompts) |
| 199 | } |
| 200 | _, saved := store.Read("release-target") |
| 201 | if saved != tc.wantMemory { |
| 202 | t.Fatalf("saved = %v, want %v", saved, tc.wantMemory) |
| 203 | } |
| 204 | }) |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | func TestHeadlessAutoAndYoloKeepFreshMemoryBoundary(t *testing.T) { |
| 209 | for _, mode := range []string{ToolApprovalAuto, ToolApprovalYolo} { |
| 210 | t.Run(mode, func(t *testing.T) { |
| 211 | gate := BuildHeadlessApprovalGate(permission.New("ask", nil, nil, nil), mode) |
| 212 | for _, name := range []string{memoryRememberTool, memoryForgetTool} { |
| 213 | allow, reason, err := gate.Check(context.Background(), name, json.RawMessage(`{}`), false) |
| 214 | if err != nil { |
| 215 | t.Fatalf("Check(%s): %v", name, err) |
| 216 | } |
| 217 | if allow { |
| 218 | t.Fatalf("%s under headless %s must require a fresh interactive approval", name, mode) |
| 219 | } |
| 220 | if !strings.Contains(reason, "fresh human approval") { |
| 221 | t.Fatalf("%s under headless %s reason = %q, want fresh-human boundary", name, mode, reason) |
| 222 | } |
| 223 | } |
| 224 | }) |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | // TestBuildHeadlessApprovalGateMatchesParentExecutorContract pins boot's single |
| 229 | // construction point for every headless-only sub-agent gate (task, |
| 230 | // writer-capable skill runners, the planner) to the identical mode contract |
| 231 | // ApplyHeadlessApprovalMode installs on the parent executor. Before this fix, |
| 232 | // boot always built the mode-unaware default gate for those surfaces |
| 233 | // regardless of the CLI-selected headless |
| 234 | // approval mode, so a task sub-agent could run a write_file call an explicit |
| 235 | // ask rule was supposed to deny under auto. runSubagentGateWriteOnce drives a |
| 236 | // write_file tool call through a gate exactly the way TaskTool.runSubSession |
| 237 | // does — a plain agent.New(...).Run(...) with the gate on agent.Options — so |
| 238 | // this exercises the same mechanism a real sub-agent uses, not a mock. |
| 239 | func TestBuildHeadlessApprovalGateMatchesParentExecutorContract(t *testing.T) { |
| 240 | runSubagentGateWriteOnce := func(t *testing.T, mode string) []string { |
| 241 | t.Helper() |
| 242 | writer := &recordingWriter{} |
| 243 | reg := tool.NewRegistry() |
| 244 | reg.Add(writer) |
| 245 | prov := &scriptedTurns{turns: [][]provider.Chunk{ |
| 246 | toolCallTurn("c1", "write_file", `{"path":"a.txt"}`), |
| 247 | textTurn("Done."), |
| 248 | }} |
| 249 | gate := BuildHeadlessApprovalGate(permission.New("ask", nil, []string{"write_file"}, nil), mode) |
| 250 | ag := agent.New(prov, reg, agent.NewSession(""), agent.Options{Gate: gate}, event.Discard) |
| 251 | |
| 252 | done := make(chan error, 1) |
| 253 | go func() { done <- ag.Run(context.Background(), "edit") }() |
| 254 | select { |
| 255 | case err := <-done: |
| 256 | if err != nil { |
| 257 | t.Fatalf("Run: %v", err) |
| 258 | } |
| 259 | case <-time.After(5 * time.Second): |
| 260 | t.Fatalf("sub-agent gate in %s mode must not block", mode) |
| 261 | } |
| 262 | writer.mu.Lock() |
| 263 | defer writer.mu.Unlock() |
| 264 | return append([]string(nil), writer.paths...) |
| 265 | } |
| 266 | |
| 267 | if got := runSubagentGateWriteOnce(t, ToolApprovalAuto); len(got) != 0 { |
| 268 | t.Fatalf("auto: executed writes = %v, want none (a sub-agent must fail closed on an explicit ask rule too)", got) |
| 269 | } |
| 270 | if got := runSubagentGateWriteOnce(t, ToolApprovalYolo); len(got) != 1 || got[0] != "a.txt" { |
| 271 | t.Fatalf("yolo: executed writes = %v, want [a.txt] (bypass runs even explicit ask rules)", got) |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | // TestSetToolApprovalModePropagatesToSubagentGate pins the interactive |
| 276 | // counterpart of the boot.Build sub-agent gate fix: a runtime mode switch |
| 277 | // (Shift+Tab -> SetToolApprovalMode) must reach sub-agents too, not just |
| 278 | // refreshInteractiveGate's parent executor gate. Before this fix, boot |
| 279 | // captured the sub-agent gate once at construction (mode-unaware default) |
| 280 | // and SetToolApprovalMode never touched it, so a task |
| 281 | // sub-agent stayed on the boot-time default even after the user switched to |
| 282 | // auto. subagentGate here stands in for what a task/skill/planner sub-agent |
| 283 | // actually reads (boot wires the same *SharedHeadlessGate into all of them). |
| 284 | func TestSetToolApprovalModePropagatesToSubagentGate(t *testing.T) { |
| 285 | policy := permission.New("ask", nil, []string{"write_file"}, nil) |
| 286 | subagentGate := NewSharedHeadlessGate(policy, ToolApprovalAsk) |
| 287 | c := newOwnedTestController(t, Options{Policy: policy, SubagentGate: subagentGate}) |
| 288 | |
| 289 | runSubagentWriteOnce := func(t *testing.T) []string { |
| 290 | t.Helper() |
| 291 | writer := &recordingWriter{} |
| 292 | reg := tool.NewRegistry() |
| 293 | reg.Add(writer) |
| 294 | prov := &scriptedTurns{turns: [][]provider.Chunk{ |
| 295 | toolCallTurn("c1", "write_file", `{"path":"a.txt"}`), |
| 296 | textTurn("Done."), |
| 297 | }} |
| 298 | ag := agent.New(prov, reg, agent.NewSession(""), agent.Options{Gate: subagentGate}, event.Discard) |
| 299 | done := make(chan error, 1) |
| 300 | go func() { done <- ag.Run(context.Background(), "edit") }() |
| 301 | select { |
| 302 | case err := <-done: |
| 303 | if err != nil { |
| 304 | t.Fatalf("Run: %v", err) |
| 305 | } |
| 306 | case <-time.After(5 * time.Second): |
| 307 | t.Fatal("sub-agent gate must not block") |
| 308 | } |
| 309 | writer.mu.Lock() |
| 310 | defer writer.mu.Unlock() |
| 311 | return append([]string(nil), writer.paths...) |
| 312 | } |
| 313 | |
| 314 | // Fresh sub-agent gate at the default Ask posture: no UI can answer, so an |
| 315 | // explicit ask rule must fail closed instead of granting itself. |
| 316 | if got := runSubagentWriteOnce(t); len(got) != 0 { |
| 317 | t.Fatalf("ask (initial): executed writes = %v, want none", got) |
| 318 | } |
| 319 | |
| 320 | c.SetToolApprovalMode(ToolApprovalAuto) |
| 321 | if got := runSubagentWriteOnce(t); len(got) != 0 { |
| 322 | t.Fatalf("auto: executed writes = %v, want none (sub-agent gate must follow the mode switch and fail closed on the ask rule)", got) |
| 323 | } |
| 324 | |
| 325 | c.SetToolApprovalMode(ToolApprovalYolo) |
| 326 | if got := runSubagentWriteOnce(t); len(got) != 1 || got[0] != "a.txt" { |
| 327 | t.Fatalf("yolo: executed writes = %v, want [a.txt] (bypass runs even explicit ask rules)", got) |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | // TestInteractiveGateIgnoresSessionAllowForFreshHumanTools guards the memory |
| 332 | // contract: --allowed-tools (SessionAllow) must never satisfy a tool that |
| 333 | // requires fresh human approval every call, even though SessionAllow outranks Ask |
| 334 | // rules for ordinary tools. |
| 335 | func TestInteractiveGateIgnoresSessionAllowForFreshHumanTools(t *testing.T) { |
| 336 | policy := permission.New("ask", nil, nil, nil). |
| 337 | WithSessionAllow([]string{"remember", "forget", "write_file"}) |
| 338 | c := newOwnedTestController(t, Options{Policy: policy}) |
| 339 | |
| 340 | gate := c.newInteractiveGate() |
| 341 | for _, name := range []string{memoryRememberTool, memoryForgetTool} { |
| 342 | if got := gate.Policy.DecideSubject(name, false, ""); got != permission.Ask { |
| 343 | t.Fatalf("%s decision = %v, want Ask (SessionAllow must not cover fresh-human tools)", name, got) |
| 344 | } |
| 345 | } |
| 346 | // An ordinary tool in the allowlist is still honored. |
| 347 | if got := gate.Policy.DecideSubject("write_file", false, ""); got != permission.Allow { |
| 348 | t.Fatalf("write_file decision = %v, want Allow (SessionAllow still applies to ordinary tools)", got) |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | // Inline interpreters and shell composition are ordinary commands in the |
| 353 | // workspace preset. Filesystem containment is enforced by the sandbox rather |
| 354 | // than by fragile command-string heuristics. |
| 355 | func TestHeadlessWorkspaceWriteAllowsInlineInterpreter(t *testing.T) { |
| 356 | gate := BuildHeadlessApprovalGate(permission.New("ask", nil, nil, nil), ToolApprovalAuto) |
| 357 | |
| 358 | allow, reason, err := gate.Check(context.Background(), "bash", |
| 359 | json.RawMessage(`{"command":"cd /testbed && python -c \"print(1)\""}`), false) |
| 360 | if err != nil { |
| 361 | t.Fatalf("Check: %v", err) |
| 362 | } |
| 363 | if !allow || reason != "" { |
| 364 | t.Fatalf("python -c in workspace-write = allow %v reason %q, want direct execution", allow, reason) |
| 365 | } |
| 366 | |
| 367 | // The workaround the message advertises must actually pass the same gate. |
| 368 | allow, reason, err = gate.Check(context.Background(), "bash", |
| 369 | json.RawMessage(`{"command":"cd /testbed && python repro.py"}`), false) |
| 370 | if err != nil || !allow { |
| 371 | t.Fatalf("script-file execution must be allowed in workspace-write: allow=%v reason=%q err=%v", allow, reason, err) |
| 372 | } |
| 373 | } |
| 374 |