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
- 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.
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
- 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
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
- Refusing to outside the install root: ' ' is not within ' '.
- Refusing to outside the install root: ' ' is not within ' '.
- artifact must be a resident regular file
- artifact path escapes output directory
- artifact path must be canonical and absolute
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, skippedView on GitHub (pinned to 8321021c54)