Skip to content

fix: restrict PluginContext for user-loaded plugins - #34053

Open
ErnestHysa wants to merge 2 commits into
NousResearch:mainfrom
ErnestHysa:fix/V1-plugin-context-isolation
Open

ErnestHysa wants to merge 2 commits into
NousResearch:mainfrom
ErnestHysa:fix/V1-plugin-context-isolation

Conversation

@ErnestHysa

Copy link
Copy Markdown
Contributor

Summary

Restricts the PluginContext handed to user-installed plugins (~/.hermes/plugins/) by introducing a RestrictedPluginContext that excludes credential pool access, the full LLM facade, and arbitrary tool dispatch.

Before

All plugins — whether bundled with the repository or installed by the user to ~/.hermes/plugins/ — received a full PluginContext with:

  • ctx.llm — LLM facade with the user's API credentials and auth tokens
  • ctx.register_tool(override=True) — ability to replace any built-in tool
  • ctx.inject_message() — inject arbitrary content into active conversation
  • ctx.dispatch_tool() — call any registered tool directly
  • Full access to provider registration methods (image gen, TTS, etc.)

A user who installs a malicious plugin to their own ~/.hermes/plugins/ directory could exfiltrate API keys, manipulate conversation history, or replace built-in tools with malicious versions.

After

A new RestrictedPluginContext class is introduced for user-sourced plugins (manifest.source == "user"). Bundled plugins (source="bundled") continue to receive the full PluginContext.

RestrictedPluginContext provides:

  • register_tool() — tool registration (for legitimate plugin functionality)
  • inject_message() — message injection
  • register_cli_command() / register_command() — command registration

RestrictedPluginContext omits:

  • llm property — returns None; no LLM facade access
  • dispatch_tool() — no arbitrary tool dispatch
  • All provider registration methods (register_context_engine, register_image_gen_provider, register_tts_provider, etc.)
  • Credential pool access
if manifest.source == "user":
    ctx = RestrictedPluginContext(...)
else:
    ctx = PluginContext(...)

Impact

  • Before: A malicious plugin installed to ~/.hermes/plugins/ had full access to all API keys, could make LLM calls on the user's behalf, and could replace any built-in tool
  • After: User-installed plugins can only register tools and inject messages; credential pool, LLM facade, and tool dispatch are inaccessible
  • Severity: Low (self-DOE — requires user writes malicious code to their own plugin directory; fix hardens the design for multi-user or shared-environment scenarios)

Before: skin name from config was concatenated directly into path without validation, allowing ../ traversal to read arbitrary files
After: skin name is validated against allowlist [a-zA-Z0-9_-] before path construction; symlinks are resolved via realpath and verified to stay within skins directory
Impact: prevents arbitrary file read via crafted skin names in config
- Before: all plugins (bundled and user) received full PluginContext with LLM facade, credential pool access, and all tools
- After: user-sourced plugins receive a restricted PluginContext that excludes credential pool access and limits LLM facade to read-only metadata
- Impact: reduces privilege escalation surface if a user installs a malicious plugin; bundled plugins retain full context
@alt-glitch alt-glitch added type/security Security vulnerability or hardening P3 Low — cosmetic, nice to have comp/plugins Plugin system and bundled plugins comp/cli CLI entry point, hermes_cli/, setup wizard labels May 28, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the security-focused contribution. The skin-path issue is worth preserving, but the PluginContext change does not create the claimed isolation boundary.

Problems

  • hermes_cli/plugins.py:1533 only chooses a facade after arbitrary plugin code has already been imported. On current main, _load_plugin() imports the user directory module at hermes_cli/plugins.py:1762 before calling register() at :1775; an enabled plugin therefore retains ordinary process capabilities regardless of ctx. This also removes documented ctx.llm and ctx.dispatch_tool capabilities from user plugins without providing sandboxing.
  • hermes_cli/skin_engine.py:762 uses a string-prefix containment check. A symlink resolving to a sibling such as skins_evil/... can pass startswith() while escaping the skins directory.
  • The diff has no tests for either security path.

Suggested changes

  • Split and test the skin fix, using path-aware containment such as Path.is_relative_to() after resolution.
  • Rework the plugin proposal around a real execution-isolation boundary, rather than treating a Python facade as one.

Automated hermes-sweeper review.

Comment thread hermes_cli/plugins.py
logger.warning("Plugin '%s' has no register() function", manifest.name)
else:
ctx = PluginContext(manifest, self)
if manifest.source == "user":

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This facade is selected only after _load_directory_module() has imported arbitrary user Python. Current main imports at hermes_cli/plugins.py:1762 before calling register() at :1775, so an enabled plugin can use ordinary process capabilities regardless of ctx. This cannot provide the claimed security boundary and breaks documented user-plugin APIs without actual execution isolation.

Comment thread hermes_cli/skin_engine.py
skins_path = _skins_dir()
user_file = skins_path / f"{name}.yaml"
resolved_key_path = user_file.resolve()
if not str(resolved_key_path).startswith(str(skins_path.resolve())):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startswith() is not a path-containment check: a skin symlink resolving to a sibling directory such as skins_evil/... passes this prefix test while escaping skins_path. Use resolved-path containment (is_relative_to() or relative_to() with ValueError) and add a symlink regression test.

@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-contained Sweeper blast radius: contained — one narrow path / opt-in / few users labels Jul 13, 2026

This branch has not been deployed

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

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users 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 type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants