返回 DeepSeek-Reasonix
path_bound_tools_test.go
根目录 / internal / agent / path_bound_tools_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "reasonix/internal/event"
11 "reasonix/internal/provider"
12 "reasonix/internal/sandbox"
13 "reasonix/internal/tool"
14 "reasonix/internal/tool/builtin"
15 )
16
17 func TestBindWritePathsRebindsBashWriteRoots(t *testing.T) {
18 root := t.TempDir()
19 claim, err := NormalizeWritePaths(root, []string{"docs"})
20 if err != nil {
21 t.Fatal(err)
22 }
23 reg := tool.NewRegistry()
24 reg.Add(builtin.ConfineBash(sandbox.Spec{
25 Mode: "enforce",
26 WriteRoots: []string{root},
27 }, builtin.SessionDataGuard{}))
28 reg.Add(foregroundOnlyBash{inner: mustGet(t, reg, "bash")})
29
30 bound, removed := BindWritePaths(reg, claim, root, true)
31 if len(removed) != 0 {
32 t.Fatalf("removed = %v, want none", removed)
33 }
34 if _, ok := bound.Get("bash"); !ok {
35 t.Fatal("bash should be kept when sandbox can rebind")
36 }
37
38 _, removed = BindWritePaths(reg, claim, root, false)
39 if len(removed) != 1 || removed[0] != "bash" {
40 t.Fatalf("removed = %v, want [bash]", removed)
41 }
42 }
43
44 func TestBindWritePathsKeepsCapabilitySchemaButBlocksResolvedWriter(t *testing.T) {
45 root := t.TempDir()
46 claim, err := NormalizeWritePaths(root, []string{"frontend"})
47 if err != nil {
48 t.Fatal(err)
49 }
50 calls := 0
51 target := readOnlyBoundaryTarget{name: "mcp__fs__write", calls: &calls}
52 proxy := readOnlyBoundaryProxy{resolved: tool.ResolvedCall{
53 ProxyAction: "call",
54 TargetName: target.Name(),
55 Target: target,
56 ReadOnly: false,
57 Args: json.RawMessage(`{}`),
58 }}
59 reg := tool.NewRegistry()
60 reg.Add(proxy)
61 bound, removed := BindWritePaths(reg, claim, root, false)
62 if len(removed) != 0 {
63 t.Fatalf("removed = %v, want stable proxy retained", removed)
64 }
65 got, ok := bound.Get("use_capability")
66 if !ok {
67 t.Fatal("path-bound registry missing use_capability")
68 }
69 if got.Name() != proxy.Name() || got.Description() != proxy.Description() || string(got.Schema()) != string(proxy.Schema()) || got.ReadOnly() != proxy.ReadOnly() {
70 t.Fatal("path-bound wrapper changed provider-visible use_capability contract")
71 }
72 a := New(nil, bound, NewSession("sys"), Options{}, event.Discard)
73 out := a.executeOne(context.Background(), provider.ToolCall{
74 ID: "writer", Name: "use_capability",
75 Arguments: `{"action":"call","capability_id":"mcp-tool:fs/write","arguments":{}}`,
76 })
77 if out.errMsg == "" || !strings.Contains(out.output, "not proven read-only") {
78 t.Fatalf("resolved writer outcome = %+v, want path-bound block", out)
79 }
80 if calls != 0 {
81 t.Fatalf("resolved MCP writer executed %d times, want zero", calls)
82 }
83 }
84
85 func TestBindWritePathsAllowsResolvedReadOnlyCapability(t *testing.T) {
86 root := t.TempDir()
87 claim, err := NormalizeWritePaths(root, []string{"frontend"})
88 if err != nil {
89 t.Fatal(err)
90 }
91 calls := 0
92 target := readOnlyBoundaryTarget{name: "mcp__search__query", readOnly: true, calls: &calls}
93 reg := tool.NewRegistry()
94 reg.Add(readOnlyBoundaryProxy{resolved: tool.ResolvedCall{
95 ProxyAction: "call",
96 TargetName: target.Name(),
97 Target: target,
98 ReadOnly: true,
99 Args: json.RawMessage(`{}`),
100 }})
101 bound, _ := BindWritePaths(reg, claim, root, false)
102 a := New(nil, bound, NewSession("sys"), Options{}, event.Discard)
103 out := a.executeOne(context.Background(), provider.ToolCall{
104 ID: "reader", Name: "use_capability",
105 Arguments: `{"action":"call","capability_id":"mcp-tool:search/query","arguments":{}}`,
106 })
107 if out.errMsg != "" || out.blocked || calls != 1 {
108 t.Fatalf("resolved reader outcome = %+v calls=%d, want one successful call", out, calls)
109 }
110 }
111
112 func TestTaskExplicitWritePathsCannotBypassBoundaryThroughCapabilityProxy(t *testing.T) {
113 root := t.TempDir()
114 var writerCalls int32
115 target := parallelResolvedWriterTarget{calls: &writerCalls}
116 parent := tool.NewRegistry()
117 parent.Add(readOnlyBoundaryProxy{resolved: tool.ResolvedCall{
118 ProxyAction: "call",
119 TargetName: target.Name(),
120 Target: target,
121 ReadOnly: false,
122 Args: json.RawMessage(`{}`),
123 }})
124 task := newTestTaskTool(t, proxyWriterCallingProvider{}, parent, "sys", "", "", nil).
125 WithTranscripts(NewSubagentStore(t.TempDir()), root, "base-model", "base-effort")
126 out, err := task.Execute(testTaskContext(), json.RawMessage(`{
127 "prompt":"attempt dynamic writer",
128 "write_paths":["frontend"]
129 }`))
130 if err != nil {
131 t.Fatalf("task Execute: %v\n%s", err, out)
132 }
133 if writerCalls != 0 {
134 t.Fatalf("path-bound task executed MCP writer %d times, want zero", writerCalls)
135 }
136 if !strings.Contains(out, "writer blocked") {
137 t.Fatalf("task did not recover after host boundary block:\n%s", out)
138 }
139 }
140
141 func TestParentWriteReservationBlocksOverlappingSubagentAcquire(t *testing.T) {
142 root := t.TempDir()
143 sched := NewSubagentScheduler(4, 2)
144 claim, err := parentWriteReservation(root, "write_file", mustJSON(t, map[string]string{
145 "path": filepath.Join(root, "a.md"),
146 "content": "x",
147 }))
148 if err != nil {
149 t.Fatal(err)
150 }
151 release, err := sched.ReserveParentWrite(claim)
152 if err != nil {
153 t.Fatal(err)
154 }
155
156 // Nested acquire must fail-fast while parent holds the path.
157 subClaim, err := NormalizeWritePaths(root, []string{"a.md"})
158 if err != nil {
159 t.Fatal(err)
160 }
161 _, err = sched.Acquire(context.Background(), AcquireRequest{
162 Writer: true, WritePaths: subClaim, Nested: true,
163 })
164 if err == nil {
165 t.Fatal("subagent should not acquire path held by parent reservation")
166 }
167 release()
168
169 // After release, acquire succeeds.
170 rel2, err := sched.Acquire(context.Background(), AcquireRequest{
171 Writer: true, WritePaths: subClaim,
172 })
173 if err != nil {
174 t.Fatal(err)
175 }
176 rel2()
177 }
178
179 // TestParentWriteReservationClosesTOCTOU proves a parent reservation held for
180 // the whole Execute window prevents a concurrent subagent from claiming the
181 // same path after a check-but-before-write window would have opened.
182 func TestParentWriteReservationClosesTOCTOU(t *testing.T) {
183 root := t.TempDir()
184 sched := NewSubagentScheduler(4, 2)
185 path := filepath.Join(root, "race.md")
186 args := mustJSON(t, map[string]string{"path": path, "content": "parent"})
187
188 parentStarted := make(chan struct{})
189 releaseParent := make(chan struct{})
190 parentDone := make(chan struct{})
191
192 go func() {
193 defer close(parentDone)
194 claim, err := parentWriteReservation(root, "write_file", args)
195 if err != nil {
196 t.Errorf("parent reservation: %v", err)
197 close(parentStarted)
198 return
199 }
200 release, err := sched.ReserveParentWrite(claim)
201 if err != nil {
202 t.Errorf("ReserveParentWrite: %v", err)
203 close(parentStarted)
204 return
205 }
206 // Signal that the parent write has "started" (reservation held).
207 close(parentStarted)
208 // Hold the reservation while a concurrent subagent tries to claim.
209 <-releaseParent
210 release()
211 }()
212
213 <-parentStarted
214
215 subClaim, err := NormalizeWritePaths(root, []string{"race.md"})
216 if err != nil {
217 t.Fatal(err)
218 }
219 // Non-nested would queue; Nested fail-fast proves conflict under reservation.
220 _, err = sched.Acquire(context.Background(), AcquireRequest{
221 Writer: true, WritePaths: subClaim, Nested: true,
222 })
223 if err == nil {
224 t.Fatal("expected TOCTOU-safe rejection while parent write holds reservation")
225 }
226 if !strings.Contains(err.Error(), "parent write") && !strings.Contains(err.Error(), "conflict") {
227 t.Fatalf("unexpected error: %v", err)
228 }
229 close(releaseParent)
230 <-parentDone
231 }
232
233 func TestAgentReservesParentWriteBeforePreToolUse(t *testing.T) {
234 root := t.TempDir()
235 sched := NewSubagentScheduler(4, 2)
236 claim, err := NormalizeWritePaths(root, []string{"hook-race.md"})
237 if err != nil {
238 t.Fatal(err)
239 }
240 hooks := &parentClaimProbeHooks{scheduler: sched, claim: claim}
241 writer := &recordingWriter{name: "write_file"}
242 reg := tool.NewRegistry()
243 reg.Add(writer)
244 a := New(nil, reg, NewSession(""), Options{
245 Hooks: hooks,
246 WriteScheduler: sched,
247 WriteWorkspaceRoot: root,
248 }, event.Discard)
249
250 out := a.executeOne(context.Background(), provider.ToolCall{
251 ID: "write-1",
252 Name: "write_file",
253 Arguments: string(mustJSON(t, map[string]string{"path": "hook-race.md", "content": "parent"})),
254 })
255 if out.errMsg != "" {
256 t.Fatalf("executeOne failed: %+v", out)
257 }
258 if hooks.acquireErr == nil {
259 t.Fatal("PreToolUse hook observed no parent claim; reservation must precede hooks")
260 }
261 if writer.calls != 1 {
262 t.Fatalf("writer calls = %d, want 1", writer.calls)
263 }
264 if n := len(sched.ActiveWriterClaims()); n != 0 {
265 t.Fatalf("claims after Execute = %d, want 0", n)
266 }
267 }
268
269 func TestParentWriteReservationBashClaimsWholeWorkspace(t *testing.T) {
270 root := t.TempDir()
271 claim, err := parentWriteReservation(root, "bash", json.RawMessage(`{"command":"echo hi"}`))
272 if err != nil {
273 t.Fatal(err)
274 }
275 if !claim.WholeWorkspace {
276 t.Fatalf("bash reservation must claim whole workspace, got %+v", claim)
277 }
278 mcp, err := parentWriteReservation(root, "mcp__srv__write", json.RawMessage(`{}`))
279 if err != nil {
280 t.Fatal(err)
281 }
282 if !mcp.WholeWorkspace {
283 t.Fatalf("MCP writer reservation must claim whole workspace")
284 }
285 }
286
287 func TestAgentReserveParentWriteSkipsSubagentDepth(t *testing.T) {
288 root := t.TempDir()
289 sched := NewSubagentScheduler(4, 2)
290 a := &Agent{
291 writeScheduler: sched,
292 writeWorkspaceRoot: root,
293 subagentDepth: 1,
294 }
295 inner := &recordingWriter{name: "write_file"}
296 release, err := a.reserveParentWrite(inner, mustJSON(t, map[string]string{
297 "path": filepath.Join(root, "a.md"), "content": "x",
298 }), false)
299 if err != nil {
300 t.Fatal(err)
301 }
302 release()
303 // No parent claim should remain — subagent depth skips reservation.
304 if n := len(sched.ActiveWriterClaims()); n != 0 {
305 t.Fatalf("claims = %d, want 0", n)
306 }
307 }
308
309 func TestAgentReserveParentWriteHoldsClaim(t *testing.T) {
310 root := t.TempDir()
311 sched := NewSubagentScheduler(4, 2)
312 a := &Agent{
313 writeScheduler: sched,
314 writeWorkspaceRoot: root,
315 subagentDepth: 0,
316 }
317 inner := &recordingWriter{name: "write_file"}
318 release, err := a.reserveParentWrite(inner, mustJSON(t, map[string]string{
319 "path": filepath.Join(root, "a.md"), "content": "x",
320 }), false)
321 if err != nil {
322 t.Fatal(err)
323 }
324 if n := len(sched.ActiveWriterClaims()); n != 1 {
325 t.Fatalf("claims = %d, want 1", n)
326 }
327 release()
328 if n := len(sched.ActiveWriterClaims()); n != 0 {
329 t.Fatalf("claims after release = %d", n)
330 }
331 }
332
333 func mustGet(t *testing.T, reg *tool.Registry, name string) tool.Tool {
334 t.Helper()
335 tl, ok := reg.Get(name)
336 if !ok {
337 t.Fatalf("missing %s", name)
338 }
339 return tl
340 }
341
342 func mustJSON(t *testing.T, v any) json.RawMessage {
343 t.Helper()
344 b, err := json.Marshal(v)
345 if err != nil {
346 t.Fatal(err)
347 }
348 return b
349 }
350
351 type recordingWriter struct {
352 name string
353 readOnly bool
354 calls int
355 }
356
357 type parentClaimProbeHooks struct {
358 scheduler *SubagentScheduler
359 claim WritePathSet
360 acquireErr error
361 }
362
363 func (h *parentClaimProbeHooks) PreToolUse(context.Context, string, json.RawMessage) (bool, string) {
364 release, err := h.scheduler.Acquire(context.Background(), AcquireRequest{
365 Writer: true, WritePaths: h.claim, Nested: true,
366 })
367 h.acquireErr = err
368 if err == nil {
369 release()
370 }
371 return false, ""
372 }
373 func (*parentClaimProbeHooks) PostToolUse(context.Context, string, json.RawMessage, string) {}
374 func (*parentClaimProbeHooks) PostToolUseFailure(context.Context, string, json.RawMessage, string, error) {
375 }
376 func (*parentClaimProbeHooks) PostLLMCall(_ context.Context, reasoning string, _ int) string {
377 return reasoning
378 }
379 func (*parentClaimProbeHooks) HasPostLLMCall() bool { return false }
380 func (*parentClaimProbeHooks) SubagentStop(context.Context, string) {}
381 func (*parentClaimProbeHooks) PreCompact(context.Context, string) string { return "" }
382
383 func (r *recordingWriter) Name() string { return r.name }
384 func (r *recordingWriter) Description() string { return r.name }
385 func (r *recordingWriter) Schema() json.RawMessage {
386 return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}`)
387 }
388 func (r *recordingWriter) ReadOnly() bool { return r.readOnly }
389 func (r *recordingWriter) Execute(context.Context, json.RawMessage) (string, error) {
390 r.calls++
391 return "ok", nil
392 }
393
393 lines GO