[security] fix(workspace): restrict session workspaces to trusted roots (#416)

* fix(workspace): restrict session workspaces to trusted roots

* fix: use boot-time DEFAULT_WORKSPACE instead of profile default for trusted workspace root

_profile_default_workspace() reads the agent's terminal.cwd which may differ
from the WebUI's configured workspace root. Use _BOOT_DEFAULT_WORKSPACE (which
respects HERMES_WEBUI_DEFAULT_WORKSPACE for test isolation) to stay consistent
with how new_session() seeds the initial workspace.

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

---------

Co-authored-by: hinotoi-agent <paperlantern.agent@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
nesquena-hermes
2026-04-13 23:44:03 -07:00
committed by GitHub
parent a5abe51cc5
commit 2a7a5ddfaf
9 changed files with 152 additions and 45 deletions

View File

@@ -214,6 +214,31 @@ def set_last_workspace(path: str) -> None:
logger.debug("Failed to set last workspace")
def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
"""Resolve and validate a workspace path under the WebUI's trusted workspace root.
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.
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.
"""
root = Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve()
candidate = root if path in (None, "") else 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}")
try:
candidate.relative_to(root)
except ValueError:
raise ValueError(f"Path is outside the trusted workspace root: {candidate}")
return candidate
def safe_resolve_ws(root: Path, requested: str) -> Path:
"""Resolve a relative path inside a workspace root, raising ValueError on traversal."""
resolved = (root / requested).resolve()