返回 DeepSeek-Reasonix
write_access.go
根目录 / internal / control / write_access.go
1 package control
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "strings"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/config"
12 "reasonix/internal/event"
13 "reasonix/internal/permission"
14 "reasonix/internal/sandbox"
15 )
16
17 const writeAccessKind = event.ApprovalKindWriteAccess
18
19 // PersistWriteAccessFunc writes permission + sandbox.allow_write in one
20 // project-config transaction. A non-nil error must not grant or execute.
21 type PersistWriteAccessFunc func(dirs []string, permRule string) error
22
23 type controllerWriteAccess struct {
24 persist PersistWriteAccessFunc
25 roots *sandbox.WritableRootSet
26 interactive bool
27 bashSandboxEnforced bool
28 }
29
30 func newControllerWriteAccess(opts Options) controllerWriteAccess {
31 return controllerWriteAccess{
32 persist: opts.OnPersistWriteAccess, roots: opts.WriteRoots,
33 bashSandboxEnforced: opts.BashSandboxEnforced,
34 }
35 }
36
37 func (c *Controller) CheckWriteAccess(ctx context.Context, req agent.WriteAccessCheck) (agent.WriteAccessDecision, error) {
38 requestedPreset := strings.TrimSpace(req.Declaration.RequestedPreset)
39 if requestedPreset == "danger-full-access" && c.approval.mode() != ToolApprovalDangerFullAccess {
40 return c.checkDangerFullAccessRetry(ctx, req)
41 }
42 if strings.EqualFold(req.Tool, "bash") && len(req.Declaration.Directories) == 0 {
43 return agent.WriteAccessDecision{Allow: true, PermissionPreset: requestedPreset}, nil
44 }
45 if strings.EqualFold(req.Tool, "bash") && !c.bashEnforcesSandbox() {
46 return agent.WriteAccessDecision{Allow: true}, nil
47 }
48 workDir := strings.TrimSpace(c.workspaceRoot)
49 home, _ := os.UserHomeDir()
50 stateRoot := config.MemoryUserDir()
51 abs, display, broadHome, err := sandbox.NormalizeWriteDirs(req.Declaration.Directories, workDir, home, stateRoot)
52 if err != nil {
53 return agent.WriteAccessDecision{Allow: false, Reason: err.Error()}, nil
54 }
55 if c.approval.mode() == ToolApprovalDangerFullAccess {
56 if c.ordinaryWriteDecision(req.Tool, req.Args, req.ReadOnly) == permission.Deny {
57 return agent.WriteAccessDecision{Allow: false, Reason: "denied by permission policy — this tool/command is on the deny list. Do not retry it; choose another approach or stop and explain."}, nil
58 }
59 return agent.WriteAccessDecision{Allow: true, PerCallRoots: abs, SkipOrdinaryGate: true, PermissionPreset: requestedPreset}, nil
60 }
61 if c.writeAccess.roots == nil {
62 if len(abs) == 0 {
63 return agent.WriteAccessDecision{Allow: true}, nil
64 }
65 return agent.WriteAccessDecision{Allow: false, Reason: agentHeadlessWriteHint(display)}, nil
66 }
67 missing := c.writeAccess.roots.Missing(abs)
68 // The current session's private temp generation is owned by this controller.
69 // Grant it for this call only, so rotation cannot leave the old directory writable.
70 var sessionTempDirs []string
71 if c.sessionTemp != nil {
72 if root := c.sessionTemp.Dir(); root != "" {
73 if resolved, resolveErr := sandbox.ResolveAbsPath(root); resolveErr == nil {
74 remaining := missing[:0]
75 for _, dir := range missing {
76 if sandbox.PathWithin(resolved, dir) {
77 sessionTempDirs = append(sessionTempDirs, dir)
78 } else {
79 remaining = append(remaining, dir)
80 }
81 }
82 missing = remaining
83 }
84 }
85 }
86 if len(missing) == 0 {
87 return agent.WriteAccessDecision{Allow: true, PerCallRoots: sessionTempDirs, PermissionPreset: requestedPreset}, nil
88 }
89 missingDisplay := displayForAbs(abs, display, missing)
90 decision := c.ordinaryWriteDecision(req.Tool, req.Args, req.ReadOnly)
91 if decision == permission.Deny {
92 return agent.WriteAccessDecision{Allow: false, Reason: "denied by permission policy — this tool/command is on the deny list. Do not retry it; choose another approach or stop and explain."}, nil
93 }
94 if !req.Expandable {
95 return agent.WriteAccessDecision{Allow: false, Reason: agent.SubagentWriteAccessMessage(missingDisplay)}, nil
96 }
97 if !c.writeAccess.interactive {
98 return agent.WriteAccessDecision{Allow: false, Reason: agentHeadlessWriteHint(missingDisplay)}, nil
99 }
100 if c.approval.mode() == ToolApprovalDontAsk {
101 return agent.WriteAccessDecision{Allow: false, Reason: agentHeadlessWriteHint(missingDisplay)}, nil
102 }
103 mergeAsk := decision == permission.Ask
104 grant, err := c.requestWriteAccess(ctx, req, missing, missingDisplay, strings.TrimSpace(req.Declaration.Justification), broadHome, mergeAsk)
105 if err != nil {
106 return agent.WriteAccessDecision{}, err
107 }
108 if !grant.Allow {
109 reason := strings.TrimSpace(grant.Reason)
110 if reason == "" {
111 reason = "the user declined to extend write access — do not retry it; ask how they would like to proceed or choose another approach."
112 }
113 return agent.WriteAccessDecision{Allow: false, Reason: reason}, nil
114 }
115 return agent.WriteAccessDecision{
116 Allow: true,
117 PerCallRoots: append(sessionTempDirs, grant.PerCall...),
118 SkipOrdinaryGate: mergeAsk || decision == permission.Allow,
119 PermissionPreset: requestedPreset,
120 }, nil
121 }
122
123 func (c *Controller) checkDangerFullAccessRetry(ctx context.Context, req agent.WriteAccessCheck) (agent.WriteAccessDecision, error) {
124 command := bashCommandForPermissionRetry(req.Args)
125 subject := command
126 if subject == "" {
127 subject = strings.TrimSpace(req.Subject)
128 }
129 if c.ordinaryWriteDecision(req.Tool, req.Args, req.ReadOnly) == permission.Deny {
130 return agent.WriteAccessDecision{Allow: false, Reason: "denied by permission policy — this tool/command is on the deny list. Do not retry it."}, nil
131 }
132 // A full-access retry is never covered by the workspace preset itself. Only
133 // an exact session authorization for this command (or an already active
134 // full-access preset, handled by the caller) may skip the prompt.
135 if c.approval.preApprovedForExactSession(req.Tool, subject) {
136 return agent.WriteAccessDecision{Allow: true, SkipOrdinaryGate: true, PermissionPreset: "danger-full-access"}, nil
137 }
138 if !sandbox.ConsumeDenial(req.Declaration.DenialID, command) {
139 return agent.WriteAccessDecision{Allow: false, Reason: "danger-full-access retry requires a current host-issued denial_id for this exact command"}, nil
140 }
141 if !req.Expandable || !c.writeAccess.interactive || c.approval.mode() == ToolApprovalDontAsk {
142 return agent.WriteAccessDecision{Allow: false, Reason: "danger-full-access retry requires an interactive explicit authorization"}, nil
143 }
144 req.Subject = subject
145 grant, err := c.requestWriteAccess(ctx, req, nil, nil, strings.TrimSpace(req.Declaration.Justification), false, true)
146 if err != nil {
147 return agent.WriteAccessDecision{}, err
148 }
149 if !grant.Allow {
150 return agent.WriteAccessDecision{Allow: false, Reason: "the user declined the full-access retry"}, nil
151 }
152 return agent.WriteAccessDecision{Allow: true, SkipOrdinaryGate: true, PermissionPreset: "danger-full-access"}, nil
153 }
154
155 func bashCommandForPermissionRetry(args []byte) string {
156 var payload struct {
157 Command string `json:"command"`
158 }
159 if json.Unmarshal(args, &payload) != nil {
160 return ""
161 }
162 return strings.TrimSpace(payload.Command)
163 }
164
165 func agentHeadlessWriteHint(display []string) string {
166 needed := strings.Join(display, ", ")
167 if needed == "" {
168 return "this directory is outside the writable roots. Restart with --add-dir /abs/path, add it to [sandbox].allow_write in reasonix.toml, or use an interactive session to approve the directory."
169 }
170 return "this directory is outside the writable roots (" + needed + "). Restart with --add-dir " + needed + ", add it to [sandbox].allow_write in reasonix.toml, or use an interactive session to approve the directory."
171 }
172
173 func displayForAbs(abs, display, missing []string) []string {
174 index := map[string]string{}
175 for i, dir := range abs {
176 if i < len(display) {
177 index[dir] = display[i]
178 }
179 }
180 out := make([]string, 0, len(missing))
181 for _, dir := range missing {
182 if shown := index[dir]; shown != "" {
183 out = append(out, shown)
184 continue
185 }
186 out = append(out, dir)
187 }
188 return out
189 }
190
191 func (c *Controller) ordinaryWriteDecision(toolName string, args []byte, readOnly bool) permission.Decision {
192 policy := c.policy
193 mode := c.approval.mode()
194 switch mode {
195 case ToolApprovalWorkspaceWrite, ToolApprovalDangerFullAccess:
196 policy.Mode = permission.Allow
197 case ToolApprovalDontAsk:
198 policy.Mode = permission.Deny
199 }
200 dec := policy.Decide(toolName, readOnly, args)
201 if dec != permission.Ask {
202 return dec
203 }
204 subject := permission.Subject(args)
205 if c.approval.preApprovedForDecisionOptions(toolName, subject, args, false, false) {
206 return permission.Allow
207 }
208 return permission.Ask
209 }
210
211 func (c *Controller) bashEnforcesSandbox() bool {
212 return c != nil && c.writeAccess.bashSandboxEnforced && sandbox.Available()
213 }
214
215 type writeAccessReply struct {
216 Allow bool
217 Reason string
218 PerCall []string
219 }
220
221 func (c *Controller) requestWriteAccess(ctx context.Context, req agent.WriteAccessCheck, dirs, display []string, justification string, broadHome, mergeAsk bool) (writeAccessReply, error) {
222 subject := strings.TrimSpace(req.Subject)
223 if subject == "" {
224 subject = strings.Join(display, ", ")
225 }
226 reason := justification
227 if mergeAsk {
228 if reason != "" {
229 reason += "\n"
230 }
231 reason += "This choice also authorizes the current matching tool operation."
232 }
233 payload := event.NormalizeWriteAccessApproval(&event.WriteAccessApproval{
234 Directories: append([]string{}, dirs...),
235 DisplayDirectories: append([]string{}, display...),
236 Justification: justification,
237 BroadHomeAccess: broadHome,
238 OrdinaryPermissionNeeded: mergeAsk,
239 PersistAllowed: false,
240 })
241 reply, err := c.requestWriteAccessDecision(ctx, req.Tool, subject, req.Args, reason, payload)
242 if err != nil {
243 return writeAccessReply{}, err
244 }
245 if reply.persistErr != nil {
246 return writeAccessReply{Reason: reply.persistErr.Error()}, nil
247 }
248 if !reply.allow {
249 return writeAccessReply{}, nil
250 }
251 return writeAccessReply{Allow: true, PerCall: append([]string(nil), reply.onceDirs...)}, nil
252 }
253
254 func (c *Controller) requestWriteAccessDecision(ctx context.Context, toolName, subject string, args []byte, reason string, payload *event.WriteAccessApproval) (approvalReply, error) {
255 c.approval.promptEmitMu.Lock()
256 id, reply := c.approval.registerWriteAccess(toolName, subject, reason, args, payload)
257 c.registerOwnedPrompt(id, PromptApproval)
258 approval := event.Approval{
259 ID: id,
260 Tool: toolName,
261 Subject: subject,
262 Reason: reason,
263 RawInput: append([]byte(nil), args...),
264 Fresh: true,
265 Kind: writeAccessKind,
266 WriteAccess: payload,
267 }
268 if err := event.EmitChecked(c.sink, c.approvalRequestEvent(approval)); err != nil {
269 c.approval.promptEmitMu.Unlock()
270 c.cancelOwnedPrompt(id)
271 return approvalReply{}, fmt.Errorf("persist write access request: %w", err)
272 }
273 c.approval.promptEmitMu.Unlock()
274 go c.hooks.Notification(ctx, approvalNotificationText(toolName, subject), "permission_prompt")
275
276 waitCtx, cancelWait := c.approval.waitContext(ctx)
277 defer cancelWait()
278 select {
279 case r := <-reply:
280 return r, nil
281 case <-waitCtx.Done():
282 c.cancelOwnedPrompt(id)
283 return approvalReply{}, waitCtx.Err()
284 }
285 }
286
287 // ResolveApproval answers a pending approval with an explicit scope.
288 func (c *Controller) ResolveApproval(id string, allow bool, scope sandbox.ApprovalScope) error {
289 defer c.refreshRuntimeState(event.Event{})
290 return c.resolveApprovalLocked(id, allow, scope)
291 }
292
293 // ResolveApprovalAt resolves an approval only while the permission runtime is
294 // still the one that emitted it. This prevents a delayed browser or remote
295 // response from authorizing work after a session restart or preset change.
296 func (c *Controller) ResolveApprovalAt(id string, allow bool, scope sandbox.ApprovalScope, generation, permissionRevision uint64) error {
297 defer c.refreshRuntimeState(event.Event{})
298 if c == nil {
299 return ErrPromptNotPending
300 }
301 c.promptResolveMu.Lock()
302 defer c.promptResolveMu.Unlock()
303 if generation != 0 && generation != c.runtimeGeneration {
304 return ErrPromptStaleRuntime
305 }
306 if permissionRevision != 0 && permissionRevision != c.permissionRevision.Load() {
307 return ErrPromptStaleRuntime
308 }
309 return c.resolveApprovalLocked(id, allow, scope)
310 }
311
312 func (c *Controller) resolveApprovalLocked(id string, allow bool, scope sandbox.ApprovalScope) error {
313 if c == nil {
314 return fmt.Errorf("controller is nil")
315 }
316 id = strings.TrimSpace(id)
317 if id == "" {
318 return fmt.Errorf("empty approval id")
319 }
320 if allow && scope == sandbox.ApprovalScopeProject {
321 return fmt.Errorf("permanent approval is no longer supported; allow once or for this session")
322 }
323 pending := c.approval.peek(id)
324 if pending.reply == nil {
325 return nil
326 }
327 if pending.kind == writeAccessKind {
328 var ok bool
329 var err error
330 pending, ok, err = c.approval.resolveAfter(id, func(p pendingApproval) error {
331 state := PromptRejected
332 if allow {
333 state = PromptAnswered
334 }
335 return c.emitTurnEventChecked(event.Event{Kind: event.PromptAnswered, ItemID: id, InteractionState: string(state), Status: event.TurnInProgress})
336 })
337 if err != nil {
338 return err
339 }
340 if !ok {
341 return fmt.Errorf("approval %q is no longer pending", id)
342 }
343 terminal := PromptRejected
344 if allow {
345 terminal = PromptAnswered
346 }
347 c.promptOwner.MarkIDTerminal(id, terminal)
348 return c.resolveWriteAccess(pending, allow, scope)
349 }
350 session := allow && scope == sandbox.ApprovalScopeSession
351 return c.approveChecked(id, allow, session, false)
352 }
353
354 func (c *Controller) resolveWriteAccess(pending pendingApproval, allow bool, scope sandbox.ApprovalScope) error {
355 if pending.reply == nil {
356 return fmt.Errorf("write access approval is no longer pending")
357 }
358 if !allow {
359 c.recordDecisionReceipt(pending, "deny")
360 pending.reply <- approvalReply{}
361 return nil
362 }
363 dirs := []string{}
364 merge := false
365 if pending.writeAccess != nil {
366 dirs = append([]string{}, pending.writeAccess.Directories...)
367 merge = pending.writeAccess.OrdinaryPermissionNeeded
368 }
369 stateRoot := config.MemoryUserDir()
370 verifiedDirs := make([]string, 0, len(dirs))
371 for _, dir := range dirs {
372 verified, err := sandbox.EnsureWriteDir(dir, stateRoot)
373 if err != nil {
374 c.recordDecisionReceipt(pending, "deny")
375 pending.reply <- approvalReply{persistErr: err}
376 c.sink.Emit(event.Event{
377 Kind: event.Notice,
378 Level: event.LevelWarn,
379 Text: fmt.Sprintf("could not create approved write directory %s: %v", dir, err),
380 })
381 return err
382 }
383 verifiedDirs = append(verifiedDirs, verified)
384 }
385 outcome := "allow_once"
386 reply := approvalReply{allow: true, onceDirs: verifiedDirs}
387 if scope == sandbox.ApprovalScopeSession {
388 c.permissionStateMu.Lock()
389 if c.writeAccess.roots != nil {
390 c.writeAccess.roots.GrantVerifiedSession(verifiedDirs)
391 }
392 if merge {
393 if approvalRequestsFullAccess(pending.rawInput) {
394 c.approval.grantExactSession(pending.tool, pending.subject)
395 } else {
396 c.approval.grantSession(pending.tool, pending.subject)
397 }
398 }
399 c.permissionStateMu.Unlock()
400 reply.session = true
401 reply.onceDirs = nil
402 outcome = "allow_session"
403 }
404 c.recordDecisionReceipt(pending, outcome)
405 pending.reply <- reply
406 return nil
407 }
408
409 func approvalRequestsFullAccess(raw json.RawMessage) bool {
410 var payload struct {
411 SandboxPermissions string `json:"sandbox_permissions"`
412 }
413 return json.Unmarshal(raw, &payload) == nil && strings.TrimSpace(payload.SandboxPermissions) == "danger-full-access"
414 }
415
416 func (c *Controller) clearSessionWriteAccess() {
417 if c.writeAccess.roots != nil {
418 c.writeAccess.roots.ClearSession()
419 }
420 }
421
422 func scopeFromApprove(allow, session, persist bool) sandbox.ApprovalScope {
423 if !allow {
424 return sandbox.ApprovalScopeOnce
425 }
426 if persist {
427 return sandbox.ApprovalScopeProject
428 }
429 if session {
430 return sandbox.ApprovalScopeSession
431 }
432 return sandbox.ApprovalScopeOnce
433 }
434
434 lines GO