返回 DeepSeek-Reasonix
headless_approval_test.go
根目录 / internal / control / headless_approval_test.go
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
374 lines GO