shareAI-lab/learn-claude-code · error · ValueError

Tasks directory escapes workspace

Error message

Tasks directory escapes workspace

What it means

list_tasks() re-validates at call time that the resolved TASKS_ROOT still sits inside the resolved WORKDIR. Unlike _task_path's per-ID check, this guards the whole directory: if the tasks root escapes the workspace (symlink, relocated WORKDIR, retargeted constant), listing is refused so no task file outside the workspace is ever read or parsed.

Source

Thrown at s15_integrated_harness/code.py:267


def load_task(task_id: str) -> Task:
    with task_lock:
        data = json.loads(_task_path(task_id).read_text(encoding="utf-8"))
        task = Task(**data)
        if task.id != task_id:
            raise ValueError(f"Task file ID does not match {task_id}")
        if task.status not in {"pending", "in_progress", "completed"}:
            raise ValueError(f"Invalid task status: {task.status}")
        return task


def list_tasks() -> list[Task]:
    with task_lock:
        if not TASKS_DIR.exists():
            return []
        if not TASKS_ROOT.is_relative_to(WORKDIR.resolve()):
            raise ValueError("Tasks directory escapes workspace")
        return [load_task(path.stem)
                for path in sorted(TASKS_DIR.glob("task_*.json"))]


def get_task_json(task_id: str) -> str:
    return json.dumps(asdict(load_task(task_id)), indent=2)


def can_start(task_id: str) -> bool:
    # Dependencies are intentionally simple: every blocker must exist and be
    # completed before the task can be claimed.
    task = load_task(task_id)
    for dep_id in task.blockedBy:
        try:
            dep_path = _task_path(dep_id)
        except ValueError:
            return False
        if not dep_path.exists():

View on GitHub (pinned to 985456f4ad)

Solutions

  1. Make the tasks directory a real directory inside the workspace (remove escaping symlinks).
  2. Avoid reassigning WORKDIR/TASKS_DIR globals at runtime; set them once at startup from a consistent base.
  3. Verify with `readlink -f` that both WORKDIR and the tasks dir resolve inside the intended workspace.
Defensive patterns

Strategy: validation

Validate before calling

from pathlib import Path

def store_inside_workspace(tasks_root: Path, workdir: Path) -> bool:
    wr, tr = workdir.resolve(), tasks_root.resolve()
    return tr.is_relative_to(wr)

Try / catch

try:
    tasks = list_tasks()
except ValueError as e:
    if "escapes workspace" in str(e):
        raise SystemExit("tasks directory misconfigured; fix symlinks/config")
    raise

Prevention

When it happens

Trigger: TASKS_DIR/TASKS_ROOT resolves outside WORKDIR at the moment list_tasks() runs — e.g. the directory is a symlink to /tmp or another checkout, WORKDIR was reassigned after import, or WORKDIR itself is a symlink whose target moved.

Common situations: Users symlinking the tasks dir for sharing between checkouts; the memory runtime reassigning runtime.WORKDIR-related globals; running the harness under a launcher that chdirs or rewrites WORKDIR.

Related errors


AI-assisted analysis of shareAI-lab/learn-claude-code@985456f4ad (2026-08-14). Data as JSON: /api/errors/fb45291c7bf69597. Report an issue: GitHub.