From 33513991be0812668dc36cd9fdc7575a53d12559 Mon Sep 17 00:00:00 2001 From: Evelyn Bruce Date: Tue, 14 Jul 2026 13:15:36 -0700 Subject: [PATCH] fix(agent): never load the install-tree AGENTS.md as project context --- agent/prompt_builder.py | 28 ++++++++++++----- agent/runtime_cwd.py | 49 +++++++++++++++++++++++++++--- tests/agent/test_prompt_builder.py | 12 ++++++++ tests/agent/test_runtime_cwd.py | 15 ++++++--- 4 files changed, 88 insertions(+), 16 deletions(-) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 957b3f5fa1ce..22681b2d2658 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -1982,13 +1982,27 @@ def build_context_files_prompt( cwd_path = Path(cwd).resolve() sections = [] - # Priority-based project context: first match wins - project_context = ( - _load_hermes_md(cwd_path, context_length) - or _load_agents_md(cwd_path, context_length) - or _load_claude_md(cwd_path, context_length) - or _load_cursorrules(cwd_path, context_length) - ) + # Never discover project context inside the Hermes install/source tree. A + # backend launched from, or self-spawning into, that tree (the desktop app + # default) would otherwise load this repo's contributor AGENTS.md as + # authoritative project context. resolve_context_cwd() already guards the + # configured-path cases; this covers the cwd=None -> os.getcwd() fallback. + from agent.runtime_cwd import _is_install_tree + + if _is_install_tree(cwd_path): + logger.info( + "skipping project-context discovery in the Hermes install tree: %s", + cwd_path, + ) + project_context = "" + else: + # Priority-based project context: first match wins + project_context = ( + _load_hermes_md(cwd_path, context_length) + or _load_agents_md(cwd_path, context_length) + or _load_claude_md(cwd_path, context_length) + or _load_cursorrules(cwd_path, context_length) + ) if project_context: sections.append(project_context) diff --git a/agent/runtime_cwd.py b/agent/runtime_cwd.py index d57a9da7e244..6485e53a5dae 100644 --- a/agent/runtime_cwd.py +++ b/agent/runtime_cwd.py @@ -10,15 +10,36 @@ Multi-session gateways can pin a logical cwd via the `_SESSION_CWD` contextvar; CLI/cron fall through to `TERMINAL_CWD`/launch cwd. """ +import logging import os from contextvars import ContextVar, Token from pathlib import Path from typing import Any +logger = logging.getLogger(__name__) + _UNSET: Any = object() _SESSION_CWD: ContextVar = ContextVar("HERMES_SESSION_CWD", default=_UNSET) +# The Python package/source root (this file lives at /agent/runtime_cwd.py). +# When a backend is launched from, or self-spawns into, this tree (the desktop +# app default), an os.getcwd() fallback would inject this repo's contributor +# AGENTS.md as authoritative project context. Context discovery must never +# resolve here. +_PACKAGE_ROOT = Path(__file__).resolve().parent.parent + + +def _is_install_tree(p: Path) -> bool: + # True only when p IS the package root or sits inside it. Ancestors of the + # package root (a user home that happens to contain the checkout, a --user + # site-packages parent) are legitimate workspaces and must not be blocked. + try: + p = p.resolve() + except Exception: + return False + return p == _PACKAGE_ROOT or _PACKAGE_ROOT in p.parents + def set_session_cwd(cwd: str | None) -> Token: """Pin the logical cwd for the current context.""" @@ -42,21 +63,39 @@ def resolve_agent_cwd() -> Path: p = Path(override).expanduser() if p.is_dir(): return p + logger.warning("configured working directory does not exist: %s", override) raw = os.environ.get("TERMINAL_CWD", "").strip() if raw: p = Path(raw).expanduser() if p.is_dir(): return p + logger.warning("TERMINAL_CWD does not exist: %s", raw) return Path(os.getcwd()) def resolve_context_cwd() -> Path | None: # None means "no configured cwd": build_context_files_prompt then falls back - # to the launch dir (os.getcwd()) — correct for the local CLI. The gateway - # avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py) - # or, per session, the _SESSION_CWD contextvar above. + # to the launch dir (os.getcwd()), correct for a local CLI launched inside a + # real project. A configured path is validated here (previously it was passed + # through unchecked, diverging from resolve_agent_cwd), and the Hermes install + # tree is never returned, since its AGENTS.md would take over the system prompt. override = _session_cwd_override() if override: - return Path(override).expanduser() + p = Path(override).expanduser() + if not p.is_dir(): + logger.warning("configured working directory does not exist: %s", override) + elif _is_install_tree(p): + logger.warning("not loading context files from the Hermes install tree: %s", p) + else: + return p + return None raw = os.environ.get("TERMINAL_CWD", "").strip() - return Path(raw).expanduser() if raw else None + if raw: + p = Path(raw).expanduser() + if not p.is_dir(): + logger.warning("TERMINAL_CWD does not exist: %s", raw) + elif _is_install_tree(p): + logger.warning("not loading context files from the Hermes install tree: %s", p) + else: + return p + return None diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 481f68eaed3d..860a50ab4b27 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -708,6 +708,18 @@ class TestBuildContextFilesPrompt: assert "Ruff for linting" in result assert "Project Context" in result + def test_skips_agents_md_in_install_tree(self, monkeypatch, tmp_path): + # A backend launched from, or self-spawning into, the install tree must not + # load that tree's contributor AGENTS.md as project context. The guard keys + # off the package root, so point it at a fake tree holding an AGENTS.md. + import agent.runtime_cwd as rt + + monkeypatch.setattr(rt, "_PACKAGE_ROOT", tmp_path.resolve()) + (tmp_path / "AGENTS.md").write_text("Never give up on the right solution.") + result = build_context_files_prompt(cwd=str(tmp_path), skip_soul=True) + assert "Never give up" not in result + assert result == "" + def test_loads_cursorrules(self, tmp_path): (tmp_path / ".cursorrules").write_text("Always use type hints.") result = build_context_files_prompt(cwd=str(tmp_path)) diff --git a/tests/agent/test_runtime_cwd.py b/tests/agent/test_runtime_cwd.py index 827cfc09c220..94325e265661 100644 --- a/tests/agent/test_runtime_cwd.py +++ b/tests/agent/test_runtime_cwd.py @@ -66,12 +66,19 @@ class TestResolveContextCwd: monkeypatch.delenv("TERMINAL_CWD", raising=False) assert resolve_context_cwd() is None - def test_returns_nonexistent_dir_unguarded(self, monkeypatch, tmp_path): - # Deliberate asymmetry vs resolve_agent_cwd: context discovery has no isdir - # guard, so a missing dir is returned (not None) — discovery just finds nothing. + def test_returns_none_for_nonexistent_dir(self, monkeypatch, tmp_path): + # A configured but missing dir must not be returned. It previously was, + # which diverged from resolve_agent_cwd and let an invalid cwd steer + # context discovery. Now it is validated and drops to None. missing = tmp_path / "gone" monkeypatch.setenv("TERMINAL_CWD", str(missing)) - assert resolve_context_cwd() == missing + assert resolve_context_cwd() is None + + def test_returns_none_for_install_tree(self, monkeypatch): + # Context discovery must never resolve to the Hermes install/source tree, + # whose contributor AGENTS.md would take over the system prompt. + monkeypatch.setenv("TERMINAL_CWD", str(rt._PACKAGE_ROOT)) + assert resolve_context_cwd() is None def test_expands_leading_tilde(self, monkeypatch): monkeypatch.setenv("TERMINAL_CWD", "~")