Skip to content

feat(delegate): optional profile param so agent_profiles archetypes declare authoritative toolsets; MCP toolsets bypass parent intersection (non-MCP preserved) - #49699

Open
davidgut1982 wants to merge 1 commit into
NousResearch:mainfrom
davidgut1982:feat/delegate-profile-param
Open

feat(delegate): optional profile param so agent_profiles archetypes declare authoritative toolsets; MCP toolsets bypass parent intersection (non-MCP preserved)#49699
davidgut1982 wants to merge 1 commit into
NousResearch:mainfrom
davidgut1982:feat/delegate-profile-param

Conversation

@davidgut1982

Copy link
Copy Markdown
Contributor

feat(delegate): optional profile param so agent_profiles archetypes declare authoritative toolsets; MCP toolsets bypass parent intersection (non-MCP preserved)

Closes/relates to: #32668 (also #32727)

Summary

Adds an optional profile parameter to delegate_task (top-level and per-task in
tasks=[...]). A profile names an entry in the top-level agent_profiles config
mapping and supplies baseline model, toolsets, max_iterations, and
system_prompt for the spawned child. Explicit call parameters still override
profile values.

The motivating problem (#32668): an orchestrator that restricts its own context
via a no_mcp platform toolset (or simply never loaded a given domain MCP server)
cannot hand a fat sub-agent the MCP toolsets that sub-agent needs. Today
_build_child_agent intersects the child's requested toolsets against the
parent's loaded tools, which silently drops any MCP toolset the orchestrator
itself isn't carrying. The result: profile-driven workers come up missing exactly
the MCP servers their archetype declares.

This change lets a named profile act as the authoritative source of a child's
toolsets. When (and only when) a delegation resolves to a valid profile that
declares a non-empty toolsets list, MCP toolsets in that list bypass the
parent-intersection check
and are resolved directly from the global
mcp_servers config. Non-MCP toolsets continue to go through intersection
unchanged.

Why this is safe — the security boundary

The bypass is deliberately narrow. The privilege-escalation surface here is "a
model names a profile, then injects arbitrary toolsets the profile never
declared." Each of those vectors is closed:

  1. Only MCP toolsets bypass. Non-MCP toolsets (terminal, file, web, …)
    still go through expanded_parent intersection in _build_child_agent. A
    profile cannot grant a child a non-MCP tool the parent lacks. (See
    test_profile_non_mcp_toolsets_still_intersected.)

  2. Empty/toolset-less profiles do NOT activate the bypass. profile_name is
    carried into _build_child_agent only when the resolved profile declares a
    non-empty toolsets list. A profile with no toolsets falls back to the normal
    intersection path with a warning — so a caller cannot name an empty profile and
    then smuggle in mcp-* toolsets via the toolsets argument.
    (See test_empty_profile_toolsets_bypass_not_activated.)

  3. Batch-mode injection is blocked. When a top-level profile is
    authoritative, its toolsets are used for every task in a tasks=[...]
    batch; per-task model-supplied toolsets are ignored. This stops a model from
    naming a valid profile (to activate the bypass) while injecting a per-task evil
    mcp-* toolset the profile never declared.
    (See test_batch_injection_blocked_model_cannot_inject_evil_mcp_toolset.)

  4. Unknown profiles fail closed. _resolve_profile raises on an unknown name
    and delegate_task returns a tool_error before any child is built — there is
    a single strict resolver, no lenient "warn and fall back to unrestricted
    toolset" path. (See test_unknown_profile_returns_error.)

  5. Profile toolsets are deep-copied (_resolve_profile deep-copies, and
    delegate_task takes an additional list() copy) so a child build can never
    mutate the cached config agent_profiles block.
    (See test_profile_toolsets_copy_prevents_config_corruption.)

  6. inherit_mcp_toolsets parent-bleed is suppressed under a profile. When a
    profile is authoritative the _preserve_parent_mcp_toolsets step is skipped, so
    a profile-restricted child does not silently inherit the parent's MCP toolsets.
    (See test_parent_mcp_bleed_blocked_under_profile.)

The empty-toolset privilege-escalation guard and the existing parent-intersection
boundary for ad-hoc (profile-less) delegation are fully preserved.

What changed

tools/delegate_tool.py (single production file):

  • delegate_task: new optional profile param (top-level) + profile key in the
    per-task schema. Resolves the named profile, applies its toolsets / model /
    max_iterations / system_prompt as baselines (explicit args win), and computes
    resolved_profile_name / profile_resolved_toolsets under the non-empty-toolsets
    gate. Per-task profiles are pre-resolved (so an unknown name surfaces as a clean
    tool_error before any child is built) and beat the top-level profile.
  • _build_child_agent: new optional profile_name param. When set, MCP toolsets
    in the requested list bypass parent intersection (_is_mcp_toolset_name(t) or t in expanded_parent); non-MCP toolsets still intersect. _preserve_parent_mcp_toolsets
    is skipped under a profile to prevent parent MCP bleed.
  • New helpers: _load_profiles() (reads the agent_profiles config block),
    _load_agent_profiles() (deprecated alias → single strict source of truth), and
    _resolve_profile(name, profiles) (validates the name, deep-copies, resolves
    system_prompt_file/system_promptsystem_prompt_text, and coerces
    max_iterations to int).
  • Schema: profile string property added to both the top-level params and the
    per-task tasks[] items, with a doc note that a profile's system_prompt
    replaces (not appends to) the child's default prompt.

No other production files are touched. The change is additive — profile defaults
to None, so existing profile-less delegation behaviour is byte-for-byte unchanged.

Test summary

tests/tools/test_delegate_toolset_scope.py extends the existing
TestToolsetIntersection base cases (unchanged) with eight focused classes:

Test class Covers
TestProfileMcpToolsetBypass MCP toolsets bypass intersection; non-MCP still intersected; profile-less still intersected
TestDelegateTaskProfileWiring profile_name forwarded to _build_child_agent; profile-less leaves it None; unknown profile → tool_error
TestDelegateTaskSchemaProfile profile present in schema, string-typed, not required
TestProfileMcpBypassEndToEnd no_mcp parent + profile retains MCP toolsets; without profile strips them
TestBatchToolsetInjectionBlocked batch per-task toolset injection blocked; single-task profile toolsets unchanged; empty-profile bypass not activated
TestProfileToolsetsAliasing profile toolsets copied — child build can't corrupt config
TestInheritMcpToolsetsProfileGuard parent MCP bleed blocked under profile; non-profile inheritance unchanged

Result on a clean v0.17.0 + this patch:

tests/tools/test_delegate_toolset_scope.py ......................  [22 passed]
tests/tools/test_delegate.py (regression) ............... [140 passed]
162 passed

Compatibility

  • Purely additive; profile=None preserves all current behaviour.
  • Builds on toolset-scoping primitives already present in v0.17.0
    (_is_mcp_toolset_name, _preserve_parent_mcp_toolsets,
    _get_inherit_mcp_toolsets), so no new cross-module dependencies are introduced.
  • One stdlib import added (import copy) for the profile deep-copy.

@alt-glitch alt-glitch added type/feature New feature or request comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint tool/delegate Subagent delegation P3 Low — cosmetic, nice to have labels Jun 20, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the detailed MCP-boundary analysis and focused tests.

Problems

  • Current main intentionally takes a different security direction. Commit ba0bc01d1 removed top-level and per-task model-facing toolsets, stating that toolset selection is capability scoping the model must not control. The live child build now always uses toolsets=None at tools/delegate_tool.py:2519-2521, and tests/tools/test_async_delegation.py:489-510 guards against forwarding model-supplied toolsets. This PR's schema-level profile restores a model-selected path to profile toolsets and an MCP parent-boundary bypass.
  • agent_profiles is introduced only through the PR's delegate-tool loader. The diff has no corresponding configuration default, config UX/example, or user documentation for the new model, prompt, iteration, and tool-access contract.

Suggested changes

  • Please do not reintroduce a model-facing toolset selector. A salvage would need a maintainer-approved, trusted profile/routing mechanism that preserves the current parent-inheritance boundary, plus configuration and end-to-end coverage on current main.

Automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit area/profiles Multi-profile isolation, HERMES_HOME scoping labels Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/profiles Multi-profile isolation, HERMES_HOME scoping comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P3 Low — cosmetic, nice to have sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data tool/delegate Subagent delegation type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants