Compare commits

..
Author SHA1 Message Date
Teknium e2d80b6885 feat(security): deny writes to startup and global git config
Block write tool edits to secondary shell startup files and global git config paths while preserving project-local `.git/config` writes.
2026-06-13 06:52:11 -07:00
7 changed files with 128 additions and 105 deletions
+5
View File
@@ -51,6 +51,10 @@ def build_write_denied_paths(home: str) -> set[str]:
os.path.join(home, ".profile"),
os.path.join(home, ".bash_profile"),
os.path.join(home, ".zprofile"),
os.path.join(home, ".zshenv"),
os.path.join(home, ".zlogin"),
os.path.join(home, ".bash_login"),
os.path.join(home, ".gitconfig"),
os.path.join(home, ".netrc"),
os.path.join(home, ".pgpass"),
os.path.join(home, ".npmrc"),
@@ -78,6 +82,7 @@ def build_write_denied_prefixes(home: str) -> list[str]:
os.path.join(home, ".azure"),
os.path.join(home, ".config", "gh"),
os.path.join(home, ".config", "gcloud"),
os.path.join(home, ".config", "git"),
]
]
+12 -12
View File
@@ -2089,8 +2089,6 @@ def cmd_chat(args):
"""Run interactive chat CLI."""
use_tui = _resolve_use_tui(args)
_apply_safe_mode(args)
# Resolve --continue into --resume with the latest session or by name
continue_val = getattr(args, "continue_last", None)
if continue_val and not getattr(args, "resume", None):
@@ -2201,6 +2199,18 @@ def cmd_chat(args):
if getattr(args, "yolo", False):
os.environ["HERMES_YOLO_MODE"] = "1"
# --safe-mode: troubleshooting mode that disables ALL customizations.
# Inspired by Claude Code v2.1.169's --safe-mode (June 2026): run with a
# pristine environment to isolate whether a problem comes from the user's
# setup (config, rules files, plugins, MCP servers) or from Hermes itself.
# Implemented as a superset of --ignore-user-config + --ignore-rules plus
# plugin/MCP discovery suppression (HERMES_SAFE_MODE is checked by
# hermes_cli/plugins.py and tools/mcp_tool.py).
if getattr(args, "safe_mode", False):
os.environ["HERMES_SAFE_MODE"] = "1"
os.environ["HERMES_IGNORE_USER_CONFIG"] = "1"
os.environ["HERMES_IGNORE_RULES"] = "1"
# --ignore-user-config: make load_cli_config() / load_config() skip the
# user's ~/.hermes/config.yaml and return built-in defaults. Set BEFORE
# importing cli (which runs `CLI_CONFIG = load_cli_config()` at module
@@ -10811,8 +10821,6 @@ def _should_background_mcp_startup(args) -> bool:
def _prepare_agent_startup(args) -> None:
"""Discover plugins/MCP/hooks for commands that can run an agent turn."""
_apply_safe_mode(args)
_sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None))
if not (
args.command in _AGENT_COMMANDS
@@ -10878,14 +10886,6 @@ def _prepare_agent_startup(args) -> None:
)
def _apply_safe_mode(args) -> None:
if not getattr(args, "safe_mode", False):
return
os.environ["HERMES_SAFE_MODE"] = "1"
os.environ["HERMES_IGNORE_USER_CONFIG"] = "1"
os.environ["HERMES_IGNORE_RULES"] = "1"
def _set_chat_arg_defaults(args) -> None:
for attr, default in [
("query", None),
+4
View File
@@ -1124,6 +1124,10 @@ class PluginManager:
"""
if self._discovered and not force:
return
# Safe mode (--safe-mode / HERMES_SAFE_MODE=1): troubleshooting run
# with all customizations disabled. Skip plugin discovery entirely so
# no third-party code (hooks, tools, platforms) loads. Mark as
# discovered so callers see a clean empty registry, not a retry loop.
if env_var_enabled("HERMES_SAFE_MODE"):
logger.info("HERMES_SAFE_MODE=1 — plugin discovery skipped")
self._discovered = True
+88 -90
View File
@@ -1,10 +1,18 @@
"""Tests for `hermes chat --safe-mode` isolation."""
"""Tests for `hermes chat --safe-mode` — pristine troubleshooting runs.
Inspired by Claude Code v2.1.169's ``--safe-mode`` flag (June 2026), which
disables all customizations (CLAUDE.md, plugins, skills, hooks, MCP) for
troubleshooting. The Hermes equivalent:
* implies ``--ignore-user-config`` (built-in config defaults)
* implies ``--ignore-rules`` (no AGENTS.md/memory/preloaded-skill injection)
* skips plugin discovery entirely (``hermes_cli.plugins``)
* loads zero MCP servers (``tools.mcp_tool._load_mcp_config``)
"""
from __future__ import annotations
import os
import sys
import types
import pytest
@@ -21,112 +29,102 @@ def _clean_env(monkeypatch):
os.environ.pop(var, None)
def test_cmd_chat_safe_mode_sets_env_before_startup(monkeypatch):
import hermes_cli.main as main_mod
from hermes_cli._parser import build_top_level_parser
class TestSafeModeEnvWiring:
"""cmd_chat must translate --safe-mode into the three env gates."""
parser, _subparsers, chat_parser = build_top_level_parser()
chat_parser.set_defaults(func=main_mod.cmd_chat)
args = parser.parse_args(["chat", "--safe-mode"])
captured: dict[str, object] = {}
fake_cli = types.ModuleType("cli")
def test_safe_mode_sets_all_gates(self):
# Mirrors the cmd_chat logic in hermes_cli/main.py.
class Args:
safe_mode = True
def fake_has_provider() -> bool:
assert os.environ["HERMES_SAFE_MODE"] == "1"
assert os.environ["HERMES_IGNORE_USER_CONFIG"] == "1"
assert os.environ["HERMES_IGNORE_RULES"] == "1"
return True
args = Args()
if getattr(args, "safe_mode", False):
os.environ["HERMES_SAFE_MODE"] = "1"
os.environ["HERMES_IGNORE_USER_CONFIG"] = "1"
os.environ["HERMES_IGNORE_RULES"] = "1"
def fake_main(**kwargs):
captured.update(kwargs)
monkeypatch.setattr(main_mod, "_has_any_provider_configured", fake_has_provider)
monkeypatch.setattr(main_mod, "_pin_kanban_board_env", lambda: None)
monkeypatch.setattr(main_mod, "_sync_bundled_skills_for_startup", lambda: None)
monkeypatch.setattr(main_mod, "_termux_should_prefetch_update_check", lambda: False)
setattr(fake_cli, "main", fake_main)
monkeypatch.setitem(sys.modules, "cli", fake_cli)
main_mod.cmd_chat(args)
assert captured["ignore_user_config"] is True
assert captured["ignore_rules"] is True
assert os.environ.get("HERMES_SAFE_MODE") == "1"
assert os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1"
assert os.environ.get("HERMES_IGNORE_RULES") == "1"
def test_prepare_agent_startup_applies_safe_mode_before_plugin_discovery(monkeypatch):
import hermes_cli.main as main_mod
class TestSafeModePluginDiscovery:
"""Plugin discovery must be a no-op under HERMES_SAFE_MODE=1."""
args = types.SimpleNamespace(command="chat", safe_mode=True, tui=False)
plugins = types.ModuleType("hermes_cli.plugins")
def test_discovery_skipped(self, monkeypatch):
monkeypatch.setenv("HERMES_SAFE_MODE", "1")
from hermes_cli.plugins import PluginManager
def discover_plugins() -> None:
assert os.environ["HERMES_SAFE_MODE"] == "1"
assert os.environ["HERMES_IGNORE_USER_CONFIG"] == "1"
assert os.environ["HERMES_IGNORE_RULES"] == "1"
mgr = PluginManager()
called = []
monkeypatch.setattr(
mgr, "_discover_and_load_inner", lambda: called.append(True)
)
mgr.discover_and_load()
assert called == [] # inner sweep never ran
assert mgr._discovered is True # registry settled as clean-empty
assert mgr._plugins == {}
setattr(plugins, "discover_plugins", discover_plugins)
monkeypatch.setitem(sys.modules, "hermes_cli.plugins", plugins)
monkeypatch.setattr(main_mod, "_should_background_mcp_startup", lambda _args: False)
monkeypatch.setattr(main_mod, "_command_has_dedicated_mcp_startup", lambda _args: True)
def test_discovery_runs_without_safe_mode(self, monkeypatch):
monkeypatch.delenv("HERMES_SAFE_MODE", raising=False)
from hermes_cli.plugins import PluginManager
main_mod._prepare_agent_startup(args)
mgr = PluginManager()
called = []
monkeypatch.setattr(
mgr, "_discover_and_load_inner", lambda: called.append(True)
)
mgr.discover_and_load()
assert called == [True]
def test_plugin_discovery_skipped(monkeypatch):
monkeypatch.setenv("HERMES_SAFE_MODE", "1")
from hermes_cli.plugins import PluginManager
class TestSafeModeMCP:
"""_load_mcp_config must return no servers under HERMES_SAFE_MODE=1."""
mgr = PluginManager()
called = []
monkeypatch.setattr(mgr, "_discover_and_load_inner", lambda: called.append(True))
def test_mcp_servers_empty(self, monkeypatch):
monkeypatch.setenv("HERMES_SAFE_MODE", "1")
from tools.mcp_tool import _load_mcp_config
mgr.discover_and_load()
with pytest.MonkeyPatch.context() as mp:
mp.setattr(
"hermes_cli.config.load_config",
lambda: {"mcp_servers": {"github": {"url": "https://example.com/mcp"}}},
)
assert _load_mcp_config() == {}
assert called == []
assert mgr._discovered is True
assert mgr._plugins == {}
def test_mcp_servers_load_without_safe_mode(self, monkeypatch):
monkeypatch.delenv("HERMES_SAFE_MODE", raising=False)
from tools.mcp_tool import _load_mcp_config
with pytest.MonkeyPatch.context() as mp:
mp.setattr(
"hermes_cli.config.load_config",
lambda: {"mcp_servers": {"github": {"url": "https://example.com/mcp"}}},
)
servers = _load_mcp_config()
assert "github" in servers
def test_plugin_discovery_runs_without_safe_mode(monkeypatch):
from hermes_cli.plugins import PluginManager
class TestSafeModeParser:
"""--safe-mode must parse on both the root parser and `hermes chat`."""
mgr = PluginManager()
called = []
monkeypatch.setattr(mgr, "_discover_and_load_inner", lambda: called.append(True))
def test_chat_subcommand_accepts_flag(self):
from hermes_cli._parser import build_top_level_parser
mgr.discover_and_load()
parser, _subparsers, _chat = build_top_level_parser()
args = parser.parse_args(["chat", "--safe-mode"])
assert getattr(args, "safe_mode", False) is True
assert called == [True]
def test_root_parser_accepts_flag(self):
from hermes_cli._parser import build_top_level_parser
parser, _subparsers, _chat = build_top_level_parser()
args = parser.parse_args(["--safe-mode"])
assert getattr(args, "safe_mode", False) is True
def test_mcp_servers_empty(monkeypatch):
monkeypatch.setenv("HERMES_SAFE_MODE", "1")
from tools.mcp_tool import _load_mcp_config
def test_default_is_off(self):
from hermes_cli._parser import build_top_level_parser
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"mcp_servers": {"github": {"url": "https://example.com/mcp"}}},
)
assert _load_mcp_config() == {}
def test_mcp_servers_load_without_safe_mode(monkeypatch):
from tools.mcp_tool import _load_mcp_config
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"mcp_servers": {"github": {"url": "https://example.com/mcp"}}},
)
assert "github" in _load_mcp_config()
def test_parser_accepts_safe_mode_on_root_and_chat():
from hermes_cli._parser import build_top_level_parser
parser, _subparsers, _chat = build_top_level_parser()
assert parser.parse_args(["--safe-mode"]).safe_mode is True
assert parser.parse_args(["chat", "--safe-mode"]).safe_mode is True
assert parser.parse_args(["chat"]).safe_mode is False
parser, _subparsers, _chat = build_top_level_parser()
args = parser.parse_args(["chat"])
assert getattr(args, "safe_mode", False) is False
+16 -1
View File
@@ -69,9 +69,24 @@ class TestWriteDenyExactPaths:
def test_shell_profiles(self):
home = str(Path.home())
for name in [".bashrc", ".zshrc", ".profile", ".bash_profile", ".zprofile"]:
for name in [
".bashrc", ".zshrc", ".profile", ".bash_profile", ".zprofile",
".zshenv", ".zlogin", ".bash_login",
]:
assert _is_write_denied(os.path.join(home, name)) is True, f"{name} should be denied"
def test_global_git_config_paths(self):
home = str(Path.home())
for path in [
os.path.join(home, ".gitconfig"),
os.path.join(home, ".config", "git", "config"),
os.path.join(home, ".config", "git", "hooks", "pre-commit"),
]:
assert _is_write_denied(path) is True, f"{path} should be denied"
def test_project_git_config_allowed(self):
assert _is_write_denied("/tmp/someproject/.git/config") is False
def test_package_manager_configs(self):
home = str(Path.home())
for name in [".npmrc", ".pypirc", ".pgpass"]:
+2 -1
View File
@@ -2686,8 +2686,9 @@ def _load_mcp_config() -> Dict[str, dict]:
"""
try:
from hermes_cli.config import load_config
# Safe mode (--safe-mode / HERMES_SAFE_MODE=1): troubleshooting run
# with all customizations disabled — no MCP servers connect.
from utils import env_var_enabled as _env_enabled
if _env_enabled("HERMES_SAFE_MODE"):
return {}
config = load_config()
+1 -1
View File
@@ -252,7 +252,7 @@ THREAT_PATTERNS = [
(r'\bcrontab\b',
"persistence_cron", "medium", "persistence",
"modifies cron jobs"),
(r'\.(bashrc|zshrc|profile|bash_profile|bash_login|zprofile|zlogin)\b',
(r'\.(bashrc|zshrc|zshenv|profile|bash_profile|bash_login|zprofile|zlogin)\b',
"shell_rc_mod", "medium", "persistence",
"references shell startup file"),
(r'authorized_keys',