Skip to content

fix(security): gate deregister() with the same plugin opt-in policy as register() - #55840

Closed
srojk34 wants to merge 2 commits into
NousResearch:mainfrom
srojk34:fix/registry-deregister-override-bypass
Closed

fix(security): gate deregister() with the same plugin opt-in policy as register()#55840
srojk34 wants to merge 2 commits into
NousResearch:mainfrom
srojk34:fix/registry-deregister-override-bypass

Conversation

@srojk34

@srojk34 srojk34 commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary

  • ToolRegistry.deregister() had zero authorization: any plugin could silently remove a built-in tool's entry and then re-register over the empty slot with a malicious handler, completely bypassing the three-commit register(override=True) opt-in chain (179eb8c310122212f5624 / fix(security): operator opt-in for plugin tool_override (sink-enforced) + enable-time consent #55599).
  • register() only runs the override-policy check when an existing entry is present — clearing the entry first causes the check to be skipped entirely, with no PermissionError and no audit log.
  • This is the symmetric gap that the register() hardening series left unaddressed.

Fix

Before removing a non-MCP entry, deregister() now inspects the calling module via _caller_module() (frame-based, matching _plugin_owner_of()'s bind-to-definition-site philosophy). If the caller is in the hermes_plugins.* namespace, does not own the entry's handler, and lacks operator opt-in, a PermissionError is raised and the entry is left intact.

MCP toolsets (mcp-*) are exempt — dynamic tool refresh legitimately nukes-and-repaves its own tools and has no plugin-override concept.

Test plan

  • test_plugin_cannot_deregister_unowned_tool_without_opt_in — core blocked case
  • test_plugin_with_opt_in_can_deregister_unowned_tool — operator opt-in allows it
  • test_plugin_can_deregister_its_own_tool — self-cleanup always allowed
  • test_mcp_toolset_always_deregisterable — MCP refresh exempt
  • test_core_code_deregister_always_allowed — internal Hermes code never gated
  • test_full_bypass_blocked — the full deregister→register chain is now rejected end-to-end

pytest tests/tools/test_registry.py → 39 passed, 0 failed

@alt-glitch alt-glitch added type/security Security vulnerability or hardening P1 High — major feature broken, no workaround comp/tools Tool registry, model_tools, toolsets sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Jun 30, 2026
@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The patch blocks the unowned built-in deregister bypass, but it also blocks a plugin from cleaning up a tool it owns when the handler is defined in one plugin submodule and cleanup runs from the plugin root module. deregister() compares caller_mod and owner as exact module strings, so hermes_plugins.pkg is treated as not owning a handler defined in hermes_plugins.pkg.handlers.

I reproduced that on PR head with a run-root probe: register own_tool with a handler whose __globals__["__name__"] is hermes_plugins.pkg.handlers, register the plugin policy for hermes_plugins.pkg with no override opt-in, then call reg.deregister("own_tool") from a function whose module is hermes_plugins.pkg. The PR raises PermissionError and leaves the tool registered, even though this is same-plugin self-cleanup rather than removal of an unowned built-in. Please bind ownership to the plugin namespace or manifest identity instead of exact module equality, while still blocking cross-plugin or built-in deregistration without allow_tool_override.

Security evidence:

Area Source Sink / boundary Evidence Result
Original unowned built-in removal bypass Third-party plugin code calling registry.deregister("protected") Global tool registry entry for a built-in tool Current main probe produced {"outcome": "bypass_succeeded", "toolset": "evil-ts"} after deregister + plain register Fixed by this PR
Rejected unowned removal path Same plugin call on PR head Same registry entry PR-head probe raised PermissionError and kept toolset="terminal" with the built-in handler Correct
Same-plugin submodule self-cleanup hermes_plugins.pkg cleanup code for a handler defined in hermes_plugins.pkg.handlers Plugin-owned registry entry removal PR-head probe raised PermissionError and left own_tool registered because caller_mod != owner Regression
MCP dynamic refresh Core tools.mcp_tool deregistration of mcp-* toolsets Dynamic MCP registry refresh Existing PR test covers the mcp-* exemption and focused registry tests passed Not blocked
reviewer validation Focused probes and tests Source and regression coverage tests/tools/test_registry.py passed 39 passed; git merge-tree --write-tree origin/main origin/pr-55840 succeeded Local blocker remains

Signed: GPT-5.5-xhigh in Codex

…to package

A plugin could bypass the register(override=True) authorization chain by
calling registry.deregister(name) to clear the existing entry, then
calling plain registry.register() over the now-empty slot — register()
only runs its override check when an existing entry is present, so
removing the entry first skips the check entirely (NousResearch#55599 sibling gap).

Ownership check binds to the plugin package root (hermes_plugins.{name}),
not the exact leaf module string: hermes_plugins.pkg (root cleanup code)
is allowed to deregister a tool whose handler was defined in
hermes_plugins.pkg.handlers — they share the same package. Exact module
equality would have blocked same-plugin self-cleanup (egilewski review).

MCP toolsets (mcp-*) are exempt — dynamic tool refresh legitimately nukes
and repaves its own tools on every server refresh.
@srojk34
srojk34 force-pushed the fix/registry-deregister-override-bypass branch from f09273f to ddc062f Compare June 30, 2026 21:47
@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The patch fixes the unowned deregister bypass and the same-plugin root-vs-submodule cleanup case, but it still misses the plugin package boundary for operator opt-in: PluginManager._load_plugin() records override policy for the plugin root namespace, while deregister() looks up self._plugin_override_policy.get(caller_mod, False) using the exact caller module.

A focused PR-head probe registered allow_tool_override=True for hermes_plugins.allowed, then called registry.deregister("protected") with _caller_module() patched to hermes_plugins.allowed.cleanup; it raised PermissionError and left the protected built-in tool registered on toolset="terminal". That means an opted-in plugin still cannot clean up from a submodule, which contradicts the intended opt-in path and leaves the fix incomplete.

I verified current main is vulnerable to the original deregister-to-register bypass, PR head blocks unowned deregister without opt-in, same-plugin submodule cleanup is covered for plugin-owned tools, and tests/tools/test_registry.py passes on the patched tree.

Signed: GPT-5.5-xhigh in Codex

…ookup

_plugin_override_policy is keyed by the plugin package root
(e.g. hermes_plugins.allowed), but the lookup used caller_mod
(the exact leaf module string). A call from hermes_plugins.allowed.cleanup
would evaluate _plugin_override_policy.get("hermes_plugins.allowed.cleanup")
→ False and raise PermissionError even when the plugin registered opt-in
under its package root.

Switch the policy lookup to caller_root (.join of the first two segments)
so submodule callers inherit the package-level allow_tool_override grant.

Adds a focused regression test for the opted-in submodule case.
@egilewski

Copy link
Copy Markdown
Contributor

fully addressed

The new head fixes the remaining package-boundary issue from my last pass. deregister() now checks allow_tool_override at the plugin package root, so an opted-in plugin calling from hermes_plugins.allowed.cleanup can remove a protected built-in, while a non-opted-in plugin from hermes_plugins.evil is still blocked.

I rechecked the original unowned deregister/register bypass against current main, the rejected unowned removal path, same-plugin submodule cleanup, operator opt-in from a plugin submodule, MCP dynamic deregistration coverage, focused registry tests, merge-tree compatibility with current main, and CodeRabbit's PR-scoped registry finding; I do not see a remaining high-confidence security blocker in this PR.

Signed: GPT-5.5-xhigh in Codex

@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Thanks @srojk34 — this is a real, well-scoped fix and it's landing.

Your branch was ~253 commits behind main (which had since diverged in tools/registry.py), so rather than merge the stale branch directly I salvaged your commit onto current main in #56261 with your authorship preserved (rebase-merge). The only adjustments were resolving a trivial popget/del conflict and restoring the _caller_module/import sys hunk against the current file — your deregister() gate, package-root ownership binding, MCP exemption, and all six tests are intact and byte-identical to your intended change.

Verified before landing: 41/41 test_registry.py + 202/202 test_mcp_tool.py green, ruff clean, and an E2E against a real ToolRegistry with a real hermes_plugins.* caller frame confirmed the deregister→re-register bypass is closed. I also confirmed the fix is keyed consistently with the merged register(override=True) policy (#55599), so an allow_tool_override opt-in applies to both paths.

One follow-up worth noting (tracked separately, not a knock on this PR): the module-name/frame identity both gates use is forgeable by actively-hostile plugin code — so this is an operator-consent + auditability guard against silent built-in shadowing, at the project's established plugin-trust level, rather than a hostile-plugin sandbox. Upgrading both register() and deregister() to unforgeable owner metadata is filed as a follow-up.

Closing this in favor of #56261. Credit is preserved in the git history there. Thanks again!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/tools Tool registry, model_tools, toolsets P1 High — major feature broken, no workaround sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants