返回 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 TestDynamicBashUsesTheSamePresetDecisionAsOtherCommands(t *testing.T) {
49 for _, tt := range []struct {
50 name string
51 mode string
52 }{
53 {name: "workspace write", mode: ToolApprovalWorkspaceWrite},
54 {name: "full access", mode: ToolApprovalDangerFullAccess},
55 } {
56 t.Run(tt.name, func(t *testing.T) {
57 approvals := make(chan event.Approval, 1)
58 c := newOwnedTestController(t, Options{Sink: event.FuncSink(func(e event.Event) {
59 if e.Kind == event.ApprovalRequest {
60 approvals <- e.Approval
61 }
62 })})
63 c.SetToolApprovalMode(tt.mode)
64 args := json.RawMessage(`{"command":"git status $(touch /tmp/reasonix-dynamic-approval)"}`)
65 allow, reason, err := c.newInteractiveGate().Check(context.Background(), "bash", args, false)
66 if err != nil || !allow || reason != "" {
67 t.Fatalf("preset decision = (%v,%q,%v), want allow", allow, reason, err)
68 }
69 select {
70 case approval := <-approvals:
71 t.Fatalf("dynamic Bash unexpectedly prompted: %+v", approval)
72 default:
73 }
74 })
75 }
76 }
77
78 func TestExactOnlyBashDoesNotPromptInAutoOrApprovedPlan(t *testing.T) {
79 commands := []string{
80 "REV=HEAD git diff",
81 "git status > status.txt",
82 "rm *.log",
83 "echo $HOME",
84 }
85 for _, tt := range []struct {
86 name string
87 setup func(*Controller)
88 }{
89 {name: "auto", setup: func(c *Controller) { c.SetToolApprovalMode(ToolApprovalAuto) }},
90 {name: "approved plan", setup: func(c *Controller) { c.approval.setPlanAutoApprove(true) }},
91 } {
92 t.Run(tt.name, func(t *testing.T) {
93 approvals := make(chan event.Approval, len(commands))
94 c := newOwnedTestController(t, Options{Sink: event.FuncSink(func(e event.Event) {
95 if e.Kind == event.ApprovalRequest {
96 approvals <- e.Approval
97 }
98 })})
99 tt.setup(c)
100 gate := c.newInteractiveGate()
101 for _, command := range commands {
102 args, err := json.Marshal(map[string]string{"command": command})
103 if err != nil {
104 t.Fatal(err)
105 }
106 allow, reason, err := gate.Check(context.Background(), "bash", args, false)
107 if err != nil || !allow || reason != "" {
108 t.Errorf("%s command %q = (%v,%q,%v), want allow without prompt", tt.name, command, allow, reason, err)
109 }
110 }
111 select {
112 case approval := <-approvals:
113 t.Fatalf("%s exact-only Bash unexpectedly prompted: %+v", tt.name, approval)
114 case <-time.After(50 * time.Millisecond):
115 }
116 })
117 }
118 }
119
120 func TestPermissionModeChangeDoesNotAutoApprovePendingRequest(t *testing.T) {
121 approvals := make(chan event.Approval, 1)
122 c := newOwnedTestController(t, Options{Sink: event.FuncSink(func(e event.Event) {
123 if e.Kind == event.ApprovalRequest {
124 approvals <- e.Approval
125 }
126 })})
127 done := requestDynamicBashApproval(c)
128 select {
129 case <-approvals:
130 case <-time.After(30 * time.Second):
131 t.Fatal("dynamic Bash approval prompt was not emitted")
132 }
133 c.SetToolApprovalMode(ToolApprovalWorkspaceWrite)
134 assertDynamicApprovalPending(t, done)
135 c.SetToolApprovalMode(ToolApprovalDangerFullAccess)
136 assertDynamicApprovalPending(t, done)
137 c.Approve("1", false, false, false)
138 if got := <-done; got.err != nil || got.allow {
139 t.Fatalf("explicit denial after mode changes = %+v, want deny", got)
140 }
141 }
142
143 func TestDynamicBashExactSessionGrantIsNotPersisted(t *testing.T) {
144 approvals := make(chan event.Approval, 2)
145 remembered := make(chan string, 1)
146 c := newOwnedTestController(t, Options{
147 Sink: event.FuncSink(func(e event.Event) {
148 if e.Kind == event.ApprovalRequest {
149 approvals <- e.Approval
150 }
151 }),
152 OnRemember: func(rule string) RememberResult {
153 remembered <- rule
154 return RememberResult{Saved: true}
155 },
156 })
157
158 done := requestDynamicBashApproval(c)
159 approval := <-approvals
160 c.Approve(approval.ID, true, true, false)
161 if got := <-done; got.err != nil || !got.allow {
162 t.Fatalf("initial approval = %+v, want allow", got)
163 }
164 allow, _, err := gateApprover{c}.Approve(context.Background(), "bash", dynamicBashCommand, nil)
165 if err != nil || !allow {
166 t.Fatalf("exact session grant = (%v,%v), want allow", allow, err)
167 }
168 select {
169 case approval := <-approvals:
170 t.Fatalf("exact session grant unexpectedly prompted: %+v", approval)
171 case <-time.After(50 * time.Millisecond):
172 }
173 select {
174 case got := <-remembered:
175 t.Fatalf("session grant was persisted as %q", got)
176 default:
177 }
178
179 old := c.SessionAuthorizations()
180 fresh := newOwnedTestController(t, Options{})
181 fresh.RestoreSessionAuthorizations(old)
182 allow, _, err = gateApprover{fresh}.Approve(context.Background(), "bash", dynamicBashCommand, nil)
183 if err != nil || !allow {
184 t.Fatalf("restored exact session grant = (%v,%v), want allow", allow, err)
185 }
186 }
187
188 func TestDynamicBashHookDecisionAppliesWithoutSyntaxSpecialCase(t *testing.T) {
189 allowJSON := `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}`
190 c, ids := wildcardClaudePermissionHookController(t, 0, allowJSON)
191 done := requestDynamicBashApproval(c)
192 if got := <-done; got.err != nil || !got.allow {
193 t.Fatalf("hook allow = %+v, want allow", got)
194 }
195 select {
196 case id := <-ids:
197 t.Fatalf("hook allow unexpectedly emitted approval %s", id)
198 default:
199 }
200
201 c, ids = wildcardClaudePermissionHookController(t, 2, "")
202 allow, _, err := gateApprover{c}.Approve(context.Background(), "bash", dynamicBashCommand, nil)
203 if err != nil || allow {
204 t.Fatalf("hook deny = (%v,%v), want deny", allow, err)
205 }
206 select {
207 case id := <-ids:
208 t.Fatalf("hook deny unexpectedly emitted approval %s", id)
209 case <-time.After(50 * time.Millisecond):
210 }
211 }
212
213 func TestDynamicBashSkipsGuardianAllow(t *testing.T) {
214 guardianProv := &recordingProvider{
215 name: "guardian",
216 streams: [][]provider.Chunk{textTurn(`{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"safe"}`)},
217 }
218 guardianSess := guardian.NewSession(guardianProv, tool.NewRegistry(), guardian.PolicyPrompt(), "guardian-test", 0, nil, event.Discard)
219 exec := agent.New(&recordingProvider{name: "executor"}, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
220 approvals := make(chan event.Approval, 1)
221 c := newOwnedTestController(t, Options{
222 Executor: exec,
223 Guardian: guardianSess,
224 Sink: event.FuncSink(func(e event.Event) {
225 if e.Kind == event.ApprovalRequest {
226 approvals <- e.Approval
227 }
228 }),
229 })
230 done := requestDynamicBashApproval(c)
231 approval := <-approvals
232 if len(guardianProv.requests) != 0 {
233 t.Fatalf("dynamic Bash Guardian reviews = %d, want 0", len(guardianProv.requests))
234 }
235 c.Approve(approval.ID, true, false, false)
236 if got := <-done; got.err != nil || !got.allow {
237 t.Fatalf("manual approval = %+v, want allow", got)
238 }
239 }
240
241 func TestHeadlessDynamicBashApprovalModes(t *testing.T) {
242 args := json.RawMessage(`{"command":"git status $(touch /tmp/reasonix-dynamic-approval)"}`)
243 for _, tt := range []struct {
244 mode string
245 want bool
246 }{
247 {mode: ToolApprovalAsk},
248 {mode: ToolApprovalAuto, want: true},
249 {mode: ToolApprovalDontAsk},
250 {mode: ToolApprovalYolo, want: true},
251 } {
252 t.Run(tt.mode, func(t *testing.T) {
253 allow, reason, err := BuildHeadlessApprovalGate(permission.New("ask", []string{"Bash(git*)"}, nil, nil), tt.mode).Check(context.Background(), "bash", args, false)
254 if err != nil || allow != tt.want {
255 t.Fatalf("headless %s = (%v,%q,%v), want allow=%v", tt.mode, allow, reason, err, tt.want)
256 }
257 if !tt.want && strings.TrimSpace(reason) == "" {
258 t.Fatalf("headless %s returned no denial reason", tt.mode)
259 }
260 })
261 }
262
263 exact := permission.New("ask", []string{"Bash=" + dynamicBashCommand}, nil, nil)
264 allow, reason, err := BuildHeadlessApprovalGate(exact, ToolApprovalAsk).Check(context.Background(), "bash", args, false)
265 if err != nil || !allow || reason != "" {
266 t.Fatalf("headless exact literal = (%v,%q,%v), want allow", allow, reason, err)
267 }
268
269 optIn := permission.New("ask", nil, nil, nil).WithAllowDynamicBashFallback(true)
270 allow, reason, err = BuildHeadlessApprovalGate(optIn, ToolApprovalAuto).Check(context.Background(), "bash", args, false)
271 if err != nil || !allow || reason != "" {
272 t.Fatalf("headless dynamic fallback opt-in = (%v,%q,%v), want allow", allow, reason, err)
273 }
274 }
275
276 func TestHeadlessExactOnlyBashApprovalModes(t *testing.T) {
277 args := json.RawMessage(`{"command":"rm *.log"}`)
278 for _, tt := range []struct {
279 mode string
280 want bool
281 }{
282 {mode: ToolApprovalAsk},
283 {mode: ToolApprovalAuto, want: true},
284 {mode: ToolApprovalDontAsk},
285 {mode: ToolApprovalYolo, want: true},
286 } {
287 t.Run(tt.mode, func(t *testing.T) {
288 allow, _, err := BuildHeadlessApprovalGate(permission.New("ask", []string{"Bash(rm*)"}, nil, nil), tt.mode).Check(context.Background(), "bash", args, false)
289 if err != nil || allow != tt.want {
290 t.Fatalf("headless %s exact-only Bash = (%v,%v), want allow=%v", tt.mode, allow, err, tt.want)
291 }
292 })
293 }
294 }
295
295 lines GO