[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:
@@ -145,10 +145,13 @@ def test_session_update():
|
||||
"""Create session, update workspace and model, verify persisted."""
|
||||
data, _ = post("/api/session/new", {})
|
||||
sid = data["session"]["session_id"]
|
||||
current_ws = pathlib.Path(data["session"]["workspace"])
|
||||
child_ws = current_ws / f"session-update-{uuid.uuid4().hex[:6]}"
|
||||
child_ws.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
updated, status = post("/api/session/update", {
|
||||
"session_id": sid,
|
||||
"workspace": "/tmp",
|
||||
"workspace": str(child_ws),
|
||||
"model": "anthropic/claude-sonnet-4.6"
|
||||
})
|
||||
assert status == 200
|
||||
|
||||
@@ -107,14 +107,16 @@ def test_workspace_add_rejects_nonexistent():
|
||||
assert status == 400
|
||||
|
||||
def test_workspace_add_accepts_real_dir():
|
||||
"""Adding a real directory succeeds."""
|
||||
import tempfile
|
||||
tmp = tempfile.mkdtemp()
|
||||
"""Adding a real directory under the trusted workspace root succeeds."""
|
||||
d, _ = post("/api/session/new", {})
|
||||
root = pathlib.Path(d["session"]["workspace"])
|
||||
tmp = root / "trusted-add-test"
|
||||
tmp.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
d, status = post("/api/workspaces/add", {"path": tmp, "name": "test-ws"})
|
||||
d, status = post("/api/workspaces/add", {"path": str(tmp), "name": "test-ws"})
|
||||
assert status == 200
|
||||
assert d["ok"] is True
|
||||
finally:
|
||||
post("/api/workspaces/remove", {"path": tmp})
|
||||
post("/api/workspaces/remove", {"path": str(tmp)})
|
||||
import shutil
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
@@ -145,6 +145,43 @@ def test_session_update_unknown_id_returns_404():
|
||||
result, status = post("/api/session/update", {"session_id": "nosuchsession", "model": "openai/gpt-5.4-mini"})
|
||||
assert status == 404
|
||||
|
||||
|
||||
def test_session_update_rejects_workspace_outside_trusted_root(tmp_path):
|
||||
d, _ = post("/api/session/new", {})
|
||||
sid = d["session"]["session_id"]
|
||||
outside = tmp_path / "outside"
|
||||
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()
|
||||
|
||||
|
||||
def test_chat_start_rejects_workspace_outside_trusted_root(tmp_path):
|
||||
d, _ = post("/api/session/new", {})
|
||||
sid = d["session"]["session_id"]
|
||||
outside = tmp_path / "outside-chat"
|
||||
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()
|
||||
|
||||
|
||||
def test_workspace_add_rejects_path_outside_trusted_root(tmp_path):
|
||||
outside = tmp_path / "outside-add"
|
||||
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()
|
||||
|
||||
|
||||
def test_session_new_rejects_workspace_outside_trusted_root(tmp_path):
|
||||
outside = tmp_path / "outside-new"
|
||||
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()
|
||||
|
||||
|
||||
def test_session_search_returns_matches(cleanup_test_sessions):
|
||||
sid, _ = make_session_tracked(cleanup_test_sessions)
|
||||
post("/api/session/rename", {"session_id": sid, "title": f"unique-s3-{sid}"})
|
||||
|
||||
@@ -149,8 +149,10 @@ def test_file_requires_path(cleanup_test_sessions):
|
||||
assert e.code == 400
|
||||
|
||||
def test_new_session_inherits_workspace(cleanup_test_sessions):
|
||||
sid, _ = make_session_tracked(cleanup_test_sessions)
|
||||
post("/api/session/update", {"session_id": sid, "workspace": "/tmp", "model": "openai/gpt-5.4-mini"})
|
||||
sid, ws = make_session_tracked(cleanup_test_sessions)
|
||||
child = ws / f"workspace-inherit-{uuid.uuid4().hex[:6]}"
|
||||
child.mkdir(parents=True, exist_ok=True)
|
||||
post("/api/session/update", {"session_id": sid, "workspace": str(child), "model": "openai/gpt-5.4-mini"})
|
||||
sid2, _ = make_session_tracked(cleanup_test_sessions)
|
||||
data, _ = get(f"/api/session?session_id={sid2}")
|
||||
assert data["session"]["workspace"] == "/tmp"
|
||||
assert data["session"]["workspace"] == str(child)
|
||||
|
||||
@@ -31,6 +31,12 @@ def make_session_tracked(created_list, ws=None):
|
||||
return sid, _pathlib.Path(d["session"]["workspace"])
|
||||
|
||||
|
||||
def make_workspace_child(base: pathlib.Path, name: str) -> pathlib.Path:
|
||||
target = base / name
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
return target
|
||||
|
||||
|
||||
def test_server_running_from_new_location():
|
||||
data, status = get("/health")
|
||||
assert status == 200 and data["status"] == "ok"
|
||||
@@ -44,11 +50,13 @@ def test_workspaces_list():
|
||||
data, status = get("/api/workspaces")
|
||||
assert status == 200 and "workspaces" in data and "last" in data
|
||||
|
||||
def test_workspace_add_valid():
|
||||
post("/api/workspaces/remove", {"path": "/tmp"})
|
||||
result, status = post("/api/workspaces/add", {"path": "/tmp", "name": "Temp"})
|
||||
assert status == 200 and any(w["path"]=="/tmp" for w in result["workspaces"])
|
||||
post("/api/workspaces/remove", {"path": "/tmp"})
|
||||
def test_workspace_add_valid(cleanup_test_sessions):
|
||||
_, ws = make_session_tracked(cleanup_test_sessions)
|
||||
child = make_workspace_child(ws, f"workspace-add-{uuid.uuid4().hex[:6]}")
|
||||
post("/api/workspaces/remove", {"path": str(child)})
|
||||
result, status = post("/api/workspaces/add", {"path": str(child), "name": "Temp"})
|
||||
assert status == 200 and any(w["path"] == str(child) for w in result["workspaces"])
|
||||
post("/api/workspaces/remove", {"path": str(child)})
|
||||
|
||||
def test_workspace_add_validates_existence():
|
||||
result, status = post("/api/workspaces/add", {"path": "/tmp/does_not_exist_xyz_999"})
|
||||
@@ -58,40 +66,47 @@ def test_workspace_add_validates_is_dir():
|
||||
result, status = post("/api/workspaces/add", {"path": "/etc/hostname"})
|
||||
assert status == 400
|
||||
|
||||
def test_workspace_add_no_duplicate():
|
||||
post("/api/workspaces/remove", {"path": "/tmp"})
|
||||
post("/api/workspaces/add", {"path": "/tmp"})
|
||||
result, status = post("/api/workspaces/add", {"path": "/tmp"})
|
||||
def test_workspace_add_no_duplicate(cleanup_test_sessions):
|
||||
_, ws = make_session_tracked(cleanup_test_sessions)
|
||||
child = make_workspace_child(ws, f"workspace-dup-{uuid.uuid4().hex[:6]}")
|
||||
post("/api/workspaces/remove", {"path": str(child)})
|
||||
post("/api/workspaces/add", {"path": str(child)})
|
||||
result, status = post("/api/workspaces/add", {"path": str(child)})
|
||||
assert status == 400 and "already" in result.get("error","").lower()
|
||||
post("/api/workspaces/remove", {"path": "/tmp"})
|
||||
post("/api/workspaces/remove", {"path": str(child)})
|
||||
|
||||
def test_workspace_add_requires_path():
|
||||
result, status = post("/api/workspaces/add", {})
|
||||
assert status == 400
|
||||
|
||||
def test_workspace_remove():
|
||||
post("/api/workspaces/remove", {"path": "/tmp"})
|
||||
post("/api/workspaces/add", {"path": "/tmp", "name": "Temp"})
|
||||
result, status = post("/api/workspaces/remove", {"path": "/tmp"})
|
||||
assert status == 200 and "/tmp" not in [w["path"] for w in result["workspaces"]]
|
||||
def test_workspace_remove(cleanup_test_sessions):
|
||||
_, ws = make_session_tracked(cleanup_test_sessions)
|
||||
child = make_workspace_child(ws, f"workspace-remove-{uuid.uuid4().hex[:6]}")
|
||||
post("/api/workspaces/remove", {"path": str(child)})
|
||||
post("/api/workspaces/add", {"path": str(child), "name": "Temp"})
|
||||
result, status = post("/api/workspaces/remove", {"path": str(child)})
|
||||
assert status == 200 and str(child) not in [w["path"] for w in result["workspaces"]]
|
||||
|
||||
def test_workspace_rename():
|
||||
post("/api/workspaces/remove", {"path": "/tmp"})
|
||||
post("/api/workspaces/add", {"path": "/tmp", "name": "Temp"})
|
||||
result, status = post("/api/workspaces/rename", {"path": "/tmp", "name": "My Temp"})
|
||||
def test_workspace_rename(cleanup_test_sessions):
|
||||
_, ws = make_session_tracked(cleanup_test_sessions)
|
||||
child = make_workspace_child(ws, f"workspace-rename-{uuid.uuid4().hex[:6]}")
|
||||
post("/api/workspaces/remove", {"path": str(child)})
|
||||
post("/api/workspaces/add", {"path": str(child), "name": "Temp"})
|
||||
result, status = post("/api/workspaces/rename", {"path": str(child), "name": "My Temp"})
|
||||
assert status == 200
|
||||
assert {w["path"]: w["name"] for w in result["workspaces"]}.get("/tmp") == "My Temp"
|
||||
post("/api/workspaces/remove", {"path": "/tmp"})
|
||||
assert {w["path"]: w["name"] for w in result["workspaces"]}.get(str(child)) == "My Temp"
|
||||
post("/api/workspaces/remove", {"path": str(child)})
|
||||
|
||||
def test_workspace_rename_unknown():
|
||||
result, status = post("/api/workspaces/rename", {"path": "/no/such/path", "name": "X"})
|
||||
assert status == 404
|
||||
|
||||
def test_last_workspace_updates_on_session_update(cleanup_test_sessions):
|
||||
sid, _ = make_session_tracked(cleanup_test_sessions)
|
||||
post("/api/session/update", {"session_id": sid, "workspace": "/tmp", "model": "openai/gpt-5.4-mini"})
|
||||
sid, ws = make_session_tracked(cleanup_test_sessions)
|
||||
child = make_workspace_child(ws, f"workspace-last-{uuid.uuid4().hex[:6]}")
|
||||
post("/api/session/update", {"session_id": sid, "workspace": str(child), "model": "openai/gpt-5.4-mini"})
|
||||
data, _ = get("/api/workspaces")
|
||||
assert data["last"] == "/tmp"
|
||||
assert data["last"] == str(child)
|
||||
|
||||
def test_file_save(cleanup_test_sessions):
|
||||
sid, ws = make_session_tracked(cleanup_test_sessions)
|
||||
@@ -133,8 +148,9 @@ def test_sessions_endpoint_returns_sorted():
|
||||
assert sessions[0]["updated_at"] >= sessions[1]["updated_at"]
|
||||
|
||||
def test_new_session_inherits_last_workspace(cleanup_test_sessions):
|
||||
sid, _ = make_session_tracked(cleanup_test_sessions)
|
||||
post("/api/session/update", {"session_id": sid, "workspace": "/tmp", "model": "openai/gpt-5.4-mini"})
|
||||
sid, ws = make_session_tracked(cleanup_test_sessions)
|
||||
child = make_workspace_child(ws, f"workspace-inherit-{uuid.uuid4().hex[:6]}")
|
||||
post("/api/session/update", {"session_id": sid, "workspace": str(child), "model": "openai/gpt-5.4-mini"})
|
||||
sid2, _ = make_session_tracked(cleanup_test_sessions)
|
||||
d, _ = get(f"/api/session?session_id={sid2}")
|
||||
assert d["session"]["workspace"] == "/tmp"
|
||||
assert d["session"]["workspace"] == str(child)
|
||||
|
||||
Reference in New Issue
Block a user