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