{"record":{"id":"4cb55b0533afc006","repo":"affaan-m/ECC","slug":"refusing-to-remove-project-dir-escapes-project","errorCode":null,"errorMessage":"refusing to remove {project_dir}: escapes {projects_root}","messagePattern":"refusing to remove (.+?): escapes (.+?)","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"warning","filePath":"skills/continuous-learning-v2/scripts/instinct-cli.py","lineNumber":628,"sourceCode":"            observations_count = 0\n\n    return {\n        \"personal\": personal_count,\n        \"inherited\": inherited_count,\n        \"observations\": observations_count,\n        \"total\": personal_count + inherited_count + observations_count,\n    }\n\n\ndef _remove_project_storage(project_id: str) -> None:\n    # Defense-in-depth: resolve and confirm the target is contained within\n    # PROJECTS_DIR before recursively deleting, even though callers validate the\n    # project id. A relaxed validator or a future caller must never be able to\n    # turn this into an arbitrary-directory delete.\n    projects_root = PROJECTS_DIR.resolve()\n    project_dir = (PROJECTS_DIR / project_id).resolve()\n    if project_dir == projects_root or projects_root not in project_dir.parents:\n        raise ValueError(f\"refusing to remove {project_dir}: escapes {projects_root}\")\n    if project_dir.exists():\n        shutil.rmtree(project_dir)\n\n\ndef _project_instinct_ids(project_dir: Path, source_type: str) -> set[str]:\n    instinct_dir = project_dir / \"instincts\" / source_type\n    return {\n        inst.get(\"id\")\n        for inst in _load_instincts_from_dir(instinct_dir, source_type, \"project\")\n        if inst.get(\"id\")\n    }\n\n\ndef _merge_instinct_dir(from_dir: Path, into_dir: Path, existing_ids: set[str]) -> tuple[int, int]:\n    moved = 0\n    skipped = 0\n    if not from_dir.exists():\n        return moved, skipped","sourceCodeStart":610,"sourceCodeEnd":646,"githubUrl":"https://github.com/affaan-m/ECC/blob/8321021c54d670126ce3b2969d5deb880b4b0c2a/skills/continuous-learning-v2/scripts/instinct-cli.py#L610-L646","documentation":"Raised by _remove_project_storage when the resolved project directory equals the projects root itself or is not a direct descendant of it. This is the final safety net against deleting the wrong tree: even if an upstream validator is relaxed or a future caller skips validation, a project_id with traversal (e.g. ../..) or the root id can never trigger an arbitrary-directory rmtree. It runs after resolve() so symlinks and '..' components are normalized first.","triggerScenarios":"Calling _remove_project_storage (via projects delete/gc/merge commands) with a project_id containing path traversal like '../something', an absolute path, or a value that resolves exactly to PROJECTS_DIR.","commonSituations":"Scripting the CLI with unvalidated ids from external input; corrupted project index storing a malformed id; symlink planted inside PROJECTS_DIR pointing elsewhere so resolve() escapes the root.","solutions":["Pass a valid project id as listed by the projects list command — a bare directory name inside PROJECTS_DIR.","Sanitize the id before invoking: reject values containing '/', '\\\\', or '..' and verify (PROJECTS_DIR / id).resolve() is inside PROJECTS_DIR.","Do not create symlinks inside PROJECTS_DIR; investigate the tree if a legit id unexpectedly resolves outside the root.","Catch the ValueError in wrappers and log the refused path instead of attempting deletion manually."],"exampleFix":"# before\n_remove_project_storage(user_supplied_id)  # e.g. \"../../home/user/data\"\n# after\nfrom pathlib import Path\ncandidate = (PROJECTS_DIR / user_supplied_id).resolve()\nroot = PROJECTS_DIR.resolve()\nif candidate == root or root not in candidate.parents:\n    raise ValueError(\"project id escapes projects root\")\n_remove_project_storage(user_supplied_id)","handlingStrategy":"validation","validationCode":"from pathlib import Path\npid = project_id\nif \"/\" in pid or \"\\\\\" in pid or \"..\" in pid:\n    raise ValueError(\"invalid project id\")\nroot = PROJECTS_DIR.resolve()\ncandidate = (PROJECTS_DIR / pid).resolve()\nif candidate == root or root not in candidate.parents:\n    raise ValueError(\"project id escapes projects root\")","typeGuard":"def is_safe_project_id(project_id: str) -> bool:\n    if not project_id or any(c in project_id for c in \"/\\\\\") or \"..\" in project_id:\n        return False\n    root = PROJECTS_DIR.resolve()\n    return root in (PROJECTS_DIR / project_id).resolve().parents","tryCatchPattern":"try:\n    run_projects_delete(project_id)\nexcept ValueError as e:\n    if \"escapes\" in str(e):\n        print(f\"refusing to delete: {e}\")","preventionTips":["Only pass ids sourced from the projects list command","Reject ids containing path separators or '..' before invoking","Avoid symlinks inside PROJECTS_DIR","Validate ids at every trust boundary, never rely on the internal check alone"],"tags":["python","security","path-traversal","filesystem"],"backgroundTag":"path-traversal-blocked","analyzedSha":"8321021c54d670126ce3b2969d5deb880b4b0c2a","analyzedAt":"2026-09-16T10:08:13.343Z","contentChangedAt":"2026-09-16T10:08:13.343Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}