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

@@ -215,28 +215,73 @@ def set_last_workspace(path: str) -> None:
def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
"""Resolve and validate a workspace path under the WebUI's trusted workspace root.
"""Resolve and validate a workspace path.
The trusted root is the WebUI boot-time DEFAULT_WORKSPACE (respects
HERMES_WEBUI_STATE_DIR for test isolation). Session creation/update and
workspace-list mutations must stay within that root so callers cannot repoint
a session to arbitrary filesystem locations outside the intended sandbox.
A path is trusted if it satisfies at least one of:
(A) It is under the user's home directory (Path.home()).
Works cross-platform: ~/... on Linux/macOS, C:\\Users\\... on Windows.
(B) It is already in the profile's saved workspace list.
This covers self-hosted deployments where workspaces live outside home
(e.g. /data/projects, /opt/workspace) — once a workspace is saved by
an admin, it can be reused without re-validation.
Note: _profile_default_workspace() reads the agent's terminal.cwd which may
differ from the WebUI's configured workspace root — always use DEFAULT_WORKSPACE
here to stay consistent with how new_session() seeds the initial workspace.
Additionally enforced regardless of (A)/(B):
1. The path must exist.
2. The path must be a directory.
3. The path must not be a known system root (/etc, /usr, /var, /bin, /sbin,
/boot, /proc, /sys, /dev, /root on Linux/macOS; Windows system dirs).
This prevents even admin-saved workspaces from pointing at OS internals.
None/empty path falls back to the boot-time DEFAULT_WORKSPACE, which is always
trusted (it was validated at server startup).
"""
root = Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve()
candidate = root if path in (None, "") else Path(path).expanduser().resolve()
_BLOCKED_SYSTEM_ROOTS = {
# Linux / macOS
Path('/etc'), Path('/usr'), Path('/var'), Path('/bin'), Path('/sbin'),
Path('/boot'), Path('/proc'), Path('/sys'), Path('/dev'), Path('/root'),
Path('/lib'), Path('/lib64'), Path('/opt/homebrew'),
}
if path in (None, ""):
return Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve()
candidate = Path(path).expanduser().resolve()
if not candidate.exists():
raise ValueError(f"Path does not exist: {candidate}")
if not candidate.is_dir():
raise ValueError(f"Path is not a directory: {candidate}")
# Block known system roots and their children
for blocked in _BLOCKED_SYSTEM_ROOTS:
try:
candidate.relative_to(blocked)
raise ValueError(f"Path points to a system directory: {candidate}")
except ValueError as e:
if "system directory" in str(e):
raise
# relative_to raised ValueError = candidate is NOT under blocked = safe
# (A) Trusted if under the user's home directory — cross-platform via Path.home()
try:
candidate.relative_to(root)
candidate.relative_to(Path.home().resolve())
return candidate
except ValueError:
raise ValueError(f"Path is outside the trusted workspace root: {candidate}")
return candidate
pass
# (B) Trusted if already in the saved workspace list — covers non-home installs
try:
saved = load_workspaces()
saved_paths = {Path(w["path"]).resolve() for w in saved if w.get("path")}
if candidate in saved_paths:
return candidate
except Exception:
pass
raise ValueError(
f"Path is outside the user home directory and not in the saved workspace "
f"list: {candidate}. Add it via Settings → Workspaces first."
)
def safe_resolve_ws(root: Path, requested: str) -> Path: