返回 DeepSeek-Reasonix
permission_runtime.go
根目录 / internal / control / permission_runtime.go
1 package control
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "runtime"
8 "slices"
9 "strings"
10 "sync/atomic"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/permissionpreset"
14 "reasonix/internal/sandbox"
15 "reasonix/internal/session"
16 )
17
18 // SessionGrantSummary is a transport-safe description of an in-memory grant.
19 // Grants remain session-local and are never converted to persistent rules.
20 type SessionGrantSummary struct {
21 Scope string `json:"scope"`
22 Target string `json:"target"`
23 }
24
25 type PermissionCapabilities struct {
26 Backend string `json:"backend"`
27 Enforcement string `json:"enforcement"`
28 SupportedPresets []string `json:"supportedPresets"`
29 UnavailableReason string `json:"unavailableReason,omitempty"`
30 WriteIsolation string `json:"writeIsolation,omitempty"`
31 ReadIsolation string `json:"readIsolation,omitempty"`
32 NetworkIsolation string `json:"networkIsolation,omitempty"`
33 }
34
35 // PermissionSnapshot is the sole user-facing permission state for a session.
36 type PermissionSnapshot struct {
37 SessionID string `json:"sessionId"`
38 Generation uint64 `json:"generation"`
39 Revision uint64 `json:"revision"`
40 Preset string `json:"preset"`
41 WorkspaceRoot string `json:"workspaceRoot"`
42 Grants []SessionGrantSummary `json:"grants"`
43 Capabilities PermissionCapabilities `json:"capabilities"`
44 }
45
46 const (
47 presetSandboxProbe int32 = iota
48 presetSandboxPinnedOn
49 presetSandboxPinnedOff
50 )
51
52 // presetSandbox selects what the offered presets assume about the host
53 // sandbox; only tests pin it, the product always probes the host.
54 var presetSandbox atomic.Int32
55
56 // SetPresetSandboxForTest pins whether the offered presets see a host sandbox,
57 // so preset tests behave the same on hosts with and without one.
58 func SetPresetSandboxForTest(available bool) (restore func()) {
59 next := presetSandboxPinnedOff
60 if available {
61 next = presetSandboxPinnedOn
62 }
63 prev := presetSandbox.Swap(next)
64 return func() { presetSandbox.Store(prev) }
65 }
66
67 func platformPermissionCapabilities() PermissionCapabilities {
68 available := sandbox.Available()
69 switch presetSandbox.Load() {
70 case presetSandboxPinnedOn:
71 available = true
72 case presetSandboxPinnedOff:
73 available = false
74 }
75 return permissionCapabilitiesForPlatform(runtime.GOOS, available, sandbox.UnavailableMessage())
76 }
77
78 func permissionCapabilitiesForPlatform(goos string, available bool, unavailableReason string) PermissionCapabilities {
79 backend := "none"
80 writeIsolation := ""
81 readIsolation := ""
82 networkIsolation := ""
83 switch goos {
84 case "darwin":
85 backend = "seatbelt"
86 writeIsolation = "seatbelt-filesystem"
87 readIsolation = "seatbelt-filesystem"
88 networkIsolation = "seatbelt-network"
89 case "linux":
90 backend = "bubblewrap"
91 writeIsolation = "bubblewrap-mount-namespace"
92 readIsolation = "bubblewrap-mount-namespace"
93 networkIsolation = "bubblewrap-network-namespace"
94 case "windows":
95 // No OS backend, but the presets still apply as tool-layer boundaries
96 // (file writers, approval prompts), so every preset stays selectable.
97 return PermissionCapabilities{
98 Backend: "none", Enforcement: "unavailable",
99 SupportedPresets: []string{string(permissionpreset.ReadOnly), string(permissionpreset.WorkspaceWrite), string(permissionpreset.DangerFullAccess)},
100 UnavailableReason: unavailableReason,
101 }
102 }
103 if available {
104 return PermissionCapabilities{
105 Backend: backend, Enforcement: "full",
106 SupportedPresets: []string{string(permissionpreset.ReadOnly), string(permissionpreset.WorkspaceWrite), string(permissionpreset.DangerFullAccess)},
107 WriteIsolation: writeIsolation, ReadIsolation: readIsolation, NetworkIsolation: networkIsolation,
108 }
109 }
110 return PermissionCapabilities{
111 Backend: backend, Enforcement: "unavailable",
112 SupportedPresets: []string{string(permissionpreset.DangerFullAccess)},
113 UnavailableReason: unavailableReason,
114 }
115 }
116
117 func (c *Controller) PermissionSnapshot() PermissionSnapshot {
118 c.permissionStateMu.RLock()
119 defer c.permissionStateMu.RUnlock()
120 auth := c.SessionAuthorizations()
121 grants := make([]SessionGrantSummary, 0, len(auth.Grants)+len(auth.WriteRoots)+len(auth.PlanModeReadOnlyCommands))
122 for _, target := range auth.Grants {
123 grants = append(grants, SessionGrantSummary{Scope: "tool", Target: target})
124 }
125 for _, target := range auth.WriteRoots {
126 grants = append(grants, SessionGrantSummary{Scope: "directory", Target: target})
127 }
128 for _, target := range auth.PlanModeReadOnlyCommands {
129 grants = append(grants, SessionGrantSummary{Scope: "command-prefix", Target: target})
130 }
131 return PermissionSnapshot{
132 SessionID: c.parentSessionID(), Generation: c.runtimeGeneration,
133 Revision: c.permissionRevision.Load(), Preset: c.ToolApprovalMode(),
134 WorkspaceRoot: strings.TrimSpace(c.workspaceRoot), Grants: grants,
135 Capabilities: platformPermissionCapabilities(),
136 }
137 }
138
139 // RestoreSessionAuthorizations re-applies grants captured from a prior
140 // controller when the same logical session is rebuilt.
141 func (c *Controller) RestoreSessionAuthorizations(auth SessionAuthorizations) {
142 c.permissionStateMu.Lock()
143 defer c.permissionStateMu.Unlock()
144 c.approval.restoreSessionAuthorizations(auth)
145 if c.writeAccess.roots != nil && len(auth.WriteRoots) > 0 {
146 c.writeAccess.roots.GrantVerifiedSession(auth.WriteRoots)
147 }
148 }
149
150 // SetPermissionPreset applies a compare-and-set update. A stale UI or remote
151 // reply cannot mutate a newer permission generation.
152 func (c *Controller) SetPermissionPreset(preset string, expectedRevision uint64) (PermissionSnapshot, []string, error) {
153 c.permissionMu.Lock()
154 defer c.permissionMu.Unlock()
155 current := c.permissionRevision.Load()
156 if expectedRevision != current {
157 return c.PermissionSnapshot(), nil, fmt.Errorf("permission revision changed: have %d, expected %d", current, expectedRevision)
158 }
159 raw := strings.ToLower(strings.TrimSpace(preset))
160 if !permissionpreset.Valid(raw) {
161 return c.PermissionSnapshot(), nil, fmt.Errorf("permission preset must be read-only, workspace-write, or danger-full-access")
162 }
163 capabilities := platformPermissionCapabilities()
164 if !slices.Contains(capabilities.SupportedPresets, raw) {
165 return c.PermissionSnapshot(), nil, fmt.Errorf("permission preset %q is unavailable: %s", raw, capabilities.UnavailableReason)
166 }
167 drained := c.applyToolApprovalModeLocked(raw)
168 return c.PermissionSnapshot(), drained, nil
169 }
170
171 func (c *Controller) applyDurablePermissionPresetLocked(preset string) []string {
172 previous := c.ToolApprovalMode()
173 drained := c.applyToolApprovalModeLocked(preset)
174 if previous == preset {
175 // A same-value explicit choice still wrote a new durable event. Advance
176 // the CAS revision so a delayed choice cannot overwrite that intent.
177 c.permissionStateMu.Lock()
178 c.permissionRevision.Add(1)
179 c.permissionStateMu.Unlock()
180 }
181 return drained
182 }
183
184 // SetSessionPermissionPreset records an explicit choice in the canonical
185 // session before exposing it to execution or returning success to a caller.
186 func (c *Controller) SetSessionPermissionPreset(ctx context.Context, preset string, expectedRevision uint64) (PermissionSnapshot, []string, error) {
187 if c == nil {
188 return PermissionSnapshot{}, nil, fmt.Errorf("controller is nil")
189 }
190 c.permissionMu.Lock()
191 defer c.permissionMu.Unlock()
192 if current := c.permissionRevision.Load(); expectedRevision != current {
193 return c.PermissionSnapshot(), nil, fmt.Errorf("permission revision changed: have %d, expected %d", current, expectedRevision)
194 }
195 raw := strings.ToLower(strings.TrimSpace(preset))
196 if !permissionpreset.Valid(raw) {
197 return c.PermissionSnapshot(), nil, fmt.Errorf("permission preset must be read-only, workspace-write, or danger-full-access")
198 }
199 capabilities := platformPermissionCapabilities()
200 if !slices.Contains(capabilities.SupportedPresets, raw) {
201 return c.PermissionSnapshot(), nil, fmt.Errorf("permission preset %q is unavailable: %s", raw, capabilities.UnavailableReason)
202 }
203 _, runtime, exclusive := c.v3Binding()
204 if !exclusive || runtime == nil {
205 return c.PermissionSnapshot(), nil, session.ErrSessionNotRunning
206 }
207 if err := c.persistSessionPermissionPreset(ctx, runtime, raw); err != nil {
208 drained, failed := c.applyFailedPermissionDowngradeLocked(raw, err)
209 return c.PermissionSnapshot(), drained, failed
210 }
211 drained := c.applyDurablePermissionPresetLocked(raw)
212 return c.PermissionSnapshot(), drained, nil
213 }
214
215 func (c *Controller) applyFailedPermissionDowngradeLocked(preset string, cause error) ([]string, error) {
216 if permissionPresetRank(preset) < permissionPresetRank(c.ToolApprovalMode()) {
217 return c.applyToolApprovalModeLocked(preset), cause
218 }
219 return nil, cause
220 }
221
222 func (c *Controller) persistSessionPermissionPreset(ctx context.Context, runtime *session.Runtime, preset string) error {
223 if ctx == nil {
224 ctx = context.Background()
225 }
226 payload, err := json.Marshal(struct {
227 Preset string `json:"preset"`
228 }{Preset: preset})
229 if err != nil {
230 return err
231 }
232 commit, err := c.appendSessionBatch(ctx, runtime.Session(), session.Batch{
233 OperationID: "session-permission-preset:" + agent.NewMessageID(),
234 Events: []session.Event{{Kind: "session/permission-preset", Payload: payload}},
235 })
236 if err != nil {
237 return err
238 }
239 // An accepted append cannot be withdrawn. Finish its durability wait even if
240 // the requesting client disconnects, so storage and enforcement do not split.
241 _, err = runtime.Session().FlushThrough(context.WithoutCancel(ctx), commit.LastSequence())
242 return err
243 }
244
245 // InvalidatePermissionSnapshots advances the revision with no permission
246 // change, so a compare-and-set against any earlier snapshot is refused.
247 func (c *Controller) InvalidatePermissionSnapshots() {
248 c.permissionMu.Lock()
249 defer c.permissionMu.Unlock()
250 c.permissionStateMu.Lock()
251 c.permissionRevision.Add(1)
252 c.permissionStateMu.Unlock()
253 }
254
255 // RevokeSessionGrant removes one exact in-memory authorization. Revocation is
256 // compare-and-set protected; the new revision is published atomically with the
257 // removal before in-flight work and background processes are stopped.
258 func (c *Controller) RevokeSessionGrant(scope, target string, expectedRevision uint64) (PermissionSnapshot, error) {
259 if c == nil {
260 return PermissionSnapshot{}, fmt.Errorf("controller is nil")
261 }
262 c.permissionMu.Lock()
263 defer c.permissionMu.Unlock()
264 current := c.permissionRevision.Load()
265 if expectedRevision != current {
266 return c.PermissionSnapshot(), fmt.Errorf("permission revision changed: have %d, expected %d", current, expectedRevision)
267 }
268 c.promptResolveMu.Lock()
269 c.permissionStateMu.Lock()
270 removed := false
271 switch strings.TrimSpace(scope) {
272 case "directory":
273 if c.writeAccess.roots != nil {
274 removed = c.writeAccess.roots.RevokeSession(target)
275 }
276 case "tool", "command-prefix":
277 removed = c.approval.revokeSessionAuthorization(scope, target)
278 default:
279 c.permissionStateMu.Unlock()
280 c.promptResolveMu.Unlock()
281 return c.PermissionSnapshot(), fmt.Errorf("unknown session grant scope %q", scope)
282 }
283 if !removed {
284 c.permissionStateMu.Unlock()
285 c.promptResolveMu.Unlock()
286 return c.PermissionSnapshot(), fmt.Errorf("session grant was not found")
287 }
288 c.permissionRevision.Add(1)
289 c.permissionStateMu.Unlock()
290 turnID, cancelled := "", false
291 if c.Running() {
292 turnID, cancelled = c.cancelTurnLocked()
293 }
294 c.promptResolveMu.Unlock()
295 if cancelled {
296 c.finishCancel(turnID, true)
297 }
298 for _, job := range c.Jobs() {
299 c.CancelJob(job.ID)
300 }
301 return c.PermissionSnapshot(), nil
302 }
303
303 lines GO