fix(agent): a home working dir is a fallback, not a project

The packaged Desktop with no default project dir resolves its spawn cwd (and
TERMINAL_CWD) to $HOME, and SubdirectoryHintTracker's containment check is
then vacuously true for the whole home subtree: any AGENTS.md, CLAUDE.md or
.cursorrules under ~ is injected into tool results on first touch. The
tracker now treats a home (or container-home) working dir as a fallback, not
a project — hint discovery stays off until the session adopts a real
workspace, and _register_session_cwd re-anchors it there (same hook that
already updates agent.session_cwd). AIAgent init also prefers the session's
own cwd over the launch TERMINAL_CWD, so an agent built for a workspace
session scopes hints to it from the start. .archive joins the excluded dir
names (retired skills trees hold stale AGENTS.md copies).

Rebases the home guard from #77145 (idea + guard direction) onto the current
class shape; covers the install-tree shape #75922 targeted via the shared
home guard.

Fixes #76902
Ref: https://github.com/NousResearch/hermes-agent/issues/75839

Co-authored-by: Sahil-SS9 <218421507+Sahil-SS9@users.noreply.github.com>
This commit is contained in:
Brooklyn NicholsonandSahil-SS9 authored and brooklyn! committed 2026-09-29 14:53:00 -05:00
1 parent d9a97ae517
commit 018d982bd9
5 files changed
+140 -2

No files matched your search

+5 -1
View File
@@ -2303,8 +2303,12 @@ def _snapshot_primary_runtime(agent):
def _init_usage_state(agent):
from agent.runtime_cwd import scope_terminal_cwd
# Prefer the session's explicitly adopted workspace (a Desktop session created under the
# spawn-time home pin records none; a picked/adopted one does — agent.session_cwd is set
# at build time and on every workspace move). TERMINAL_CWD is the launch fallback.
working_dir = getattr(agent, "session_cwd", None) or scope_terminal_cwd() or None
agent._subdirectory_hints = SubdirectoryHintTracker(
working_dir=scope_terminal_cwd() or None, enabled=not agent.skip_context_files)
working_dir=working_dir, enabled=not agent.skip_context_files)
_set_defaults(agent, _USAGE_STATE)
+4
View File
@@ -21,6 +21,10 @@ SEARCH_PRUNE_DIR_NAMES = frozenset({
# Generated/build output.
"build", "dist", "target", "out", "coverage",
".next", ".turbo", ".parcel-cache", ".nuxt", ".svelte-kit",
# Archived trees: stale copies of projects that used to be live workspaces
# (~/.hermes/skills/.archive/ holds dozens of retired skills, each of which
# can carry an AGENTS.md — #76902).
".archive",
# Python and package-manager caches.
"__pycache__", ".cache", ".Trash", ".tox", ".nox", ".mypy_cache",
".pytest_cache", ".ruff_cache", ".npm", ".yarn", ".pnpm-store",
+50 -1
View File
@@ -110,6 +110,25 @@ def _nav_targets(cmd: str) -> list:
return targets
# A container cwd is not a host path: a docker/local session whose working dir is the backend
# container's default /home (or /root) home must not start hint discovery either — the same
# vacuous-containment hazard as the desktop home pin, just container-shaped (#76902).
_CONTAINER_HOMES = frozenset({Path("/home"), Path("/root"), Path("/workspace")})
def _is_home_like_working_dir(working_dir: Path) -> bool:
"""True when ``working_dir`` is a home the desktop/container fell back to — not a project."""
home = Path.home()
if working_dir == home or working_dir in _CONTAINER_HOMES or working_dir.parent in _CONTAINER_HOMES:
return True
# Also the local-sandbox home mount: a docker backend binds the host home under a
# container path whose basename stays the user name (e.g. /mnt/host-home/brooklyn).
for part in working_dir.parts:
if part in (".home", "host-home", "host_home"):
return True
return False
class SubdirectoryHintTracker:
"""Track which directories the agent visits and load hints on first access.
@@ -123,6 +142,13 @@ class SubdirectoryHintTracker:
# tool results later — cron jobs relaying exact stdout leaked them to chat (#9441).
self.enabled = enabled
self.working_dir = Path(working_dir or os.getcwd()).resolve()
# $HOME is not a project (#76902): the packaged Desktop app with no default project
# dir configured resolves its cwd (and TERMINAL_CWD) to home, and the containment
# check is then vacuously true for the whole home subtree — every AGENTS.md /
# CLAUDE.md / .cursorrules under ~ would be injected on tool calls. Hint discovery
# runs only inside a real project workspace; a home cwd only resumes when the
# session adopts a real project (see ``rebind_working_dir``).
self._home_is_working_dir = _is_home_like_working_dir(self.working_dir)
# The working dir is pre-marked loaded (startup context handles it).
self._loaded_dirs: Set[Path] = {self.working_dir}
# Content digests already injected: the same file reached through
@@ -133,10 +159,33 @@ class SubdirectoryHintTracker:
if found and found[1]:
self._loaded_digests.add(_digest(found[1]))
def rebind_working_dir(self, working_dir: str) -> None:
"""Re-anchor hint discovery to *working_dir* (a session workspace adoption)."""
try:
new_dir = Path(working_dir).expanduser().resolve()
if not new_dir.is_dir() or _is_home_like_working_dir(new_dir):
return
except (OSError, ValueError, RuntimeError):
return
if new_dir == self.working_dir:
self._home_is_working_dir = False
return
self.working_dir = new_dir
self._home_is_working_dir = False
# Fresh anchor, fresh bookkeeping: the new project's directories get their
# own first-visit hints (startup never loaded context files for it).
self._loaded_dirs = {self.working_dir}
self._loaded_digests = set()
found = _first_hint_file(self.working_dir)
if found and found[1]:
self._loaded_digests.add(_digest(found[1]))
def check_tool_call(self, tool_name: str, tool_args: Dict[str, Any]) -> Optional[str]:
"""Return formatted hint text for newly visited directories, or None."""
if not self.enabled:
return None
if self._home_is_working_dir:
return None
all_hints = [h for d in self._extract_directories(tool_name, tool_args) if (h := self._load_hints_for_directory(d))]
return "\n\n" + "\n\n".join(all_hints) if all_hints else None
@@ -225,7 +274,7 @@ class SubdirectoryHintTracker:
def _load_hints_for_directory(self, directory: Path) -> Optional[str]:
"""Load the first hint file in *directory*; formatted text or None."""
self._loaded_dirs.add(directory)
if not self._within_working_dir(directory):
if self._home_is_working_dir or not self._within_working_dir(directory):
logger.debug("Skipping hint files in %s — outside working_dir %s", directory, self.working_dir)
return None
for filename in _HINT_FILENAMES:
+74
View File
@@ -411,3 +411,77 @@ class TestSymlinkedHintTargets:
assert result is not None and "Shared in-tree instructions" in result
result = tracker.check_tool_call("read_file", {"path": str(plain / "f.py")})
assert result is not None and "Legit subdirectory rules" in result
class TestHomeWorkingDirGuard:
"""$HOME is not a project (#76902): the packaged Desktop with no default project dir
pins cwd (and TERMINAL_CWD) to home, making containment vacuously true for the whole
home subtree — every AGENTS.md/CLAUDE.md/.cursorrules under ~ would inject on tool calls."""
@pytest.fixture
def home_tree(self, tmp_path, monkeypatch):
"""A fake $HOME holding unrelated hint files across its subtree."""
monkeypatch.setenv("HOME", str(tmp_path)) # Path.home() reads os.environ on macOS fallback
for rel in ("notes/AGENTS.md", ".hermes/AGENTS.md", ".hermes/skills/s1/AGENTS.md",
"projects/whatever/CLAUDE.md"):
p = tmp_path / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(f"HOME-SUBTREE-HINT {rel}", encoding="utf-8")
return tmp_path
def _home_tracker(self, home) -> SubdirectoryHintTracker:
return SubdirectoryHintTracker(working_dir=str(home))
def test_home_cwd_never_injects_subtree_hints(self, home_tree):
tracker = self._home_tracker(home_tree)
assert tracker.check_tool_call(
"read_file", {"path": str(home_tree / ".hermes" / "skills" / "s1" / "x.py")}
) is None
assert tracker.check_tool_call(
"terminal", {"command": f"cat {home_tree / 'notes' / 'AGENTS.md'}"}
) is None
def test_home_guard_blocks_direct_load_path(self, home_tree):
"""check_tool_call suppression alone is not enough: _load_hints_for_directory
(the other reachable entry) must refuse home-rooted loads too."""
tracker = self._home_tracker(home_tree)
assert tracker._load_hints_for_directory(home_tree / "notes") is None
def test_session_workspace_adoption_rebinds_discovery(self, home_tree):
"""A home-pinned tracker whose session adopted a real project resumes hint
discovery, scoped to that project."""
project = home_tree / "projects" / "app"
project.mkdir(parents=True)
(project / "src").mkdir()
(project / "src" / "AGENTS.md").write_text("App src rules", encoding="utf-8")
tracker = self._home_tracker(home_tree)
assert tracker.check_tool_call("read_file", {"path": str(project / "src" / "f.py")}) is None
tracker.rebind_working_dir(str(project))
assert tracker.working_dir == project.resolve()
result = tracker.check_tool_call("read_file", {"path": str(project / "src" / "f.py")})
assert result is not None and "App src rules" in result
def test_rebound_tracker_stays_inside_the_project(self, home_tree):
"""After rebind, hints outside the adopted project stay suppressed (a stray
read of ~/.hermes does not resurrect the home-subtree scan)."""
project = home_tree / "projects" / "app2"
project.mkdir(parents=True)
(project / "AGENTS.md").write_text("App2 rules", encoding="utf-8")
tracker = self._home_tracker(home_tree)
tracker.rebind_working_dir(str(project))
assert tracker.check_tool_call(
"read_file", {"path": str(home_tree / ".hermes" / "skills" / "s1" / "x.py")}
) is None
def test_real_project_cwd_keeps_discovery(self, home_tree):
"""A non-home working dir keeps today's containment behavior."""
project = home_tree / "projects" / "app3"
sub = project / "pkg"
sub.mkdir(parents=True)
(sub / "AGENTS.md").write_text("Package rules", encoding="utf-8")
tracker = SubdirectoryHintTracker(working_dir=str(project))
result = tracker.check_tool_call("read_file", {"path": str(sub / "f.py")})
assert result is not None and "Package rules" in result
+7
View File
@@ -340,6 +340,13 @@ def _register_session_cwd(session: dict | None) -> None:
# Do not reinitialize memory providers or invalidate the cached system prompt.
if hasattr(agent := session.get("agent"), "session_cwd"):
agent.session_cwd = session.get("cwd") or None
# A session that adopted a real workspace out of a home-fallback cwd (#76902: the
# packaged Desktop pins $HOME when no default project dir is configured) resumes
# subdirectory-hint discovery anchored to that project. No prompt/system-prompt
# state changes — the tracker only scopes future tool-result hints.
hints = getattr(agent, "_subdirectory_hints", None) if session.get("cwd") else None
if hints is not None and hasattr(hints, "rebind_working_dir"):
hints.rebind_working_dir(str(session.get("cwd")))
with contextlib.suppress(Exception):
from tools.terminal_tool import register_task_env_overrides
cwd, cwd_source = _terminal_task_cwd_with_source(session)