From ce3e67d61378305375b79de5a876a2dc4ac346f9 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 16 Jul 2026 19:30:40 -0700 Subject: [PATCH] [eric] agents: SDK deny list merges effective_disallowed + hard blocks (plain reassignment silently discarded the computed denies; runtime gate was the only wall) --- backend/apps/agents/manager/run/RunOptions.py | 10 +++------- .../agents/manager/run/run_options_helpers.py | 9 +++++++++ backend/tests/test_spawn_agent.py | 20 ++++++++++++++----- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/backend/apps/agents/manager/run/RunOptions.py b/backend/apps/agents/manager/run/RunOptions.py index c9266625..81d086b2 100644 --- a/backend/apps/agents/manager/run/RunOptions.py +++ b/backend/apps/agents/manager/run/RunOptions.py @@ -29,7 +29,7 @@ from backend.apps.agents.manager.prompt.tool_catalog import get_all_tool_names from backend.apps.agents.manager.prompt.prompt_context import resolve_mode from backend.apps.agents.manager.run.run_options_helpers import ( pre_send_context_guard, set_framework_overhead, register_web_mcp_server, - append_web_tools_hint, inject_thinking_options, + append_web_tools_hint, inject_thinking_options, merge_hard_blocked_tools, ) logger = logging.getLogger(__name__) @@ -227,12 +227,8 @@ class RunOptions(AgentManagerProtocol): options_kwargs["extra_args"] = p_ea # The claude_code preset auto-attaches the user's claude.ai- connected partner MCPs (`mcp__claude_ai_*`). Those bypass our MCPActivate gate, don't share OAuth state with the OpenSwarm Gmail/Calendar/Drive connectors the user actually configured here, and confuse the model into picking the partner shim instead of our vetted server. Hard-block them at the SDK layer so the model can't even attempt the call. - options_kwargs["disallowed_tools"] = [ - "mcp__claude_ai_*", - # The CLI's built-in sub-agent tool is replaced by our SpawnAgent MCP (prompt + run_in_background only); it is named Task on CLI 2.1.122 (Agent on older builds, kept for safety), and its subagent types resolve to models router setups can't serve. - "Agent", - "Task", - ] + # merge EXTENDS effective_disallowed: the old plain assignment silently discarded the computed denies (Cron*/Skill/web-swap/per-tool MCP) and left the runtime gate as the only wall. + options_kwargs["disallowed_tools"] = merge_hard_blocked_tools(effective_disallowed) if session.cwd: # Pre-existing sessions may have workspaces that predate the git-init block in launch_agent, leaving them without a valid HEAD. Ensure it here so subagent worktree-add always works. diff --git a/backend/apps/agents/manager/run/run_options_helpers.py b/backend/apps/agents/manager/run/run_options_helpers.py index c88fbd81..a471a974 100644 --- a/backend/apps/agents/manager/run/run_options_helpers.py +++ b/backend/apps/agents/manager/run/run_options_helpers.py @@ -12,6 +12,15 @@ from backend.apps.agents.manager.session.history_compaction import estimate_post logger = logging.getLogger(__name__) +# Always SDK-blocked regardless of permissions: claude.ai partner MCPs bypass our MCPActivate gate, and the CLI's built-in sub-agent tool (Task on 2.1.122, Agent on older builds) is replaced by our SpawnAgent MCP. +HARD_BLOCKED_TOOLS: List[str] = ["mcp__claude_ai_*", "Agent", "Task"] + + +@typechecked +def merge_hard_blocked_tools(effective_disallowed: List[str]) -> List[str]: + """The SDK deny list = the computed per-turn denies PLUS the unconditional hard blocks. A plain assignment here once silently discarded effective_disallowed (Cron*/Skill/web-swap/per-tool MCP denies), leaving the runtime gate as the only wall; merge, never overwrite.""" + return effective_disallowed + [t for t in HARD_BLOCKED_TOOLS if t not in effective_disallowed] + # `manager` is the AgentManager; it isn't annotated because typing it would import agent_manager back into a module agent_manager already imports (a cycle). Same reason self is never annotated. @typechecked diff --git a/backend/tests/test_spawn_agent.py b/backend/tests/test_spawn_agent.py index 757fbd1c..9ab47e35 100644 --- a/backend/tests/test_spawn_agent.py +++ b/backend/tests/test_spawn_agent.py @@ -73,13 +73,23 @@ def test_spawn_agent_unknown_parent_raises() -> None: def test_builtin_subagent_tools_stay_blocked() -> None: # The CLI's built-in sub-agent tool (Task on 2.1.122, Agent on older builds) must not be offered: out of the catalog AND hard-blocked at the SDK layer. from backend.apps.agents.manager.prompt.tool_catalog import FULL_TOOLS + from backend.apps.agents.manager.run.run_options_helpers import merge_hard_blocked_tools assert "Agent" not in FULL_TOOLS assert "Task" not in FULL_TOOLS - import inspect - from backend.apps.agents.manager.run import RunOptions - src = inspect.getsource(RunOptions) - block = src.split('disallowed_tools"] = [')[1][:400] - assert '"Agent",' in block and '"Task",' in block + blocked = merge_hard_blocked_tools([]) + assert "Agent" in blocked and "Task" in blocked and "mcp__claude_ai_*" in blocked + + +def test_effective_disallowed_reaches_the_sdk_deny_list() -> None: + # Regression: a plain assignment once DISCARDED the computed denies (Cron*/Skill/web-swap/per-tool MCP), leaving the runtime gate as the only wall. The merge must keep them AND append the hard blocks, deduped. + from backend.apps.agents.manager.run.run_options_helpers import merge_hard_blocked_tools + computed = ["CronCreate", "CronList", "CronDelete", "Skill", "mcp__gmail__DeleteEmail", "Task"] + merged = merge_hard_blocked_tools(computed) + for t in computed: + assert t in merged + assert "Agent" in merged and "mcp__claude_ai_*" in merged + assert merged.count("Task") == 1 + assert merged[:len(computed)] == computed def test_spawn_server_schema_is_prompt_plus_background_only() -> None: