666ghj/MiroFish · critical · StarHistoryError

output path escaped the workspace

Error message

output path escaped the workspace

What it means

Raised by _safe_target() when the requested relative output path is absolute or contains a '..' component, and separately (same message, scripts/star_history.py:493) when the resolved parent of the target does not stay under the workspace root. It is a path-traversal guard ensuring output files remain confined to the workspace.

Source

Thrown at scripts/star_history.py:467

    return (
        json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
    ).encode("utf-8")


def _safe_workspace(workspace: Path) -> Path:
    try:
        root = workspace.resolve(strict=True)
    except OSError as exc:
        raise StarHistoryError("workspace does not exist") from exc
    if not root.is_dir():
        raise StarHistoryError("workspace is not a directory")
    return root


def _safe_target(workspace: Path, relative: Path, create_parent: bool) -> Path:
    root = _safe_workspace(workspace)
    if relative.is_absolute() or ".." in relative.parts:
        raise StarHistoryError("output path escaped the workspace")

    current = root
    for part in relative.parts[:-1]:
        current = current / part
        if current.is_symlink():
            raise StarHistoryError("output directory cannot be a symbolic link")
    target = root / relative
    if target.is_symlink():
        raise StarHistoryError("output file cannot be a symbolic link")
    if create_parent:
        try:
            target.parent.mkdir(parents=True, exist_ok=True)
        except OSError as exc:
            raise StarHistoryError("could not create output directory") from exc
        current = root
        for part in relative.parts[:-1]:
            current = current / part
            if current.is_symlink():

View on GitHub (pinned to b5b53acc57)

Solutions

  1. Pass a plain relative filename (no leading '/', no '..') as the output target
  2. Sanitize externally derived filenames: strip path separators and reject '..'
  3. Remove or relocate symlinks inside the workspace that point outside it
  4. If you genuinely need an output elsewhere, change the workspace configuration rather than escaping it

Example fix

# before
relative = Path(user_supplied_name)  # may be "../../etc/cron.d/x"
# after
name = user_supplied_name.replace("/", "_").replace("\\", "_")
if ".." in name:
    raise ValueError("unsafe output name")
relative = Path(name)
Defensive patterns

Strategy: validation

Validate before calling

rel = Path(relative)
if rel.is_absolute() or ".." in rel.parts:
    raise ValueError("output path escaped the workspace")

Type guard

from pathlib import Path

def is_safe_relative_target(relative: Path) -> bool:
    p = Path(relative)
    return not p.is_absolute() and ".." not in p.parts

Try / catch

try:
    write_output(workspace, relative, data)
except StarHistoryError as exc:
    if "output path escaped the workspace" in str(exc):
        # log the rejected path and the sanitized replacement; never retry as-is
        ...

Prevention

When it happens

Trigger: Passing an output relative path like '../../etc/passwd', '/etc/cron.d/x', or a symlinked directory whose resolution escapes the workspace root. Also triggered via the second site when target.parent.resolve() cannot be made relative to the resolved root.

Common situations: User- or config-supplied output filenames containing '..' or leading slashes; output names built from unvalidated external input (issue titles, repo names); symlinks inside the workspace pointing outside it.

Related errors


AI-assisted analysis of 666ghj/MiroFish@b5b53acc57 (2026-08-14). Data as JSON: /api/errors/e97f0ef8be1be243. Report an issue: GitHub.