Skip to content
Open
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
4 changes: 3 additions & 1 deletion cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,9 @@ def load_cli_config() -> Dict[str, Any]:

# --ignore-user-config: force-skip the user config.yaml (still honor project
# config as a fallback so defaults stay sensible).
ignore_user_config = os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1"
from hermes_cli.config import should_ignore_user_config

ignore_user_config = should_ignore_user_config()

# Use user config if it exists, otherwise project config
if user_config_path.exists() and not ignore_user_config:
Expand Down
20 changes: 16 additions & 4 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@
_CONFIG_PARSE_WARNED: set = set()


def should_ignore_user_config() -> bool:
"""Return True when user-level config.yaml should be skipped."""
return os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1"


def _backup_corrupt_config(config_path: Path) -> Optional[Path]:
"""Preserve a corrupted ``config.yaml`` by copying it to a timestamped ``.bak``.

Expand Down Expand Up @@ -7402,6 +7407,9 @@ def read_raw_config() -> Dict[str, Any]:
mutate the result before passing to ``save_config()``.
"""
with _CONFIG_LOCK:
if should_ignore_user_config():
return {}

try:
config_path = get_config_path()
st = config_path.stat()
Expand Down Expand Up @@ -7639,12 +7647,16 @@ def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:
ensure_hermes_home()
config_path = get_config_path()
path_key = str(config_path)
ignore_user_config = should_ignore_user_config()

try:
st = config_path.stat()
user_sig: Optional[Tuple[int, int]] = (st.st_mtime_ns, st.st_size)
except FileNotFoundError:
if ignore_user_config:
user_sig = None
else:
try:
st = config_path.stat()
user_sig: Optional[Tuple[int, int]] = (st.st_mtime_ns, st.st_size)
except FileNotFoundError:
user_sig = None

# Managed scope: fold the managed config file's (mtime, size) into the
# cache signature so editing /etc/hermes/config.yaml invalidates the
Expand Down
3 changes: 3 additions & 0 deletions hermes_cli/oneshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ def _run_agent(
toolsets_list = sorted(_get_platform_tools(cfg, "cli"))

session_db = _create_session_db_for_oneshot()
ignore_rules = os.environ.get("HERMES_IGNORE_RULES") == "1"

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 consumes the environment flag, but top-level -z --ignore-rules never sets it: one-shot bypasses cmd_chat(), where explicit ignore flags are currently exported. Please wire those arguments before one-shot startup and add a top-level dispatch regression.

# The try spans agent construction (not just ``chat``) so the SQLite store
# opened above is always closed — including when ``AIAgent(...)`` itself
# raises on a provider/config error. The one-shot exit path hard-exits via
Expand All @@ -420,6 +421,8 @@ def _run_agent(
session_db=session_db,
credential_pool=runtime.get("credential_pool"),
fallback_model=_fb or None,
skip_context_files=ignore_rules,
skip_memory=ignore_rules,
# Interactive callbacks are intentionally NOT wired beyond this
# one. In oneshot mode there's no user sitting at a terminal:
# - clarify → returns a synthetic "pick a default" instruction
Expand Down
194 changes: 194 additions & 0 deletions tests/hermes_cli/test_oneshot_isolation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
"""Regression tests for ``hermes -z`` isolation env gates."""

from __future__ import annotations

import sys
import types

from hermes_cli import config as config_mod
from hermes_cli import oneshot


SENTINEL_MODEL = "sentinel-user-config-model-73191"
SENTINEL_SECRET = "sentinel-user-config-secret-73191"


class CapturingAgent:
calls: list[dict] = []

def __init__(self, **kwargs):
self.kwargs = kwargs
self.__class__.calls.append(kwargs)

def run_conversation(self, prompt):
return {
"final_response": f"model={self.kwargs.get('model')}",
"completed": True,
"failed": False,
"model": self.kwargs.get("model"),
"provider": self.kwargs.get("provider"),
}

def shutdown_memory_provider(self, *args, **kwargs):
return None

def close(self):
return None


def _write_user_config(home):
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"\n".join([
"model:",
f" default: {SENTINEL_MODEL}",
" provider: openrouter",
"toolsets:",
" cli:",
" - shell",
"agent:",
f" system_prompt: {SENTINEL_SECRET}",
"fallback:",
" enabled: true",
" chain:",
" - provider: openrouter",
f" model: {SENTINEL_MODEL}-fallback",
"",
]),
encoding="utf-8",
)


def _install_fakes(monkeypatch):
CapturingAgent.calls.clear()
fake_run_agent = types.ModuleType("run_agent")
fake_run_agent.AIAgent = CapturingAgent
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)

monkeypatch.setattr(oneshot, "_create_session_db_for_oneshot", lambda: None)

import hermes_cli.runtime_provider as runtime_provider
import hermes_cli.tools_config as tools_config

monkeypatch.setattr(
runtime_provider,
"resolve_runtime_provider",
lambda **kwargs: {
"api_key": "fake-key",
"base_url": "https://example.invalid/v1",
"provider": kwargs.get("requested") or "openrouter",
"requested_provider": kwargs.get("requested") or "openrouter",
"api_mode": "chat_completions",
"credential_pool": None,
},
)
monkeypatch.setattr(
tools_config, "_get_platform_tools", lambda cfg, platform: {"files"}
)


def _clear_config_caches():
config_mod._LOAD_CONFIG_CACHE.clear()
config_mod._RAW_CONFIG_CACHE.clear()
config_mod._LAST_EXPANDED_CONFIG_BY_PATH.clear()


def test_oneshot_ignore_rules_env_passes_skip_flags(monkeypatch, tmp_path):
_install_fakes(monkeypatch)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("HERMES_IGNORE_RULES", "1")
_clear_config_caches()

oneshot._run_agent(
"hello",
model="explicit/model",
provider="openrouter",
toolsets=["files"],
use_config_toolsets=False,
)

kwargs = CapturingAgent.calls[-1]
assert kwargs["skip_context_files"] is True
assert kwargs["skip_memory"] is True


def test_oneshot_without_ignore_rules_passes_false_skip_flags(monkeypatch, tmp_path):
_install_fakes(monkeypatch)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("HERMES_IGNORE_RULES", raising=False)
_clear_config_caches()

oneshot._run_agent(
"hello",
model="explicit/model",
provider="openrouter",
toolsets=["files"],
use_config_toolsets=False,
)

kwargs = CapturingAgent.calls[-1]
assert kwargs["skip_context_files"] is False
assert kwargs["skip_memory"] is False


def test_oneshot_ignore_user_config_skips_cached_user_config(monkeypatch, tmp_path):
_install_fakes(monkeypatch)
_write_user_config(tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("HERMES_IGNORE_USER_CONFIG", raising=False)
_clear_config_caches()

assert config_mod.load_config()["model"]["default"] == SENTINEL_MODEL

monkeypatch.setenv("HERMES_IGNORE_USER_CONFIG", "1")
response, _result = oneshot._run_agent("hello")

kwargs = CapturingAgent.calls[-1]
assert kwargs["model"] != SENTINEL_MODEL
assert SENTINEL_MODEL not in response
assert all(SENTINEL_MODEL not in repr(call) for call in CapturingAgent.calls)


def test_oneshot_explicit_runtime_options_survive_isolation(monkeypatch, tmp_path):
_install_fakes(monkeypatch)
_write_user_config(tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("HERMES_IGNORE_USER_CONFIG", "1")
monkeypatch.setenv("HERMES_IGNORE_RULES", "1")
_clear_config_caches()

oneshot._run_agent(
"hello",
model="explicit/model",
provider="openrouter",
toolsets="files,shell",
use_config_toolsets=False,
)

kwargs = CapturingAgent.calls[-1]
assert kwargs["model"] == "explicit/model"
assert kwargs["provider"] == "openrouter"
assert kwargs["enabled_toolsets"] == ["files", "shell"]
assert kwargs["skip_context_files"] is True
assert kwargs["skip_memory"] is True


def test_oneshot_isolation_does_not_leak_user_config_sentinel(
monkeypatch,
tmp_path,
capsys,
):
_install_fakes(monkeypatch)
_write_user_config(tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("HERMES_IGNORE_USER_CONFIG", "1")
_clear_config_caches()

rc = oneshot.run_oneshot("hello")

captured = capsys.readouterr()
assert rc == 0
assert SENTINEL_MODEL not in captured.out
assert SENTINEL_MODEL not in captured.err
assert SENTINEL_SECRET not in captured.out
assert SENTINEL_SECRET not in captured.err