Files
hermes-webui/tests/test_symlink_cycle_detection.py
T
nesquena-hermes 8b8ff3328a fix: batch triage — 12 contributor PRs (v0.50.227) (#1168)
Merged as v0.50.227. 2634 tests passing, browser QA 21/21 (desktop + mobile). Full attribution below.

Thanks to all 12 contributors:
@jundev0001 (#1138), @franksong2702 (#1142, #1157, #1162), @dso2ng (#1143), @bergeouss (#1145, #1146, #1156, #1159), @jasonjcwu (#1149), @ccqqlo (#1161), @frap129 (#1165)

Two fixes applied during integration and two more by the independent reviewer (@nesquena):
- messages.js: per-turn cost delta capture order (#1159)
- workspace.py: symlink target blocked-roots check + HOME sanity guard (#1149, #1165)
- panels.js: cron unread counter bookkeeping (in-loop increment bug)
- tests/test_symlink_cycle_detection.py: register workspace before session/new
2026-04-27 13:34:59 -07:00

159 lines
6.5 KiB
Python

"""
Tests for symlink cycle detection in workspace file browser.
When a workspace contains symlinks (especially to directories outside the
workspace root), the directory listing must terminate without infinite
recursion. Covers:
- External symlink dirs (e.g. ln -s /some/path ~/workspace/link)
- Self-referencing symlink (ln -s . ~/workspace/loop)
- Ancestor symlink (ln -s .. ~/workspace/up)
- Symlink entries carry correct type / is_dir / target fields
- Browsing into a symlink directory via workspace-relative path works
"""
import json
import os
import pathlib
import urllib.request
import urllib.error
import tempfile
from tests._pytest_port import BASE
def get(path):
url = BASE + path
with urllib.request.urlopen(url, timeout=10) as r:
return json.loads(r.read())
def post(path, body=None):
url = BASE + path
data = json.dumps(body or {}).encode()
req = urllib.request.Request(url, data=data,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
def make_session(created_list, ws=None):
body = {}
if ws:
# tmp_path_factory creates dirs under /var/folders or /tmp which sit
# outside the user home tree, so they aren't trusted by default.
# Register the workspace first via the explicit add API (intent-trusted)
# before requesting a session against it.
post("/api/workspaces/add", {"path": str(ws)})
body["workspace"] = str(ws)
d, _ = post("/api/session/new", body)
sid = d["session"]["session_id"]
created_list.append(sid)
return sid, pathlib.Path(d["session"]["workspace"])
class TestSymlinkCycleDetection:
"""Symlink cycle detection in list_dir / safe_resolve_ws."""
def test_external_symlink_listed_as_symlink(self, cleanup_test_sessions, tmp_path_factory):
"""External symlink dir should appear with type='symlink', is_dir=True."""
ws = tmp_path_factory.mktemp("ws")
target = tmp_path_factory.mktemp("target")
(target / "file.txt").write_text("hello")
link = ws / "ext"
link.symlink_to(target)
sid, _ = make_session(cleanup_test_sessions, ws)
listing = get(f"/api/list?session_id={sid}&path=.")
entries = listing["entries"]
ext = [e for e in entries if e["name"] == "ext"]
assert len(ext) == 1
assert ext[0]["type"] == "symlink"
assert ext[0]["is_dir"] is True
assert ext[0]["target"] == str(target)
def test_external_symlink_browsable(self, cleanup_test_sessions, tmp_path_factory):
"""Listing inside an external symlink dir returns its contents."""
ws = tmp_path_factory.mktemp("ws")
target = tmp_path_factory.mktemp("target")
(target / "inner.txt").write_text("data")
(ws / "ext").symlink_to(target)
sid, _ = make_session(cleanup_test_sessions, ws)
listing = get(f"/api/list?session_id={sid}&path=ext")
entries = listing["entries"]
names = [e["name"] for e in entries]
assert "inner.txt" in names
def test_self_referencing_symlink_filtered(self, cleanup_test_sessions, tmp_path_factory):
"""Symlink pointing to the workspace root itself must be filtered out."""
ws = tmp_path_factory.mktemp("ws")
(ws / "file.txt").write_text("data")
(ws / "loop").symlink_to(ws)
sid, _ = make_session(cleanup_test_sessions, ws)
listing = get(f"/api/list?session_id={sid}&path=.")
names = [e["name"] for e in listing["entries"]]
assert "loop" not in names, "Self-referencing symlink should be filtered"
def test_ancestor_symlink_filtered(self, cleanup_test_sessions, tmp_path_factory):
"""Symlink pointing to a parent of the workspace must be filtered out."""
parent = tmp_path_factory.mktemp("parent")
ws = parent / "workspace"
ws.mkdir()
(ws / "file.txt").write_text("data")
# Symlink pointing to parent dir (ancestor of workspace)
(ws / "up").symlink_to(parent)
sid, _ = make_session(cleanup_test_sessions, ws)
listing = get(f"/api/list?session_id={sid}&path=.")
names = [e["name"] for e in listing["entries"]]
assert "up" not in names, "Ancestor symlink should be filtered"
def test_symlink_cycle_in_subdir(self, cleanup_test_sessions, tmp_path_factory):
"""Symlink cycle inside a symlink target's subtree must not recurse."""
ws = tmp_path_factory.mktemp("ws")
target = tmp_path_factory.mktemp("target")
(target / "subdir").mkdir()
# Create a symlink inside target that points back to workspace
(target / "subdir" / "back").symlink_to(ws)
(ws / "ext").symlink_to(target)
sid, _ = make_session(cleanup_test_sessions, ws)
# List root — should show ext but not recurse
listing = get(f"/api/list?session_id={sid}&path=.")
names = [e["name"] for e in listing["entries"]]
assert "ext" in names
# List inside ext/subdir — 'back' should be filtered
listing2 = get(f"/api/list?session_id={sid}&path=ext/subdir")
names2 = [e["name"] for e in listing2["entries"]]
assert "back" not in names2, "Cycle symlink inside external target should be filtered"
def test_symlink_file_entry(self, cleanup_test_sessions, tmp_path_factory):
"""Symlink to a file should have is_dir=False and include size."""
ws = tmp_path_factory.mktemp("ws")
real = tmp_path_factory.mktemp("real")
(real / "data.txt").write_text("hello world")
(ws / "link.txt").symlink_to(real / "data.txt")
sid, _ = make_session(cleanup_test_sessions, ws)
listing = get(f"/api/list?session_id={sid}&path=.")
link = [e for e in listing["entries"] if e["name"] == "link.txt"]
assert len(link) == 1
assert link[0]["type"] == "symlink"
assert link[0]["is_dir"] is False
assert link[0]["size"] == 11 # len("hello world")
def test_path_traversal_still_blocked(self, cleanup_test_sessions, tmp_path_factory):
"""Raw .. traversal must still be blocked even with symlink support."""
ws = tmp_path_factory.mktemp("ws")
sid, _ = make_session(cleanup_test_sessions, ws)
try:
get(f"/api/list?session_id={sid}&path=../../../etc")
assert False, "Path traversal should be blocked"
except urllib.error.HTTPError as e:
assert e.code in (400, 404, 500)