| 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 |