From 018d982bd98dceae8e9e2f65daaa5b711be2bfd2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 29 Sep 2026 13:38:52 -0500 Subject: [PATCH] fix(agent): a home working dir is a fallback, not a project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- agent/agent_init.py | 6 ++- agent/search_policy.py | 4 ++ agent/subdirectory_hints.py | 51 +++++++++++++++++- tests/agent/test_subdirectory_hints.py | 74 ++++++++++++++++++++++++++ tui_gateway/session_workdir.py | 7 +++ 5 files changed, 140 insertions(+), 2 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index 1711fdd12a..5e00a582dc 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -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) diff --git a/agent/search_policy.py b/agent/search_policy.py index 2972d5cae8..c30f4adff9 100644 --- a/agent/search_policy.py +++ b/agent/search_policy.py @@ -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", diff --git a/agent/subdirectory_hints.py b/agent/subdirectory_hints.py index da267dbae8..c5b495a6e9 100644 --- a/agent/subdirectory_hints.py +++ b/agent/subdirectory_hints.py @@ -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: diff --git a/tests/agent/test_subdirectory_hints.py b/tests/agent/test_subdirectory_hints.py index fdabc48ac2..5f083e2c3f 100644 --- a/tests/agent/test_subdirectory_hints.py +++ b/tests/agent/test_subdirectory_hints.py @@ -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 diff --git a/tui_gateway/session_workdir.py b/tui_gateway/session_workdir.py index 94aade17a0..a3fd1c20e4 100644 --- a/tui_gateway/session_workdir.py +++ b/tui_gateway/session_workdir.py @@ -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)