返回 DeepSeek-Reasonix
bash_dynamic_approval_test.go
根目录 / internal / control / bash_dynamic_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/guardian"
13 "reasonix/internal/permission"
14 "reasonix/internal/provider"
15 "reasonix/internal/tool"
16 )
17
18 const dynamicBashCommand = "git status $(touch /tmp/reasonix-dynamic-approval)"
19
20 type dynamicApprovalResult struct {
21 allow bool
22 err error
23 }
24
25 func requestDynamicBashApproval(c *Controller) <-chan dynamicApprovalResult {
26 done := make(chan dynamicApprovalResult, 1)
27 go func() {
28 allow, _, err := gateApprover{c}.Approve(
29 context.Background(),
30 "bash",
31 dynamicBashCommand,
32 json.RawMessage(`{"command":"git status $(touch /tmp/reasonix-dynamic-approval)"}`),
33 )
34 done <- dynamicApprovalResult{allow: allow, err: err}
35 }()
36 return done
37 }
38
39 func assertDynamicApprovalPending(t *testing.T, done <-chan dynamicApprovalResult) {
40 t.Helper()
41 select {
42 case got := <-done:
43 t.Fatalf("dynamic Bash approval completed without a human decision: %+v", got)
44 case <-time.After(50 * time.Millisecond):
45 }
46 }
47
48 func TestDynamicBashRequiresInteractiveHumanInAutoAndApprovedPlan(t *testing.T) {
49 for _, tt := range []struct {
50 name string
51 setup func(*Controller)
52 }{
53 {name: "auto", setup: func(c *Controller) { c.SetToolApprovalMode(ToolApprovalAuto) }},
54 {name: "approved plan", setup: func(c *Controller) { c.approval.setPlanAutoApprove(true) }},
55 } {
56 t.Run(tt.name, func(t *testing.T) {
57 approvals := make(chan event.Approval, 1)
58 c := New(Options{Sink: event.FuncSink(func(e event.Event) {
59 if e.Kind == event.ApprovalRequest {
60 approvals <- e.Approval
61 }
62 })})
63 tt.setup(c)
64
65 done := requestDynamicBashApproval(c)
66 var approval event.Approval
67 select {
68 case approval = <-approvals:
69 case <-time.After(30 * time.Second):
70 t.Fatal("dynamic Bash approval prompt was not emitted")
71 }
72 if approval.Fresh {
73 t.Fatal("dynamic Bash must keep the ordinary four-choice approval UI")
74 }
75 if approval.Reason != dynamicBashApprovalReason {
76 t.Fatalf("dynamic Bash approval reason = %q, want actionable classification", approval.Reason)
77 }
78 assertDynamicApprovalPending(t, done)
79 c.Approve(approval.ID, true, false, false)
80 select {
81 case got := <-done:
82 if got.err != nil || !got.allow {
83 t.Fatalf("manual approval = %+v, want allow", got)
84 }
85 case <-time.After(30 * time.Second):
86 t.Fatal("dynamic Bash approval stayed blocked")
87 }
88 })
89 }
90 }
91
92 func TestExactOnlyBashDoesNotPromptInAutoOrApprovedPlan(t *testing.T) {
93 commands := []string{
94 "REV=HEAD git diff",
95 "git status > status.txt",
96 "rm *.log",
97 "echo $HOME",
98 }
99 for _, tt := range []struct {
100 name string
101 setup func(*Controller)
102 }{
103 {name: "auto", setup: func(c *Controller) { c.SetToolApprovalMode(ToolApprovalAuto) }},
104 {name: "approved plan", setup: func(c *Controller) { c.approval.setPlanAutoApprove(true) }},
105 } {
106 t.Run(tt.name, func(t *testing.T) {
107 approvals := make(chan event.Approval, len(commands))
108 c := New(Options{Sink: event.FuncSink(func(e event.Event) {
109 if e.Kind == event.ApprovalRequest {
110 approvals <- e.Approval
111 }
112 })})
113 tt.setup(c)
114 gate := c.newInteractiveGate()
115 for _, command := range commands {
116 args, err := json.Marshal(map[string]string{"command": command})
117 if err != nil {
118 t.Fatal(err)
119 }
120 allow, reason, err := gate.Check(context.Background(), "bash", args, false)
121 if err != nil || !allow || reason != "" {
122 t.Errorf("%s command %q = (%v,%q,%v), want allow without prompt", tt.name, command, allow, reason, err)
123 }
124 }
125 select {
126 case approval := <-approvals:
127 t.Fatalf("%s exact-only Bash unexpectedly prompted: %+v", tt.name, approval)
128 case <-time.After(50 * time.Millisecond):
129 }
130 })
131 }
132 }
133
134 func TestDynamicBashPendingApprovalOnlyYoloCanDrain(t *testing.T) {
135 approvals := make(chan event.Approval, 1)
136 c := New(Options{Sink: event.FuncSink(func(e event.Event) {
137 if e.Kind == event.ApprovalRequest {
138 approvals <- e.Approval
139 }
140 })})
141 done := requestDynamicBashApproval(c)
142 select {
143 case <-approvals:
144 case <-time.After(30 * time.Second):
145 t.Fatal("dynamic Bash approval prompt was not emitted")
146 }
147 c.SetToolApprovalMode(ToolApprovalAuto)
148 assertDynamicApprovalPending(t, done)
149 c.SetToolApprovalMode(ToolApprovalYolo)
150 select {
151 case got := <-done:
152 if got.err != nil || !got.allow {
153 t.Fatalf("YOLO-drained approval = %+v, want allow", got)
154 }
155 case <-time.After(30 * time.Second):
156 t.Fatal("YOLO did not drain dynamic Bash approval")
157 }
158 }
159
160 func TestDynamicBashExactSessionAndPersistentGrants(t *testing.T) {
161 approvals := make(chan event.Approval, 2)
162 remembered := make(chan string, 1)
163 c := New(Options{
164 Sink: event.FuncSink(func(e event.Event) {
165 if e.Kind == event.ApprovalRequest {
166 approvals <- e.Approval
167 }
168 }),
169 OnRemember: func(rule string) RememberResult {
170 remembered <- rule
171 return RememberResult{Saved: true}
172 },
173 })
174
175 done := requestDynamicBashApproval(c)
176 approval := <-approvals
177 c.Approve(approval.ID, true, true, true)
178 if got := <-done; got.err != nil || !got.allow {
179 t.Fatalf("initial approval = %+v, want allow", got)
180 }
181 wantRule := "Bash=" + dynamicBashCommand
182 if got := <-remembered; got != wantRule {
183 t.Fatalf("remembered rule = %q, want %q", got, wantRule)
184 }
185
186 allow, _, err := gateApprover{c}.Approve(context.Background(), "bash", dynamicBashCommand, nil)
187 if err != nil || !allow {
188 t.Fatalf("exact session grant = (%v,%v), want allow", allow, err)
189 }
190 select {
191 case approval := <-approvals:
192 t.Fatalf("exact session grant unexpectedly prompted: %+v", approval)
193 case <-time.After(50 * time.Millisecond):
194 }
195
196 old := c.SessionAuthorizations()
197 fresh := New(Options{})
198 fresh.RestoreSessionAuthorizations(old)
199 allow, _, err = gateApprover{fresh}.Approve(context.Background(), "bash", dynamicBashCommand, nil)
200 if err != nil || !allow {
201 t.Fatalf("restored exact session grant = (%v,%v), want allow", allow, err)
202 }
203 }
204
205 func TestDynamicBashHookAllowCannotReplaceHumanButDenyStillApplies(t *testing.T) {
206 allowJSON := `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}`
207 c, ids := wildcardClaudePermissionHookController(t, 0, allowJSON)
208 done := requestDynamicBashApproval(c)
209 id := waitApprovalID(t, ids)
210 assertDynamicApprovalPending(t, done)
211 c.Approve(id, true, false, false)
212 if got := <-done; got.err != nil || !got.allow {
213 t.Fatalf("manual approval after hook allow = %+v, want allow", got)
214 }
215
216 c, ids = wildcardClaudePermissionHookController(t, 2, "")
217 allow, _, err := gateApprover{c}.Approve(context.Background(), "bash", dynamicBashCommand, nil)
218 if err != nil || allow {
219 t.Fatalf("hook deny = (%v,%v), want deny", allow, err)
220 }
221 select {
222 case id := <-ids:
223 t.Fatalf("hook deny unexpectedly emitted approval %s", id)
224 case <-time.After(50 * time.Millisecond):
225 }
226 }
227
228 func TestDynamicBashSkipsGuardianAllow(t *testing.T) {
229 guardianProv := &recordingProvider{
230 name: "guardian",
231 streams: [][]provider.Chunk{textTurn(`{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"safe"}`)},
232 }
233 guardianSess := guardian.NewSession(guardianProv, tool.NewRegistry(), guardian.PolicyPrompt(), "guardian-test", 0, nil, event.Discard)
234 exec := agent.New(&recordingProvider{name: "executor"}, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
235 approvals := make(chan event.Approval, 1)
236 c := New(Options{
237 Executor: exec,
238 Guardian: guardianSess,
239 Sink: event.FuncSink(func(e event.Event) {
240 if e.Kind == event.ApprovalRequest {
241 approvals <- e.Approval
242 }
243 }),
244 })
245 done := requestDynamicBashApproval(c)
246 approval := <-approvals
247 if len(guardianProv.requests) != 0 {
248 t.Fatalf("dynamic Bash Guardian reviews = %d, want 0", len(guardianProv.requests))
249 }
250 c.Approve(approval.ID, true, false, false)
251 if got := <-done; got.err != nil || !got.allow {
252 t.Fatalf("manual approval = %+v, want allow", got)
253 }
254 }
255
256 func TestHeadlessDynamicBashApprovalModes(t *testing.T) {
257 args := json.RawMessage(`{"command":"git status $(touch /tmp/reasonix-dynamic-approval)"}`)
258 for _, tt := range []struct {
259 mode string
260 want bool
261 }{
262 {mode: ToolApprovalAsk},
263 {mode: ToolApprovalAuto},
264 {mode: ToolApprovalDontAsk},
265 {mode: ToolApprovalYolo, want: true},
266 } {
267 t.Run(tt.mode, func(t *testing.T) {
268 allow, reason, err := BuildHeadlessApprovalGate(permission.New("ask", []string{"Bash(git*)"}, nil, nil), tt.mode).Check(context.Background(), "bash", args, false)
269 if err != nil || allow != tt.want {
270 t.Fatalf("headless %s = (%v,%q,%v), want allow=%v", tt.mode, allow, reason, err, tt.want)
271 }
272 if !tt.want && !strings.Contains(reason, "requires human approval") {
273 t.Fatalf("headless %s reason = %q", tt.mode, reason)
274 }
275 })
276 }
277
278 exact := permission.New("ask", []string{"Bash=" + dynamicBashCommand}, nil, nil)
279 allow, reason, err := BuildHeadlessApprovalGate(exact, ToolApprovalAsk).Check(context.Background(), "bash", args, false)
280 if err != nil || !allow || reason != "" {
281 t.Fatalf("headless exact literal = (%v,%q,%v), want allow", allow, reason, err)
282 }
283
284 optIn := permission.New("ask", nil, nil, nil).WithAllowDynamicBashFallback(true)
285 allow, reason, err = BuildHeadlessApprovalGate(optIn, ToolApprovalAuto).Check(context.Background(), "bash", args, false)
286 if err != nil || !allow || reason != "" {
287 t.Fatalf("headless dynamic fallback opt-in = (%v,%q,%v), want allow", allow, reason, err)
288 }
289 }
290
291 func TestHeadlessExactOnlyBashApprovalModes(t *testing.T) {
292 args := json.RawMessage(`{"command":"rm *.log"}`)
293 for _, tt := range []struct {
294 mode string
295 want bool
296 }{
297 {mode: ToolApprovalAsk},
298 {mode: ToolApprovalAuto, want: true},
299 {mode: ToolApprovalDontAsk},
300 {mode: ToolApprovalYolo, want: true},
301 } {
302 t.Run(tt.mode, func(t *testing.T) {
303 allow, _, err := BuildHeadlessApprovalGate(permission.New("ask", []string{"Bash(rm*)"}, nil, nil), tt.mode).Check(context.Background(), "bash", args, false)
304 if err != nil || allow != tt.want {
305 t.Fatalf("headless %s exact-only Bash = (%v,%v), want allow=%v", tt.mode, allow, err, tt.want)
306 }
307 })
308 }
309 }
310
310 lines GO