{"record":{"id":"fb45291c7bf69597","repo":"shareAI-lab/learn-claude-code","slug":"tasks-directory-escapes-workspace-fb4529","errorCode":null,"errorMessage":"Tasks directory escapes workspace","messagePattern":"Tasks directory escapes workspace","errorType":"exception","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"s15_integrated_harness/code.py","lineNumber":267,"sourceCode":"\n\ndef load_task(task_id: str) -> Task:\n    with task_lock:\n        data = json.loads(_task_path(task_id).read_text(encoding=\"utf-8\"))\n        task = Task(**data)\n        if task.id != task_id:\n            raise ValueError(f\"Task file ID does not match {task_id}\")\n        if task.status not in {\"pending\", \"in_progress\", \"completed\"}:\n            raise ValueError(f\"Invalid task status: {task.status}\")\n        return task\n\n\ndef list_tasks() -> list[Task]:\n    with task_lock:\n        if not TASKS_DIR.exists():\n            return []\n        if not TASKS_ROOT.is_relative_to(WORKDIR.resolve()):\n            raise ValueError(\"Tasks directory escapes workspace\")\n        return [load_task(path.stem)\n                for path in sorted(TASKS_DIR.glob(\"task_*.json\"))]\n\n\ndef get_task_json(task_id: str) -> str:\n    return json.dumps(asdict(load_task(task_id)), indent=2)\n\n\ndef can_start(task_id: str) -> bool:\n    # Dependencies are intentionally simple: every blocker must exist and be\n    # completed before the task can be claimed.\n    task = load_task(task_id)\n    for dep_id in task.blockedBy:\n        try:\n            dep_path = _task_path(dep_id)\n        except ValueError:\n            return False\n        if not dep_path.exists():","sourceCodeStart":249,"sourceCodeEnd":285,"githubUrl":"https://github.com/shareAI-lab/learn-claude-code/blob/985456f4adea6f4df8fbad4112245dbd97444eae/s15_integrated_harness/code.py#L249-L285","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Make the tasks directory a real directory inside the workspace (remove escaping symlinks).","Avoid reassigning WORKDIR/TASKS_DIR globals at runtime; set them once at startup from a consistent base.","Verify with `readlink -f` that both WORKDIR and the tasks dir resolve inside the intended workspace."],"exampleFix":null,"handlingStrategy":"validation","validationCode":"from pathlib import Path\n\ndef store_inside_workspace(tasks_root: Path, workdir: Path) -> bool:\n    wr, tr = workdir.resolve(), tasks_root.resolve()\n    return tr.is_relative_to(wr)","typeGuard":null,"tryCatchPattern":"try:\n    tasks = list_tasks()\nexcept ValueError as e:\n    if \"escapes workspace\" in str(e):\n        raise SystemExit(\"tasks directory misconfigured; fix symlinks/config\")\n    raise","preventionTips":["Keep the tasks directory a real directory inside WORKDIR.","Don't symlink it to shared storage; use a copy or export tool instead.","Verify configuration at startup with an is_relative_to check."],"tags":["security","path-traversal","symlink","filesystem"],"backgroundTag":null,"analyzedSha":"985456f4adea6f4df8fbad4112245dbd97444eae","analyzedAt":"2026-08-14T22:02:26.028Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}