Skip to content

Commit 5c2faa5

Browse files
haofeifclaude
andauthored
fix(security): honor child allowedTools=["*"] instead of inheriting parent restrictions (#141) (#144)
_resolve_child_allowed_tools() treated ["*"] the same as None (no opinion), causing restricted parents to override a child's explicit unrestricted config. Split the condition so ["*"] returns None (unrestricted) while None inherits. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2baf341 commit 5c2faa5

2 files changed

Lines changed: 106 additions & 2 deletions

File tree

src/cli_agent_orchestrator/mcp_server/server.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,14 @@ def _resolve_child_allowed_tools(
7272
return ",".join(child_allowed)
7373
return None
7474

75-
# If child has no restrictions, inherit parent's
76-
if child_allowed is None or "*" in child_allowed:
75+
# If child has no opinion (None), inherit parent's restrictions
76+
if child_allowed is None:
7777
return ",".join(parent_allowed_tools)
7878

79+
# If child explicitly requests unrestricted ("*"), honor it
80+
if "*" in child_allowed:
81+
return None
82+
7983
# Both have restrictions: child gets its own profile tools
8084
# (the child profile defines what it needs; parent's restrictions
8185
# are enforced by the parent not delegating unauthorized work)
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Tests for _resolve_child_allowed_tools in MCP server."""
2+
3+
from unittest.mock import MagicMock, patch
4+
5+
import pytest
6+
7+
from cli_agent_orchestrator.mcp_server.server import _resolve_child_allowed_tools
8+
9+
10+
class TestResolveChildAllowedTools:
11+
"""Tests for _resolve_child_allowed_tools function."""
12+
13+
@patch("cli_agent_orchestrator.utils.tool_mapping.resolve_allowed_tools")
14+
@patch("cli_agent_orchestrator.utils.agent_profiles.load_agent_profile")
15+
def test_child_wildcard_with_restricted_parent_returns_unrestricted(
16+
self, mock_load, mock_resolve
17+
):
18+
"""Issue #141: child with allowedTools=["*"] should NOT inherit parent restrictions."""
19+
mock_profile = MagicMock()
20+
mock_profile.allowedTools = ["*"]
21+
mock_profile.role = "developer"
22+
mock_profile.mcpServers = None
23+
mock_load.return_value = mock_profile
24+
mock_resolve.return_value = ["*"]
25+
26+
result = _resolve_child_allowed_tools(
27+
parent_allowed_tools=["@cao-mcp-server", "fs_read", "fs_list"],
28+
child_profile_name="code-reviewer",
29+
)
30+
31+
assert result is None # unrestricted
32+
33+
@patch("cli_agent_orchestrator.utils.tool_mapping.resolve_allowed_tools")
34+
@patch("cli_agent_orchestrator.utils.agent_profiles.load_agent_profile")
35+
def test_child_none_inherits_parent_restrictions(self, mock_load, mock_resolve):
36+
"""Child with no profile (FileNotFoundError) inherits parent's tools."""
37+
mock_load.side_effect = FileNotFoundError("not found")
38+
39+
result = _resolve_child_allowed_tools(
40+
parent_allowed_tools=["fs_read", "fs_list"],
41+
child_profile_name="nonexistent",
42+
)
43+
44+
assert result == "fs_read,fs_list"
45+
46+
@patch("cli_agent_orchestrator.utils.tool_mapping.resolve_allowed_tools")
47+
@patch("cli_agent_orchestrator.utils.agent_profiles.load_agent_profile")
48+
def test_unrestricted_parent_uses_child_tools(self, mock_load, mock_resolve):
49+
"""Unrestricted parent lets child use its own tools."""
50+
mock_profile = MagicMock()
51+
mock_profile.allowedTools = ["fs_read", "execute_bash"]
52+
mock_profile.role = None
53+
mock_profile.mcpServers = None
54+
mock_load.return_value = mock_profile
55+
mock_resolve.return_value = ["fs_read", "execute_bash"]
56+
57+
result = _resolve_child_allowed_tools(
58+
parent_allowed_tools=None,
59+
child_profile_name="developer",
60+
)
61+
62+
assert result == "fs_read,execute_bash"
63+
64+
@patch("cli_agent_orchestrator.utils.tool_mapping.resolve_allowed_tools")
65+
@patch("cli_agent_orchestrator.utils.agent_profiles.load_agent_profile")
66+
def test_both_restricted_uses_child_tools(self, mock_load, mock_resolve):
67+
"""Both parent and child restricted: child gets its own tools."""
68+
mock_profile = MagicMock()
69+
mock_profile.allowedTools = ["fs_read", "execute_bash"]
70+
mock_profile.role = None
71+
mock_profile.mcpServers = None
72+
mock_load.return_value = mock_profile
73+
mock_resolve.return_value = ["fs_read", "execute_bash"]
74+
75+
result = _resolve_child_allowed_tools(
76+
parent_allowed_tools=["@cao-mcp-server", "fs_read"],
77+
child_profile_name="developer",
78+
)
79+
80+
assert result == "fs_read,execute_bash"
81+
82+
@patch("cli_agent_orchestrator.utils.tool_mapping.resolve_allowed_tools")
83+
@patch("cli_agent_orchestrator.utils.agent_profiles.load_agent_profile")
84+
def test_parent_wildcard_child_wildcard_returns_unrestricted(self, mock_load, mock_resolve):
85+
"""Both parent and child unrestricted: returns None (unrestricted)."""
86+
mock_profile = MagicMock()
87+
mock_profile.allowedTools = ["*"]
88+
mock_profile.role = None
89+
mock_profile.mcpServers = None
90+
mock_load.return_value = mock_profile
91+
mock_resolve.return_value = ["*"]
92+
93+
result = _resolve_child_allowed_tools(
94+
parent_allowed_tools=["*"],
95+
child_profile_name="developer",
96+
)
97+
98+
# Parent is unrestricted, child has ["*"] → child_allowed is truthy → joins it
99+
# But ["*"] joined is "*", which is fine
100+
assert result == "*"

0 commit comments

Comments
 (0)