返回 DeepSeek-Reasonix
write_access.go
根目录 / internal / agent / write_access.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "strings"
9
10 "reasonix/internal/nilutil"
11 "reasonix/internal/sandbox"
12 "reasonix/internal/tool"
13 "reasonix/internal/tool/builtin"
14 )
15
16 const (
17 headlessWriteAccessHint = "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."
18 subagentWriteAccessHint = "this sub-agent cannot expand write access. Ask the parent agent to request the directories (bash additional_write_dirs, or write the file after the parent is granted that directory)."
19 )
20
21 // SubagentWriteAccessMessage is the structured failure a child agent returns
22 // when it needs a directory the parent has not granted.
23 func SubagentWriteAccessMessage(display []string) string {
24 if len(display) == 0 {
25 return subagentWriteAccessHint
26 }
27 return subagentWriteAccessHint + " Needed directories: " + strings.Join(display, ", ")
28 }
29
30 // WriteAccessCheck is the host-local write-directory preflight for one tool call.
31 type WriteAccessCheck struct {
32 Tool string
33 Subject string
34 Args json.RawMessage
35 ReadOnly bool
36 Declaration tool.WriteAccessDeclaration
37 Expandable bool
38 }
39
40 // WriteAccessDecision is the result of a write-directory preflight.
41 type WriteAccessDecision struct {
42 Allow bool
43 Reason string
44 PerCallRoots []string
45 SkipOrdinaryGate bool
46 PermissionPreset string
47 }
48
49 // WriteAccessGate authorizes extra writable directories before a tool runs.
50 type WriteAccessGate interface {
51 CheckWriteAccess(ctx context.Context, req WriteAccessCheck) (WriteAccessDecision, error)
52 }
53
54 func (b foregroundOnlyBash) DeclareWriteAccess(args json.RawMessage) (tool.WriteAccessDeclaration, error) {
55 if d, ok := b.inner.(tool.WriteAccessDeclarer); ok {
56 return d.DeclareWriteAccess(args)
57 }
58 return tool.WriteAccessDeclaration{}, nil
59 }
60
61 func (t *TaskTool) buildSubagentRegistry(spec ProfileExecSpec, toolNames []string, childDepth int) (*tool.Registry, *sandbox.WritableRootSet, error) {
62 if spec.Grant.ReadOnly {
63 reg := ReadOnlySubagentToolRegistryForDepthWithRuntime(t.parentReg, toolNames, childDepth, t.maxDepth(), t.capabilityRuntime)
64 if reg.Len() == 0 && !spec.Grant.AllowNoTools {
65 return nil, nil, fmt.Errorf("no read-only tools available for this sub-agent")
66 }
67 return reg, nil, nil
68 }
69 reg := t.buildSubReg(toolNames, childDepth)
70 // Explicit paths are an execution boundary and rebind/drop tools that cannot
71 // honor it. A synthesized whole-workspace claim preserves legacy boundaries.
72 if !spec.Grant.WritePaths.Empty() && !spec.Grant.WritePaths.WholeWorkspace {
73 bound, removed := BindWritePaths(reg, spec.Grant.WritePaths, t.workspaceRoot, t.bashCanEnforceWriteRoots())
74 reg = bound
75 if len(removed) > 0 && reg.Len() == 0 {
76 return nil, nil, fmt.Errorf("no path-bound write tools available after dropping unbound writers: %s", strings.Join(removed, ", "))
77 }
78 }
79 reg, roots := BindChildWriteRoots(reg, t.writeRoots, spec.Grant.WritePaths)
80 return reg, roots, nil
81 }
82
83 // SetConfigWriteApprover installs the optional per-write approval path used by
84 // file tools for Reasonix-managed config outside the workspace roots.
85 func (a *Agent) SetConfigWriteApprover(g tool.ConfigWriteApprover) {
86 if nilutil.IsNil(g) {
87 g = nil
88 }
89 a.svc.configWrite = g
90 }
91
92 func (a *Agent) SetWriteAccessGate(g WriteAccessGate) {
93 if nilutil.IsNil(g) {
94 g = nil
95 }
96 a.svc.writeAccess = g
97 }
98
99 func (a *Agent) SetWriteRoots(set *sandbox.WritableRootSet) {
100 a.svc.writeRoots = set
101 }
102
103 func (a *Agent) SetPermissionPresetProvider(provider func() string) {
104 a.svc.permissionPreset = provider
105 }
106
107 func (c *Coordinator) SetWriteAccessGate(g WriteAccessGate) {
108 if c == nil {
109 return
110 }
111 if c.plannerAgent != nil {
112 c.plannerAgent.SetWriteAccessGate(g)
113 }
114 if c.executor != nil {
115 c.executor.SetWriteAccessGate(g)
116 }
117 }
118
119 func (c *Coordinator) SetWriteRoots(set *sandbox.WritableRootSet) {
120 if c == nil {
121 return
122 }
123 if c.plannerAgent != nil {
124 c.plannerAgent.SetWriteRoots(set)
125 }
126 if c.executor != nil {
127 c.executor.SetWriteRoots(set)
128 }
129 }
130
131 func (c *Coordinator) SetPermissionPresetProvider(provider func() string) {
132 if c == nil {
133 return
134 }
135 if c.plannerAgent != nil {
136 c.plannerAgent.SetPermissionPresetProvider(provider)
137 }
138 if c.executor != nil {
139 c.executor.SetPermissionPresetProvider(provider)
140 }
141 }
142
143 func (a *Agent) applyWriteAccess(ctx context.Context, plan *toolCallPlan) (toolOutcome, bool) {
144 if a == nil || plan == nil || plan.readOnly {
145 return toolOutcome{}, false
146 }
147 decl, ok := plan.execTool.(tool.WriteAccessDeclarer)
148 if !ok {
149 return toolOutcome{}, false
150 }
151 declaration, err := decl.DeclareWriteAccess(plan.permArgs)
152 if err != nil {
153 return toolOutcome{
154 output: fmt.Sprintf("error: %v", err),
155 errMsg: firstLine(err.Error()),
156 blocked: true,
157 }, true
158 }
159 if a.svc.writeAccess == nil {
160 if a.svc.writeRoots == nil || len(declaration.Directories) == 0 {
161 return toolOutcome{}, false
162 }
163 abs, display, _, nerr := sandbox.NormalizeWriteDirs(declaration.Directories, a.workspaceRoot(), a.homeDir(), a.stateRoot())
164 if nerr != nil {
165 return writeAccessBlocked(nerr.Error()), true
166 }
167 if left := a.svc.writeRoots.Missing(abs); len(left) > 0 {
168 if !a.svc.writeAccessExpandable {
169 return writeAccessBlocked(SubagentWriteAccessMessage(displayList(display))), true
170 }
171 return writeAccessBlocked(headlessWriteAccessHint + " Needed: " + strings.Join(displayList(display), ", ")), true
172 }
173 return toolOutcome{}, false
174 }
175 dec, err := a.svc.writeAccess.CheckWriteAccess(ctx, WriteAccessCheck{
176 Tool: plan.permName,
177 Subject: permissionSubject(plan),
178 Args: plan.permArgs,
179 ReadOnly: plan.readOnly,
180 Declaration: declaration,
181 Expandable: a.svc.writeAccessExpandable,
182 })
183 if err != nil {
184 return toolOutcome{
185 output: fmt.Sprintf("blocked: %v", err),
186 blocked: true,
187 errMsg: firstLine(err.Error()),
188 }, true
189 }
190 if !dec.Allow {
191 msg := strings.TrimSpace(dec.Reason)
192 if msg == "" {
193 msg = "write access was denied"
194 }
195 if !strings.HasPrefix(msg, "blocked:") {
196 msg = "blocked: " + msg
197 }
198 return toolOutcome{output: msg, blocked: true, errMsg: firstLine(msg)}, true
199 }
200 plan.perCallWriteRoots = dec.PerCallRoots
201 plan.skipOrdinaryGate = dec.SkipOrdinaryGate
202 plan.permissionPreset = dec.PermissionPreset
203 return toolOutcome{}, false
204 }
205
206 func writeAccessBlocked(reason string) toolOutcome {
207 msg := strings.TrimSpace(reason)
208 if !strings.HasPrefix(msg, "blocked:") {
209 msg = "blocked: " + msg
210 }
211 return toolOutcome{output: msg, blocked: true, errMsg: firstLine(msg)}
212 }
213
214 func permissionSubject(plan *toolCallPlan) string {
215 if plan == nil {
216 return ""
217 }
218 if tool.IsShellToolName(plan.evidenceName) {
219 return strings.TrimSpace(bashCommandFromArgs(plan.permArgs))
220 }
221 return strings.TrimSpace(string(plan.permArgs))
222 }
223
224 func displayList(dirs []string) []string {
225 if dirs == nil {
226 return []string{}
227 }
228 return dirs
229 }
230
231 func (a *Agent) workspaceRoot() string {
232 if a == nil {
233 return ""
234 }
235 return strings.TrimSpace(a.svc.workspaceRoot)
236 }
237
238 func (a *Agent) homeDir() string {
239 if a != nil && a.svc.homeDir != "" {
240 return a.svc.homeDir
241 }
242 home, _ := os.UserHomeDir()
243 return home
244 }
245
246 func (a *Agent) stateRoot() string {
247 if a == nil {
248 return ""
249 }
250 return strings.TrimSpace(a.svc.stateRoot)
251 }
252
253 func (a *Agent) stampWriteRoots(ctx context.Context, plan *toolCallPlan) context.Context {
254 if plan.permissionPreset != "" {
255 ctx = sandbox.WithPermissionPreset(ctx, plan.permissionPreset)
256 } else if a != nil && a.svc.permissionPreset != nil {
257 ctx = sandbox.WithPermissionPreset(ctx, a.svc.permissionPreset())
258 }
259 if len(plan.perCallWriteRoots) > 0 {
260 ctx = sandbox.WithPerCallWriteRoots(ctx, plan.perCallWriteRoots)
261 }
262 return ctx
263 }
264
265 // BindChildWriteRoots rebinds writer/bash tools onto a snapshot of the parent
266 // set (or the write_paths intersection). Later parent grants cannot expand the
267 // child. A whole-workspace or empty claim inherits the current snapshot.
268 func BindChildWriteRoots(reg *tool.Registry, parent *sandbox.WritableRootSet, claims WritePathSet) (*tool.Registry, *sandbox.WritableRootSet) {
269 if parent == nil || reg == nil {
270 return reg, parent
271 }
272 cap := claims.Roots()
273 if claims.Empty() || claims.WholeWorkspace {
274 cap = nil
275 }
276 childSet := parent.CloneRestricted(cap)
277 for _, name := range reg.Names() {
278 tl, ok := reg.Get(name)
279 if !ok {
280 continue
281 }
282 reg.Add(bindToolWriteRootSet(tl, childSet))
283 }
284 return reg, childSet
285 }
286
287 func bindToolWriteRootSet(tl tool.Tool, set *sandbox.WritableRootSet) tool.Tool {
288 switch t := tl.(type) {
289 case foregroundOnlyBash:
290 t.inner = builtin.BindWriteRootSet(t.inner, set)
291 return t
292 case readOnlyBash:
293 t.inner = builtin.BindWriteRootSet(t.inner, set)
294 return t
295 case pathBoundWriter:
296 t.inner = builtin.BindWriteRootSet(t.inner, set)
297 return t
298 default:
299 return builtin.BindWriteRootSet(tl, set)
300 }
301 }
302
302 lines GO