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