返回 DeepSeek-Reasonix
path_bound_tools.go
根目录 / internal / agent / path_bound_tools.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "path/filepath"
8 "strings"
9
10 "reasonix/internal/tool"
11 "reasonix/internal/tool/builtin"
12 )
13
14 // pathBoundWriter wraps a built-in write tool so each Execute stays inside a
15 // declared WritePathSet. Unknown/custom/MCP writers that cannot be path-scoped
16 // are dropped from the parallel-writer registry instead (see BindWritePaths).
17 type pathBoundWriter struct {
18 inner tool.Tool
19 claims WritePathSet
20 workDir string
21 }
22
23 // pathBoundCapabilityProxy preserves the provider-visible use_capability
24 // contract while enforcing an explicit write_paths boundary after dynamic
25 // resolution. Discovery stays available, but a call must resolve to a proven
26 // read-only, non-destructive target before any MCP process or tool executes.
27 type pathBoundCapabilityProxy struct {
28 inner tool.Tool
29 resolver tool.CallResolver
30 }
31
32 func (p pathBoundCapabilityProxy) bindToolResultSession(session func() *Session) {
33 if binder, ok := p.inner.(toolResultSessionBinder); ok {
34 binder.bindToolResultSession(session)
35 }
36 }
37
38 func (p pathBoundCapabilityProxy) bindMCPListObserver(observer func(mcpListObservation)) {
39 if binder, ok := p.inner.(mcpListObserverBinder); ok {
40 binder.bindMCPListObserver(observer)
41 }
42 }
43
44 func (p pathBoundCapabilityProxy) activateMCPListObserver() func() {
45 if activator, ok := p.inner.(mcpListObserverActivator); ok {
46 return activator.activateMCPListObserver()
47 }
48 return func() {}
49 }
50
51 func (p pathBoundCapabilityProxy) Name() string { return p.inner.Name() }
52 func (p pathBoundCapabilityProxy) Description() string { return p.inner.Description() }
53 func (p pathBoundCapabilityProxy) Schema() json.RawMessage { return p.inner.Schema() }
54 func (p pathBoundCapabilityProxy) ReadOnly() bool { return p.inner.ReadOnly() }
55
56 func (p pathBoundCapabilityProxy) ClassifyCall(args json.RawMessage) tool.CallClass {
57 classifier, ok := p.inner.(tool.BatchClassifier)
58 if !ok {
59 return tool.CallClass{}
60 }
61 class := classifier.ClassifyCall(args)
62 if class.Known && (!class.ReadOnly || !class.ParallelSafe) {
63 return tool.CallClass{}
64 }
65 return class
66 }
67
68 func (p pathBoundCapabilityProxy) ResolveCall(ctx context.Context, args json.RawMessage) (tool.ResolvedCall, error) {
69 resolved, err := p.resolver.ResolveCall(ctx, args)
70 if err != nil {
71 return tool.ResolvedCall{}, err
72 }
73 if resolved.ProxyAction != "call" || resolved.SkipExecute {
74 return resolved, nil
75 }
76 if resolved.Target == nil || !resolved.ReadOnly || mcpDestructiveHint(resolved.Target) {
77 return tool.ResolvedCall{}, fmt.Errorf("use_capability target %q is not proven read-only; explicit write_paths sub-agents cannot execute unscoped MCP writers", resolved.TargetName)
78 }
79 return resolved, nil
80 }
81
82 func (p pathBoundCapabilityProxy) Execute(ctx context.Context, args json.RawMessage) (string, error) {
83 resolved, err := p.ResolveCall(ctx, args)
84 if err != nil {
85 return "", err
86 }
87 if resolved.Commit != nil {
88 if err := resolved.Commit(); err != nil {
89 return "", err
90 }
91 }
92 if resolved.SkipExecute {
93 return resolved.Result, nil
94 }
95 if resolved.Target == nil {
96 return "", fmt.Errorf("use_capability resolved no target")
97 }
98 return resolved.Target.Execute(ctx, resolved.Args)
99 }
100
101 func (w pathBoundWriter) Name() string { return w.inner.Name() }
102 func (w pathBoundWriter) Description() string { return w.inner.Description() }
103 func (w pathBoundWriter) Schema() json.RawMessage { return w.inner.Schema() }
104 func (w pathBoundWriter) ReadOnly() bool { return w.inner.ReadOnly() }
105 func (w pathBoundWriter) PlanModeSafe() bool {
106 if p, ok := w.inner.(interface{ PlanModeSafe() bool }); ok {
107 return p.PlanModeSafe()
108 }
109 return false
110 }
111
112 func (w pathBoundWriter) DeclareWriteAccess(args json.RawMessage) (tool.WriteAccessDeclaration, error) {
113 if d, ok := w.inner.(tool.WriteAccessDeclarer); ok {
114 return d.DeclareWriteAccess(args)
115 }
116 return tool.WriteAccessDeclaration{}, nil
117 }
118
119 func (w pathBoundWriter) DeclareEvidenceTarget(ctx context.Context, args json.RawMessage) (tool.EvidenceTargetInfo, error) {
120 paths, err := extractWritePathsFromArgs(w.inner.Name(), w.workDir, args)
121 if err != nil {
122 return tool.EvidenceTargetInfo{}, err
123 }
124 for _, path := range paths {
125 if !w.claims.AllowsPath(path) {
126 return tool.EvidenceTargetInfo{}, fmt.Errorf("write target is outside declared write_paths")
127 }
128 }
129 if declarer, ok := w.inner.(tool.EvidenceDeclarer); ok {
130 return declarer.DeclareEvidenceTarget(ctx, args)
131 }
132 return tool.EvidenceTargetInfo{}, fmt.Errorf("writer does not declare evidence")
133 }
134
135 func (w pathBoundWriter) Execute(ctx context.Context, args json.RawMessage) (string, error) {
136 paths, err := extractWritePathsFromArgs(w.inner.Name(), w.workDir, args)
137 if err != nil {
138 return "", err
139 }
140 for _, p := range paths {
141 if !w.claims.AllowsPath(p) {
142 return "", fmt.Errorf("write path %q is outside this subagent's declared write_paths", p)
143 }
144 }
145 return w.inner.Execute(ctx, args)
146 }
147
148 // pathBoundWriterNames are built-in tools whose arguments expose file paths we
149 // can enforce against write_paths claims.
150 var pathBoundWriterNames = map[string]bool{
151 "write_file": true,
152 "edit_file": true,
153 "multi_edit": true,
154 "move_file": true,
155 "notebook_edit": true,
156 "delete_range": true,
157 "delete_symbol": true,
158 }
159
160 // BindWritePaths returns a copy of reg where built-in writers are re-bound to
161 // the claim and non-path-scoped writer tools (MCP/custom) are dropped.
162 // Bash is kept only when keepBash is true AND its OS sandbox WriteRoots can be
163 // re-bound to the claim roots; otherwise bash is removed.
164 func BindWritePaths(reg *tool.Registry, claims WritePathSet, workDir string, keepBash bool) (bound *tool.Registry, removed []string) {
165 bound = tool.NewRegistry()
166 if reg == nil {
167 return bound, nil
168 }
169 if claims.Empty() {
170 for _, name := range reg.Names() {
171 if tl, ok := reg.Get(name); ok {
172 bound.Add(tl)
173 }
174 }
175 return bound, nil
176 }
177 roots := claims.Roots()
178 for _, name := range reg.Names() {
179 tl, ok := reg.Get(name)
180 if !ok {
181 continue
182 }
183 if tool.IsShellToolName(name) {
184 if !keepBash {
185 removed = append(removed, name)
186 continue
187 }
188 rebound, ok := rebindBashToClaimRoots(tl, roots)
189 if !ok {
190 removed = append(removed, name)
191 continue
192 }
193 bound.Add(rebound)
194 continue
195 }
196 if name == "use_capability" {
197 resolver, ok := tl.(tool.CallResolver)
198 if !ok {
199 removed = append(removed, name)
200 continue
201 }
202 bound.Add(pathBoundCapabilityProxy{inner: tl, resolver: resolver})
203 continue
204 }
205 if tl.ReadOnly() {
206 bound.Add(tl)
207 continue
208 }
209 if pathBoundWriterNames[name] {
210 bound.Add(pathBoundWriter{inner: tl, claims: claims, workDir: workDir})
211 continue
212 }
213 // MCP / custom writers cannot be path-scoped reliably.
214 removed = append(removed, name)
215 }
216 return bound, removed
217 }
218
219 // rebindBashToClaimRoots rebinds a bash tool (or foregroundOnlyBash wrapper)
220 // so OS sandbox WriteRoots equal the claim roots.
221 func rebindBashToClaimRoots(tl tool.Tool, roots []string) (tool.Tool, bool) {
222 if len(roots) == 0 {
223 return nil, false
224 }
225 if fb, ok := tl.(foregroundOnlyBash); ok {
226 rebound, ok := builtin.RebindBashWriteRoots(fb.inner, roots)
227 if !ok {
228 return nil, false
229 }
230 return foregroundOnlyBash{inner: rebound}, true
231 }
232 return builtin.RebindBashWriteRoots(tl, roots)
233 }
234
235 // parentWriteGuardTarget reports tools whose parent-side execution can mutate
236 // workspace files and must reserve write claims for the duration of Execute.
237 // Meta/delegation tools (task, fleet, run_skill, …) are excluded so the parent
238 // can still schedule while background writers run.
239 func parentWriteGuardTarget(name string) bool {
240 if pathBoundWriterNames[name] || tool.IsShellToolName(name) {
241 return true
242 }
243 return strings.HasPrefix(name, tool.MCPNamePrefix)
244 }
245
246 // parentWriteReservation builds the WritePathSet a parent tool must hold while
247 // executing. Path-aware built-ins reserve concrete targets; bash/MCP reserve
248 // the whole workspace (targets cannot be judged reliably).
249 func parentWriteReservation(workDir, toolName string, args json.RawMessage) (WritePathSet, error) {
250 if pathBoundWriterNames[toolName] {
251 paths, err := extractWritePathsFromArgs(toolName, workDir, args)
252 if err != nil {
253 return WritePathSet{}, fmt.Errorf("could not parse %s path for write reservation: %w", toolName, err)
254 }
255 // NormalizeWritePaths accepts relative paths against the workspace.
256 // Absolute paths already inside the workspace also work.
257 raw := make([]string, 0, len(paths))
258 for _, p := range paths {
259 raw = append(raw, resolveMaybeRelative(workDir, p))
260 }
261 set, err := NormalizeWritePaths(workDir, raw)
262 if err != nil {
263 // Outside workspace: still take a whole-workspace reservation so we
264 // cannot race background writers while writing managed paths outside
265 // roots (config write approval path).
266 whole, werr := WholeWorkspaceWriteClaim(workDir)
267 if werr != nil {
268 return WritePathSet{}, err
269 }
270 return whole, nil
271 }
272 return set, nil
273 }
274 // Bash and MCP/custom writers.
275 return WholeWorkspaceWriteClaim(workDir)
276 }
277
278 func extractWritePathsFromArgs(toolName, workDir string, args json.RawMessage) ([]string, error) {
279 switch toolName {
280 case "move_file":
281 var p struct {
282 SourcePath string `json:"source_path"`
283 DestinationPath string `json:"destination_path"`
284 }
285 if err := json.Unmarshal(args, &p); err != nil {
286 return nil, fmt.Errorf("invalid args: %w", err)
287 }
288 if strings.TrimSpace(p.SourcePath) == "" || strings.TrimSpace(p.DestinationPath) == "" {
289 return nil, fmt.Errorf("source_path and destination_path are required")
290 }
291 return []string{
292 resolveMaybeRelative(workDir, p.SourcePath),
293 resolveMaybeRelative(workDir, p.DestinationPath),
294 }, nil
295 default:
296 var p struct {
297 Path string `json:"path"`
298 }
299 if err := json.Unmarshal(args, &p); err != nil {
300 return nil, fmt.Errorf("invalid args: %w", err)
301 }
302 if strings.TrimSpace(p.Path) == "" {
303 return nil, fmt.Errorf("path is required")
304 }
305 return []string{resolveMaybeRelative(workDir, p.Path)}, nil
306 }
307 }
308
309 func resolveMaybeRelative(workDir, path string) string {
310 path = strings.TrimSpace(path)
311 if path == "" {
312 return path
313 }
314 if filepath.IsAbs(path) {
315 return path
316 }
317 if strings.TrimSpace(workDir) == "" {
318 return path
319 }
320 return filepath.Join(workDir, path)
321 }
322
322 lines GO