From 244f70aae58f3ec114822f881b7f857115d1a3ee Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:12:58 -0700 Subject: [PATCH] fix(agent): scope install-tree guard to fallback-picked cwds, allow cli/tui in-tree dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the salvaged #64611 commit: the original guard blocked the install tree unconditionally, which would have broken the legitimate 'developing Hermes from a source clone' CLI flow (launching hermes inside the repo and getting its AGENTS.md as project context). Refined policy: - resolve_context_cwd(): validates configured paths (missing dir -> None + warning) but honors an EXPLICIT install-tree cwd verbatim — deliberate user choice. - build_context_files_prompt(): blocks only the cwd=None -> os.getcwd() FALLBACK into the install tree, with a new allow_install_tree_fallback param. system_prompt.py passes it for platform cli/tui (launch dir is the user's real shell cwd there); desktop/gateway surfaces keep the guard (their fallback dir is self-spawned, never user-picked). - Warning log names the resolved dir and the terminal.cwd remedy. E2E-verified all five scenarios: desktop fallback blocked, in-tree CLI dev keeps AGENTS.md, explicit install-tree cwd honored, invalid TERMINAL_CWD falls to None then blocked, normal workspace loads. --- agent/prompt_builder.py | 29 ++++++++++++++++------- agent/runtime_cwd.py | 11 ++++----- agent/system_prompt.py | 9 ++++++- scripts/release.py | 1 + tests/agent/test_prompt_builder.py | 38 +++++++++++++++++++++++++----- tests/agent/test_runtime_cwd.py | 10 ++++---- tests/agent/test_system_prompt.py | 5 +++- 7 files changed, 77 insertions(+), 26 deletions(-) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 22681b2d2658..fc7a7d2f6eb7 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -1957,6 +1957,7 @@ def build_context_files_prompt( cwd: Optional[str] = None, skip_soul: bool = False, context_length: Optional[int] = None, + allow_install_tree_fallback: bool = False, ) -> str: """Discover and load context files for the system prompt. @@ -1978,20 +1979,32 @@ def build_context_files_prompt( """ if cwd is None: cwd = os.getcwd() + cwd_is_fallback = True + else: + cwd_is_fallback = False cwd_path = Path(cwd).resolve() sections = [] - # 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. + # Never let a FALLBACK-picked directory inside the Hermes install/source + # tree gain system-prompt authority. A backend that self-spawns into that + # tree (the desktop app default) would otherwise load this repo's + # contributor AGENTS.md as authoritative project context (#64590). An + # explicitly configured cwd is honored verbatim — the Hermes tree is a + # legitimate workspace when the user deliberately points a session at it — + # and CLI-style surfaces pass allow_install_tree_fallback=True because + # their launch dir IS the user's shell cwd (developing Hermes in-tree). 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", + if ( + cwd_is_fallback + and not allow_install_tree_fallback + and _is_install_tree(cwd_path) + ): + logger.warning( + "skipping project-context discovery: working-directory resolution " + "fell back to the Hermes install tree (%s) — set terminal.cwd to " + "your project directory", cwd_path, ) project_context = "" diff --git a/agent/runtime_cwd.py b/agent/runtime_cwd.py index 6485e53a5dae..712e38ed137e 100644 --- a/agent/runtime_cwd.py +++ b/agent/runtime_cwd.py @@ -77,15 +77,16 @@ 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 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. + # through unchecked, diverging from resolve_agent_cwd). An explicitly + # configured path is otherwise honored verbatim — including the Hermes + # source tree itself, which is a legitimate workspace when the user is + # developing Hermes (per-surface policy for fallback-picked directories + # lives in build_context_files_prompt; see #64590). override = _session_cwd_override() if override: 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 @@ -94,8 +95,6 @@ def resolve_context_cwd() -> Path | None: 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/agent/system_prompt.py b/agent/system_prompt.py index 17959af3c510..41337e4e9fbf 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -463,9 +463,16 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) # CLI), None lets build_context_files_prompt fall back to the launch # dir — the user's real cwd there, but the install dir for the gateway # daemon, which is why the gateway sets TERMINAL_CWD. + # + # allow_install_tree_fallback: for cli/tui the launch dir IS the + # user's shell cwd, so an in-tree fallback is a deliberate choice + # (developing Hermes). Every other surface (desktop chat panel, + # gateway daemons) self-spawns into the install tree, where the + # fallback would inject this repo's contributor AGENTS.md (#64590). context_files_prompt = _r.build_context_files_prompt( cwd=resolve_context_cwd(), skip_soul=_soul_loaded, - context_length=_ctx_len) + context_length=_ctx_len, + allow_install_tree_fallback=agent.platform in ("cli", "tui")) if context_files_prompt: context_parts.append(context_files_prompt) diff --git a/scripts/release.py b/scripts/release.py index 96b4b2fe6064..97e63fed82d3 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -46,6 +46,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { "doogie@spark.local": "SAMBAS123", # PR #64986 salvage (gateway: multiplex primary bot token scope) + "evefromwayback@gmail.com": "evefromwayback", # PR #64611 salvage (agent: never load install-tree AGENTS.md as project context) "41409874+2751738943@users.noreply.github.com": "2751738943", # PR #54785 salvage (tui: post-turn completion ownership routing) "Burgunthy@users.noreply.github.com": "Burgunthy", # PR #20096 salvage (gateway: profile-based routing for inbound messages) "75556242+webtecnica@users.noreply.github.com": "webtecnica", # PR #63360 salvage (nous: restore inference-api base_url) diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 860a50ab4b27..13b664dc978a 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -708,17 +708,43 @@ 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. + def test_skips_agents_md_in_install_tree_on_fallback(self, monkeypatch, tmp_path): + # A backend that FALLS BACK into the install tree (cwd=None → getcwd, + # the desktop default) 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 and getcwd into it. + 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.") + monkeypatch.chdir(tmp_path) + result = build_context_files_prompt(cwd=None, skip_soul=True) + assert "Never give up" not in result + assert result == "" + + def test_loads_agents_md_in_install_tree_when_explicit(self, monkeypatch, tmp_path): + # An EXPLICIT cwd pointing at the install tree is a deliberate user + # choice (developing Hermes) — discovery must still run. 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 == "" + assert "Never give up" in result + + def test_loads_agents_md_in_install_tree_fallback_for_cli(self, monkeypatch, tmp_path): + # CLI/TUI surfaces launch from the user's shell cwd, so an in-tree + # fallback there is deliberate — allow_install_tree_fallback=True + # (system_prompt.py passes it for platform cli/tui) keeps discovery on. + 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.") + monkeypatch.chdir(tmp_path) + result = build_context_files_prompt( + cwd=None, skip_soul=True, allow_install_tree_fallback=True + ) + assert "Never give up" in result def test_loads_cursorrules(self, tmp_path): (tmp_path / ".cursorrules").write_text("Always use type hints.") diff --git a/tests/agent/test_runtime_cwd.py b/tests/agent/test_runtime_cwd.py index 94325e265661..0f0b5677e9a9 100644 --- a/tests/agent/test_runtime_cwd.py +++ b/tests/agent/test_runtime_cwd.py @@ -74,11 +74,13 @@ class TestResolveContextCwd: monkeypatch.setenv("TERMINAL_CWD", str(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. + def test_returns_install_tree_when_explicitly_configured(self, monkeypatch): + # An EXPLICITLY configured install-tree cwd is honored verbatim — the + # Hermes source tree is a legitimate workspace when the user is + # developing Hermes. Only the fallback path (cwd=None → os.getcwd()) + # is policed, in build_context_files_prompt (#64590). monkeypatch.setenv("TERMINAL_CWD", str(rt._PACKAGE_ROOT)) - assert resolve_context_cwd() is None + assert resolve_context_cwd() == rt._PACKAGE_ROOT def test_expands_leading_tilde(self, monkeypatch): monkeypatch.setenv("TERMINAL_CWD", "~") diff --git a/tests/agent/test_system_prompt.py b/tests/agent/test_system_prompt.py index 0536de1dccd0..78ea4cda8152 100644 --- a/tests/agent/test_system_prompt.py +++ b/tests/agent/test_system_prompt.py @@ -31,7 +31,10 @@ def _captured_context_cwd(agent): """The cwd build_system_prompt_parts hands to build_context_files_prompt.""" captured = {} - def fake_context_files(cwd=None, skip_soul=False, context_length=None): + def fake_context_files( + cwd=None, skip_soul=False, context_length=None, + allow_install_tree_fallback=False, + ): captured["cwd"] = cwd return ""