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