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