fix: HERMES_WEBUI_DEFAULT_WORKSPACE wins over settings.json; trust DEFAULT_WORKSPACE subtree (#610)

Squash-merges PR #610. Fixes Docker workspace env var override and trust validation (issue #609). 1367 tests passing, QA harness green. Reviewed by independent agent (see PR comments).
This commit is contained in:
nesquena-hermes
2026-04-16 18:09:16 -07:00
committed by GitHub
parent b608f8837e
commit 2484409b7a
6 changed files with 182 additions and 6 deletions

107
tests/test_issue609.py Normal file
View File

@@ -0,0 +1,107 @@
"""
Tests for GitHub issue #609 — Docker workspace path trust and env-var priority.
Two independent bugs were fixed:
1. HERMES_WEBUI_DEFAULT_WORKSPACE env var was silently overridden by
settings.json at server startup. The env var must always win.
2. resolve_trusted_workspace() rejected paths that are children of
DEFAULT_WORKSPACE (e.g. /data/workspace/project) when the default is a
Docker volume mount outside the user's home directory. Any path under
the boot-time default should be trusted automatically.
"""
from pathlib import Path
import pytest
from api.workspace import resolve_trusted_workspace
# ── Fix 2: trust paths under DEFAULT_WORKSPACE ───────────────────────────────
def test_subdir_of_boot_default_is_trusted(monkeypatch, tmp_path):
"""A subdirectory of BOOT_DEFAULT_WORKSPACE must be trusted without being in
the saved workspace list and without being under the user's home directory.
This is the core Docker case: DEFAULT_WORKSPACE=/data/workspace, and the
user tries to open /data/workspace/myproject — should NOT raise ValueError.
"""
import api.workspace as ws_mod
boot_default = tmp_path / "data" / "workspace"
boot_default.mkdir(parents=True)
sub = boot_default / "myproject"
sub.mkdir()
monkeypatch.setattr(ws_mod, "_BOOT_DEFAULT_WORKSPACE", str(boot_default))
# Should not raise — sub is under the boot default
result = resolve_trusted_workspace(str(sub))
assert result == sub.resolve()
def test_boot_default_itself_is_trusted(monkeypatch, tmp_path):
"""The DEFAULT_WORKSPACE path itself must also be trusted (not only subdirs)."""
import api.workspace as ws_mod
boot_default = tmp_path / "data" / "workspace"
boot_default.mkdir(parents=True)
monkeypatch.setattr(ws_mod, "_BOOT_DEFAULT_WORKSPACE", str(boot_default))
result = resolve_trusted_workspace(str(boot_default))
assert result == boot_default.resolve()
def test_path_outside_boot_default_and_home_is_rejected(monkeypatch, tmp_path):
"""A path that is not under home, not in the saved list, and not under
DEFAULT_WORKSPACE must still be rejected."""
import api.workspace as ws_mod
boot_default = tmp_path / "data" / "workspace"
boot_default.mkdir(parents=True)
outside = tmp_path / "other_mount" / "secret"
outside.mkdir(parents=True)
monkeypatch.setattr(ws_mod, "_BOOT_DEFAULT_WORKSPACE", str(boot_default))
with pytest.raises(ValueError, match="outside the user home"):
resolve_trusted_workspace(str(outside))
def test_none_path_returns_boot_default(monkeypatch, tmp_path):
"""resolve_trusted_workspace(None) always returns the boot default unchanged."""
import api.workspace as ws_mod
boot_default = tmp_path / "data" / "workspace"
boot_default.mkdir(parents=True)
monkeypatch.setattr(ws_mod, "_BOOT_DEFAULT_WORKSPACE", str(boot_default))
result = resolve_trusted_workspace(None)
assert result == boot_default.resolve()
def test_path_traversal_via_dotdot_does_not_escape_boot_default(monkeypatch, tmp_path):
"""A path that uses `..` to escape DEFAULT_WORKSPACE must not be trusted by (C).
`Path.resolve()` collapses `..` before the `relative_to(boot_default)` check
runs, so `/data/workspace/../etc` resolves to `/etc` and is rejected (it's
also caught earlier by the system-roots block, but this test pins the
behavior in case the order of conditions ever changes).
"""
import api.workspace as ws_mod
boot_default = tmp_path / "data" / "workspace"
boot_default.mkdir(parents=True)
sibling = tmp_path / "data" / "private"
sibling.mkdir(parents=True)
monkeypatch.setattr(ws_mod, "_BOOT_DEFAULT_WORKSPACE", str(boot_default))
# `boot_default/../private` resolves to `tmp_path/data/private`, which is
# NOT a child of boot_default and not under home — must reject.
escape = boot_default / ".." / "private"
with pytest.raises(ValueError, match="outside the user home"):
resolve_trusted_workspace(str(escape))