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.

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.

Appendix: 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)