返回 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{"plan mode is off", "permission and sandbox restrictions", "Update todos"} {
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 := newOwnedTestController(t, 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 freezes ForbidMutation on the
108 // turn constraints so ordinary writers are host-blocked even when YOLO would
109 // otherwise auto-allow them. Permission may still prompt first in Ask mode;
110 // the host floor is enforced after the gate.
111 func TestPlanModeApprovalPostureMatrix(t *testing.T) {
112 tests := []struct {
113 name string
114 mode string
115 askRules []string
116 denyRules []string
117 wantWrites int
118 }{
119 // YOLO/Auto avoid hanging on interactive approval while still proving
120 // Plan ForbidMutation blocks the write.
121 {name: "Auto blocks writer under plan constraints", mode: ToolApprovalAuto, wantWrites: 0},
122 {name: "YOLO blocks writer under plan constraints", mode: ToolApprovalYolo, askRules: []string{"write_file"}, wantWrites: 0},
123 {name: "deny still blocks in YOLO plan", mode: ToolApprovalYolo, denyRules: []string{"write_file"}, wantWrites: 0},
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 c := newOwnedTestController(t, Options{
137 Runner: ag,
138 Executor: ag,
139 Policy: permission.New("ask", nil, tc.askRules, tc.denyRules),
140 Sink: event.Discard,
141 })
142 defer c.Close()
143 c.EnableInteractiveApproval()
144 c.SetPlanMode(true)
145 c.SetToolApprovalMode(tc.mode)
146 if got := c.ToolApprovalMode(); got != tc.mode {
147 t.Fatalf("Plan changed approval mode to %q, want %q", got, tc.mode)
148 }
149
150 if err := ag.Run(context.Background(), "draft a plan for this change"); err != nil {
151 t.Fatalf("Plan run: %v", err)
152 }
153 writer.mu.Lock()
154 writes := len(writer.paths)
155 writer.mu.Unlock()
156 if writes != tc.wantWrites {
157 t.Fatalf("executed writes = %d, want %d", writes, tc.wantWrites)
158 }
159 })
160 }
161 }
162
163 func TestApprovedPlanExecutionUsesAutoSemantics(t *testing.T) {
164 policy := permission.New("ask", nil, []string{"sensitive_writer"}, nil)
165 approvalTools := make(chan string, 3)
166 var c *Controller
167 c = newOwnedTestController(t, Options{
168 Policy: policy,
169 Sink: event.FuncSink(func(e event.Event) {
170 if e.Kind != event.ApprovalRequest {
171 return
172 }
173 approvalTools <- e.Approval.Tool
174 allow := e.Approval.Tool == planApprovalTool
175 go c.Approve(e.Approval.ID, allow, false, false)
176 }),
177 })
178 defer c.Close()
179 c.EnableInteractiveApproval()
180
181 runCalled := false
182 err := (plannerPlanApprover{c: c}).RunWithPlannerApproval(t.Context(), "1. Apply the change", func(ctx context.Context) error {
183 runCalled = true
184 gate := c.newInteractiveGate()
185 allow, _, err := gate.Check(ctx, "ordinary_writer", json.RawMessage(`{"path":"ordinary.txt"}`), false)
186 if err != nil {
187 return err
188 }
189 if !allow {
190 t.Error("ordinary writer fallback should be auto-approved in the approved-plan execution window")
191 }
192
193 allow, _, err = gate.Check(ctx, "sensitive_writer", json.RawMessage(`{"path":"sensitive.txt"}`), false)
194 if err != nil {
195 return err
196 }
197 if allow {
198 t.Error("explicit ask rule should still require and honor a decision after plan approval")
199 }
200 return nil
201 })
202 if err != nil {
203 t.Fatal(err)
204 }
205 if !runCalled {
206 t.Fatal("approved plan did not enter its execution window")
207 }
208
209 for i, want := range []string{planApprovalTool, "sensitive_writer"} {
210 select {
211 case got := <-approvalTools:
212 if got != want {
213 t.Fatalf("approval prompt %d = %q, want %q", i+1, got, want)
214 }
215 default:
216 t.Fatalf("missing approval prompt %d for %q", i+1, want)
217 }
218 }
219 select {
220 case got := <-approvalTools:
221 t.Fatalf("unexpected approval prompt for %q; ordinary fallback should not prompt", got)
222 default:
223 }
224 }
225
226 // TestApprovalTimeoutDeniesWhenUnanswered verifies a positive ApprovalTimeout
227 // turns an unanswered prompt into a denial (error) instead of blocking forever
228 // (#4626, #4402). Ask shares the same wait context as tool-approval prompts.
229 func TestApprovalTimeoutDeniesWhenUnanswered(t *testing.T) {
230 c := newOwnedTestController(t, Options{
231 Policy: permission.New("ask", nil, nil, nil),
232 Sink: event.Discard,
233 ApprovalTimeout: 40 * time.Millisecond,
234 })
235 c.EnableInteractiveApproval()
236
237 start := time.Now()
238 _, err := c.Ask(context.Background(), []event.AskQuestion{{ID: "q1", Prompt: "pick one"}})
239 elapsed := time.Since(start)
240
241 if err == nil {
242 t.Fatal("Ask should error when the approval timeout elapses unanswered")
243 }
244 // Must return near the timeout, not hang. Allow generous slack for CI scheduling.
245 if elapsed > 2*time.Second {
246 t.Fatalf("Ask blocked for %v; timeout should have fired near 40ms", elapsed)
247 }
248 }
249
250 // TestApprovalTimeoutZeroWaitsIndefinitely confirms the default (zero) keeps the
251 // interactive behavior: an unanswered Ask blocks rather than timing out, so a
252 // human at a terminal is never cut off.
253 func TestApprovalTimeoutZeroWaitsIndefinitely(t *testing.T) {
254 c := newOwnedTestController(t, Options{
255 Policy: permission.New("ask", nil, nil, nil),
256 Sink: event.Discard,
257 // ApprovalTimeout intentionally zero (default).
258 })
259 c.EnableInteractiveApproval()
260
261 done := make(chan error, 1)
262 go func() {
263 _, err := c.Ask(context.Background(), []event.AskQuestion{{ID: "q1", Prompt: "pick one"}})
264 done <- err
265 }()
266
267 select {
268 case <-done:
269 t.Fatal("Ask with zero timeout must block until answered, not return on its own")
270 case <-time.After(120 * time.Millisecond):
271 // Good: still blocked, as expected for interactive use.
272 }
273
274 // Clean up so the goroutine doesn't linger: answer the prompt.
275 c.approval.mu.Lock()
276 var ids []string
277 for id := range c.approval.asks {
278 ids = append(ids, id)
279 }
280 c.approval.mu.Unlock()
281
282 for _, id := range ids {
283 c.AnswerQuestion(id, []event.AskAnswer{{QuestionID: "q1", Selected: []string{"x"}}})
284 }
285 select {
286 case <-done:
287 case <-time.After(30 * time.Second):
288 t.Fatal("Ask did not unblock after answering")
289 }
290 }
291
291 lines GO