返回 DeepSeek-Reasonix
write_access_test.go
根目录 / internal / control / write_access_test.go
1 package control
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "reasonix/internal/agent"
14 "reasonix/internal/event"
15 "reasonix/internal/permission"
16 "reasonix/internal/sandbox"
17 "reasonix/internal/tool"
18 )
19
20 // Exercise the same exact-identity endpoint as Desktop, including a replay
21 // while the first write is waiting and a subsequent write to the same root.
22 func TestWriteAccessExactApprovalAcrossConsecutiveWrites(t *testing.T) {
23 for _, sessionScope := range []bool{false, true} {
24 name := "once"
25 if sessionScope {
26 name = "session"
27 }
28 t.Run(name, func(t *testing.T) {
29 workspace, outside := canonicalWriteTestDir(t), canonicalWriteTestDir(t)
30 requests := make(chan event.Event, 8)
31 results := make(chan agent.WriteAccessDecision, 2)
32 finished := make(chan error, 1)
33 c := newOwnedTestController(t, Options{
34 WorkspaceRoot: workspace, WriteRoots: sandbox.NewWritableRootSet([]string{workspace}),
35 RuntimeGeneration: 1, Policy: permission.New("allow", nil, nil, nil),
36 Sink: event.FuncSink(func(e event.Event) {
37 if e.Kind == event.ApprovalRequest {
38 requests <- e
39 }
40 }),
41 })
42 c.EnableInteractiveApproval()
43 c.SetToolApprovalMode(ToolApprovalWorkspaceWrite)
44 c.SetTurnEventRoutingMetadata("write-access-runtime", "")
45 t.Cleanup(c.Close)
46 c.runGuarded(func(ctx context.Context) error {
47 for range 2 {
48 decision, err := c.CheckWriteAccess(ctx, agent.WriteAccessCheck{
49 Tool: "write_file", Subject: filepath.Join(outside, "animation.html"), Expandable: true,
50 Args: json.RawMessage(`{"content":"fixture"}`),
51 Declaration: tool.WriteAccessDeclaration{Directories: []string{outside}},
52 })
53 if err != nil {
54 finished <- err
55 return err
56 }
57 results <- decision
58 }
59 finished <- nil
60 return nil
61 })
62 resolve := func(request event.Event) {
63 t.Helper()
64 answer := PromptAnswer{Allow: true, Session: sessionScope,
65 Generation: request.Approval.Generation, PermissionRevision: request.Approval.PermissionRevision}
66 identity := PromptIdentity{PromptID: request.Approval.ID, TurnID: request.TurnID,
67 RuntimeEpoch: "write-access-runtime", Kind: PromptApproval}
68 if err := c.ResolvePromptExact(identity, answer); err != nil {
69 t.Fatalf("exact approval: %v", err)
70 }
71 }
72 first := awaitPromptLedgerTest(t, requests, "first write approval")
73 c.ReplayPendingPrompts()
74 replay := awaitPromptLedgerTest(t, requests, "replayed write approval")
75 if replay.Approval.ID != first.Approval.ID || replay.TurnID != first.TurnID {
76 t.Fatal("replay changed the pending write identity")
77 }
78 resolve(replay)
79 if got := awaitPromptLedgerTest(t, results, "first allowed write"); !got.Allow {
80 t.Fatal("first write denied")
81 }
82 if !sessionScope {
83 second := awaitPromptLedgerTest(t, requests, "second write approval")
84 if second.Approval.ID == first.Approval.ID {
85 t.Fatal("consecutive writes reused a prompt id")
86 }
87 resolve(second)
88 }
89 select {
90 case got := <-results:
91 if !got.Allow {
92 t.Fatal("second write denied")
93 }
94 case unexpected := <-requests:
95 t.Fatalf("session-scoped directory prompted again: %s", unexpected.Approval.ID)
96 case <-time.After(5 * time.Second):
97 t.Fatal("second write did not resume")
98 }
99 if err := awaitPromptLedgerTest(t, finished, "write completion"); err != nil {
100 t.Fatal(err)
101 }
102 waitIdle(t, c)
103 })
104 }
105 }
106
107 func TestResolveApprovalWriteAccessOnceDoesNotGrantSession(t *testing.T) {
108 dir := t.TempDir()
109 outside := canonicalWriteTestDir(t)
110 set := sandbox.NewWritableRootSet([]string{dir})
111 c := newOwnedTestController(t, Options{Policy: permission.New("allow", nil, nil, nil), WriteRoots: set})
112 id, reply := c.approval.registerWriteAccess("write_file", outside, "test", json.RawMessage(`{}`), &event.WriteAccessApproval{
113 Directories: []string{outside},
114 DisplayDirectories: []string{"out"},
115 })
116 if err := c.ResolveApproval(id, true, sandbox.ApprovalScopeOnce); err != nil {
117 t.Fatal(err)
118 }
119 got := <-reply
120 if !got.allow || got.session || len(got.onceDirs) != 1 {
121 t.Fatalf("once reply = %+v", got)
122 }
123 if set.Covers(outside) {
124 t.Fatal("once grant must not enter the session set")
125 }
126 }
127
128 func TestResolveApprovalWriteAccessSessionPersistsInSet(t *testing.T) {
129 dir := t.TempDir()
130 extra := canonicalWriteTestDir(t)
131 set := sandbox.NewWritableRootSet([]string{dir})
132 c := newOwnedTestController(t, Options{Policy: permission.New("allow", nil, nil, nil), WriteRoots: set})
133 id, reply := c.approval.registerWriteAccess("write_file", extra, "test", json.RawMessage(`{}`), &event.WriteAccessApproval{
134 Directories: []string{extra},
135 })
136 if err := c.ResolveApproval(id, true, sandbox.ApprovalScopeSession); err != nil {
137 t.Fatal(err)
138 }
139 got := <-reply
140 if !got.allow || !got.session {
141 t.Fatalf("session reply = %+v", got)
142 }
143 if !set.Covers(extra) {
144 t.Fatal("session grant should cover the directory")
145 }
146 }
147
148 func TestResolveApprovalWriteAccessProjectScopeIsRejected(t *testing.T) {
149 dir := t.TempDir()
150 extra := canonicalWriteTestDir(t)
151 set := sandbox.NewWritableRootSet([]string{dir})
152 c := newOwnedTestController(t, Options{
153 Policy: permission.New("allow", nil, nil, nil),
154 WriteRoots: set,
155 OnPersistWriteAccess: func(dirs []string, permRule string) error { return errors.New("must not be called") },
156 })
157 id, reply := c.approval.registerWriteAccess("write_file", extra, "test", json.RawMessage(`{}`), &event.WriteAccessApproval{
158 Directories: []string{extra},
159 })
160 if err := c.ResolveApproval(id, true, sandbox.ApprovalScopeProject); err == nil {
161 t.Fatal("expected permanent scope rejection")
162 }
163 if set.Covers(extra) {
164 t.Fatal("rejected permanent scope must not grant access")
165 }
166 select {
167 case got := <-reply:
168 t.Fatalf("rejected scope must keep the request pending, got %+v", got)
169 default:
170 }
171 }
172
173 func TestDangerFullAccessRetryRequiresRealExactDenialAndCanGrantSession(t *testing.T) {
174 command := "installer --write-protected-state"
175 denialID := sandbox.IssueDenial(command, "workspace-write")
176 approvals := make(chan event.Approval, 1)
177 c := newOwnedTestController(t, Options{
178 Policy: permission.New("allow", nil, nil, nil),
179 WriteRoots: sandbox.NewWritableRootSet([]string{t.TempDir()}),
180 RuntimeGeneration: 1,
181 Sink: event.FuncSink(func(e event.Event) {
182 if e.Kind == event.ApprovalRequest {
183 approvals <- e.Approval
184 }
185 }),
186 })
187 c.writeAccess.interactive = true
188 request := func(id, cmd string) (agent.WriteAccessDecision, error) {
189 args, _ := json.Marshal(map[string]string{"command": cmd, "sandbox_permissions": "danger-full-access", "justification": "complete the requested install", "denial_id": id})
190 return c.CheckWriteAccess(context.Background(), agent.WriteAccessCheck{
191 Tool: "bash", Subject: cmd, Args: args, Expandable: true,
192 Declaration: tool.WriteAccessDeclaration{RequestedPreset: "danger-full-access", Justification: "complete the requested install", DenialID: id},
193 })
194 }
195 result := make(chan agent.WriteAccessDecision, 1)
196 go func() {
197 decision, _ := request(denialID, command)
198 result <- decision
199 }()
200 approval := <-approvals
201 if approval.Generation == 0 || approval.PermissionRevision == 0 {
202 t.Fatalf("approval lacks runtime identity: %+v", approval)
203 }
204 if err := c.ResolveApprovalAt(approval.ID, true, sandbox.ApprovalScopeSession, approval.Generation, approval.PermissionRevision); err != nil {
205 t.Fatal(err)
206 }
207 if decision := <-result; !decision.Allow || decision.PermissionPreset != "danger-full-access" {
208 t.Fatalf("authorized retry = %+v", decision)
209 }
210 decision, err := request("", command)
211 if err != nil || !decision.Allow || decision.PermissionPreset != "danger-full-access" {
212 t.Fatalf("session-scoped exact retry = (%+v, %v)", decision, err)
213 }
214 decision, err = request("", command+" --other")
215 if err != nil || decision.Allow || !strings.Contains(decision.Reason, "denial_id") {
216 t.Fatalf("different command retry = (%+v, %v)", decision, err)
217 }
218 }
219
220 func TestSessionAuthorizationsCarryWriteRoots(t *testing.T) {
221 dir := t.TempDir()
222 extra := t.TempDir()
223 set := sandbox.NewWritableRootSet([]string{dir})
224 c := newOwnedTestController(t, Options{Policy: permission.New("allow", nil, nil, nil), WriteRoots: set})
225 set.GrantSession([]string{extra})
226 auth := c.SessionAuthorizations()
227 if len(auth.WriteRoots) != 1 {
228 t.Fatalf("WriteRoots = %v", auth.WriteRoots)
229 }
230 freshSet := sandbox.NewWritableRootSet([]string{dir})
231 fresh := newOwnedTestController(t, Options{Policy: permission.New("allow", nil, nil, nil), WriteRoots: freshSet})
232 fresh.RestoreSessionAuthorizations(auth)
233 if !freshSet.Covers(extra) {
234 t.Fatal("rebuild must restore session write roots")
235 }
236 }
237
238 func TestNewSessionClearsWriteRoots(t *testing.T) {
239 dir := t.TempDir()
240 extra := t.TempDir()
241 set := sandbox.NewWritableRootSet([]string{dir})
242 exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
243 c := newOwnedTestController(t, Options{Executor: exec, Policy: permission.New("allow", nil, nil, nil), WriteRoots: set})
244 set.GrantSession([]string{extra})
245 if err := c.NewSession(); err != nil {
246 t.Fatal(err)
247 }
248 if set.Covers(extra) {
249 t.Fatal("/new must clear session write roots")
250 }
251 }
252
253 func TestCheckWriteAccessHeadlessMissingDir(t *testing.T) {
254 dir := t.TempDir()
255 set := sandbox.NewWritableRootSet([]string{dir})
256 c := newOwnedTestController(t, Options{Policy: permission.New("allow", nil, nil, nil), WriteRoots: set})
257 dec, err := c.CheckWriteAccess(context.Background(), agent.WriteAccessCheck{
258 Tool: "write_file",
259 Expandable: true,
260 Declaration: tool.WriteAccessDeclaration{
261 Directories: []string{filepath.Join(os.TempDir(), "reasonix-write-access-outside")},
262 },
263 })
264 if err != nil {
265 t.Fatal(err)
266 }
267 if dec.Allow {
268 t.Fatal("headless must not grant a new directory")
269 }
270 if dec.Reason == "" {
271 t.Fatal("expected --add-dir guidance")
272 }
273 }
274
275 func TestCheckWriteAccessSubagentCannotExpand(t *testing.T) {
276 dir := t.TempDir()
277 set := sandbox.NewWritableRootSet([]string{dir})
278 c := newOwnedTestController(t, Options{Policy: permission.New("allow", nil, nil, nil), WriteRoots: set})
279 c.writeAccess.interactive = true
280 dec, err := c.CheckWriteAccess(context.Background(), agent.WriteAccessCheck{
281 Tool: "write_file",
282 Expandable: false,
283 Declaration: tool.WriteAccessDeclaration{
284 Directories: []string{filepath.Join(os.TempDir(), "reasonix-write-access-child")},
285 },
286 })
287 if err != nil {
288 t.Fatal(err)
289 }
290 if dec.Allow {
291 t.Fatal("sub-agent must not expand write access")
292 }
293 }
294
295 func TestWriteAccessNotDrainedByAutoOrYolo(t *testing.T) {
296 dir := t.TempDir()
297 extra := t.TempDir()
298 set := sandbox.NewWritableRootSet([]string{dir})
299 c := newOwnedTestController(t, Options{Policy: permission.New("allow", nil, nil, nil), WriteRoots: set})
300 id, reply := c.approval.registerWriteAccess("bash", extra, "test", json.RawMessage(`{}`), &event.WriteAccessApproval{
301 Directories: []string{extra},
302 })
303 if drained := c.approval.setMode(ToolApprovalAuto); len(drained) != 0 {
304 t.Fatalf("Auto drained write-access: %+v", drained)
305 }
306 if drained := c.approval.setMode(ToolApprovalYolo); len(drained) != 0 {
307 t.Fatalf("YOLO drained write-access: %+v", drained)
308 }
309 pending := c.approval.peek(id)
310 if pending.reply == nil {
311 t.Fatal("write-access approval must stay pending")
312 }
313 pending = c.approval.resolve(id)
314 pending.reply <- approvalReply{}
315 <-reply
316 }
317
318 func TestCheckWriteAccessDenyBeatsDirectoryPrompt(t *testing.T) {
319 dir := t.TempDir()
320 set := sandbox.NewWritableRootSet([]string{dir})
321 c := newOwnedTestController(t, Options{
322 Policy: permission.New("ask", nil, nil, []string{"write_file"}),
323 WriteRoots: set,
324 })
325 c.writeAccess.interactive = true
326 dec, err := c.CheckWriteAccess(context.Background(), agent.WriteAccessCheck{
327 Tool: "write_file",
328 Expandable: true,
329 Declaration: tool.WriteAccessDeclaration{
330 Directories: []string{t.TempDir()},
331 },
332 })
333 if err != nil {
334 t.Fatal(err)
335 }
336 if dec.Allow {
337 t.Fatal("explicit deny must not show a directory approval")
338 }
339 if !strings.Contains(dec.Reason, "deny") {
340 t.Fatalf("reason = %q", dec.Reason)
341 }
342 }
343
344 func TestCheckWriteAccessBashWithoutSandboxSkips(t *testing.T) {
345 dir := t.TempDir()
346 set := sandbox.NewWritableRootSet([]string{dir})
347 c := newOwnedTestController(t, Options{Policy: permission.New("allow", nil, nil, nil), WriteRoots: set})
348 c.writeAccess.interactive = true
349 dec, err := c.CheckWriteAccess(context.Background(), agent.WriteAccessCheck{
350 Tool: "bash",
351 Expandable: true,
352 Declaration: tool.WriteAccessDeclaration{
353 Directories: []string{t.TempDir()},
354 Justification: "install",
355 },
356 })
357 if err != nil {
358 t.Fatal(err)
359 }
360 if !dec.Allow {
361 t.Fatalf("unenforced bash must keep existing platform behavior, got %+v", dec)
362 }
363 }
364
365 func canonicalWriteTestDir(t *testing.T) string {
366 t.Helper()
367 dir, err := sandbox.ResolveAbsPath(t.TempDir())
368 if err != nil {
369 t.Fatal(err)
370 }
371 return dir
372 }
373
373 lines GO