返回 DeepSeek-Reasonix
subagent_registry_test.go
根目录 / internal / agent / subagent_registry_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "strings"
7 "testing"
8
9 "reasonix/internal/capability"
10 "reasonix/internal/plugin"
11 "reasonix/internal/tool"
12 )
13
14 type subagentRegistryTool struct {
15 name string
16 schema string
17 readOnly bool
18 result string
19 }
20
21 type subagentCapabilityProxy struct {
22 subagentRegistryTool
23 }
24
25 type subagentMCPTool struct {
26 subagentRegistryTool
27 server string
28 raw string
29 destructive bool
30 serverAuthorized bool
31 }
32
33 func (t subagentMCPTool) MCPServerName() string { return t.server }
34 func (t subagentMCPTool) MCPRawToolName() string { return t.raw }
35 func (t subagentMCPTool) MCPDestructiveHint() bool { return t.destructive }
36 func (t subagentMCPTool) MCPServerAuthorized() bool { return t.serverAuthorized }
37
38 func (t subagentCapabilityProxy) ResolveCall(_ context.Context, args json.RawMessage) (tool.ResolvedCall, error) {
39 var p struct {
40 CapabilityID string `json:"capability_id"`
41 }
42 if err := json.Unmarshal(args, &p); err != nil {
43 return tool.ResolvedCall{}, err
44 }
45 return tool.ResolvedCall{DisplayName: t.Name(), CapabilityID: p.CapabilityID, ReadOnly: true, SkipExecute: true, Result: p.CapabilityID}, nil
46 }
47
48 func (t subagentRegistryTool) Name() string { return t.name }
49 func (t subagentRegistryTool) Description() string {
50 return "Execute a command in the shell and return combined stdout/stderr."
51 }
52 func (t subagentRegistryTool) Schema() json.RawMessage {
53 if t.schema != "" {
54 return json.RawMessage(t.schema)
55 }
56 return json.RawMessage(`{"type":"object"}`)
57 }
58 func (t subagentRegistryTool) ReadOnly() bool { return t.readOnly }
59 func (t subagentRegistryTool) Execute(context.Context, json.RawMessage) (string, error) {
60 return t.result, nil
61 }
62
63 func TestSubagentToolRegistryFiltersUnavailableToolsAndWrapsBash(t *testing.T) {
64 parent := tool.NewRegistry()
65 for _, name := range []string{
66 "task",
67 "read_only_task",
68 "parallel_tasks",
69 "fleet",
70 "run_skill",
71 "read_only_skill",
72 "read_skill",
73 "install_skill",
74 "install_source",
75 "explore",
76 "research",
77 "review",
78 "security_review",
79 "wait",
80 "bash_output",
81 "kill_shell",
82 } {
83 parent.Add(subagentRegistryTool{name: name})
84 }
85 parent.Add(subagentRegistryTool{name: "read_file", readOnly: true})
86 parent.Add(subagentRegistryTool{
87 name: "bash",
88 schema: `{"type":"object","properties":{"command":{"type":"string"},"run_in_background":{"type":"boolean"}},"required":["command"]}`,
89 result: "foreground ok",
90 })
91
92 sub := SubagentToolRegistry(parent, nil)
93 for _, hidden := range []string{
94 "task",
95 "read_only_task",
96 "parallel_tasks",
97 "fleet",
98 "run_skill",
99 "read_only_skill",
100 "install_skill",
101 "install_source",
102 "explore",
103 "research",
104 "review",
105 "security_review",
106 "wait",
107 "bash_output",
108 "kill_shell",
109 } {
110 if _, ok := sub.Get(hidden); ok {
111 t.Fatalf("subagent registry should hide %q; got %v", hidden, sub.Names())
112 }
113 }
114 if _, ok := sub.Get("read_file"); !ok {
115 t.Fatalf("subagent registry should keep read_file; got %v", sub.Names())
116 }
117 if _, ok := sub.Get("read_skill"); !ok {
118 t.Fatalf("depth-capped subagent registry should keep read_skill (it renders text, it cannot recurse); got %v", sub.Names())
119 }
120 bash, ok := sub.Get("bash")
121 if !ok {
122 t.Fatalf("subagent registry should keep foreground bash; got %v", sub.Names())
123 }
124 if bash.ReadOnly() {
125 t.Fatal("foreground-only bash must remain a writer")
126 }
127 if strings.Contains(string(bash.Schema()), "run_in_background") {
128 t.Fatalf("subagent bash schema should not advertise run_in_background: %s", bash.Schema())
129 }
130 out, err := bash.Execute(context.Background(), json.RawMessage(`{"command":"printf ok"}`))
131 if err != nil || out != "foreground ok" {
132 t.Fatalf("foreground bash delegated to inner tool = %q, %v; want foreground ok, nil", out, err)
133 }
134 if _, err := bash.Execute(context.Background(), json.RawMessage(`{"command":"sleep 1","run_in_background":true}`)); err == nil || !strings.Contains(err.Error(), "background bash is unavailable in subagents") {
135 t.Fatalf("background bash should return a subagent-specific error, got %v", err)
136 }
137 }
138
139 func TestSubagentToolRegistryRestrictsCapabilityProxyToAllowedMCPIDs(t *testing.T) {
140 parent := tool.NewRegistry()
141 parent.Add(subagentCapabilityProxy{subagentRegistryTool{name: "use_capability", readOnly: true}})
142 allowedID := "mcp-tool:figma/search"
143
144 for _, sub := range []*tool.Registry{
145 SubagentToolRegistry(parent, []string{allowedID}),
146 ReadOnlySubagentToolRegistry(parent, []string{allowedID}),
147 } {
148 proxy, ok := sub.Get("use_capability")
149 if !ok {
150 t.Fatalf("restricted capability proxy missing: %v", sub.Names())
151 }
152 resolver, ok := proxy.(tool.CallResolver)
153 if !ok {
154 t.Fatalf("restricted proxy does not resolve calls: %T", proxy)
155 }
156 if _, err := resolver.ResolveCall(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-tool:figma/search"}`)); err != nil {
157 t.Fatalf("allowed capability was rejected: %v", err)
158 }
159 if _, err := resolver.ResolveCall(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-tool:other/delete"}`)); err == nil || !strings.Contains(err.Error(), "outside this subagent's allowed-tools") {
160 t.Fatalf("disallowed capability was not rejected: %v", err)
161 }
162 if _, err := resolver.ResolveCall(context.Background(), json.RawMessage(`{"action":"inspect","capability_id":"mcp-server:figma"}`)); err == nil || !strings.Contains(err.Error(), "outside this subagent's allowed-tools") {
163 t.Fatalf("tool-only allowlist must not widen to server inspection: %v", err)
164 }
165 if _, err := proxy.Execute(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-tool:other/delete"}`)); err == nil {
166 t.Fatal("direct execution bypassed the restricted capability allowlist")
167 }
168 }
169
170 parent.Add(subagentMCPTool{
171 subagentRegistryTool: subagentRegistryTool{name: "mcp__figma__search", readOnly: true},
172 server: "figma",
173 raw: "search",
174 serverAuthorized: true,
175 })
176 // Direct mcp__* names convert into a capability allowlist; the model never
177 // sees mcp__ schemas on the sub-agent surface.
178 converted := SubagentToolRegistry(parent, []string{"mcp__figma__search"})
179 if _, ok := converted.Get("mcp__figma__search"); ok {
180 t.Fatalf("direct MCP tool must not enter subagent registry: %v", converted.Names())
181 }
182 proxy, ok := converted.Get("use_capability")
183 if !ok {
184 t.Fatalf("MCP allowlist should install restricted use_capability: %v", converted.Names())
185 }
186 resolver, ok := proxy.(tool.CallResolver)
187 if !ok {
188 t.Fatalf("proxy is not a CallResolver: %T", proxy)
189 }
190 if _, err := resolver.ResolveCall(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-tool:figma/search"}`)); err != nil {
191 t.Fatalf("converted mcp__ name should allow capability call: %v", err)
192 }
193 if _, err := resolver.ResolveCall(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-tool:other/delete"}`)); err == nil {
194 t.Fatal("converted allowlist must reject other MCP capabilities")
195 }
196 }
197
198 func TestSubagentToolRegistryDefaultGetsUnrestrictedProxy(t *testing.T) {
199 parent := tool.NewRegistry()
200 parent.Add(subagentCapabilityProxy{subagentRegistryTool{name: "use_capability", readOnly: true}})
201 parent.Add(subagentMCPTool{
202 subagentRegistryTool: subagentRegistryTool{name: "mcp__gh__search", readOnly: true},
203 server: "gh", raw: "search", serverAuthorized: true,
204 })
205 parent.Add(subagentRegistryTool{name: "read_file", readOnly: true})
206
207 sub := SubagentToolRegistry(parent, nil)
208 if _, ok := sub.Get("mcp__gh__search"); ok {
209 t.Fatalf("default subagent registry must strip direct MCP: %v", sub.Names())
210 }
211 if _, ok := sub.Get("use_capability"); !ok {
212 t.Fatalf("default subagent registry must include use_capability: %v", sub.Names())
213 }
214 if _, ok := sub.Get("read_file"); !ok {
215 t.Fatal("default subagent registry should keep read_file")
216 }
217 }
218
219 func TestReadOnlySubagentToolRegistryKeepsProxyButNotDirectMCP(t *testing.T) {
220 parent := tool.NewRegistry()
221 parent.Add(subagentCapabilityProxy{subagentRegistryTool{name: "use_capability", readOnly: true}})
222 parent.Add(subagentMCPTool{
223 subagentRegistryTool: subagentRegistryTool{name: "mcp__gh__search", readOnly: true},
224 server: "gh", raw: "search", serverAuthorized: true,
225 })
226 parent.Add(subagentMCPTool{
227 subagentRegistryTool: subagentRegistryTool{name: "mcp__gh__write", readOnly: false},
228 server: "gh", raw: "write", serverAuthorized: true,
229 })
230 parent.Add(subagentRegistryTool{name: "read_file", readOnly: true})
231
232 sub := ReadOnlySubagentToolRegistry(parent, nil)
233 if _, ok := sub.Get("mcp__gh__search"); ok {
234 t.Fatalf("read-only registry must not expose direct MCP: %v", sub.Names())
235 }
236 if _, ok := sub.Get("use_capability"); !ok {
237 t.Fatalf("read-only registry must keep use_capability for discovery: %v", sub.Names())
238 }
239 }
240
241 func TestReadOnlySubagentToolRegistryKeepsOnlyResearchToolsAndSafeBash(t *testing.T) {
242 parent := tool.NewRegistry()
243 parent.Add(subagentRegistryTool{name: "task"})
244 parent.Add(subagentRegistryTool{name: "read_only_task"})
245 parent.Add(subagentRegistryTool{name: "read_only_skill", readOnly: true})
246 parent.Add(subagentRegistryTool{name: "write_file"})
247 parent.Add(subagentRegistryTool{name: "remember"})
248 parent.Add(subagentRegistryTool{name: "todo_write", readOnly: true})
249 parent.Add(subagentRegistryTool{name: "complete_step", readOnly: true})
250 parent.Add(subagentRegistryTool{name: "connect_tool_source", readOnly: true})
251 parent.Add(subagentRegistryTool{name: "read_file", readOnly: true})
252 parent.Add(subagentRegistryTool{
253 name: "bash",
254 schema: `{"type":"object","properties":{"command":{"type":"string"},"run_in_background":{"type":"boolean"}},"required":["command"]}`,
255 result: "safe bash ok",
256 })
257
258 sub := ReadOnlySubagentToolRegistry(parent, nil)
259 for _, hidden := range []string{"task", "read_only_task", "read_only_skill", "write_file", "remember", "todo_write", "complete_step", "connect_tool_source"} {
260 if _, ok := sub.Get(hidden); ok {
261 t.Fatalf("read-only subagent registry should hide %q; got %v", hidden, sub.Names())
262 }
263 }
264 if _, ok := sub.Get("read_file"); !ok {
265 t.Fatalf("read-only subagent registry should keep read_file; got %v", sub.Names())
266 }
267 bash, ok := sub.Get("bash")
268 if !ok {
269 t.Fatalf("read-only subagent registry should keep safe bash; got %v", sub.Names())
270 }
271 if !bash.ReadOnly() {
272 t.Fatal("read-only subagent bash wrapper must report ReadOnly")
273 }
274 if strings.Contains(string(bash.Schema()), "run_in_background") {
275 t.Fatalf("read-only subagent bash schema should not advertise run_in_background: %s", bash.Schema())
276 }
277 out, err := bash.Execute(context.Background(), json.RawMessage(`{"command":"git status"}`))
278 if err != nil || out != "safe bash ok" {
279 t.Fatalf("safe bash delegated to inner tool = %q, %v; want safe bash ok, nil", out, err)
280 }
281 out, err = bash.Execute(context.Background(), json.RawMessage(`{"command":"git status 2>/dev/null"}`))
282 if err != nil || out != "safe bash ok" {
283 t.Fatalf("safe redirected bash delegated to inner tool = %q, %v; want safe bash ok, nil", out, err)
284 }
285 out, err = bash.Execute(context.Background(), json.RawMessage(`{"command":"rm -rf tmp"}`))
286 if err != nil || !strings.HasPrefix(out, "blocked:") {
287 t.Fatalf("unsafe bash should be blocked as tool output, got %q, %v", out, err)
288 }
289 out, err = bash.Execute(context.Background(), json.RawMessage(`{"command":"Test-NetConnection -ComputerName example.com -Port 443"}`))
290 if err != nil || !strings.HasPrefix(out, "blocked:") {
291 t.Fatalf("network probe should require the parent permission path, got %q, %v", out, err)
292 }
293 out, err = bash.Execute(context.Background(), json.RawMessage(`{"command":"git status","run_in_background":true}`))
294 if err != nil || !strings.HasPrefix(out, "blocked:") {
295 t.Fatalf("background read-only bash should be blocked as tool output, got %q, %v", out, err)
296 }
297 out, err = bash.Execute(context.Background(), json.RawMessage(`{"command":"git status","preserve_background_processes":true}`))
298 if err != nil || !strings.HasPrefix(out, "blocked:") {
299 t.Fatalf("process-preserving read-only bash should be blocked as tool output, got %q, %v", out, err)
300 }
301 }
302
303 func TestReadOnlySubagentToolRegistryAllowsOnlyReadOnlyDelegationBeforeDepthLimit(t *testing.T) {
304 parent := tool.NewRegistry()
305 for _, name := range []string{"task", "run_skill", "explore", "read_only_task", "read_only_skill", "read_skill", "write_file"} {
306 parent.Add(subagentRegistryTool{name: name, readOnly: strings.HasPrefix(name, "read_only") || name == "read_skill"})
307 }
308 parent.Add(subagentRegistryTool{name: "read_file", readOnly: true})
309
310 firstLayer := ReadOnlySubagentToolRegistryForDepth(parent, nil, 1, 2)
311 for _, want := range []string{"read_file", "read_only_task", "read_only_skill", "read_skill"} {
312 if _, ok := firstLayer.Get(want); !ok {
313 t.Fatalf("first-layer read-only registry should expose %q; got %v", want, firstLayer.Names())
314 }
315 }
316 for _, hidden := range []string{"task", "run_skill", "explore", "write_file"} {
317 if _, ok := firstLayer.Get(hidden); ok {
318 t.Fatalf("first-layer read-only registry should hide %q; got %v", hidden, firstLayer.Names())
319 }
320 }
321
322 secondLayer := ReadOnlySubagentToolRegistryForDepth(parent, nil, 2, 2)
323 for _, hidden := range []string{"task", "run_skill", "read_only_task", "read_only_skill", "explore", "write_file"} {
324 if _, ok := secondLayer.Get(hidden); ok {
325 t.Fatalf("depth-limited read-only registry should hide %q; got %v", hidden, secondLayer.Names())
326 }
327 }
328 if _, ok := secondLayer.Get("read_skill"); !ok {
329 t.Fatalf("depth-limited read-only registry should keep read_skill (it renders text, it cannot recurse); got %v", secondLayer.Names())
330 }
331 }
332
333 func TestReadOnlySubagentToolRegistryIncludesMCPReadOnlyHint(t *testing.T) {
334 parent := tool.NewRegistry()
335 parent.Add(subagentCapabilityProxy{subagentRegistryTool{name: "use_capability", readOnly: true}})
336 parent.Add(subagentRegistryTool{name: "read_file", readOnly: true})
337 parent.Add(subagentMCPTool{
338 subagentRegistryTool: subagentRegistryTool{name: "mcp__srv__read", readOnly: true},
339 server: "srv",
340 raw: "read",
341 serverAuthorized: true,
342 })
343
344 sub := ReadOnlySubagentToolRegistry(parent, nil)
345 if _, ok := sub.Get("mcp__srv__read"); ok {
346 t.Fatalf("read-only subagent registry must not expose direct MCP schemas; got %v", sub.Names())
347 }
348 if _, ok := sub.Get("use_capability"); !ok {
349 t.Fatalf("read-only subagent registry should expose use_capability for MCP readers; got %v", sub.Names())
350 }
351 if _, ok := sub.Get("read_file"); !ok {
352 t.Fatalf("a trusted read-only tool should remain; got %v", sub.Names())
353 }
354 }
355
356 func TestCustomProfileAllowlistRestrictsMCPTools(t *testing.T) {
357 parent := tool.NewRegistry()
358 parent.Add(subagentCapabilityProxy{subagentRegistryTool{name: "use_capability", readOnly: true}})
359 parent.Add(subagentRegistryTool{name: "read_file", readOnly: true})
360 parent.Add(subagentRegistryTool{name: "write_file"})
361 parent.Add(subagentMCPTool{
362 subagentRegistryTool: subagentRegistryTool{name: "mcp__chrome__list_pages", readOnly: true},
363 server: "chrome",
364 raw: "list_pages",
365 serverAuthorized: true,
366 })
367 parent.Add(subagentMCPTool{
368 subagentRegistryTool: subagentRegistryTool{name: "mcp__chrome__new_page"},
369 server: "chrome",
370 raw: "new_page",
371 serverAuthorized: true,
372 })
373 parent.Add(subagentMCPTool{
374 subagentRegistryTool: subagentRegistryTool{name: "mcp__other__secret"},
375 server: "other",
376 raw: "secret",
377 serverAuthorized: false,
378 })
379
380 // A custom profile boundary is authoritative even for installed MCP tools.
381 general := SubagentToolRegistry(parent, []string{"read_file"})
382 if _, ok := general.Get("read_file"); !ok {
383 t.Fatalf("custom profile should keep allowlisted built-in; got %v", general.Names())
384 }
385 if _, ok := general.Get("write_file"); ok {
386 t.Fatalf("custom profile should not include non-allowlisted writer; got %v", general.Names())
387 }
388 if _, ok := general.Get("use_capability"); ok {
389 t.Fatalf("built-in-only allowlist should not install MCP proxy; got %v", general.Names())
390 }
391 for _, name := range []string{"mcp__chrome__list_pages", "mcp__chrome__new_page", "mcp__other__secret"} {
392 if _, ok := general.Get(name); ok {
393 t.Fatalf("custom profile should exclude direct MCP %q; got %v", name, general.Names())
394 }
395 }
396
397 explicit := SubagentToolRegistry(parent, []string{"mcp__chrome__*"})
398 if _, ok := explicit.Get("mcp__chrome__list_pages"); ok {
399 t.Fatalf("explicit MCP wildcard must not expose direct schemas: %v", explicit.Names())
400 }
401 proxy, ok := explicit.Get("use_capability")
402 if !ok {
403 t.Fatalf("explicit MCP wildcard should install restricted proxy; got %v", explicit.Names())
404 }
405 resolver, ok := proxy.(tool.CallResolver)
406 if !ok {
407 t.Fatalf("proxy is not CallResolver: %T", proxy)
408 }
409 if _, err := resolver.ResolveCall(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-tool:chrome/list_pages"}`)); err != nil {
410 t.Fatalf("wildcard should allow chrome/list_pages: %v", err)
411 }
412 if _, err := resolver.ResolveCall(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-tool:chrome/new_page"}`)); err != nil {
413 t.Fatalf("wildcard should allow chrome/new_page on writer-capable subagent: %v", err)
414 }
415 if _, err := resolver.ResolveCall(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-tool:other/secret"}`)); err == nil {
416 t.Fatal("wildcard must reject other server capabilities")
417 }
418
419 ro := ReadOnlySubagentToolRegistry(parent, []string{"read_file"})
420 if _, ok := ro.Get("use_capability"); ok {
421 t.Fatalf("read-only built-in-only profile should not install MCP proxy; got %v", ro.Names())
422 }
423
424 explicitRO := ReadOnlySubagentToolRegistry(parent, []string{"mcp__chrome__*"})
425 if _, ok := explicitRO.Get("mcp__chrome__list_pages"); ok {
426 t.Fatalf("read-only MCP wildcard must not expose direct schemas: %v", explicitRO.Names())
427 }
428 roProxy, ok := explicitRO.Get("use_capability")
429 if !ok {
430 t.Fatalf("read-only MCP wildcard should install restricted proxy; got %v", explicitRO.Names())
431 }
432 roResolver, ok := roProxy.(tool.CallResolver)
433 if !ok {
434 t.Fatalf("read-only proxy is not CallResolver: %T", roProxy)
435 }
436 // Registry allowlist conversion includes both chrome tools; execution-time
437 // ReadOnlyExecution still blocks the writer. The schema surface stays proxy-only.
438 if _, err := roResolver.ResolveCall(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-tool:chrome/list_pages"}`)); err != nil {
439 t.Fatalf("read-only wildcard should allow reader capability resolve: %v", err)
440 }
441 }
442
443 func TestMCPToolAvailabilityAcrossGeneralAndReadOnlySubagents(t *testing.T) {
444 // Direct mcp__* schemas never enter child registries; MCP is only via
445 // use_capability. Presence of the proxy (with parent proxy available) is the
446 // zero-config surface for both general and strict read-only children.
447 parent := tool.NewRegistry()
448 parent.Add(subagentCapabilityProxy{subagentRegistryTool{name: "use_capability", readOnly: true}})
449 parent.Add(subagentMCPTool{
450 subagentRegistryTool: subagentRegistryTool{name: "mcp__srv__tool", readOnly: true},
451 server: "srv", raw: "tool", serverAuthorized: true,
452 })
453
454 general := SubagentToolRegistry(parent, nil)
455 if _, ok := general.Get("mcp__srv__tool"); ok {
456 t.Fatalf("general subagent must not expose direct MCP: %v", general.Names())
457 }
458 if _, ok := general.Get("use_capability"); !ok {
459 t.Fatalf("general subagent must expose use_capability: %v", general.Names())
460 }
461 ro := ReadOnlySubagentToolRegistry(parent, nil)
462 if _, ok := ro.Get("mcp__srv__tool"); ok {
463 t.Fatalf("read-only subagent must not expose direct MCP: %v", ro.Names())
464 }
465 if _, ok := ro.Get("use_capability"); !ok {
466 t.Fatalf("read-only subagent must expose use_capability: %v", ro.Names())
467 }
468 // FilterReadOnlyRegistry (guardian and similar) still surfaces authorized
469 // read-only MCP tools; PlannerToolRegistry strips them for proxy-only.
470 if _, ok := FilterReadOnlyRegistry(parent).Get("mcp__srv__tool"); !ok {
471 t.Fatalf("FilterReadOnlyRegistry should keep authorized read-only MCP for non-planner surfaces; got %v", FilterReadOnlyRegistry(parent).Names())
472 }
473 if _, ok := PlannerToolRegistry(parent).Get("mcp__srv__tool"); ok {
474 t.Fatalf("PlannerToolRegistry must strip direct MCP: %v", PlannerToolRegistry(parent).Names())
475 }
476 if _, ok := PlannerToolRegistry(parent).Get("use_capability"); !ok {
477 t.Fatalf("PlannerToolRegistry must keep use_capability: %v", PlannerToolRegistry(parent).Names())
478 }
479 }
480
481 func TestRestrictedCapabilityProxyDescriptionIsStable(t *testing.T) {
482 parent := tool.NewRegistry()
483 // Real UseCapabilityTool so description bytes match production.
484 proxy := NewUseCapabilityTool(context.Background(), nil, []plugin.Spec{
485 {Name: "alpha", Authorized: true},
486 {Name: "beta", Authorized: true},
487 }, parent, nil, nil, nil)
488 parent.Add(proxy)
489 parent.Add(subagentMCPTool{
490 subagentRegistryTool: subagentRegistryTool{name: "mcp__alpha__search", readOnly: true},
491 server: "alpha", raw: "search", serverAuthorized: true,
492 })
493
494 before := SubagentToolRegistry(parent, []string{"mcp__alpha__*"})
495 beforeProxy, ok := before.Get("use_capability")
496 if !ok {
497 t.Fatal("restricted proxy missing")
498 }
499 beforeDesc := beforeProxy.Description()
500 beforeSchema := string(beforeProxy.Schema())
501
502 // Install another MCP tool that expands the same wildcard — description and
503 // schema must not change (provider-visible prefix stability).
504 parent.Add(subagentMCPTool{
505 subagentRegistryTool: subagentRegistryTool{name: "mcp__alpha__list", readOnly: true},
506 server: "alpha", raw: "list", serverAuthorized: true,
507 })
508 after := SubagentToolRegistry(parent, []string{"mcp__alpha__*"})
509 afterProxy, ok := after.Get("use_capability")
510 if !ok {
511 t.Fatal("restricted proxy missing after MCP install")
512 }
513 if afterProxy.Description() != beforeDesc {
514 t.Fatalf("description changed after MCP install\nbefore=%q\nafter=%q", beforeDesc, afterProxy.Description())
515 }
516 if string(afterProxy.Schema()) != beforeSchema {
517 t.Fatalf("schema changed after MCP install")
518 }
519 if afterProxy.Name() != "use_capability" || beforeProxy.Name() != "use_capability" {
520 t.Fatal("proxy name must stay use_capability")
521 }
522 }
523
524 func TestRestrictedCapabilityProxyListFiltersServers(t *testing.T) {
525 host := plugin.NewHost()
526 defer host.Close()
527 proxy := NewUseCapabilityTool(context.Background(), host, []plugin.Spec{
528 {Name: "alpha", Authorized: true},
529 {Name: "beta", Authorized: true},
530 {Name: "secret-db", Authorized: true},
531 }, tool.NewRegistry(), nil, nil, nil)
532 parent := tool.NewRegistry()
533 parent.Add(proxy)
534 parent.Add(subagentMCPTool{
535 subagentRegistryTool: subagentRegistryTool{name: "mcp__alpha__search", readOnly: true},
536 server: "alpha", raw: "search", serverAuthorized: true,
537 })
538
539 sub := SubagentToolRegistry(parent, []string{"mcp__alpha__search"})
540 tl, ok := sub.Get("use_capability")
541 if !ok {
542 t.Fatal("missing restricted proxy")
543 }
544 resolver, ok := tl.(tool.CallResolver)
545 if !ok {
546 t.Fatalf("not CallResolver: %T", tl)
547 }
548 rc, err := resolver.ResolveCall(context.Background(), json.RawMessage(`{"action":"list"}`))
549 if err != nil {
550 t.Fatal(err)
551 }
552 if !strings.Contains(rc.Result, `"name": "alpha"`) {
553 t.Fatalf("list should include allowlisted server alpha:\n%s", rc.Result)
554 }
555 if strings.Contains(rc.Result, "secret-db") || strings.Contains(rc.Result, `"name": "beta"`) {
556 t.Fatalf("list leaked servers outside allowlist:\n%s", rc.Result)
557 }
558 }
559
560 func TestMalformedCapabilityAllowlistDoesNotInstallProxy(t *testing.T) {
561 parent := tool.NewRegistry()
562 parent.Add(NewUseCapabilityTool(context.Background(), nil, []plugin.Spec{
563 {Name: "alpha", Authorized: true},
564 {Name: "secret-db", Authorized: true},
565 }, parent, nil, nil, nil))
566 parent.Add(subagentRegistryTool{name: "read_file", readOnly: true})
567
568 // Incomplete IDs must not create a restricted proxy that fail-opens list.
569 for _, allow := range [][]string{
570 {"mcp-server:"},
571 {"mcp-tool:"},
572 {"mcp-tool:onlyserver"},
573 {"mcp-server:/bad"},
574 } {
575 sub := SubagentToolRegistry(parent, allow)
576 if _, ok := sub.Get("use_capability"); ok {
577 t.Fatalf("malformed allowlist %v must not install use_capability; got %v", allow, sub.Names())
578 }
579 }
580 }
581
582 func TestFilterCapabilityListResultFailClosed(t *testing.T) {
583 // Empty server set must not return the raw full inventory.
584 full := `{"servers":[{"name":"secret-db","capability_id":"mcp-server:secret-db","status":"configured","authorized":true,"connected":false}],"note":"all"}`
585 out := filterCapabilityListResult(full, nil)
586 if strings.Contains(out, "secret-db") {
587 t.Fatalf("empty servers must fail closed:\n%s", out)
588 }
589 if !strings.Contains(out, `"servers": []`) && !strings.Contains(out, `"servers":[]`) {
590 t.Fatalf("expected empty servers array:\n%s", out)
591 }
592
593 // Malformed JSON must not pass through raw text that might contain names.
594 leaky := `not-json but mentions secret-db and production`
595 out = filterCapabilityListResult(leaky, map[string]bool{"alpha": true})
596 if strings.Contains(out, "secret-db") || strings.Contains(out, "not-json") {
597 t.Fatalf("malformed payload must fail closed:\n%s", out)
598 }
599 if !strings.Contains(out, `"servers"`) {
600 t.Fatalf("fail-closed payload should still be JSON list shape:\n%s", out)
601 }
602 }
603
604 func TestRestrictedListWithEmptyServersMapFailClosed(t *testing.T) {
605 // Direct unit path: restricted proxy with empty servers still filters list.
606 inner := NewUseCapabilityTool(context.Background(), nil, []plugin.Spec{
607 {Name: "secret-db", Authorized: true},
608 }, tool.NewRegistry(), nil, nil, nil)
609 proxy := &restrictedCapabilityProxy{
610 Tool: inner,
611 resolver: inner,
612 allowed: map[string]bool{"mcp-tool:incomplete": true}, // invalid shape should never happen after validation
613 servers: map[string]bool{},
614 }
615 rc, err := proxy.ResolveCall(context.Background(), json.RawMessage(`{"action":"list"}`))
616 if err != nil {
617 t.Fatal(err)
618 }
619 if strings.Contains(rc.Result, "secret-db") {
620 t.Fatalf("empty servers map must not leak inventory:\n%s", rc.Result)
621 }
622 }
623
624 func TestPlannerToolRegistryClonesUseCapability(t *testing.T) {
625 parent := tool.NewRegistry()
626 ledger := capability.NewLedger()
627 proxy := NewUseCapabilityTool(context.Background(), nil, nil, parent, ledger, nil, nil)
628 parent.Add(proxy)
629 parent.Add(subagentRegistryTool{name: "read_file", readOnly: true})
630
631 planner := PlannerToolRegistry(parent)
632 got, ok := planner.Get("use_capability")
633 if !ok {
634 t.Fatal("planner missing use_capability")
635 }
636 uc, ok := got.(*UseCapabilityTool)
637 if !ok {
638 t.Fatalf("planner proxy type = %T, want *UseCapabilityTool", got)
639 }
640 if uc == proxy {
641 t.Fatal("planner must not share the executor UseCapabilityTool pointer")
642 }
643 if uc.ledger == ledger {
644 t.Fatal("planner frontend must not share the executor capability ledger")
645 }
646 }
647
648 func TestTaskToolBuildSubRegUsesSubagentToolRegistry(t *testing.T) {
649 parent := tool.NewRegistry()
650 parent.Add(subagentRegistryTool{name: "task"})
651 parent.Add(subagentRegistryTool{name: "read_only_task"})
652 parent.Add(subagentRegistryTool{name: "read_only_skill", readOnly: true})
653 parent.Add(subagentRegistryTool{name: "parallel_tasks"})
654 parent.Add(subagentRegistryTool{name: "fleet"})
655 parent.Add(subagentRegistryTool{name: "wait"})
656 parent.Add(subagentRegistryTool{
657 name: "bash",
658 schema: `{"type":"object","properties":{"command":{"type":"string"},"run_in_background":{"type":"boolean"}}}`,
659 })
660 task := (&TaskTool{parentReg: parent}).WithMaxSubagentDepth(2)
661
662 firstLayer := task.buildSubReg(nil, 1)
663 for _, exposed := range []string{"task", "read_only_task", "read_only_skill"} {
664 if _, ok := firstLayer.Get(exposed); !ok {
665 t.Fatalf("first-layer subagent registry should expose %q; got %v", exposed, firstLayer.Names())
666 }
667 }
668 for _, hidden := range []string{"parallel_tasks", "fleet", "wait"} {
669 if _, ok := firstLayer.Get(hidden); ok {
670 t.Fatalf("first-layer subagent registry should hide %q; got %v", hidden, firstLayer.Names())
671 }
672 }
673
674 sub := task.buildSubReg(nil, 2)
675 for _, hidden := range []string{"task", "read_only_task", "read_only_skill", "parallel_tasks", "fleet", "wait"} {
676 if _, ok := sub.Get(hidden); ok {
677 t.Fatalf("depth-limited subagent registry should hide %q; got %v", hidden, sub.Names())
678 }
679 }
680 bash, ok := sub.Get("bash")
681 if !ok {
682 t.Fatalf("task subagent registry should keep bash; got %v", sub.Names())
683 }
684 if strings.Contains(string(bash.Schema()), "run_in_background") {
685 t.Fatalf("task subagent bash schema should be foreground-only: %s", bash.Schema())
686 }
687 }
688
689 func TestTaskToolDescribesSubagentToolBoundary(t *testing.T) {
690 task := &TaskTool{}
691 for label, text := range map[string]string{
692 "description": task.Description(),
693 "schema": string(task.Schema()),
694 } {
695 for _, want := range []string{"wait", "bash_output", "kill_shell", "foreground-only"} {
696 if !strings.Contains(text, want) {
697 t.Fatalf("task %s should mention %q in subagent tool boundary: %s", label, want, text)
698 }
699 }
700 }
701 }
702
702 lines GO