fix(agent): scope install-tree guard to fallback-picked cwds, allow cli/tui in-tree dev

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.
This commit is contained in:
Teknium 2026-07-16 01:12:58 -07:00
parent 33513991be
commit 244f70aae5
7 changed files with 77 additions and 26 deletions

View file

@ -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 = ""

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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.")

View file

@ -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", "~")

View file

@ -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 ""