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