{"record":{"id":"1ca657db739dc792","repo":"shareAI-lab/learn-claude-code","slug":"tasks-directory-escapes-workspace","errorCode":null,"errorMessage":"Tasks directory escapes workspace","messagePattern":"Tasks directory escapes workspace","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"s13_agent_teams/code.py","lineNumber":192,"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(task_id: str) -> str:\n    \"\"\"Return full task details as JSON.\"\"\"\n    task = load_task(task_id)\n    return json.dumps(asdict(task), indent=2)\n\n\ndef can_start(task_id: str) -> bool:\n    \"\"\"Check if all blockedBy dependencies are completed.\n    Missing dependencies are treated as blocked.\"\"\"\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:","sourceCodeStart":174,"sourceCodeEnd":210,"githubUrl":"https://github.com/shareAI-lab/learn-claude-code/blob/985456f4adea6f4df8fbad4112245dbd97444eae/s13_agent_teams/code.py#L174-L210","documentation":"list_tasks() re-checks at call time that TASKS_ROOT (the resolved .tasks directory, computed at import) is still inside WORKDIR.resolve(). This guards against the workspace being swapped, moved, or re-symlinked after startup so the listing never enumerates a foreign directory.","triggerScenarios":"WORKDIR is a symlink that is retargeted between import and the list_tasks() call; the workspace directory is moved and a symlink left behind; tests monkeypatching WORKDIR without recomputing TASKS_ROOT.","commonSituations":"Deployments where the workspace path goes through a 'current' symlink that is rotated during the process's lifetime; test fixtures that mutate module globals inconsistently.","solutions":["Avoid rotating/re-pointing the workspace symlink while the agent process runs; restart after rotations.","Set WORKDIR to the fully resolved real path at startup.","In tests, reload the module or recompute TASKS_ROOT after changing WORKDIR."],"exampleFix":"# before\nWORKDIR = Path('/srv/app/current')  # 'current' is a rotating symlink\n\n# after\nWORKDIR = Path('/srv/app/current').resolve()  # pinned to the real release dir","handlingStrategy":"validation","validationCode":"from pathlib import Path\n\ndef tasks_root_inside_workdir() -> bool:\n    return TASKS_ROOT.is_relative_to(WORKDIR.resolve())","typeGuard":null,"tryCatchPattern":"try:\n    tasks = list_tasks()\nexcept ValueError as exc:\n    if 'escapes workspace' in str(exc):\n        abort_run('workspace moved or re-symlinked; restart the agent')\n    raise","preventionTips":["Pin WORKDIR with .resolve() at startup.","Restart processes after rotating workspace symlinks (deploys).","Don't monkeypatch WORKDIR in tests without recomputing module roots."],"tags":["workspace","symlink","runtime-environment","path-traversal"],"backgroundTag":null,"analyzedSha":"985456f4adea6f4df8fbad4112245dbd97444eae","analyzedAt":"2026-08-14T22:02:26.028Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}