| 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 |