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