返回 DeepSeek-Reasonix
permission_runtime_test.go
根目录 / internal / control / permission_runtime_test.go
1 package control
2
3 import (
4 "encoding/json"
5 "errors"
6 "path/filepath"
7 "runtime"
8 "testing"
9 "time"
10
11 "reasonix/internal/event"
12 "reasonix/internal/permission"
13 "reasonix/internal/permissionpreset"
14 "reasonix/internal/sandbox"
15 )
16
17 func TestPermissionPresetChangeWaitsForApprovalCommit(t *testing.T) {
18 previousProcs := runtime.GOMAXPROCS(1)
19 t.Cleanup(func() { runtime.GOMAXPROCS(previousProcs) })
20
21 workspace := t.TempDir()
22 extra, err := filepath.EvalSymlinks(t.TempDir())
23 if err != nil {
24 t.Fatal(err)
25 }
26 roots := sandbox.NewWritableRootSet([]string{workspace})
27 c := newOwnedTestController(t, Options{WriteRoots: roots, WorkspaceRoot: workspace})
28 c.SetToolApprovalMode(ToolApprovalWorkspaceWrite)
29
30 entered := make(chan struct{})
31 release := make(chan struct{})
32 c.sink = event.FuncSink(func(e event.Event) {
33 if e.Kind == event.PromptAnswered {
34 close(entered)
35 <-release
36 }
37 })
38 id, reply := c.approval.registerWriteAccess("bash", "outside", "test", json.RawMessage(`{}`), &event.WriteAccessApproval{
39 Directories: []string{extra},
40 })
41 before := c.PermissionSnapshot()
42
43 resolved := make(chan error, 1)
44 go func() {
45 resolved <- c.ResolveApprovalAt(id, true, sandbox.ApprovalScopeSession, before.Generation, before.Revision)
46 }()
47 select {
48 case <-entered:
49 case <-time.After(5 * time.Second):
50 t.Fatal("approval did not reach its commit barrier")
51 }
52
53 switched := make(chan struct{})
54 go func() {
55 c.SetToolApprovalMode(ToolApprovalReadOnly)
56 close(switched)
57 }()
58 // With one scheduler P, Gosched lets the preset goroutine run until it is
59 // blocked behind the approval transaction. Publishing a revision before
60 // that lock is acquired exposes a mixed permission snapshot.
61 runtime.Gosched()
62 if got := c.permissionRevision.Load(); got != before.Revision {
63 close(release)
64 t.Fatalf("permission revision became visible during approval commit: got %d, want %d", got, before.Revision)
65 }
66 during := c.PermissionSnapshot()
67 if during.Revision != before.Revision || during.Preset != before.Preset {
68 close(release)
69 t.Fatalf("permission snapshot changed during approval commit: before=%+v during=%+v", before, during)
70 }
71
72 close(release)
73 if err := <-resolved; err != nil {
74 t.Fatalf("approval that committed first was rejected: %v", err)
75 }
76 if got := <-reply; !got.allow || !got.session {
77 t.Fatalf("approval reply = %+v, want session allow", got)
78 }
79 <-switched
80 after := c.PermissionSnapshot()
81 if after.Preset != ToolApprovalReadOnly || after.Revision <= before.Revision {
82 t.Fatalf("permission snapshot after switch = %+v", after)
83 }
84 if !roots.Covers(extra) {
85 t.Fatal("session grant committed before the preset switch was lost")
86 }
87 found := false
88 for _, grant := range after.Grants {
89 if grant.Scope == "directory" && grant.Target == extra {
90 found = true
91 }
92 }
93 if !found {
94 t.Fatalf("committed session grant missing from permission snapshot: %+v", after.Grants)
95 }
96 }
97
98 func TestResolveApprovalAtRejectsStalePermissionRevision(t *testing.T) {
99 c := newOwnedTestController(t, Options{Policy: permission.New("ask", nil, nil, nil)})
100 id, reply := c.approval.registerWriteAccess("bash", "outside", "test", json.RawMessage(`{}`), &event.WriteAccessApproval{})
101 revision := c.permissionRevision.Load()
102 if err := c.ResolveApprovalAt(id, true, sandbox.ApprovalScopeOnce, c.runtimeGeneration, revision+1); !errors.Is(err, ErrPromptStaleRuntime) {
103 t.Fatalf("ResolveApprovalAt error = %v, want ErrPromptStaleRuntime", err)
104 }
105 select {
106 case got := <-reply:
107 t.Fatalf("stale response resolved approval: %+v", got)
108 default:
109 }
110 if err := c.ResolveApprovalAt(id, false, sandbox.ApprovalScopeOnce, c.runtimeGeneration, revision); err != nil {
111 t.Fatalf("current response failed: %v", err)
112 }
113 }
114
115 func TestPermissionPresetChangePublishesRevisionBeforeOldApprovalCanResolve(t *testing.T) {
116 c := newOwnedTestController(t, Options{Policy: permission.New("ask", nil, nil, nil)})
117 id, reply := c.approval.registerWriteAccess("bash", "outside", "test", json.RawMessage(`{}`), &event.WriteAccessApproval{})
118 before := c.PermissionSnapshot()
119 after, _, err := c.SetPermissionPreset(ToolApprovalDangerFullAccess, before.Revision)
120 if err != nil {
121 t.Fatalf("SetPermissionPreset: %v", err)
122 }
123 if after.Revision <= before.Revision {
124 t.Fatalf("revision did not advance: before=%d after=%d", before.Revision, after.Revision)
125 }
126 if err := c.ResolveApprovalAt(id, true, sandbox.ApprovalScopeOnce, before.Generation, before.Revision); !errors.Is(err, ErrPromptStaleRuntime) {
127 t.Fatalf("old approval reply error = %v, want ErrPromptStaleRuntime", err)
128 }
129 select {
130 case got := <-reply:
131 t.Fatalf("stale response resolved approval: %+v", got)
132 default:
133 }
134 }
135
136 func TestSettingSamePermissionPresetKeepsRevisionStable(t *testing.T) {
137 c := newOwnedTestController(t, Options{Policy: permission.New("allow", nil, nil, nil)})
138 c.SetToolApprovalMode(ToolApprovalDangerFullAccess)
139 before := c.PermissionSnapshot()
140 after, _, err := c.SetPermissionPreset(before.Preset, before.Revision)
141 if err != nil {
142 t.Fatalf("SetPermissionPreset: %v", err)
143 }
144 if after.Revision != before.Revision {
145 t.Fatalf("same preset changed revision: before=%d after=%d", before.Revision, after.Revision)
146 }
147 }
148
149 func TestPermissionSnapshotAndExactGrantRevocation(t *testing.T) {
150 workspace := t.TempDir()
151 extra := t.TempDir()
152 roots := sandbox.NewWritableRootSet([]string{workspace})
153 roots.GrantSession([]string{extra})
154 c := newOwnedTestController(t, Options{Policy: permission.New("allow", nil, nil, nil), WriteRoots: roots, WorkspaceRoot: workspace})
155 snapshot := c.PermissionSnapshot()
156 if snapshot.Preset == "" || snapshot.WorkspaceRoot != workspace {
157 t.Fatalf("permission snapshot = %+v", snapshot)
158 }
159 found := false
160 for _, grant := range snapshot.Grants {
161 if grant.Scope == "directory" && grant.Target != "" {
162 found = true
163 var err error
164 snapshot, err = c.RevokeSessionGrant(grant.Scope, grant.Target, snapshot.Revision)
165 if err != nil {
166 t.Fatalf("RevokeSessionGrant: %v", err)
167 }
168 break
169 }
170 }
171 if !found {
172 t.Fatalf("directory grant missing from snapshot: %+v", snapshot.Grants)
173 }
174 if roots.Covers(extra) {
175 t.Fatal("revoked directory remains writable")
176 }
177 if _, err := c.RevokeSessionGrant("directory", extra, snapshot.Revision-1); err == nil {
178 t.Fatal("stale grant revocation should fail")
179 }
180 }
181
182 func TestWindowsPermissionCapabilitiesKeepPresetsWithoutBackend(t *testing.T) {
183 // The Windows backend is retired: regardless of what the host reports,
184 // no isolation is advertised, yet every preset remains selectable because
185 // the presets are enforced by Reasonix's own tools there.
186 for _, available := range []bool{true, false} {
187 got := permissionCapabilitiesForPlatform("windows", available, "no OS sandbox")
188 if got.Backend != "none" || got.Enforcement != "unavailable" || got.UnavailableReason != "no OS sandbox" {
189 t.Fatalf("available=%v: Windows capability summary = %+v", available, got)
190 }
191 if got.WriteIsolation != "" || got.ReadIsolation != "" || got.NetworkIsolation != "" {
192 t.Fatalf("available=%v: Windows advertised isolation: %+v", available, got)
193 }
194 if len(got.SupportedPresets) != 3 {
195 t.Fatalf("available=%v: supported presets = %v", available, got.SupportedPresets)
196 }
197 }
198 }
199
200 func TestUnavailablePermissionBackendOnlyOffersFullAccess(t *testing.T) {
201 got := permissionCapabilitiesForPlatform("linux", false, "native API unavailable")
202 if got.Enforcement != "unavailable" || got.UnavailableReason != "native API unavailable" {
203 t.Fatalf("unavailable capability summary = %+v", got)
204 }
205 if len(got.SupportedPresets) != 1 || got.SupportedPresets[0] != string(permissionpreset.DangerFullAccess) {
206 t.Fatalf("supported presets = %v", got.SupportedPresets)
207 }
208 if got.WriteIsolation != "" || got.ReadIsolation != "" || got.NetworkIsolation != "" {
209 t.Fatalf("unavailable backend advertised active isolation: %+v", got)
210 }
211 }
212
212 lines GO