返回 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) Name() string { return p.inner.Name() }
33 func (p pathBoundCapabilityProxy) Description() string { return p.inner.Description() }
34 func (p pathBoundCapabilityProxy) Schema() json.RawMessage { return p.inner.Schema() }
35 func (p pathBoundCapabilityProxy) ReadOnly() bool { return p.inner.ReadOnly() }
36
37 func (p pathBoundCapabilityProxy) ResolveCall(ctx context.Context, args json.RawMessage) (tool.ResolvedCall, error) {
38 resolved, err := p.resolver.ResolveCall(ctx, args)
39 if err != nil {
40 return tool.ResolvedCall{}, err
41 }
42 if resolved.ProxyAction != "call" || resolved.SkipExecute {
43 return resolved, nil
44 }
45 if resolved.Target == nil || !resolved.ReadOnly || mcpDestructiveHint(resolved.Target) {
46 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)
47 }
48 return resolved, nil
49 }
50
51 func (p pathBoundCapabilityProxy) Execute(ctx context.Context, args json.RawMessage) (string, error) {
52 resolved, err := p.ResolveCall(ctx, args)
53 if err != nil {
54 return "", err
55 }
56 if resolved.Commit != nil {
57 if err := resolved.Commit(); err != nil {
58 return "", err
59 }
60 }
61 if resolved.SkipExecute {
62 return resolved.Result, nil
63 }
64 if resolved.Target == nil {
65 return "", fmt.Errorf("use_capability resolved no target")
66 }
67 return resolved.Target.Execute(ctx, resolved.Args)
68 }
69
70 func (w pathBoundWriter) Name() string { return w.inner.Name() }
71 func (w pathBoundWriter) Description() string { return w.inner.Description() }
72 func (w pathBoundWriter) Schema() json.RawMessage { return w.inner.Schema() }
73 func (w pathBoundWriter) ReadOnly() bool { return w.inner.ReadOnly() }
74 func (w pathBoundWriter) PlanModeSafe() bool {
75 if p, ok := w.inner.(interface{ PlanModeSafe() bool }); ok {
76 return p.PlanModeSafe()
77 }
78 return false
79 }
80
81 func (w pathBoundWriter) Execute(ctx context.Context, args json.RawMessage) (string, error) {
82 paths, err := extractWritePathsFromArgs(w.inner.Name(), w.workDir, args)
83 if err != nil {
84 return "", err
85 }
86 for _, p := range paths {
87 if !w.claims.AllowsPath(p) {
88 return "", fmt.Errorf("write path %q is outside this subagent's declared write_paths", p)
89 }
90 }
91 return w.inner.Execute(ctx, args)
92 }
93
94 // pathBoundWriterNames are built-in tools whose arguments expose file paths we
95 // can enforce against write_paths claims.
96 var pathBoundWriterNames = map[string]bool{
97 "write_file": true,
98 "edit_file": true,
99 "multi_edit": true,
100 "move_file": true,
101 "notebook_edit": true,
102 "delete_range": true,
103 "delete_symbol": true,
104 }
105
106 // BindWritePaths returns a copy of reg where built-in writers are re-bound to
107 // the claim and non-path-scoped writer tools (MCP/custom) are dropped.
108 // Bash is kept only when keepBash is true AND its OS sandbox WriteRoots can be
109 // re-bound to the claim roots; otherwise bash is removed.
110 func BindWritePaths(reg *tool.Registry, claims WritePathSet, workDir string, keepBash bool) (bound *tool.Registry, removed []string) {
111 bound = tool.NewRegistry()
112 if reg == nil {
113 return bound, nil
114 }
115 if claims.Empty() {
116 for _, name := range reg.Names() {
117 if tl, ok := reg.Get(name); ok {
118 bound.Add(tl)
119 }
120 }
121 return bound, nil
122 }
123 roots := claims.Roots()
124 for _, name := range reg.Names() {
125 tl, ok := reg.Get(name)
126 if !ok {
127 continue
128 }
129 if name == "bash" {
130 if !keepBash {
131 removed = append(removed, name)
132 continue
133 }
134 rebound, ok := rebindBashToClaimRoots(tl, roots)
135 if !ok {
136 removed = append(removed, name)
137 continue
138 }
139 bound.Add(rebound)
140 continue
141 }
142 if name == "use_capability" {
143 resolver, ok := tl.(tool.CallResolver)
144 if !ok {
145 removed = append(removed, name)
146 continue
147 }
148 bound.Add(pathBoundCapabilityProxy{inner: tl, resolver: resolver})
149 continue
150 }
151 if tl.ReadOnly() {
152 bound.Add(tl)
153 continue
154 }
155 if pathBoundWriterNames[name] {
156 bound.Add(pathBoundWriter{inner: tl, claims: claims, workDir: workDir})
157 continue
158 }
159 // MCP / custom writers cannot be path-scoped reliably.
160 removed = append(removed, name)
161 }
162 return bound, removed
163 }
164
165 // rebindBashToClaimRoots rebinds a bash tool (or foregroundOnlyBash wrapper)
166 // so OS sandbox WriteRoots equal the claim roots.
167 func rebindBashToClaimRoots(tl tool.Tool, roots []string) (tool.Tool, bool) {
168 if len(roots) == 0 {
169 return nil, false
170 }
171 if fb, ok := tl.(foregroundOnlyBash); ok {
172 rebound, ok := builtin.RebindBashWriteRoots(fb.inner, roots)
173 if !ok {
174 return nil, false
175 }
176 return foregroundOnlyBash{inner: rebound}, true
177 }
178 return builtin.RebindBashWriteRoots(tl, roots)
179 }
180
181 // parentWriteGuardTarget reports tools whose parent-side execution can mutate
182 // workspace files and must reserve write claims for the duration of Execute.
183 // Meta/delegation tools (task, fleet, run_skill, …) are excluded so the parent
184 // can still schedule while background writers run.
185 func parentWriteGuardTarget(name string) bool {
186 if pathBoundWriterNames[name] || name == "bash" {
187 return true
188 }
189 return strings.HasPrefix(name, tool.MCPNamePrefix)
190 }
191
192 // parentWriteReservation builds the WritePathSet a parent tool must hold while
193 // executing. Path-aware built-ins reserve concrete targets; bash/MCP reserve
194 // the whole workspace (targets cannot be judged reliably).
195 func parentWriteReservation(workDir, toolName string, args json.RawMessage) (WritePathSet, error) {
196 if pathBoundWriterNames[toolName] {
197 paths, err := extractWritePathsFromArgs(toolName, workDir, args)
198 if err != nil {
199 return WritePathSet{}, fmt.Errorf("could not parse %s path for write reservation: %w", toolName, err)
200 }
201 // NormalizeWritePaths accepts relative paths against the workspace.
202 // Absolute paths already inside the workspace also work.
203 raw := make([]string, 0, len(paths))
204 for _, p := range paths {
205 raw = append(raw, resolveMaybeRelative(workDir, p))
206 }
207 set, err := NormalizeWritePaths(workDir, raw)
208 if err != nil {
209 // Outside workspace: still take a whole-workspace reservation so we
210 // cannot race background writers while writing managed paths outside
211 // roots (config write approval path).
212 whole, werr := WholeWorkspaceWriteClaim(workDir)
213 if werr != nil {
214 return WritePathSet{}, err
215 }
216 return whole, nil
217 }
218 return set, nil
219 }
220 // Bash and MCP/custom writers.
221 return WholeWorkspaceWriteClaim(workDir)
222 }
223
224 func extractWritePathsFromArgs(toolName, workDir string, args json.RawMessage) ([]string, error) {
225 switch toolName {
226 case "move_file":
227 var p struct {
228 SourcePath string `json:"source_path"`
229 DestinationPath string `json:"destination_path"`
230 }
231 if err := json.Unmarshal(args, &p); err != nil {
232 return nil, fmt.Errorf("invalid args: %w", err)
233 }
234 if strings.TrimSpace(p.SourcePath) == "" || strings.TrimSpace(p.DestinationPath) == "" {
235 return nil, fmt.Errorf("source_path and destination_path are required")
236 }
237 return []string{
238 resolveMaybeRelative(workDir, p.SourcePath),
239 resolveMaybeRelative(workDir, p.DestinationPath),
240 }, nil
241 default:
242 var p struct {
243 Path string `json:"path"`
244 }
245 if err := json.Unmarshal(args, &p); err != nil {
246 return nil, fmt.Errorf("invalid args: %w", err)
247 }
248 if strings.TrimSpace(p.Path) == "" {
249 return nil, fmt.Errorf("path is required")
250 }
251 return []string{resolveMaybeRelative(workDir, p.Path)}, nil
252 }
253 }
254
255 func resolveMaybeRelative(workDir, path string) string {
256 path = strings.TrimSpace(path)
257 if path == "" {
258 return path
259 }
260 if filepath.IsAbs(path) {
261 return path
262 }
263 if strings.TrimSpace(workDir) == "" {
264 return path
265 }
266 return filepath.Join(workDir, path)
267 }
268
268 lines GO