feat(tools): MCP profile bypass, lazy discovery, and delegate security hardening#44279
Open
davidgut1982 wants to merge 9 commits into
Open
feat(tools): MCP profile bypass, lazy discovery, and delegate security hardening#44279davidgut1982 wants to merge 9 commits into
davidgut1982 wants to merge 9 commits into
Conversation
When delegate_task is called with a named agent_profile, MCP toolsets declared in the profile now resolve from global mcp_servers config rather than being filtered against the parent agent's loaded tools. Previously, if the orchestrator restricted its own MCP context (via no_mcp or simply not loading domain servers), child agents spawned with profile toolsets like ["mcp-fastmail"] received empty tool lists silently. The intersection logic in _build_child_agent() treated parent-loaded tools as the upper bound for all children. This fix adds a profile_name parameter to _build_child_agent(). When set, _is_mcp_toolset_name() gates MCP toolsets through unconditionally; non-MCP toolsets still require parent membership (security boundary preserved). delegate_task() passes the resolved per-task profile name through to _build_child_agent() at every call site. Fixes the child-tool-loss failure mode described in issue NousResearch#32668. Three regression tests added to test_delegate_toolset_scope.py. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…string Add AGENTS.md note to the delegation section explaining that named agent_profile toolsets bypass the parent intersection for MCP servers (fix introduced in NousResearch#32668). Expand the _build_child_agent() docstring to describe the profile_name parameter's semantics and the rationale for the security-boundary split. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… toolsets bypass no_mcp parent intersection (NousResearch#32668/NousResearch#32727) Under a no_mcp orchestrator (platform_toolsets: [no_mcp, delegation, knowledge]), expanded_parent has zero MCP entries. The previous call site hardcoded profile_name=None into _build_child_agent, so the MCP-bypass branch (lines 972-976 of delegate_tool.py) never activated and fat sub-agents received an empty toolset. Fix: - Add _load_agent_profiles() helper to read agent_profiles from the top-level config (not the delegation sub-key that _load_config() returns). - Add profile: Optional[str] param to delegate_task(); when set, resolve the named profile's toolsets from agent_profiles and store as the effective toolsets for the child. - Change the _build_child_agent call site from profile_name=None to profile_name=resolved_profile_name so the MCP-bypass branch activates for named profiles. - Add "profile" to DELEGATE_TASK_SCHEMA so the model reliably emits the field; without a formal schema entry the LLM strips it at the provider API boundary. - Update registry lambda and _dispatch_delegate_task in run_agent.py to forward profile= through both invocation paths. Security: the bypass is scoped to MCP toolsets only, and only when a named profile explicitly declares them in config. Non-MCP toolsets still go through the parent intersection. Unknown profile names fall back gracefully (warning + no bypass). Tests added (test_delegate_toolset_scope.py): - TestDelegateTaskProfileWiring: verifies delegate_task() calls _build_child_agent with profile_name='documents' and the profile's resolved toolsets. Key regression guard: FAILS against pre-fix code (profile_name=None hardcoded) and PASSES after the fix. - TestDelegateTaskSchemaProfile: asserts 'profile' is a declared string property in DELEGATE_TASK_SCHEMA and is not in required[]. - TestProfileMcpBypassEndToEnd: direct _build_child_agent tests covering the post-fix bypass (profile_name set → mcp-nextcloud-files retained) and the security baseline (profile_name=None → MCP stripped). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…toolset injection (security)
Two privilege-escalation vectors closed:
1. Batch-mode injection (primary fix): the batch loop previously used
`t.get("toolsets") or toolsets` for each task, so a model could name
a valid profile (activating the mcp-* bypass in _build_child_agent)
while supplying per-task toolsets that the profile never declared.
Fix: when resolved_profile_name is set, all tasks in the batch receive
profile_resolved_toolsets exclusively — per-task model-supplied toolsets
are ignored.
2. Empty-profile bypass (secondary fix): a profile with no/empty toolsets
still set resolved_profile_name, activating the mcp-* bypass with
whatever caller-supplied toolsets were present. Fix: resolved_profile_name
is only set when the profile declares a non-empty toolsets list; otherwise
a warning is logged and the normal intersection path is used.
Tests added (tests/tools/test_delegate_toolset_scope.py):
- test_batch_injection_blocked_model_cannot_inject_evil_mcp_toolset: FAILS
pre-fix (mcp-injected-evil present), PASSES post-fix
- test_single_task_profile_toolsets_unchanged: regression guard, PASSES both
- test_empty_profile_toolsets_bypass_not_activated: FAILS pre-fix
(profile_name set), PASSES post-fix
Total: 16 → 19 tests, all green. tool_search: 63 pass, no collateral.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…l security hardening) Assign list(profile_toolsets) instead of the bare reference at line ~2082 so in-place mutations of the working `toolsets` variable cannot corrupt agent_profiles config for the process lifetime. Also take an independent list(toolsets) copy for profile_resolved_toolsets at line ~2110 (defence- in-depth against future code inserted between the two assignments). Adds TestProfileToolsetsAliasing::test_profile_toolsets_copy_prevents_config_corruption: resolves a profile, mutates the child toolsets list returned to _build_child_agent, and asserts the original profiles["documents"]["toolsets"] config list is unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rent MCP bleed (security hardening) When a named agent_profile is used, the profile's declared toolsets are authoritative. Previously _preserve_parent_mcp_toolsets ran unconditionally when inherit_mcp_toolsets=True, silently appending every parent MCP toolset absent from the child's list — including ones the profile never declared. Guard the bleed path with `and not profile_name` so inheritance is skipped whenever delegation resolves through a profile. Non-profile (ad-hoc) delegation continues to inherit parent MCP toolsets unchanged. The variable `profile_name` is already in scope at the call site (function parameter, also used at line 972 for the existing MCP bypass guard). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the active platform includes the no_mcp sentinel in its toolsets, skip eager MCP server discovery at gateway/CLI startup. Discovery is deferred until the first delegate_task() call that targets an MCP toolset, using a thread-safe one-shot Event/Lock pattern. This eliminates unnecessary MCP connection overhead for api_server platform (orchestrator) while preserving full MCP access for child agents via the Phase 1 profile_name bypass. cli/cron/telegram platforms are unaffected: their toolsets lack no_mcp, so the gate evaluates False and eager discovery runs exactly as before. Changes: - tools/mcp_tool.py: add mark_eager_discovery_skipped() + ensure_mcp_discovered() (one-shot, thread-safe, failure-tolerant lazy discovery trigger) - gateway/run.py: add _active_platform_uses_no_mcp() helper; gate the eager discover_mcp_tools() call in start_gateway() on the platform no_mcp check - hermes_cli/main.py: gate the inline CLI-startup discover_mcp_tools() call in _prepare_agent_startup() with the same no_mcp check (covers `gateway run`) - tools/delegate_tool.py: call ensure_mcp_discovered() before building any child agent that requests MCP toolsets - tests/tools/test_mcp_lazy_discovery.py: 12 tests covering the skip flag, no-op/once/idempotent/thread-safe/failure paths, and platform resolution Part of fix/profile-mcp-toolset-bypass branch (stacked on Phase 1). Resolves: NousResearch#32668 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pre-declare _cfg as Dict[str, Any] = {} so it is always bound and
Pyright knows the type is dict throughout the config bridge block.
Use an intermediate _expanded variable after _expand_env_vars() and
guard with isinstance(_, dict) so the re-assignment stays within the
declared dict type — _expand_env_vars has no return annotation and
Pyright infers a broad str | list | dict union.
Simplify the IPv4 network_cfg line: now that _cfg is always bound and
typed as dict, the old ('_cfg' in dir() else {}) guard is unnecessary.
Fixes Pyright errors at lines 863, 901, 917, 926, 930, 936, 978 that
were introduced by the Phase 2 lazy MCP discovery work.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…set_scope.py Cherry-pick of 1342418 onto current main silently dropped the `from unittest.mock import MagicMock, patch` line because both the cherry-pick source and the HEAD file lacked it relative to the diff's three-way base. The TestProfileMcpToolsetBypass class uses @patch and MagicMock, so without this import collection fails with NameError. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Contributor
|
Verification: security review — clean ✅ Reviewed the full diff (8 files, +1346/-8). Checked:
No issues found. Test coverage is comprehensive (856-line test file for profile bypass + 185-line test file for lazy discovery). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
This PR delivers three tightly-related improvements to how delegated subagents and
no_mcpplatforms handle MCP toolsets, plus the security hardening that makes the new bypass safe:Profile MCP bypass — When a parent delegates with a named
agent_profile, the MCP toolsets declared by that profile bypass the parent'senabled_toolsetsintersection. This fixes the case (feat: per-agent toolset restriction for orchestrator pattern (parent-scoped no_mcp) #32668 / fix(delegate): lazy MCP discovery + profile toolset bypass for no_mcp platforms #32727) where an orchestrator that restricts its own MCP servers could never hand a domain MCP toolset (e.g.mcp-fastmail,mcp-knowledge) to a child. Non-MCP toolsets the parent lacks are still dropped — the privilege-escalation boundary is preserved.Lazy MCP discovery for
no_mcpplatforms (Phase 2) — Platforms that start with no MCP context can discover and attach MCP toolsets on demand instead of eagerly loading every server at startup.Delegate security hardening — A series of fixes ensuring profile toolsets are authoritative and cannot be tampered with: batch-mode toolset injection is blocked, profile toolsets are deep-copied to prevent config aliasing/corruption across calls, and profile toolsets override
inherit_mcp_toolsetsso no parent MCP context bleeds into a child.The approach is correct because the bypass is scoped to MCP toolsets resolved from a trusted, named profile — the LLM cannot request an arbitrary toolset and have it bypass intersection. Every non-profile and non-MCP path keeps the original intersection security boundary.
Related Issue
Fixes #32668
Relates to #32727
Type of Change
Changes Made
tools/delegate_tool.py— profile MCP toolsets bypass parent intersection; profile toolsets are authoritative (block batch-mode injection); deep-copy profile toolsets to prevent config aliasing; profile toolsets overrideinherit_mcp_toolsets.run_agent.py— wireprofile_namethrough the delegate call chain so the bypass is applied at_build_child_agent.tools/mcp_tool.py— lazy MCP discovery support forno_mcpplatforms.gateway/run.py,hermes_cli/main.py— lazy MCP discovery wiring (Phase 2) + Pyright type-error fixes in the_cfgconfig bridge block.AGENTS.md— documentprofile_nameMCP bypass behavior.tests/tools/test_delegate_toolset_scope.py— regression coverage for the profile bypass and security hardening (intersection still enforced without a profile; non-MCP toolsets still dropped; config aliasing protected).tests/tools/test_mcp_lazy_discovery.py— coverage for lazy MCP discovery.How to Test
pytest tests/tools/test_delegate_toolset_scope.py tests/tools/test_mcp_lazy_discovery.py -q→ expect 34 passed, 0 failures (verified locally on Ubuntu, Python 3.13).
test_no_profile_mcp_toolsets_still_intersectedproves an MCP toolset request without a profile is dropped;test_profile_non_mcp_toolsets_still_intersectedproves a non-MCP toolset the parent lacks is still dropped even with a profile.test_profile_mcp_toolsets_bypass_parent_intersectionproves profile-declared MCP toolsets pass through to ano_mcpparent's child.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -q(scoped to the affected suites) and all tests passDocumentation & Housekeeping
docs/, docstrings) —AGENTS.md+ docstrings updatedcli-config.yaml.exampleif I added/changed config keys — N/A (no new config keys)CONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows —AGENTS.mdupdatedScreenshots / Logs