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