host.controller_web_editor.paths

Path resolution chokepoint.

All file paths accepted from the web client flow through safe_resolve. v1 allowlists data/ under the repo root. Widening the scope later is a one-line change here.

 1"""Path resolution chokepoint.
 2
 3All file paths accepted from the web client flow through ``safe_resolve``.
 4v1 allowlists ``data/`` under the repo root.  Widening the scope later is a
 5one-line change here.
 6"""
 7
 8from pathlib import Path
 9
10
11REPO_ROOT = Path(__file__).resolve().parents[2]
12
13# Directories under REPO_ROOT that the editor may read/write.
14ALLOWED_ROOTS: tuple[Path, ...] = (
15    REPO_ROOT / "data",
16)
17
18
19class PathNotAllowedError(ValueError):
20    """Raised when a requested path falls outside the allowlist."""
21
22
23def safe_resolve(repo_relative: str) -> Path:
24    """Resolve a client-supplied path against the repo root.
25
26    The path must be relative and, after resolution, must live inside one
27    of ``ALLOWED_ROOTS``.  Symlinks are followed; traversal via ``..`` is
28    rejected because the resolved path leaves the allowed root.
29    """
30    if not repo_relative:
31        raise PathNotAllowedError("empty path")
32    candidate = (REPO_ROOT / repo_relative).resolve()
33    for root in ALLOWED_ROOTS:
34        try:
35            candidate.relative_to(root)
36        except ValueError:
37            continue
38        return candidate
39    raise PathNotAllowedError(
40        f"path '{repo_relative}' is outside the allowed roots")
41
42
43def list_yaml_configs() -> list[str]:
44    """Return repo-relative YAML paths under every allowed root."""
45    results: list[str] = []
46    for root in ALLOWED_ROOTS:
47        if not root.is_dir():
48            continue
49        for p in sorted(root.rglob("*.yaml")):
50            results.append(str(p.relative_to(REPO_ROOT)).replace("\\", "/"))
51    return results
REPO_ROOT = PosixPath('/home/runner/work/Robot-2026/Robot-2026')
ALLOWED_ROOTS: tuple[pathlib.Path, ...] = (PosixPath('/home/runner/work/Robot-2026/Robot-2026/data'),)
class PathNotAllowedError(builtins.ValueError):
20class PathNotAllowedError(ValueError):
21    """Raised when a requested path falls outside the allowlist."""

Raised when a requested path falls outside the allowlist.

def safe_resolve(repo_relative: str) -> pathlib.Path:
24def safe_resolve(repo_relative: str) -> Path:
25    """Resolve a client-supplied path against the repo root.
26
27    The path must be relative and, after resolution, must live inside one
28    of ``ALLOWED_ROOTS``.  Symlinks are followed; traversal via ``..`` is
29    rejected because the resolved path leaves the allowed root.
30    """
31    if not repo_relative:
32        raise PathNotAllowedError("empty path")
33    candidate = (REPO_ROOT / repo_relative).resolve()
34    for root in ALLOWED_ROOTS:
35        try:
36            candidate.relative_to(root)
37        except ValueError:
38            continue
39        return candidate
40    raise PathNotAllowedError(
41        f"path '{repo_relative}' is outside the allowed roots")

Resolve a client-supplied path against the repo root.

The path must be relative and, after resolution, must live inside one of ALLOWED_ROOTS. Symlinks are followed; traversal via .. is rejected because the resolved path leaves the allowed root.

def list_yaml_configs() -> list[str]:
44def list_yaml_configs() -> list[str]:
45    """Return repo-relative YAML paths under every allowed root."""
46    results: list[str] = []
47    for root in ALLOWED_ROOTS:
48        if not root.is_dir():
49            continue
50        for p in sorted(root.rglob("*.yaml")):
51            results.append(str(p.relative_to(REPO_ROOT)).replace("\\", "/"))
52    return results

Return repo-relative YAML paths under every allowed root.