Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
242 changes: 242 additions & 0 deletions fixos/cli/_cleanup_space.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from __future__ import annotations

import json
import os
import re
import shutil
import subprocess
Expand Down Expand Up @@ -151,6 +152,7 @@ def _confirm_remove() -> bool:


def _remove_path(path: Path) -> tuple[bool, str]:
path = Path(path)
try:
if path.is_symlink() or path.is_file():
path.unlink()
Expand All @@ -160,6 +162,8 @@ def _remove_path(path: Path) -> tuple[bool, str]:
except PermissionError as exc:
return False, f"PERMISSION_DENIED:{exc}"
except OSError as exc:
if exc.errno in (1, 13) or "permission denied" in str(exc).lower():
return False, f"PERMISSION_DENIED:{exc}"
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path, ignore_errors=True)
if not path.exists():
Expand Down Expand Up @@ -224,6 +228,195 @@ def _batch_sudo_remove_tmp(items: list[dict], path_key: str = "path") -> tuple[i
return removed, freed


def _project_artifact_allowed(
path: Path | str | dict[str, Any],
project_root: Path | str | None = None,
) -> bool:
"""Strictly validate that path is a safe project artifact directory inside project_root.

The path must:
- be an existing directory,
- not be a symlink (neither the artifact itself nor any ancestor up to project root),
- resolve strictly inside resolved project root (not equal to project root, not escaping),
- not be a mount point and contain no nested mounts,
- have the same filesystem device as project root.
"""
if isinstance(path, dict):
if project_root is None:
project_root = path.get("project")
path = path.get("path")

if path is None:
return False

try:
path = Path(path)
if project_root is None:
project_root = path.parent
else:
project_root = Path(project_root)
except (TypeError, ValueError, OSError):
return False

# Path and project_root must be existing directories and not symlinks
if path.is_symlink() or project_root.is_symlink():
return False
if not path.is_dir() or not project_root.is_dir():
return False

# Disallow forbidden critical names
if path.name in (".git", "", ".", ".."):
return False

# Check structural relationship before resolving
try:
rel = path.absolute().relative_to(project_root.absolute())
except ValueError:
return False

if rel == Path(".") or not rel.parts:
return False

# Ensure no symlinks in path segments from project_root down to path
curr = project_root.absolute()
for part in rel.parts:
curr = curr / part
if curr.is_symlink():
return False

# Check fully resolved paths to prevent symlink traversal or escaping
try:
resolved_proj = project_root.resolve()
resolved_path = path.resolve()
except (OSError, RuntimeError):
return False

if resolved_path.is_symlink() or resolved_proj.is_symlink():
return False

try:
rel_resolved = resolved_path.relative_to(resolved_proj)
except ValueError:
return False

if rel_resolved == Path(".") or not rel_resolved.parts:
return False

# Check plain path
from fixos.diagnostics.venv_cleanup import _plain_path
if not _plain_path(resolved_path):
return False

# Check mount points
if path.is_mount() or resolved_path.is_mount():
return False

# Check filesystem devices
try:
dev_art = path.stat().st_dev
dev_proj = project_root.stat().st_dev
if dev_art != dev_proj:
return False
except OSError:
pass

# Check nested mounts inside path
try:
device = path.stat().st_dev
for parent, dirs, _ in os.walk(path, followlinks=False):
for name in dirs:
child = Path(parent) / name
if child.is_symlink():
continue
if child.is_mount():
return False
try:
if child.stat().st_dev != device:
return False
except OSError:
pass
except OSError:
pass

# Check system mount points via psutil if available
try:
import psutil
mounts = {Path(part.mountpoint).resolve() for part in psutil.disk_partitions(all=True)}
if any(m == resolved_path or resolved_path in m.parents for m in mounts):
return False
except (ImportError, OSError, RuntimeError, ValueError):
pass

return True


def _batch_sudo_remove_project_artifacts(
items: list[dict], path_key: str = "path"
) -> tuple[int, int, list[dict]]:
"""Remove root-owned stale project artifacts in batches using non-interactive sudo if available.

Only items that pass strict `_project_artifact_allowed` validation are removed.
Returns (removed_count, freed_bytes, unhandled_items).
"""
if not items:
return 0, 0, []

project_items: list[dict] = []
for item in items:
p = Path(item[path_key])
if p.parent == Path("/tmp"):
continue
if "project" in item or (p.parent != Path("/") and p.parent != Path(".")):
project_items.append(item)

if not project_items:
return 0, 0, []

validated_safe: list[dict] = []
unhandled: list[dict] = []

for item in project_items:
p = Path(item[path_key])
proj = item.get("project") or p.parent
if _project_artifact_allowed(p, proj):
validated_safe.append(item)
else:
unhandled.append(item)
click.echo(
click.style(
f" Pominięto usuwanie sudo dla {item.get('description', str(p))}: "
f"ścieżka nie przeszła weryfikacji bezpieczeństwa (symlink lub punkt montowania)",
fg="yellow",
)
)

if not validated_safe:
return 0, 0, unhandled

if shutil.which("sudo") is None:
unhandled.extend(validated_safe)
return 0, 0, unhandled

removed = 0
freed = 0
chunk_size = 50
for start in range(0, len(validated_safe), chunk_size):
chunk = validated_safe[start : start + chunk_size]
cmd = ["sudo", "-n", "rm", "-rf", "--", *[str(it[path_key]) for it in chunk]]
try:
res = _run_cmd(cmd, timeout=60)
if res.returncode == 0:
removed += len(chunk)
freed += sum(int(it.get("size_bytes", 0) or 0) for it in chunk)
else:
unhandled.extend(chunk)
except (OSError, subprocess.TimeoutExpired):
unhandled.extend(chunk)
break

return removed, freed, unhandled


def _remove_selected(
candidates: list[dict], selected: list[int], path_key: str = "path"
) -> None:
Expand Down Expand Up @@ -260,6 +453,55 @@ def _remove_selected(
fg="green",
)
)

proj_removed, proj_freed, unhandled_proj = _batch_sudo_remove_project_artifacts(
perm_denied_items, path_key=path_key
)
if proj_removed > 0:
removed_count += proj_removed
freed_bytes += proj_freed
failed_count = max(0, failed_count - proj_removed)
if verbose:
click.echo(
click.style(
f" Usunięto przez sudo (projekty): {proj_removed} pozycji ({_format_bytes(proj_freed)})",
fg="green",
)
)
if unhandled_proj:
click.echo(
click.style(
f" Brak uprawnień (PermissionError) dla {len(unhandled_proj)} artefaktów projektów:",
fg="yellow",
)
)
for it in unhandled_proj[:_MAX_DISPLAY_CANDIDATES]:
click.echo(
click.style(
f" - {it.get('description', it.get(path_key))}: {it.get(path_key)}",
fg="yellow",
)
)
remediation_paths = [
str(it[path_key])
for it in unhandled_proj
if not Path(it[path_key]).is_symlink() and not Path(it[path_key]).is_mount()
]
if remediation_paths:
click.echo(
click.style(
" Polecenie naprawcze (uruchom ręcznie z sudo):",
fg="cyan",
)
)
paths_arg = " ".join(f"'{p}'" if " " in p else p for p in remediation_paths[:20])
click.echo(
click.style(
f" sudo rm -rf -- {paths_arg}",
fg="cyan",
bold=True,
)
)
if not verbose:
click.echo(
click.style(
Expand Down
11 changes: 11 additions & 0 deletions project/TICKETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,18 @@ This file indexes governance tickets without taking ownership of
| **ticket-089** | [`README.md`](./ticket-089/README.md) | - | - | - | - | - |
| **ticket-090** | [`README.md`](./ticket-090/README.md) | - | - | - | - | - |
| **ticket-091** | [`README.md`](./ticket-091/README.md) | - | - | - | - | - |
| **ticket-092** | [`README.md`](./ticket-092/README.md) | - | - | - | - | - |
| **ticket-093** | [`README.md`](./ticket-093/README.md) | - | - | - | - | - |
| **ticket-094** | [`README.md`](./ticket-094/README.md) | - | - | - | - | - |
| **ticket-095** | [`README.md`](./ticket-095/README.md) | - | - | - | - | - |
| **ticket-096** | [`README.md`](./ticket-096/README.md) | - | - | - | - | - |
| **ticket-097** | [`README.md`](./ticket-097/README.md) | - | - | - | - | - |
| **ticket-100** | [`README.md`](./ticket-100/README.md) | - | - | - | - | - |
| **ticket-101** | [`README.md`](./ticket-101/README.md) | - | - | - | - | - |
| **ticket-102** | [`README.md`](./ticket-102/README.md) | - | - | - | - | - |
| **ticket-103** | [`README.md`](./ticket-103/README.md) | - | - | - | - | - |
| **ticket-104** | [`README.md`](./ticket-104/README.md) | - | - | - | - | - |
| **ticket-105** | [`README.md`](./ticket-105/README.md) | - | - | - | - | - |
| **ticket-107** | [`README.md`](./ticket-107/README.md) | - | - | - | - | - |
| **ticket-108** | [`README.md`](./ticket-108/README.md) | - | - | - | - | - |
<!-- AUTO:TICKET_INDEX:END -->
26 changes: 26 additions & 0 deletions project/ticket-108/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Ticket 108: Handle root-owned project artifacts in projects-old cleanup with bounded non-interactive sudo fallback

- **ID**: ticket-108
- **Owner**: unresolved:human
- **Status**: IN_PROGRESS
- **Workflow state**: EDIT
- **Created**: 2026-10-26

## Goal and scope

1. In `fixos/cli/_cleanup_space.py`, handle `PermissionError` during project artifact cleanup (`_cleanup_projects_old` / `_remove_selected`) when build tools or Docker leave root-owned artifacts (e.g. `node_modules`, `target`, `.pytest_cache`).
2. Add strict `_project_artifact_allowed` validation ensuring paths resolve strictly within the project root, without following symlinks or escaping the project root, and avoiding mount points.
3. Use bounded non-interactive sudo (`sudo -n rm -rf -- ...`) when available, or emit an actionable remediation command.
4. Add comprehensive unit tests in `tests/unit/test_cleanup_space.py` covering `PermissionError` handling on stale project artifacts without following symlinks or escaping the project root.

SESSION_EXECUTION_AUTHORIZATION: Governed Subactor publication ticket PLF-20295 (GitHub issue semcod/fixos#132).

## Acceptance criteria

- [x] AC-01: When `shutil.rmtree` raises `PermissionError` in `_cleanup_projects_old` (or `_remove_selected`), clearly report permission-denied items and, when `sudo -n` is available and target paths pass strict `_project_artifact_allowed` validation, allow bounded non-interactive removal or emit an actionable remediation command.
- [x] AC-02: Unit tests in `tests/unit/test_cleanup_space.py` cover `PermissionError` handling on stale project artifacts without following symlinks or escaping the project root.

## Tracking boundary

This directory contains the minimal reviewed intent. Optional participant prose
and raw command logs are not required delivery output.
Loading
Loading