affaan-m/ECC · warning · ValueError

refusing to remove : escapes

Error message

refusing to remove {project_dir}: escapes {projects_root}

What it means

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.

Solutions

  1. Pass a valid project id as listed by the projects list command — a bare directory name inside PROJECTS_DIR.
  2. Sanitize the id before invoking: reject values containing '/', '\\', or '..' and verify (PROJECTS_DIR / id).resolve() is inside PROJECTS_DIR.
  3. Do not create symlinks inside PROJECTS_DIR; investigate the tree if a legit id unexpectedly resolves outside the root.
  4. Catch the ValueError in wrappers and log the refused path instead of attempting deletion manually.

Example fix

# before
_remove_project_storage(user_supplied_id)  # e.g. "../../home/user/data"
# after
from pathlib import Path
candidate = (PROJECTS_DIR / user_supplied_id).resolve()
root = PROJECTS_DIR.resolve()
if candidate == root or root not in candidate.parents:
    raise ValueError("project id escapes projects root")
_remove_project_storage(user_supplied_id)
Defensive patterns

Strategy: validation

Validate before calling

from pathlib import Path
pid = project_id
if "/" in pid or "\\" in pid or ".." in pid:
    raise ValueError("invalid project id")
root = PROJECTS_DIR.resolve()
candidate = (PROJECTS_DIR / pid).resolve()
if candidate == root or root not in candidate.parents:
    raise ValueError("project id escapes projects root")

Type guard

def is_safe_project_id(project_id: str) -> bool:
    if not project_id or any(c in project_id for c in "/\\") or ".." in project_id:
        return False
    root = PROJECTS_DIR.resolve()
    return root in (PROJECTS_DIR / project_id).resolve().parents

Try / catch

try:
    run_projects_delete(project_id)
except ValueError as e:
    if "escapes" in str(e):
        print(f"refusing to delete: {e}")

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


AI-assisted analysis of affaan-m/ECC@8321021c54 (2026-09-16). Data as JSON: /api/errors/4cb55b0533afc006. Report an issue: GitHub.

Appendix: source

Thrown at skills/continuous-learning-v2/scripts/instinct-cli.py:628

            observations_count = 0

    return {
        "personal": personal_count,
        "inherited": inherited_count,
        "observations": observations_count,
        "total": personal_count + inherited_count + observations_count,
    }


def _remove_project_storage(project_id: str) -> None:
    # Defense-in-depth: resolve and confirm the target is contained within
    # PROJECTS_DIR before recursively deleting, even though callers validate the
    # project id. A relaxed validator or a future caller must never be able to
    # turn this into an arbitrary-directory delete.
    projects_root = PROJECTS_DIR.resolve()
    project_dir = (PROJECTS_DIR / project_id).resolve()
    if project_dir == projects_root or projects_root not in project_dir.parents:
        raise ValueError(f"refusing to remove {project_dir}: escapes {projects_root}")
    if project_dir.exists():
        shutil.rmtree(project_dir)


def _project_instinct_ids(project_dir: Path, source_type: str) -> set[str]:
    instinct_dir = project_dir / "instincts" / source_type
    return {
        inst.get("id")
        for inst in _load_instincts_from_dir(instinct_dir, source_type, "project")
        if inst.get("id")
    }


def _merge_instinct_dir(from_dir: Path, into_dir: Path, existing_ids: set[str]) -> tuple[int, int]:
    moved = 0
    skipped = 0
    if not from_dir.exists():
        return moved, skipped

View on GitHub (pinned to 8321021c54)