abi/screenshot-to-code · error · HTTPException

Invalid asset path

Error message

Invalid asset path

What it means

Raised by GET /agent-runs/{run_id}/assets/{filename} (400) when the resolved, realpath'd candidate does not stay inside the run's assets directory. Because os.path.basename() already strips any '..'/slash components from filename, plain traversal strings never reach this branch in practice; it fires when realpath escapes the assets dir — i.e. a symlink inside assets/ pointing outside it. It is a defense-in-depth containment check.

Source

Thrown at backend/routes/agent_runs.py:222

    _fetch_run(run_id)
    run_dir = os.path.join(get_agent_runs_directory(), run_id)
    for candidate in ("final_selfcontained.html", "final.html"):
        path = os.path.join(run_dir, candidate)
        if os.path.isfile(path):
            with open(path, "r", encoding="utf-8") as f:
                return HTMLResponse(content=f.read())
    raise HTTPException(status_code=404, detail="Run has no captured output")


@router.get("/agent-runs/{run_id}/assets/{filename}")
async def get_agent_run_asset(run_id: str, filename: str) -> FileResponse:
    _fetch_run(run_id)
    assets_dir = os.path.join(get_agent_runs_directory(), run_id, "assets")
    # basename() strips any traversal components before the containment check.
    safe_name = os.path.basename(filename)
    path = os.path.realpath(os.path.join(assets_dir, safe_name))
    if not path.startswith(os.path.realpath(assets_dir) + os.sep):
        raise HTTPException(status_code=400, detail="Invalid asset path")
    if not os.path.isfile(path):
        raise HTTPException(status_code=404, detail="Asset not found")
    return FileResponse(path)


@router.post("/agent-runs/prune", response_model=PruneAgentRunsResponse)
async def prune_agent_runs(request: PruneAgentRunsRequest) -> PruneAgentRunsResponse:
    if request.max_age_days < 1:
        raise HTTPException(status_code=400, detail="max_age_days must be >= 1")

    runs_directory = get_agent_runs_directory()
    if not os.path.isdir(runs_directory):
        return PruneAgentRunsResponse(deleted_count=0, freed_bytes=0)

    cutoff_timestamp = (
        datetime.now() - timedelta(days=request.max_age_days)
    ).timestamp()

View on GitHub (pinned to d026163f58)

Solutions

  1. Inspect assets/ under the run directory for symlinks (find <run_dir>/assets -type l) and replace them with regular files.
  2. Request only plain file names produced by the run's asset manifest.
  3. If you control asset writing, ensure it copies files rather than symlinking.
  4. Treat occurrences as a security signal — audit how the symlink got there.

Example fix

# before: assets/logo.png -> symlink to /etc/hostname
GET /agent-runs/run_.../assets/logo.png  # 400 'Invalid asset path'

# after: replace the symlink with a real file
rm <runs_dir>/<run_id>/assets/logo.png
cp /real/path/logo.png <runs_dir>/<run_id>/assets/logo.png
GET /agent-runs/run_.../assets/logo.png  # 200
Defensive patterns

Strategy: validation

Validate before calling

import os

def is_plain_asset_filename(filename: str) -> bool:
    """Bare name, no separators — keeps you on the basename() happy path."""
    return filename == os.path.basename(filename) and filename not in ("", ".", "..")

Type guard

def is_safe_asset_name(filename: str) -> bool:
    return (
        isinstance(filename, str)
        and filename == os.path.basename(filename)
        and not filename.startswith(".")
    )

Try / catch

try:
    resp = client.get(f"/agent-runs/{run_id}/assets/{filename}")
    resp.raise_for_status()
except httpx.HTTPStatusError as e:
    if e.response.status_code == 400 and "Invalid asset path" in e.response.text:
        raise RuntimeError(f"symlink escape in assets dir for {filename!r}") from e
    raise

Prevention

When it happens

Trigger: Requesting an asset whose name matches a symlink stored inside assets/ that resolves to a file outside the assets directory. Path strings like ../../etc/passwd are neutralized by basename() and instead yield 'Asset not found' (404).

Common situations: A generation step or user created symlinks in the run's assets folder (e.g. to save disk space); compromised or hand-crafted assets directories; test suites probing traversal behavior with symlinks.

Related errors


AI-assisted analysis of abi/screenshot-to-code@d026163f58 (2026-08-14). Data as JSON: /api/errors/18a8fa2c300cdb30. Report an issue: GitHub.