fix(workspace): allow adding external paths not under home directory (#991)

The workspace add endpoint used resolve_trusted_workspace() which blocks any path
outside the user's home directory, the saved workspace list, or BOOT_DEFAULT_WORKSPACE.
This created a circular dependency: to add /mnt/d/Projects you need it in the saved
list, but to get it in the list you need to add it.

Fix: introduce validate_workspace_to_add() used by /api/workspaces/add, which only
blocks non-existent paths, non-directories, and known system roots. The stricter
resolve_trusted_workspace() is still used for actual file operations within a workspace.

Fixes #953.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
This commit is contained in:
nesquena-hermesandnesquena-hermes authored and GitHub committed 2026-04-24 13:04:36 -07:00
1 parent 3c457d178d
commit 2800ebdcff
3 files changed
+47 -3

No files matched your search

+30
View File
@@ -430,6 +430,36 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
)
def validate_workspace_to_add(path: str) -> Path:
"""Validate a path for *adding* to the workspace list (less restrictive than resolve_trusted_workspace).
When a user explicitly adds a new workspace path, we trust their intent — they
have console or filesystem access to that path and are consciously registering it.
We only block: non-existent paths, non-directories, and known system roots.
The stricter ``resolve_trusted_workspace`` is used when *using* an existing workspace
(file reads/writes) to prevent path traversal after the list is built.
"""
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 immediate children
for blocked in _workspace_blocked_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
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()