返回 DeepSeek-Reasonix
approval_e2e_test.go
根目录 / internal / control / approval_e2e_test.go
1 package control
2
3 import (
4 "context"
5 "encoding/json"
6 "strings"
7 "sync"
8 "testing"
9 "time"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/event"
13 "reasonix/internal/permission"
14 "reasonix/internal/provider"
15 "reasonix/internal/tool"
16 )
17
18 func TestPlanApprovedMessageStatesAutoSemantics(t *testing.T) {
19 for _, want := range []string{"ordinary writer fallback", "explicit ask/deny rules", "forced fresh reviews"} {
20 if !strings.Contains(planApprovedMessage, want) {
21 t.Fatalf("planApprovedMessage missing %q: %s", want, planApprovedMessage)
22 }
23 }
24 if strings.Contains(planApprovedMessage, "without asking again") {
25 t.Fatalf("planApprovedMessage overstates the approval window: %s", planApprovedMessage)
26 }
27 }
28
29 type recordingWriter struct {
30 mu sync.Mutex
31 paths []string
32 }
33
34 func (w *recordingWriter) Name() string { return "write_file" }
35 func (w *recordingWriter) Description() string { return "write a file" }
36 func (w *recordingWriter) Schema() json.RawMessage {
37 return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`)
38 }
39 func (w *recordingWriter) ReadOnly() bool { return false }
40 func (w *recordingWriter) Execute(_ context.Context, args json.RawMessage) (string, error) {
41 var a struct {
42 Path string `json:"path"`
43 }
44 _ = json.Unmarshal(args, &a)
45 w.mu.Lock()
46 w.paths = append(w.paths, a.Path)
47 w.mu.Unlock()
48 return "ok", nil
49 }
50
51 func toolCallTurn(id, name, args string) []provider.Chunk {
52 return []provider.Chunk{
53 {Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: id, Name: name, Arguments: args}},
54 {Type: provider.ChunkDone},
55 }
56 }
57
58 // TestApprovalToolWideEndToEnd drives a full agent turn through the real gate:
59 // the model writes two different files, the user answers "allow for this session"
60 // on the first, and the second must run without a second prompt. Regression for
61 // #3498 / #3520 (a session/persist grant used to pin the exact subject, so every
62 // new file/command re-prompted).
63 func TestApprovalToolWideEndToEnd(t *testing.T) {
64 writer := &recordingWriter{}
65 reg := tool.NewRegistry()
66 reg.Add(writer)
67
68 prov := &scriptedTurns{turns: [][]provider.Chunk{
69 toolCallTurn("c1", "write_file", `{"path":"a.txt"}`),
70 toolCallTurn("c2", "write_file", `{"path":"b.txt"}`),
71 textTurn("Done."),
72 }}
73 ag := agent.New(prov, reg, agent.NewSession(""), agent.Options{}, event.Discard)
74
75 approvalID := make(chan string, 4)
76 prompts := 0
77 c := New(Options{
78 Runner: ag,
79 Executor: ag,
80 Policy: permission.New("ask", nil, nil, nil), // writers ask by default
81 Sink: event.FuncSink(func(e event.Event) {
82 if e.Kind == event.ApprovalRequest {
83 prompts++
84 approvalID <- e.Approval.ID
85 }
86 }),
87 })
88 c.EnableInteractiveApproval()
89
90 // Answer the first prompt with "allow for this session" (allow, session, !persist).
91 go func() { c.Approve(<-approvalID, true, true, false) }()
92
93 if err := c.runTurnWithRaw(context.Background(), "edit the files", "edit the files"); err != nil {
94 t.Fatalf("runTurnWithRaw: %v", err)
95 }
96
97 if prompts != 1 {
98 t.Errorf("approval prompts = %d, want 1 (the session grant must cover the second file too)", prompts)
99 }
100 writer.mu.Lock()
101 defer writer.mu.Unlock()
102 if len(writer.paths) != 2 || writer.paths[0] != "a.txt" || writer.paths[1] != "b.txt" {
103 t.Errorf("executed writes = %v, want both a.txt and b.txt", writer.paths)
104 }
105 }
106
107 // TestPlanModeApprovalPostureMatrix proves Plan does not replace the active
108 // approval policy. Ordinary writers still ask, auto-approve, bypass explicit
109 // asks in YOLO, or stop at deny exactly as they do in Standard mode.
110 func TestPlanModeApprovalPostureMatrix(t *testing.T) {
111 tests := []struct {
112 name string
113 mode string
114 askRules []string
115 denyRules []string
116 wantPrompts int
117 wantWrites int
118 }{
119 {name: "Ask prompts for fallback writer", mode: ToolApprovalAsk, wantPrompts: 1, wantWrites: 1},
120 {name: "Auto allows fallback writer", mode: ToolApprovalAuto, wantWrites: 1},
121 {name: "Auto preserves explicit ask", mode: ToolApprovalAuto, askRules: []string{"write_file"}, wantPrompts: 1, wantWrites: 1},
122 {name: "YOLO bypasses explicit ask", mode: ToolApprovalYolo, askRules: []string{"write_file"}, wantWrites: 1},
123 {name: "deny wins in YOLO", mode: ToolApprovalYolo, denyRules: []string{"write_file"}},
124 }
125 for _, tc := range tests {
126 t.Run(tc.name, func(t *testing.T) {
127 writer := &recordingWriter{}
128 reg := tool.NewRegistry()
129 reg.Add(writer)
130 prov := &scriptedTurns{turns: [][]provider.Chunk{
131 toolCallTurn("write", "write_file", `{"path":"plan.txt"}`),
132 textTurn("Plan ready."),
133 }}
134 ag := agent.New(prov, reg, agent.NewSession(""), agent.Options{}, event.Discard)
135
136 approvalIDs := make(chan string, 1)
137 prompts := 0
138 c := New(Options{
139 Runner: ag,
140 Executor: ag,
141 Policy: permission.New("ask", nil, tc.askRules, tc.denyRules),
142 Sink: event.FuncSink(func(e event.Event) {
143 if e.Kind == event.ApprovalRequest {
144 prompts++
145 approvalIDs <- e.Approval.ID
146 }
147 }),
148 })
149 defer c.Close()
150 c.EnableInteractiveApproval()
151 c.SetPlanMode(true)
152 c.SetToolApprovalMode(tc.mode)
153 if got := c.ToolApprovalMode(); got != tc.mode {
154 t.Fatalf("Plan changed approval mode to %q, want %q", got, tc.mode)
155 }
156 if tc.wantPrompts > 0 {
157 go func() { c.Approve(<-approvalIDs, true, false, false) }()
158 }
159
160 if err := ag.Run(context.Background(), "draft a plan for this change"); err != nil {
161 t.Fatalf("Plan run: %v", err)
162 }
163 if prompts != tc.wantPrompts {
164 t.Fatalf("approval prompts = %d, want %d", prompts, tc.wantPrompts)
165 }
166 writer.mu.Lock()
167 writes := len(writer.paths)
168 writer.mu.Unlock()
169 if writes != tc.wantWrites {
170 t.Fatalf("executed writes = %d, want %d", writes, tc.wantWrites)
171 }
172 })
173 }
174 }
175
176 func TestApprovedPlanExecutionUsesAutoSemantics(t *testing.T) {
177 policy := permission.New("ask", nil, []string{"sensitive_writer"}, nil)
178 approvalTools := make(chan string, 3)
179 var c *Controller
180 c = New(Options{
181 Policy: policy,
182 Sink: event.FuncSink(func(e event.Event) {
183 if e.Kind != event.ApprovalRequest {
184 return
185 }
186 approvalTools <- e.Approval.Tool
187 allow := e.Approval.Tool == planApprovalTool
188 go c.Approve(e.Approval.ID, allow, false, false)
189 }),
190 })
191 defer c.Close()
192 c.EnableInteractiveApproval()
193
194 runCalled := false
195 err := (plannerPlanApprover{c: c}).RunWithPlannerApproval(t.Context(), "1. Apply the change", func(ctx context.Context) error {
196 runCalled = true
197 gate := c.newInteractiveGate()
198 allow, _, err := gate.Check(ctx, "ordinary_writer", json.RawMessage(`{"path":"ordinary.txt"}`), false)
199 if err != nil {
200 return err
201 }
202 if !allow {
203 t.Error("ordinary writer fallback should be auto-approved in the approved-plan execution window")
204 }
205
206 allow, _, err = gate.Check(ctx, "sensitive_writer", json.RawMessage(`{"path":"sensitive.txt"}`), false)
207 if err != nil {
208 return err
209 }
210 if allow {
211 t.Error("explicit ask rule should still require and honor a decision after plan approval")
212 }
213 return nil
214 })
215 if err != nil {
216 t.Fatal(err)
217 }
218 if !runCalled {
219 t.Fatal("approved plan did not enter its execution window")
220 }
221
222 for i, want := range []string{planApprovalTool, "sensitive_writer"} {
223 select {
224 case got := <-approvalTools:
225 if got != want {
226 t.Fatalf("approval prompt %d = %q, want %q", i+1, got, want)
227 }
228 default:
229 t.Fatalf("missing approval prompt %d for %q", i+1, want)
230 }
231 }
232 select {
233 case got := <-approvalTools:
234 t.Fatalf("unexpected approval prompt for %q; ordinary fallback should not prompt", got)
235 default:
236 }
237 }
238
239 // TestApprovalTimeoutDeniesWhenUnanswered verifies a positive ApprovalTimeout
240 // turns an unanswered prompt into a denial (error) instead of blocking forever
241 // (#4626, #4402). Ask shares the same wait context as tool-approval prompts.
242 func TestApprovalTimeoutDeniesWhenUnanswered(t *testing.T) {
243 c := New(Options{
244 Policy: permission.New("ask", nil, nil, nil),
245 Sink: event.Discard,
246 ApprovalTimeout: 40 * time.Millisecond,
247 })
248 c.EnableInteractiveApproval()
249
250 start := time.Now()
251 _, err := c.Ask(context.Background(), []event.AskQuestion{{ID: "q1", Prompt: "pick one"}})
252 elapsed := time.Since(start)
253
254 if err == nil {
255 t.Fatal("Ask should error when the approval timeout elapses unanswered")
256 }
257 // Must return near the timeout, not hang. Allow generous slack for CI scheduling.
258 if elapsed > 2*time.Second {
259 t.Fatalf("Ask blocked for %v; timeout should have fired near 40ms", elapsed)
260 }
261 }
262
263 // TestApprovalTimeoutZeroWaitsIndefinitely confirms the default (zero) keeps the
264 // interactive behavior: an unanswered Ask blocks rather than timing out, so a
265 // human at a terminal is never cut off.
266 func TestApprovalTimeoutZeroWaitsIndefinitely(t *testing.T) {
267 c := New(Options{
268 Policy: permission.New("ask", nil, nil, nil),
269 Sink: event.Discard,
270 // ApprovalTimeout intentionally zero (default).
271 })
272 c.EnableInteractiveApproval()
273
274 done := make(chan error, 1)
275 go func() {
276 _, err := c.Ask(context.Background(), []event.AskQuestion{{ID: "q1", Prompt: "pick one"}})
277 done <- err
278 }()
279
280 select {
281 case <-done:
282 t.Fatal("Ask with zero timeout must block until answered, not return on its own")
283 case <-time.After(120 * time.Millisecond):
284 // Good: still blocked, as expected for interactive use.
285 }
286
287 // Clean up so the goroutine doesn't linger: answer the prompt.
288 c.approval.mu.Lock()
289 var ids []string
290 for id := range c.approval.asks {
291 ids = append(ids, id)
292 }
293 c.approval.mu.Unlock()
294
295 for _, id := range ids {
296 c.AnswerQuestion(id, []event.AskAnswer{{QuestionID: "q1", Selected: []string{"x"}}})
297 }
298 select {
299 case <-done:
300 case <-time.After(30 * time.Second):
301 t.Fatal("Ask did not unblock after answering")
302 }
303 }
304
304 lines GO