Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion hermes_cli/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ def get_bundled_plugins_dir() -> Path:
except ImportError: # pragma: no cover – yaml is optional at import time
yaml = None # type: ignore[assignment]


class PluginToolOverrideError(PermissionError):
"""Raised when a plugin attempts to override a built-in tool without
operator opt-in via ``plugins.entries.<plugin_id>.allow_tool_override``.
"""


logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -398,7 +405,24 @@ def register_tool(
same name (e.g. swap the default ``browser_navigate`` for a custom
CDP-backed implementation). Without it, attempting to register a name
already claimed by a different toolset is rejected.

``override=True`` against a built-in tool requires the operator to
opt in via ``plugins.entries.<plugin_id>.allow_tool_override: true``
in config.yaml — mirrors the trust gate pattern used for
``ctx.llm`` provider/model overrides (#23194). Without that gate,
any enabled plugin could silently replace a privileged built-in
like ``shell_exec`` or ``write_file`` and exfiltrate everything
the model invokes through it.
"""
if override and not self._tool_override_allowed(name):
plugin_id = self.manifest.key or self.manifest.name
raise PluginToolOverrideError(
f"Plugin {self.manifest.name!r} cannot override built-in tool "
f"{name!r}. Set "
f"plugins.entries.{plugin_id}.allow_tool_override: true "
f"in config.yaml to allow this plugin to replace built-in tools."
)

from tools.registry import registry

registry.register(
Expand All @@ -419,6 +443,32 @@ def register_tool(
self.manifest.name, name, " (override)" if override else "",
)

# -- override trust gate ------------------------------------------------

def _tool_override_allowed(self, tool_name: str) -> bool:
"""Return True if this plugin is configured to override built-in tools.

Bundled plugins (shipped with Hermes core) are trusted by default —
an override there is a deliberate maintainer choice, not a third-party
plugin trying to elevate privilege. For every other source, require
``allow_tool_override: true`` under
``plugins.entries.<plugin_id>`` in config.yaml.
"""
source = getattr(self.manifest, "source", "") or ""
if source == "bundled":
return True
try:
from hermes_cli.config import load_config
cfg = load_config() or {}
except Exception:
# If we can't load config, fail closed — better to break the
# override than silently grant it.
return False
plugin_id = self.manifest.key or self.manifest.name
entries = (cfg.get("plugins") or {}).get("entries") or {}
entry = entries.get(plugin_id) or {}
return bool(entry.get("allow_tool_override", False))

# -- message injection --------------------------------------------------

def inject_message(self, content: str, role: str = "user") -> bool:
Expand Down Expand Up @@ -1658,6 +1708,13 @@ def _load_plugin(self, manifest: PluginManifest) -> None:
manifest.key or manifest.name, manifest.source, manifest.kind, manifest.path,
)

from tools.registry import registry as _registry
_plugin_id = manifest.key or manifest.name
_slug = _plugin_id.replace("/", "__").replace("-", "_")
_registry.register_plugin_override_policy(
f"{_NS_PARENT}.{_slug}",
PluginContext(manifest, self)._tool_override_allowed(""),
)
try:
if manifest.source in {"user", "project", "bundled"}:
module = self._load_directory_module(manifest)
Expand Down Expand Up @@ -1725,7 +1782,6 @@ def _load_plugin(self, manifest: PluginManifest) -> None:
"Failed to load plugin '%s': %s",
manifest.name, exc, exc_info=_PLUGINS_DEBUG,
)

self._plugins[manifest.key or manifest.name] = loaded

def _load_directory_module(self, manifest: PluginManifest) -> types.ModuleType:
Expand Down
141 changes: 123 additions & 18 deletions hermes_cli/plugins_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -769,37 +769,135 @@ def _resolve_plugin_key(name: str) -> Optional[str]:
return None


def cmd_enable(name: str) -> None:
"""Add a plugin to the enabled allow-list (and remove it from disabled)."""
def _resolve_plugin_key_and_source(name: str) -> Optional[tuple]:
"""Resolve *name* to ``(canonical_key, source)`` or ``None`` if no match.

Mirrors :func:`_resolve_plugin_key`'s normalization but also returns the
plugin's source (``"bundled"``, ``"user"``, ``"project"``, ...) so the
enable path can tell whether a built-in-override consent prompt is needed.
"""
entries = _discover_all_plugins()
for entry in entries:
# entry = (name, version, description, source, dir_path, key)
if name == entry[5] or name == entry[0]:
return (entry[5], entry[3])
leaf_matches = [
(entry[5], entry[3]) for entry in entries
if name == entry[5].split("/")[-1]
]
if len(leaf_matches) == 1:
return leaf_matches[0]
return None


def _set_plugin_entry_flag(plugin_id: str, key: str, value: bool) -> None:
"""Write ``plugins.entries.<plugin_id>.<key> = value`` into config.yaml."""
from hermes_cli.config import load_config, save_config
config = load_config()
plugins_cfg = config.setdefault("plugins", {})
if not isinstance(plugins_cfg, dict):
plugins_cfg = {}
config["plugins"] = plugins_cfg
entries = plugins_cfg.setdefault("entries", {})
if not isinstance(entries, dict):
entries = {}
plugins_cfg["entries"] = entries
entry = entries.setdefault(plugin_id, {})
if not isinstance(entry, dict):
entry = {}
entries[plugin_id] = entry
entry[key] = bool(value)
save_config(config)


def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None:
"""Add a plugin to the enabled allow-list (and remove it from disabled).

For non-bundled plugins, prompt the operator about granting the
privileged ``allow_tool_override`` capability (replacing built-in tools
like ``shell_exec`` / ``write_file``). ``allow_tool_override`` is a
tri-state: ``True`` grants without prompting, ``False`` declines without
prompting, ``None`` (default) asks interactively. Bundled plugins are
trusted and never prompted.
"""
from rich.console import Console

console = Console()
# Discover the plugin — check installed (user) AND bundled, including
# nested category plugins — and normalize to its canonical registry key.
key = _resolve_plugin_key(name)
if key is None:
resolved = _resolve_plugin_key_and_source(name)
if resolved is None:
console.print(f"[red]Plugin '{name}' is not installed or bundled.[/red]")
sys.exit(1)
key, source = resolved

enabled = _get_enabled_set()
disabled = _get_disabled_set()

if key in enabled and key not in disabled:
already_enabled = key in enabled and key not in disabled

if not already_enabled:
enabled.add(key)
disabled.discard(key)
# Drop any legacy bare-name entry so the two don't drift out of sync.
bare = key.split("/")[-1]
if bare != key:
disabled.discard(bare)
_save_enabled_set(enabled)
_save_disabled_set(disabled)
console.print(
f"[green]✓[/green] Plugin [bold]{key}[/bold] enabled. "
"Takes effect on next session."
)
else:
console.print(f"[dim]Plugin '{key}' is already enabled.[/dim]")

# Built-in tool override is a privileged grant. Bundled plugins ship with
# Hermes core and are trusted; every other source needs operator opt-in.
if source == "bundled":
return

enabled.add(key)
disabled.discard(key)
# Drop any legacy bare-name entry so the two don't drift out of sync.
bare = key.split("/")[-1]
if bare != key:
disabled.discard(bare)
_save_enabled_set(enabled)
_save_disabled_set(disabled)
console.print(
f"[green]✓[/green] Plugin [bold]{key}[/bold] enabled. "
"Takes effect on next session."
)
_resolve_tool_override_grant(console, key, allow_tool_override)


def _resolve_tool_override_grant(
console, key: str, allow_tool_override: Optional[bool]
) -> None:
"""Resolve and persist the ``allow_tool_override`` grant for a plugin.

``allow_tool_override`` tri-state: True grants, False declines, None
prompts interactively (defaulting to deny on a non-interactive stdin).
"""
if allow_tool_override is None:
# Interactive consent. Default to NO so a blind Enter doesn't grant
# a privileged capability, and a non-interactive stdin denies safely.
prompt = (
"[yellow]Allow this plugin to replace built-in tools "
"(e.g. shell_exec, write_file)?[/yellow]\n"
" This is a privileged capability: an override can intercept "
"everything the agent routes through that tool.\n"
" Grant it? [y/N] "
)
try:
answer = console.input(prompt).strip().lower()
except (EOFError, KeyboardInterrupt):
answer = ""
allow_tool_override = answer in {"y", "yes"}

plugin_id = key
_set_plugin_entry_flag(plugin_id, "allow_tool_override", allow_tool_override)
if allow_tool_override:
console.print(
f"[green]✓[/green] Granted [bold]{key}[/bold] permission to "
"override built-in tools "
f"([dim]plugins.entries.{plugin_id}.allow_tool_override: true[/dim])."
)
else:
console.print(
f"[dim]{key} may not override built-in tools. Re-run "
f"`hermes plugins enable {key} --allow-tool-override` to grant "
"this later.[/dim]"
)


def cmd_disable(name: str) -> None:
Expand Down Expand Up @@ -1821,7 +1919,14 @@ def plugins_command(args) -> None:
elif action in {"remove", "rm", "uninstall"}:
cmd_remove(args.name)
elif action == "enable":
cmd_enable(args.name)
# Tri-state: --allow-tool-override=True, --no-allow-tool-override=False,
# neither=None (interactive prompt for non-bundled plugins).
allow_override = None
if getattr(args, "allow_tool_override", False):
allow_override = True
elif getattr(args, "no_allow_tool_override", False):
allow_override = False
cmd_enable(args.name, allow_tool_override=allow_override)
elif action == "disable":
cmd_disable(args.name)
elif action in {"list", "ls"}:
Expand Down
12 changes: 12 additions & 0 deletions hermes_cli/subcommands/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None:
"enable", help="Enable a disabled plugin"
)
plugins_enable.add_argument("name", help="Plugin name to enable")
_enable_override_group = plugins_enable.add_mutually_exclusive_group()
_enable_override_group.add_argument(
"--allow-tool-override",
action="store_true",
help="Grant this plugin permission to replace built-in tools "
"(e.g. shell_exec, write_file). Skips the confirmation prompt.",
)
_enable_override_group.add_argument(
"--no-allow-tool-override",
action="store_true",
help="Enable without granting built-in tool override (skip prompt).",
)

plugins_disable = plugins_subparsers.add_parser(
"disable", help="Disable a plugin without removing it"
Expand Down
Loading
Loading