fix(context): bound non-git project-context file walk at the workspace (#4164) (#4231)

* fix(#4164): bound project-context file walk in non-git workspaces

_project_context_candidates mirrored the agent's _find_hermes_md exactly,
including the bug that 'stop_at = git_root' is None in a non-git tree —
so the walk 'for directory in [cwd, *cwd.parents]: ... if stop_at and
directory == stop_at: break' never broke and ran to filesystem root.
A workspace at /tmp/x/project/subdir could surface /tmp/x/HERMES.md
(a file outside the workspace) in the Memory > Project Context tab.

Fix: when no git root is found, treat the workspace itself as the stop
boundary. The cwd is still scanned (preserving in-workspace AGENTS.md /
HERMES.md behavior), but parents are skipped — we never walk into the
user's home directory or /tmp.

Git workspaces are unchanged (still walk up to the git root). The
existing test_3866 walk-to-git-root test still passes.

The issue notes the agent-side walk in agent/prompt_builder._find_hermes_md
should be bounded the same way for full parity; that lives in a separate
repo (hermes-agent), so this PR fixes only the WebUI side. Until the
agent ships the same bound, the WebUI tab will under-report context
files that live above a non-git workspace — strictly less surprising
than the previous over-report.

tests/test_issue4164_bound_non_git_project_context_walk.py (4 tests) pins:
- non-git workspace does not leak a parent HERMES.md
- non-git workspace still reads in-workspace context files
- git workspace walk-to-git-root is unchanged
- workspace-level HERMES.md wins, parent copy doesn't appear even shadowed

All 10 existing test_3866_project_context_memory_section tests still pass.

Closes #4164.

* chore(release): stamp v0.51.428 (Release OO) for #4164

---------

Co-authored-by: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
This commit is contained in:
authored and GitHub committed 2026-06-14 23:22:29 -07:00
1 parent ed5afd2ae3
commit 44ead2a73d
3 files changed
+134 -3

No files matched your search

+6
View File
@@ -3,6 +3,12 @@
## [Unreleased] ## [Unreleased]
## [v0.51.428] — 2026-06-15 — Release OO (bound non-git project-context file walk, #4164)
### Fixed
- **The Project Context tab no longer surfaces `AGENTS.md` / `HERMES.md` / `CLAUDE.md` files from ABOVE a non-git workspace.** When a workspace isn't a git repo, the context-file walk had no stop boundary and climbed to the filesystem root, so a file in a parent directory (e.g. `/tmp/HERMES.md` or one in your home dir) could appear in the tab even though it's outside the workspace. The walk is now bounded at the workspace root when there's no git root (the cwd is still scanned, preserving in-workspace context files). A matching bound on the agent-side walk is a tracked follow-up; until then the WebUI may under-report context files that live above a non-git workspace, which is strictly safer than over-reporting them. (#4164)
## [v0.51.427] — 2026-06-15 — Release ON (CLI sessions on by default for new installs #3988 + symlink delete/rename guard #4217) ## [v0.51.427] — 2026-06-15 — Release ON (CLI sessions on by default for new installs #3988 + symlink delete/rename guard #4217)
### Changed ### Changed
+25 -3
View File
@@ -12623,15 +12623,37 @@ def _project_context_git_root(start: Path) -> Path | None:
def _project_context_candidates(workspace: Path) -> list[Path]: def _project_context_candidates(workspace: Path) -> list[Path]:
"""Mirror the agent's first-match project context file priority.""" """Mirror the agent's first-match project context file priority.
#4164: in a non-git workspace the agent's ``_find_hermes_md`` walks all
the way up to filesystem root because its ``stop_at = git_root`` is
``None`` — so a workspace at ``/tmp/x/project/subdir`` could surface
``/tmp/x/HERMES.md`` (a file *outside* the user's workspace) in the
Project Context tab.
The WebUI tab is a read-only mirror of what the agent injects, so the
safest bound that does not over-promise is: when there is no git root,
treat the workspace itself as the stop boundary. The cwd is still
scanned (preserving the in-workspace AGENTS.md / HERMES.md behavior),
but we no longer walk into the user's home directory or ``/tmp``.
The agent-side walk in ``agent/prompt_builder._find_hermes_md`` should
be bounded the same way for full parity; until that ships the WebUI
will under-report context files that live *above* a non-git workspace,
which is strictly less surprising than over-reporting them.
"""
cwd = workspace.resolve() cwd = workspace.resolve()
candidates: list[Path] = [] candidates: list[Path] = []
stop_at = _project_context_git_root(cwd) git_root = _project_context_git_root(cwd)
# When inside a git tree, walk up to the git root as before. When not,
# bound the walk at the workspace itself so we never surface files
# above the user's workspace.
stop_at = git_root if git_root is not None else cwd
for directory in [cwd, *cwd.parents]: for directory in [cwd, *cwd.parents]:
for name in _PROJECT_CONTEXT_HERMES_NAMES: for name in _PROJECT_CONTEXT_HERMES_NAMES:
candidates.append(directory / name) candidates.append(directory / name)
if stop_at and directory == stop_at: if directory == stop_at:
break break
for name in _PROJECT_CONTEXT_CWD_NAMES: for name in _PROJECT_CONTEXT_CWD_NAMES:
@@ -0,0 +1,103 @@
"""Regression for #4164: bound the project-context file walk in non-git workspaces.
Before this fix, ``_project_context_candidates`` mirrored the agent's
``_find_hermes_md`` exactly — including the bug that ``stop_at = git_root``
is ``None`` in a non-git tree, which caused the ``for directory in [cwd,
*cwd.parents]: ... if stop_at and directory == stop_at: break`` walk to
never break and run all the way to filesystem root. A workspace at
``/tmp/x/project/subdir`` could then surface ``/tmp/x/HERMES.md`` (a file
*outside* the user's workspace) in the Project Context tab.
The fix bounds the walk at the workspace itself when no git root is found:
the cwd is still scanned, but parents are skipped. This is the minimal,
non-breaking bound — git workspaces are unchanged, and in-workspace files
still load.
"""
from __future__ import annotations
import pathlib
import api.routes as routes
def _ctx(workspace):
return routes._read_active_project_context(pathlib.Path(workspace))
def test_non_git_workspace_does_not_walk_above_workspace(tmp_path):
"""Sentinel HERMES.md placed *above* the workspace in a non-git tree must
not be surfaced — the pre-fix walk would have loaded it because stop_at
was None and the loop ran to filesystem root."""
above = tmp_path / "outside"
above.mkdir()
(above / "HERMES.md").write_text("LEAKED FROM OUTSIDE", encoding="utf-8")
workspace = above / "project" / "subdir"
workspace.mkdir(parents=True)
# Intentionally no .git anywhere in the tree, and no HERMES/AGENTS file
# inside the workspace — so any non-empty result means we walked above.
data = _ctx(workspace)
assert "LEAKED FROM OUTSIDE" not in data["content"], (
"Non-git workspace walk leaked an HERMES.md above the workspace root"
)
assert data["path"] == "", (
f"Expected empty path (no in-workspace context file); got {data['path']!r}"
)
def test_non_git_workspace_still_reads_in_workspace_context(tmp_path):
"""The bound must only block *parents* — files inside the workspace itself
must still load exactly as before."""
workspace = tmp_path / "no-git-here"
workspace.mkdir()
(workspace / "AGENTS.md").write_text("# In-workspace rules", encoding="utf-8")
data = _ctx(workspace)
assert "In-workspace rules" in data["content"]
assert data["path"].endswith("AGENTS.md")
assert data["name"] == "AGENTS.md"
def test_git_workspace_walk_to_git_root_is_unchanged(tmp_path):
"""Git workspaces must keep walking up to the git root — the bound only
activates when git_root is None. This duplicates the pre-existing
``test_project_context_walks_hermes_md_to_git_root_but_not_agents_md``
intent specifically against the #4164 patch site."""
root = tmp_path / "repo"
nested = root / "src" / "deep" / "pkg"
nested.mkdir(parents=True)
(root / ".git").mkdir()
(root / "HERMES.md").write_text("# Root-of-repo context", encoding="utf-8")
data = _ctx(nested)
assert "Root-of-repo context" in data["content"]
assert data["path"].endswith("HERMES.md")
def test_non_git_workspace_bound_is_workspace_not_first_parent(tmp_path):
"""Edge case: if the workspace itself contains a HERMES.md AND a parent
also contains one, the workspace copy must win and the parent copy must
NOT leak in via the candidate list (i.e. the bound triggers immediately,
not after one extra parent step)."""
parent = tmp_path / "container"
parent.mkdir()
(parent / "HERMES.md").write_text("PARENT COPY", encoding="utf-8")
workspace = parent / "ws"
workspace.mkdir()
(workspace / "HERMES.md").write_text("WORKSPACE COPY", encoding="utf-8")
data = _ctx(workspace)
assert "WORKSPACE COPY" in data["content"]
assert "PARENT COPY" not in data["content"]
# And the parent copy must NOT appear as a "shadowed" hit either —
# it's not just lower priority, it's out of scope entirely.
assert all("PARENT COPY" not in (s.get("path") or "") for s in data["shadowed"])
assert not any(
str(parent / "HERMES.md") == s.get("path") for s in data["shadowed"]
)