* 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:
3 files changed
+134
-3
No files matched your search
@@ -3,6 +3,12 @@
|
||||
|
||||
## [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)
|
||||
|
||||
### Changed
|
||||
|
||||
+25
-3
@@ -12623,15 +12623,37 @@ def _project_context_git_root(start: Path) -> Path | None:
|
||||
|
||||
|
||||
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()
|
||||
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 name in _PROJECT_CONTEXT_HERMES_NAMES:
|
||||
candidates.append(directory / name)
|
||||
if stop_at and directory == stop_at:
|
||||
if directory == stop_at:
|
||||
break
|
||||
|
||||
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"]
|
||||
)
|
||||
Reference in new issue
Block a user