fix: cross-platform multi-workspace trust boundary (#417)

* fix: relax workspace trust boundary to user home directory

The previous restriction required workspaces to be under DEFAULT_WORKSPACE
(/home/hermes/workspace), which blocked all profile-specific workspaces
(~/CodePath, ~/General, ~/WebUI, ~/Camanji, etc.) since each profile uses
a different directory under home.

New boundary: any directory under Path.home() is trusted.
This still blocks /etc, /tmp, /var, /root, /usr and all paths outside the
user's home, while allowing any legitimate workspace under ~/

Also updates test assertions from 'trusted workspace root' to 'outside'
since the new error message says 'outside the user home directory'.

* fix: workspace trust uses home-dir + saved-list, not single ancestor

Three-layer trust model that works cross-platform and multi-workspace:

1. BLOCKLIST: /etc, /usr, /var, /bin, /sbin, /boot, /proc, /sys, /dev, /root,
   /lib, /lib64, /opt/homebrew — always rejected, even if somehow saved
2. HOME CHECK: any path under Path.home() is trusted — covers ~/CodePath,
   ~/hermes-webui-public, ~/WebUI, ~/General, ~/Camanji simultaneously;
   Path.home() is cross-platform (Linux ~/..., macOS ~/..., Windows C:\Users\...\...)
3. SAVED LIST ESCAPE HATCH: if a path is already in the saved workspace list,
   it's trusted regardless of location — covers self-hosted deployments where
   workspaces live outside home (/data/projects, /opt/workspace, etc.)

None/empty → DEFAULT_WORKSPACE (always trusted, validated at startup)

* docs: v0.50.35 release — version badge and CHANGELOG

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
nesquena-hermes
2026-04-13 23:57:51 -07:00
committed by GitHub
parent 2a7a5ddfaf
commit 415270ff03
4 changed files with 77 additions and 18 deletions

View File

@@ -153,7 +153,7 @@ def test_session_update_rejects_workspace_outside_trusted_root(tmp_path):
outside.mkdir(parents=True, exist_ok=True)
result, status = post("/api/session/update", {"session_id": sid, "workspace": str(outside)})
assert status == 400
assert "trusted workspace root" in result.get("error", "").lower()
assert "outside" in result.get("error", "").lower()
def test_chat_start_rejects_workspace_outside_trusted_root(tmp_path):
@@ -163,7 +163,7 @@ def test_chat_start_rejects_workspace_outside_trusted_root(tmp_path):
outside.mkdir(parents=True, exist_ok=True)
result, status = post("/api/chat/start", {"session_id": sid, "message": "hello", "workspace": str(outside)})
assert status == 400
assert "trusted workspace root" in result.get("error", "").lower()
assert "outside" in result.get("error", "").lower()
def test_workspace_add_rejects_path_outside_trusted_root(tmp_path):
@@ -171,7 +171,7 @@ def test_workspace_add_rejects_path_outside_trusted_root(tmp_path):
outside.mkdir(parents=True, exist_ok=True)
result, status = post("/api/workspaces/add", {"path": str(outside), "name": "Outside"})
assert status == 400
assert "trusted workspace root" in result.get("error", "").lower()
assert "outside" in result.get("error", "").lower()
def test_session_new_rejects_workspace_outside_trusted_root(tmp_path):
@@ -179,7 +179,7 @@ def test_session_new_rejects_workspace_outside_trusted_root(tmp_path):
outside.mkdir(parents=True, exist_ok=True)
result, status = post("/api/session/new", {"workspace": str(outside)})
assert status == 400
assert "trusted workspace root" in result.get("error", "").lower()
assert "outside" in result.get("error", "").lower()
def test_session_search_returns_matches(cleanup_test_sessions):