From 83c412b33d9aee30d3f1e20d259bedaf59b28629 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 11 Sep 2026 20:52:02 -0400 Subject: [PATCH] feat(ai): catalogue d'outils mutations + confirmations two-step (#79 phase D) --- CHANGELOG.md | 14 + backend/main.py | 796 ++++++++++------------------------ backend/services/backups.py | 68 +++ backend/services/mutations.py | 675 ++++++++++++++++++++++++++++ backend/tools/context.py | 21 + backend/tools/registry.py | 7 + backend/tools/schemas.py | 99 +++++ backend/tools/service.py | 208 ++++++++- docs/AI_ARCHITECTURE_GUIDE.md | 16 +- docs/ROADMAP.md | 6 +- docs/features/ai-tools-mcp.md | 18 +- tests/test_tools.py | 12 + tests/test_tools_mutations.py | 396 +++++++++++++++++ 13 files changed, 1747 insertions(+), 589 deletions(-) create mode 100644 backend/services/mutations.py create mode 100644 tests/test_tools_mutations.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e3d2d6..be87283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,20 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Ajouté +- **#79 Phase D — Catalogue d'outils mutations + confirmations two-step** — 11 nouveaux outils IA + destructifs ou d'écriture exposés par la couche partagée (`backend/tools/service.py`) : + `create_file`, `create_directory`, `edit_file`, `append_to_file`, `rename_file`, + `rename_directory`, `move_path`, `replace_in_files`, `delete_file`, `delete_directory`, + `restore_backup`. Nouveau service réutilisable `backend/services/mutations.py` (anti + path-traversal, garde lecture seule, **backup automatique** avant écriture/suppression/restauration) ; + les routes `/api/file/{vault}` (POST/PATCH/DELETE), `/api/file/{vault}/save`, `/api/directory/{vault}` + (POST/PATCH/DELETE), `/api/move/{vault}`, `/api/file/{vault}/restore` et `/api/search/replace` + délèguent désormais à cette couche. `backend/services/backups.py` expose `create_backup` + (source unique, réutilisée par `main._backup_file`). Confirmations **two-step** : tout outil + `WRITE`/`DANGEROUS` déclenche `ToolConfirmationRequired` (carte Apply in-app, `propose`/`apply` + côté MCP en phase E). **Toggle par vault** `aiDestructiveTools` (défaut : activés) pour désactiver + delete/rename/move/replace. Tests : `tests/test_tools_mutations.py` (40 tests). + - **#79 Phase C — Catalogue d'outils lecture & recherche** — 10 nouveaux outils IA exposés par la couche partagée (`backend/tools/service.py`) : `list_all_files`, `read_file_raw`, `get_backlinks`, `list_backups`, `diff_backup`, `get_graph`, `search_advanced`, `search_paths`, `suggest_tags`, diff --git a/backend/main.py b/backend/main.py index 4ea0b40..878054a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -36,7 +36,6 @@ from backend.history import ( ) from backend.image_processor import preprocess_images from backend.indexer import ( - SUPPORTED_EXTENSIONS, _extract_tags, add_vault_to_index, build_index, @@ -94,7 +93,6 @@ from backend.schemas import ( WebhookModel, ) from backend.search import ( - advanced_search, init_inverted_index, suggest_tags, suggest_titles, @@ -105,6 +103,36 @@ from backend.services.backups import list_backup_files as service_list_backup_fi from backend.services.errors import ServiceError from backend.services.files import read_raw_file from backend.services.graph import get_graph as service_get_graph +from backend.services.mutations import ( + create_directory as service_create_directory, +) +from backend.services.mutations import ( + create_file as service_create_file, +) +from backend.services.mutations import ( + delete_directory as service_delete_directory, +) +from backend.services.mutations import ( + delete_file as service_delete_file, +) +from backend.services.mutations import ( + edit_file as service_edit_file, +) +from backend.services.mutations import ( + move_path as service_move_path, +) +from backend.services.mutations import ( + rename_directory as service_rename_directory, +) +from backend.services.mutations import ( + rename_file as service_rename_file, +) +from backend.services.mutations import ( + replace_in_files as service_replace_in_files, +) +from backend.services.mutations import ( + restore_backup as service_restore_backup, +) from backend.services.recent import humanize_mtime, list_recent from backend.services.search import advanced_search_vaults, search_paths, search_vaults from backend.services.search import list_tags as service_list_tags @@ -912,39 +940,14 @@ def _resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path: def _backup_file(file_path: Path, vault_name: str, relative_path: str): """Create a timestamped backup of a file before modification. - Backups are stored in {backup_root}/{vault}/{relative_path}.{timestamp}.bak - Silently skips if the file doesn't exist or can't be read. + Thin wrapper around :func:`backend.services.backups.create_backup` + (single implementation used by both routes and tools). Backups are stored + in ``{backup_root}/{vault}/{relative_path}.{timestamp}.bak``; the operation + is best-effort and never blocks the caller. """ - try: - if not file_path.exists() or not file_path.is_file(): - logger.debug(f"Backup skipped: file not found {file_path}") - return - backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup")) - # Resolve relative to vault root (inside vault, so it's always writable) - if not backup_root.is_absolute(): - vault_data = get_vault_data(vault_name) - if vault_data: - backup_root = Path(vault_data["path"]) / backup_root - backup_dir = backup_root / vault_name / Path(relative_path).parent - backup_dir.mkdir(parents=True, exist_ok=True) - timestamp = int(time.time()) - backup_name = f"{file_path.name}.{timestamp}.bak" - backup_path = backup_dir / backup_name - shutil.copy2(file_path, backup_path) - logger.info(f"Backup created: {relative_path} -> {backup_path}") + from backend.services.backups import create_backup - # Auto-cleanup: keep only the N most recent backups - config = _load_config() - max_backups = config.get("max_backups_per_file", 10) - all_backups = sorted( - [f for f in backup_dir.iterdir() if f.is_file() and f.name.startswith(file_path.name + ".") and f.name.endswith(".bak")], - key=lambda f: f.stat().st_mtime, reverse=True - ) - for old in all_backups[max_backups:]: - old.unlink() - logger.debug(f"Auto-cleanup: removed old backup {old.name}") - except Exception as e: - logger.warning(f"Failed to backup {relative_path} (vault={vault_name}): {e}", exc_info=True) + create_backup(file_path, vault_name, relative_path) def _check_vault_writable(vault_root: Path) -> bool: @@ -1642,36 +1645,15 @@ async def api_file_save( """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - vault_data = get_vault_data(vault_name) - if not vault_data: - raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") - vault_root = Path(vault_data["path"]) - file_path = _resolve_safe_path(vault_root, path) + content = body.get("content", "") + result = service_edit_file(vault_name, path, content, backup=backup) - if not file_path.exists(): - raise HTTPException(status_code=404, detail=f"File not found: {path}") + # Audit log + client_ip = current_user.get("_request_ip", "unknown") + log_file_save(current_user["username"], vault_name, path, len(content), client_ip) - content = body.get('content', '') - - try: - # Backup original content before overwriting (skip on auto-save) - if backup: - _backup_file(file_path, vault_name, path) - - file_path.write_text(content, encoding="utf-8") - logger.info(f"File saved: {vault_name}/{path}") - - # Audit log - client_ip = current_user.get("_request_ip", "unknown") - log_file_save(current_user["username"], vault_name, path, len(content), client_ip) - - return {"status": "ok", "vault": vault_name, "path": path, "size": len(content)} - except PermissionError: - raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only") - except Exception as e: - logger.error(f"Error saving file {vault_name}/{path}: {e}") - raise HTTPException(status_code=500, detail=f"Error saving file: {e!s}") + return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]} @app.delete("/api/file/{vault_name}", response_model=FileDeleteResponse) @@ -1689,55 +1671,32 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - vault_data = get_vault_data(vault_name) - if not vault_data: - raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") - vault_root = Path(vault_data["path"]) - file_path = _resolve_safe_path(vault_root, path) + result = service_delete_file(vault_name, path) - if not file_path.exists() or not file_path.is_file(): - raise HTTPException(status_code=404, detail=f"File not found: {path}") + # Audit log + client_ip = current_user.get("_request_ip", "unknown") + log_file_delete(current_user["username"], vault_name, path, client_ip) - # Check if vault is writable - if not _check_vault_writable(vault_root): - raise HTTPException(status_code=403, detail="Vault is read-only") + # Update index + await remove_single_file(vault_name, path) - try: - # Backup original content before deletion - _backup_file(file_path, vault_name, path) + # Broadcast SSE event + await sse_manager.broadcast("file_deleted", { + "vault": vault_name, + "path": path, + }) - file_path.unlink() - logger.info(f"File deleted: {vault_name}/{path}") + from backend.plugins import emit_file_deleted + emit_file_deleted(vault_name, path) - # Audit log - client_ip = current_user.get("_request_ip", "unknown") - log_file_delete(current_user["username"], vault_name, path, client_ip) + # Remove from recent files + remove_recent(current_user["username"], vault_name, path) - # Update index - await remove_single_file(vault_name, path) - - # Broadcast SSE event - await sse_manager.broadcast("file_deleted", { - "vault": vault_name, - "path": path, - }) + # Dispatch webhooks + await dispatch_webhooks("file_deleted", {"vault": vault_name, "path": path}) - from backend.plugins import emit_file_deleted - emit_file_deleted(vault_name, path) - - # Remove from recent files - remove_recent(current_user["username"], vault_name, path) - - # Dispatch webhooks - await dispatch_webhooks("file_deleted", {"vault": vault_name, "path": path}) - - return {"status": "ok", "vault": vault_name, "path": path} - except PermissionError: - raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only") - except Exception as e: - logger.error(f"Error deleting file {vault_name}/{path}: {e}") - raise HTTPException(status_code=500, detail=f"Error deleting file: {e!s}") + return {"status": "ok", "vault": result["vault"], "path": result["path"]} # --------------------------------------------------------------------------- @@ -1761,61 +1720,36 @@ async def api_directory_create( """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - - vault_data = get_vault_data(vault_name) - if not vault_data: - raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") - - vault_root = Path(vault_data["path"]) - - # Check if vault is writable - if not _check_vault_writable(vault_root): - raise HTTPException(status_code=403, detail="Vault is read-only") - - # Resolve and validate path - dir_path = _resolve_safe_path(vault_root, body.path) - - # Check if directory already exists - if dir_path.exists(): - raise HTTPException(status_code=409, detail=f"Directory already exists: {body.path}") - - try: - # Create directory with parents - dir_path.mkdir(parents=True, exist_ok=False) - logger.info(f"Directory created: {vault_name}/{body.path}") - - # Update path_index with the new directory - from backend.indexer import _index_lock - from backend.indexer import path_index as _path_idx - with _index_lock: - if vault_name not in _path_idx: - _path_idx[vault_name] = [] - existing = {p["path"] for p in _path_idx[vault_name]} - # Build all parent segments - parts = body.path.split("/") - for i in range(1, len(parts) + 1): - seg_path = "/".join(parts[:i]) - if seg_path and seg_path not in existing: - existing.add(seg_path) - _path_idx[vault_name].append({ - "path": seg_path, - "name": parts[i - 1], - "type": "directory", - }) - - # Broadcast SSE event - await sse_manager.broadcast("directory_created", { - "vault": vault_name, - "path": body.path, - }) - await dispatch_webhooks("directory_created", {"vault": vault_name, "path": body.path}) - - return {"success": True, "path": body.path} - except PermissionError: - raise HTTPException(status_code=403, detail="Permission denied: cannot create directory") - except Exception as e: - logger.error(f"Error creating directory {vault_name}/{body.path}: {e}") - raise HTTPException(status_code=500, detail=f"Error creating directory: {e!s}") + + result = service_create_directory(vault_name, body.path) + + # Update path_index with the new directory + from backend.indexer import _index_lock + from backend.indexer import path_index as _path_idx + with _index_lock: + if vault_name not in _path_idx: + _path_idx[vault_name] = [] + existing = {p["path"] for p in _path_idx[vault_name]} + # Build all parent segments + parts = body.path.split("/") + for i in range(1, len(parts) + 1): + seg_path = "/".join(parts[:i]) + if seg_path and seg_path not in existing: + existing.add(seg_path) + _path_idx[vault_name].append({ + "path": seg_path, + "name": parts[i - 1], + "type": "directory", + }) + + # Broadcast SSE event + await sse_manager.broadcast("directory_created", { + "vault": vault_name, + "path": result["path"], + }) + await dispatch_webhooks("directory_created", {"vault": vault_name, "path": result["path"]}) + + return {"success": True, "path": result["path"]} @app.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse) @@ -1835,60 +1769,24 @@ async def api_directory_rename( """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - - vault_data = get_vault_data(vault_name) - if not vault_data: - raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") - - vault_root = Path(vault_data["path"]) - - # Check if vault is writable - if not _check_vault_writable(vault_root): - raise HTTPException(status_code=403, detail="Vault is read-only") - - # Resolve old path - old_path = _resolve_safe_path(vault_root, body.path) - - if not old_path.exists() or not old_path.is_dir(): - raise HTTPException(status_code=404, detail=f"Directory not found: {body.path}") - - # Construct new path (same parent, new name) - new_path = old_path.parent / body.new_name - - # Validate new path is still within vault - new_path = _resolve_safe_path(vault_root, str(new_path.relative_to(vault_root))) - - # Check if destination already exists - if new_path.exists(): - raise HTTPException(status_code=409, detail=f"Destination already exists: {body.new_name}") - - try: - # Rename directory - old_path.rename(new_path) - - old_path_str = str(old_path.relative_to(vault_root)).replace("\\", "/") - new_path_str = str(new_path.relative_to(vault_root)).replace("\\", "/") - - logger.info(f"Directory renamed: {vault_name}/{old_path_str} -> {new_path_str}") - - # Update index for all files in the directory - from backend.indexer import reload_single_vault - await reload_single_vault(vault_name) - - # Broadcast SSE event - await sse_manager.broadcast("directory_renamed", { - "vault": vault_name, - "old_path": old_path_str, - "new_path": new_path_str, - }) - await dispatch_webhooks("directory_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str}) - - return {"success": True, "old_path": old_path_str, "new_path": new_path_str} - except PermissionError: - raise HTTPException(status_code=403, detail="Permission denied: cannot rename directory") - except Exception as e: - logger.error(f"Error renaming directory {vault_name}/{body.path}: {e}") - raise HTTPException(status_code=500, detail=f"Error renaming directory: {e!s}") + + result = service_rename_directory(vault_name, body.path, body.new_name) + old_path_str = result["old_path"] + new_path_str = result["new_path"] + + # Update index for all files in the directory + from backend.indexer import reload_single_vault + await reload_single_vault(vault_name) + + # Broadcast SSE event + await sse_manager.broadcast("directory_renamed", { + "vault": vault_name, + "old_path": old_path_str, + "new_path": new_path_str, + }) + await dispatch_webhooks("directory_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str}) + + return {"success": True, "old_path": old_path_str, "new_path": new_path_str} @app.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse) @@ -1908,49 +1806,23 @@ async def api_directory_delete( """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - - vault_data = get_vault_data(vault_name) - if not vault_data: - raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") - - vault_root = Path(vault_data["path"]) - - # Check if vault is writable - if not _check_vault_writable(vault_root): - raise HTTPException(status_code=403, detail="Vault is read-only") - - # Resolve and validate path - dir_path = _resolve_safe_path(vault_root, path) - - if not dir_path.exists() or not dir_path.is_dir(): - raise HTTPException(status_code=404, detail=f"Directory not found: {path}") - - try: - # Count files before deletion - file_count = sum(1 for _ in dir_path.rglob('*') if _.is_file()) - - # Delete directory recursively - shutil.rmtree(dir_path) - logger.info(f"Directory deleted: {vault_name}/{path} ({file_count} files)") - - # Update index - from backend.indexer import reload_single_vault - await reload_single_vault(vault_name) - - # Broadcast SSE event - await sse_manager.broadcast("directory_deleted", { - "vault": vault_name, - "path": path, - "deleted_count": file_count, - }) - await dispatch_webhooks("directory_deleted", {"vault": vault_name, "path": path}) - - return {"success": True, "deleted_count": file_count} - except PermissionError: - raise HTTPException(status_code=403, detail="Permission denied: cannot delete directory") - except Exception as e: - logger.error(f"Error deleting directory {vault_name}/{path}: {e}") - raise HTTPException(status_code=500, detail=f"Error deleting directory: {e!s}") + + result = service_delete_directory(vault_name, path, recursive=True) + file_count = result["deleted_count"] + + # Update index + from backend.indexer import reload_single_vault + await reload_single_vault(vault_name) + + # Broadcast SSE event + await sse_manager.broadcast("directory_deleted", { + "vault": vault_name, + "path": result["path"], + "deleted_count": file_count, + }) + await dispatch_webhooks("directory_deleted", {"vault": vault_name, "path": result["path"]}) + + return {"success": True, "deleted_count": file_count} # --------------------------------------------------------------------------- @@ -1974,60 +1846,22 @@ async def api_file_create( """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - - vault_data = get_vault_data(vault_name) - if not vault_data: - raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") - - vault_root = Path(vault_data["path"]) - - # Check if vault is writable - if not _check_vault_writable(vault_root): - raise HTTPException(status_code=403, detail="Vault is read-only") - - # Resolve and validate path - file_path = _resolve_safe_path(vault_root, body.path) - - # Validate file extension - ext = file_path.suffix.lower() - if ext not in SUPPORTED_EXTENSIONS and file_path.name.lower() not in ("dockerfile", "makefile"): - raise HTTPException(status_code=400, detail=f"Unsupported file extension: {ext}") - - # Check if file already exists - if file_path.exists(): - raise HTTPException(status_code=409, detail=f"File already exists: {body.path}") - - try: - # Create parent directories if needed - file_path.parent.mkdir(parents=True, exist_ok=True) - - # For .excalidraw files, inject skeleton JSON if content is empty - content = body.content - if ext == ".excalidraw" and not content.strip(): - content = '{"type":"excalidraw","version":2,"elements":[],"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}' - - # Create file with initial content - file_path.write_text(content, encoding="utf-8") - logger.info(f"File created: {vault_name}/{body.path}") - - # Update index - await update_single_file(vault_name, body.path) - - # Broadcast SSE event - await sse_manager.broadcast("file_created", { - "vault": vault_name, - "path": body.path, - }) - await dispatch_webhooks("file_created", {"vault": vault_name, "path": body.path}) - from backend.plugins import emit_file_created - emit_file_created(vault_name, body.path) - - return {"success": True, "path": body.path} - except PermissionError: - raise HTTPException(status_code=403, detail="Permission denied: cannot create file") - except Exception as e: - logger.error(f"Error creating file {vault_name}/{body.path}: {e}") - raise HTTPException(status_code=500, detail=f"Error creating file: {e!s}") + + result = service_create_file(vault_name, body.path, body.content) + + # Update index + await update_single_file(vault_name, result["path"]) + + # Broadcast SSE event + await sse_manager.broadcast("file_created", { + "vault": vault_name, + "path": result["path"], + }) + await dispatch_webhooks("file_created", {"vault": vault_name, "path": result["path"]}) + from backend.plugins import emit_file_created + emit_file_created(vault_name, result["path"]) + + return {"success": True, "path": result["path"]} @app.patch("/api/file/{vault_name}", response_model=FileRenameResponse) @@ -2047,69 +1881,28 @@ async def api_file_rename( """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - - vault_data = get_vault_data(vault_name) - if not vault_data: - raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") - - vault_root = Path(vault_data["path"]) - - # Check if vault is writable - if not _check_vault_writable(vault_root): - raise HTTPException(status_code=403, detail="Vault is read-only") - - # Resolve old path - old_path = _resolve_safe_path(vault_root, body.path) - - if not old_path.exists() or not old_path.is_file(): - raise HTTPException(status_code=404, detail=f"File not found: {body.path}") - - # Construct new path (same parent, new name) - new_path = old_path.parent / body.new_name - - # Validate new path is still within vault - new_path = _resolve_safe_path(vault_root, str(new_path.relative_to(vault_root))) - - # Validate file extension - ext = new_path.suffix.lower() - if ext not in SUPPORTED_EXTENSIONS and new_path.name.lower() not in ("dockerfile", "makefile"): - raise HTTPException(status_code=400, detail=f"Unsupported file extension: {ext}") - - # Check if destination already exists - if new_path.exists(): - raise HTTPException(status_code=409, detail=f"Destination already exists: {body.new_name}") - - try: - # Rename file - old_path.rename(new_path) - - old_path_str = str(old_path.relative_to(vault_root)).replace("\\", "/") - new_path_str = str(new_path.relative_to(vault_root)).replace("\\", "/") - - logger.info(f"File renamed: {vault_name}/{old_path_str} -> {new_path_str}") - - # Update index - await handle_file_move(vault_name, old_path_str, new_path_str) - - # Update bookmarks, history, and shares - update_bookmarks_after_rename(vault_name, old_path_str, new_path_str) - update_history_after_rename(vault_name, old_path_str, new_path_str) - update_shares_after_rename(vault_name, old_path_str, new_path_str) - - # Broadcast SSE event - await sse_manager.broadcast("file_renamed", { - "vault": vault_name, - "old_path": old_path_str, - "new_path": new_path_str, - }) - await dispatch_webhooks("file_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str}) - - return {"success": True, "old_path": old_path_str, "new_path": new_path_str} - except PermissionError: - raise HTTPException(status_code=403, detail="Permission denied: cannot rename file") - except Exception as e: - logger.error(f"Error renaming file {vault_name}/{body.path}: {e}") - raise HTTPException(status_code=500, detail=f"Error renaming file: {e!s}") + + result = service_rename_file(vault_name, body.path, body.new_name) + old_path_str = result["old_path"] + new_path_str = result["new_path"] + + # Update index + await handle_file_move(vault_name, old_path_str, new_path_str) + + # Update bookmarks, history, and shares + update_bookmarks_after_rename(vault_name, old_path_str, new_path_str) + update_history_after_rename(vault_name, old_path_str, new_path_str) + update_shares_after_rename(vault_name, old_path_str, new_path_str) + + # Broadcast SSE event + await sse_manager.broadcast("file_renamed", { + "vault": vault_name, + "old_path": old_path_str, + "new_path": new_path_str, + }) + await dispatch_webhooks("file_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str}) + + return {"success": True, "old_path": old_path_str, "new_path": new_path_str} @app.post("/api/move/{vault_name}", response_model=FileMoveResponse) @@ -2133,95 +1926,28 @@ async def api_file_move( if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - vault_data = get_vault_data(vault_name) - if not vault_data: - raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") + result = service_move_path(vault_name, body.source_path, body.destination_dir) + old_path_str = result["old_path"] + new_path_str = result["new_path"] + item_type = result["item_type"] - vault_root = Path(vault_data["path"]) - - # Check if vault is writable - if not _check_vault_writable(vault_root): - raise HTTPException(status_code=403, detail="Vault is read-only") - - # Resolve source path - source = _resolve_safe_path(vault_root, body.source_path) - - if not source.exists(): - raise HTTPException(status_code=404, detail=f"Source not found: {body.source_path}") - - is_directory = source.is_dir() - - # Determine the item name (last segment) - item_name = source.name - - # Construct destination path: destination_dir / item_name - dest_dir_clean = body.destination_dir.strip("/") - if dest_dir_clean: - dest_parent = _resolve_safe_path(vault_root, dest_dir_clean) - if not dest_parent.exists() or not dest_parent.is_dir(): - raise HTTPException(status_code=404, detail=f"Destination directory not found: {body.destination_dir}") + # Update index + if item_type == "directory": + from backend.indexer import reload_single_vault + await reload_single_vault(vault_name) else: - dest_parent = vault_root + await handle_file_move(vault_name, old_path_str, new_path_str) - destination = dest_parent / item_name + # Broadcast SSE event + await sse_manager.broadcast("item_moved", { + "vault": vault_name, + "old_path": old_path_str, + "new_path": new_path_str, + "item_type": item_type, + }) + await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}) - # Validate destination is within vault - destination = _resolve_safe_path(vault_root, str(destination.relative_to(vault_root))) - - # Don't move to the same location - if source.resolve() == destination.resolve(): - return { - "success": True, - "old_path": body.source_path, - "new_path": body.source_path, - "item_type": "directory" if is_directory else "file", - } - - # Check if destination already exists - if destination.exists(): - raise HTTPException(status_code=409, detail=f"A file or directory already exists at the destination: {destination.name}") - - # For files, validate extension - if not is_directory: - ext = destination.suffix.lower() - if ext not in SUPPORTED_EXTENSIONS and destination.name.lower() not in ("dockerfile", "makefile"): - raise HTTPException(status_code=400, detail=f"Unsupported file extension: {ext}") - - try: - # Move the file/directory - source.rename(destination) - - old_path_str = str(source.relative_to(vault_root)).replace("\\", "/") - # After rename, source is now at destination — reconstruct the new relative path - new_path_str = body.destination_dir.strip("/") - new_path_str = (new_path_str + "/" if new_path_str else "") + item_name - - item_type = "directory" if is_directory else "file" - - logger.info(f"Item moved: {vault_name}/{old_path_str} -> {new_path_str}") - - # Update index - if is_directory: - from backend.indexer import reload_single_vault - await reload_single_vault(vault_name) - else: - await handle_file_move(vault_name, old_path_str, new_path_str) - - # Broadcast SSE event - await sse_manager.broadcast("item_moved", { - "vault": vault_name, - "old_path": old_path_str, - "new_path": new_path_str, - "item_type": item_type, - }) - await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}) - - return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type} - except PermissionError: - raise HTTPException(status_code=403, detail="Permission denied: cannot move item") - except Exception as e: - logger.error(f"Error moving item {vault_name}/{body.source_path}: {e}") - raise HTTPException(status_code=500, detail=f"Error moving item: {e!s}") + return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type} # --------------------------------------------------------------------------- @@ -2331,64 +2057,28 @@ async def api_file_restore( if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - vault_data = get_vault_data(vault_name) - if not vault_data: - raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") + result = service_restore_backup(vault_name, path, body.version) + current_backed_up = result["current_backed_up"] - vault_root = Path(vault_data["path"]) + # Update index + await update_single_file(vault_name, path) - # Check if vault is writable - if not _check_vault_writable(vault_root): - raise HTTPException(status_code=403, detail="Vault is read-only") + # Broadcast SSE event + await sse_manager.broadcast("file_restored", { + "vault": vault_name, + "path": path, + "restored_from": body.version, + "current_backed_up": current_backed_up, + }) + await dispatch_webhooks("file_restored", {"vault": vault_name, "path": path, "restored_from": body.version}) - file_path = _resolve_safe_path(vault_root, path) - original_name = Path(path).name - - backup_dir = _get_backup_dir(vault_name, path) - backup_path = backup_dir / f"{original_name}.{body.version}.bak" - - if not backup_path.exists(): - raise HTTPException(status_code=404, detail=f"Backup version {body.version} not found for {path}") - - try: - # Backup the current file before restoring - current_backed_up = None - if file_path.exists() and file_path.is_file(): - _backup_file(file_path, vault_name, path) - current_backed_up = int(time.time()) - - # Read backup content - backup_content = backup_path.read_text(encoding="utf-8") - - # Write restored content - file_path.write_text(backup_content, encoding="utf-8") - - logger.info(f"File restored from backup: {vault_name}/{path} <- version {body.version}") - - # Update index - await update_single_file(vault_name, path) - - # Broadcast SSE event - await sse_manager.broadcast("file_restored", { - "vault": vault_name, - "path": path, - "restored_from": body.version, - "current_backed_up": current_backed_up, - }) - await dispatch_webhooks("file_restored", {"vault": vault_name, "path": path, "restored_from": body.version}) - - return { - "success": True, - "vault": vault_name, - "path": path, - "restored_from": body.version, - "current_backed_up": current_backed_up, - } - except PermissionError: - raise HTTPException(status_code=403, detail="Permission denied: cannot restore file") - except Exception as e: - logger.error(f"Error restoring file {vault_name}/{path}: {e}") - raise HTTPException(status_code=500, detail=f"Error restoring file: {e!s}") + return { + "success": True, + "vault": vault_name, + "path": path, + "restored_from": body.version, + "current_backed_up": current_backed_up, + } @app.get("/api/file/{vault_name}/backlinks", response_model=BacklinksResponse) @@ -2924,7 +2614,6 @@ async def api_search_replace( current_user=Depends(require_auth), ): """Find and replace across vault files.""" - import re as re_mod query = body.get("query", "") replacement = body.get("replacement", "") vault_filter = body.get("vault", "all") @@ -2939,71 +2628,32 @@ async def api_search_replace( if not query: raise HTTPException(400, "Query is required") - search_results = advanced_search( - query, vault_filter=vault_filter, - case_sensitive=case_sensitive, whole_word=whole_word, - regex=regex_mode, include_paths=include_paths, exclude_paths=exclude_paths, - limit=500, sort_by="relevance", + result = service_replace_in_files( + query, + replacement, + vault=vault_filter, + case_sensitive=case_sensitive, + whole_word=whole_word, + regex=regex_mode, + include_paths=include_paths, + exclude_paths=exclude_paths, + replace_all=replace_all, + dry_run=dry_run, + is_vault_allowed=lambda v: check_vault_access(v, current_user), ) - if not search_results["results"]: - return {"matches": [], "total_matches": 0} - - flags = 0 if case_sensitive else re_mod.IGNORECASE - if regex_mode: - pattern = re_mod.compile(query, flags) - elif whole_word: - pattern = re_mod.compile(rf"\b{re_mod.escape(query)}\b", flags) - else: - pattern = re_mod.compile(re_mod.escape(query), flags) - - matches = [] - total_replacements = 0 - - for result in search_results["results"]: - if not check_vault_access(result["vault"], current_user): - continue - vault_data = get_vault_data(result["vault"]) - if not vault_data: - continue - file_path = _resolve_safe_path(Path(vault_data["path"]), result["path"]) - if not file_path.exists(): - continue - try: - original = file_path.read_text(encoding="utf-8", errors="replace") - except Exception: # nosec B112 — fichier illisible, on passe au suivant - continue - occurrences = list(pattern.finditer(original)) - if not occurrences: - continue - if dry_run: - previews = [] - for m in occurrences[:3]: - start = max(0, m.start() - 40) - end = min(len(original), m.end() + 40) - previews.append(f"...{original[start:end]}...") - matches.append({ - "vault": result["vault"], "path": result["path"], - "title": result["title"], "match_count": len(occurrences), - "preview": previews, - }) - total_replacements += len(occurrences) - else: - new_content, count = pattern.subn(replacement, original) - if count > 0: - _backup_file(file_path, result["vault"], result["path"]) - file_path.write_text(new_content, encoding="utf-8") - log_file_save(current_user["username"], result["vault"], result["path"], len(new_content)) - matches.append({ - "vault": result["vault"], "path": result["path"], - "title": result["title"], "replacements": count, - }) - total_replacements += count - await update_single_file(result["vault"], str(file_path)) - if dry_run: - return {"matches": matches, "total_matches": total_replacements, "dry_run": True} - return {"replaced": matches, "total_replacements": total_replacements} + return result + + # Side effects for applied replacements (audit + incremental index). + for match in result.get("replaced", []): + log_file_save(current_user["username"], match["vault"], match["path"], match.get("size", 0)) + vault_data = get_vault_data(match["vault"]) + if vault_data: + abs_path = str(Path(vault_data["path"]) / match["path"]) + await update_single_file(match["vault"], abs_path) + + return result @app.get("/api/suggest", response_model=SuggestResponse) diff --git a/backend/services/backups.py b/backend/services/backups.py index 01ee019..c843c6e 100644 --- a/backend/services/backups.py +++ b/backend/services/backups.py @@ -12,6 +12,8 @@ from __future__ import annotations import difflib import logging import os +import shutil +import time from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -22,6 +24,19 @@ from backend.services.vaults import get_vault_root logger = logging.getLogger("obsigate.services.backups") +# Default number of backups kept per file (matches ``max_backups_per_file``). +DEFAULT_MAX_BACKUPS = 10 + + +def _default_max_backups() -> int: + """Read ``max_backups_per_file`` from app config (lazy, best-effort).""" + try: + from backend.main import _load_config + + return int(_load_config().get("max_backups_per_file", DEFAULT_MAX_BACKUPS)) + except Exception: # pragma: no cover - config unavailable + return DEFAULT_MAX_BACKUPS + def get_backup_dir(vault_name: str, relative_path: str) -> Path: """Return the directory where backups for a specific file are stored. @@ -39,6 +54,59 @@ def get_backup_dir(vault_name: str, relative_path: str) -> Path: return backup_root / vault_name / Path(relative_path).parent +def create_backup( + file_path: Path, + vault_name: str, + relative_path: str, + *, + max_backups: int | None = None, +) -> Path | None: + """Create a timestamped backup of a file before it is modified. + + Backups are stored as ``{backup_root}/{vault}/{relative_path}.{ts}.bak``. + Missing or unreadable files are skipped silently (returns ``None``) so a + backup failure never blocks the caller's mutation. + + Args: + file_path: Absolute path of the file to back up. + vault_name: Name of the vault the file belongs to. + relative_path: Vault-relative path (used to mirror the tree). + max_backups: Number of backups to keep per file. ``None`` reads + ``max_backups_per_file`` from the app config. + + Returns: + The created backup path, or ``None`` when skipped. + """ + try: + if not file_path.exists() or not file_path.is_file(): + logger.debug(f"Backup skipped: file not found {file_path}") + return None + + backup_dir = get_backup_dir(vault_name, relative_path) + backup_dir.mkdir(parents=True, exist_ok=True) + backup_path = backup_dir / f"{file_path.name}.{int(time.time())}.bak" + shutil.copy2(file_path, backup_path) + logger.info(f"Backup created: {relative_path} -> {backup_path}") + + keep = max_backups if max_backups is not None else _default_max_backups() + all_backups = sorted( + [ + f + for f in backup_dir.iterdir() + if f.is_file() and f.name.startswith(file_path.name + ".") and f.name.endswith(".bak") + ], + key=lambda f: f.stat().st_mtime, + reverse=True, + ) + for old in all_backups[keep:]: + old.unlink() + logger.debug(f"Auto-cleanup: removed old backup {old.name}") + return backup_path + except Exception as e: + logger.warning(f"Failed to backup {relative_path} (vault={vault_name}): {e}", exc_info=True) + return None + + def list_backup_files(vault_name: str, relative_path: str) -> list[dict[str, Any]]: """List all backup files for a vault file, sorted newest first. diff --git a/backend/services/mutations.py b/backend/services/mutations.py new file mode 100644 index 0000000..0b0d188 --- /dev/null +++ b/backend/services/mutations.py @@ -0,0 +1,675 @@ +"""File and directory mutation services shared by REST routes and the AI tool layer. + +Single source of truth for the write-side operations (create, edit, append, +rename, move, delete, restore, find/replace). Every function performs the +anti path-traversal check, the read-only guard and an automatic backup before +any destructive change, then returns a JSON-friendly result dict. + +Index refresh, SSE broadcasts, webhooks and audit logging stay in the route / +agent layers: these services are synchronous and side-effect free apart from +the filesystem mutation (plus the backup). +""" + +from __future__ import annotations + +import logging +import os +import shutil +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from backend.services.backups import create_backup, get_backup_dir +from backend.services.errors import ServiceError +from backend.services.paths import resolve_safe_path +from backend.services.vaults import get_vault_root + +logger = logging.getLogger("obsigate.services.mutations") + +# Skeleton injected into empty ``.excalidraw`` files (mirrors the route logic). +_EXCALIDRAW_SKELETON = ( + '{"type":"excalidraw","version":2,"elements":[],' + '"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}' +) + + +def _rel(root: Path, path: Path) -> str: + """Return *path* relative to *root* as a POSIX-style string.""" + return str(path.relative_to(root)).replace("\\", "/") + + +def _ensure_writable(root: Path) -> None: + """Raise ``read_only`` (403) when the vault root is not writable.""" + if not os.access(root, os.W_OK): + raise ServiceError("Vault is read-only", code="read_only", status=403) + + +def _validate_extension(file_path: Path) -> None: + """Reject unsupported file extensions (400).""" + from backend.indexer import SUPPORTED_EXTENSIONS + + ext = file_path.suffix.lower() + if ext not in SUPPORTED_EXTENSIONS and file_path.name.lower() not in ("dockerfile", "makefile"): + raise ServiceError( + f"Unsupported file extension: {ext}", + code="unsupported_extension", + status=400, + details={"extension": ext}, + ) + + +def _validate_new_name(name: str) -> str: + """Validate a rename target (a plain name, not a path).""" + candidate = (name or "").strip() + if not candidate or candidate in (".", "..") or "/" in candidate or "\\" in candidate: + raise ServiceError( + f"Invalid name: {name!r}", + code="invalid_arguments", + status=400, + details={"new_name": name}, + ) + return candidate + + +# ── D1. Creation ─────────────────────────────────────────────────────────── + + +def create_file( + vault_name: str, + path: str, + content: str = "", + *, + overwrite: bool = False, +) -> dict[str, Any]: + """Create a text file in a vault. + + Args: + vault_name: Name of the vault. + path: Vault-relative path of the new file. + content: Initial content (an Excalidraw skeleton is injected for empty + ``.excalidraw`` files). + overwrite: When True, replace an existing file (with a backup) instead + of raising ``already_exists``. + + Returns: + ``{"success", "vault", "path", "size"}``. + + Raises: + ServiceError: ``not_found`` (404) unknown vault, ``read_only`` (403), + ``unsupported_extension`` (400) or ``already_exists`` (409). + """ + root = get_vault_root(vault_name) + _ensure_writable(root) + file_path = resolve_safe_path(root, path) + _validate_extension(file_path) + + if file_path.exists(): + if not overwrite: + raise ServiceError( + f"File already exists: {path}", + code="already_exists", + status=409, + details={"vault": vault_name, "path": path}, + ) + create_backup(file_path, vault_name, _rel(root, file_path)) + + try: + file_path.parent.mkdir(parents=True, exist_ok=True) + if file_path.suffix.lower() == ".excalidraw" and not content.strip(): + content = _EXCALIDRAW_SKELETON + file_path.write_text(content, encoding="utf-8") + except PermissionError as e: + raise ServiceError("Permission denied: cannot create file", code="permission_denied", status=403) from e + + rel_path = _rel(root, file_path) + logger.info(f"File created: {vault_name}/{rel_path}") + return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)} + + +def create_directory(vault_name: str, path: str) -> dict[str, Any]: + """Create a directory (and its parents) in a vault. + + Raises: + ServiceError: ``not_found`` (404), ``read_only`` (403) or + ``already_exists`` (409). + """ + root = get_vault_root(vault_name) + _ensure_writable(root) + dir_path = resolve_safe_path(root, path) + + if dir_path.exists(): + raise ServiceError( + f"Directory already exists: {path}", + code="already_exists", + status=409, + details={"vault": vault_name, "path": path}, + ) + + try: + dir_path.mkdir(parents=True, exist_ok=False) + except PermissionError as e: + raise ServiceError("Permission denied: cannot create directory", code="permission_denied", status=403) from e + + rel_path = _rel(root, dir_path) + logger.info(f"Directory created: {vault_name}/{rel_path}") + return {"success": True, "vault": vault_name, "path": rel_path} + + +# ── D2. Edition / rename / move ──────────────────────────────────────────── + + +def edit_file( + vault_name: str, + path: str, + content: str, + *, + backup: bool = True, +) -> dict[str, Any]: + """Overwrite an existing file's content (with a backup by default). + + Raises: + ServiceError: ``not_found`` (404) or ``read_only`` (403). + """ + root = get_vault_root(vault_name) + _ensure_writable(root) + file_path = resolve_safe_path(root, path) + + if not file_path.exists() or not file_path.is_file(): + raise ServiceError( + f"File not found: {path}", + code="not_found", + status=404, + details={"vault": vault_name, "path": path}, + ) + + rel_path = _rel(root, file_path) + if backup: + create_backup(file_path, vault_name, rel_path) + + try: + file_path.write_text(content, encoding="utf-8") + except PermissionError as e: + raise ServiceError("Permission denied: cannot save file", code="permission_denied", status=403) from e + + logger.info(f"File saved: {vault_name}/{rel_path}") + return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)} + + +def append_to_file( + vault_name: str, + path: str, + content: str, + *, + backup: bool = True, +) -> dict[str, Any]: + """Append text to an existing file (a newline is inserted if needed). + + Raises: + ServiceError: ``not_found`` (404) or ``read_only`` (403). + """ + root = get_vault_root(vault_name) + _ensure_writable(root) + file_path = resolve_safe_path(root, path) + + if not file_path.exists() or not file_path.is_file(): + raise ServiceError( + f"File not found: {path}", + code="not_found", + status=404, + details={"vault": vault_name, "path": path}, + ) + + rel_path = _rel(root, file_path) + if backup: + create_backup(file_path, vault_name, rel_path) + + try: + existing = file_path.read_text(encoding="utf-8", errors="replace") + separator = "" if (not existing or existing.endswith("\n")) else "\n" + new_content = existing + separator + content + file_path.write_text(new_content, encoding="utf-8") + except PermissionError as e: + raise ServiceError("Permission denied: cannot append to file", code="permission_denied", status=403) from e + + logger.info(f"File appended: {vault_name}/{rel_path} (+{len(content)} chars)") + return { + "success": True, + "vault": vault_name, + "path": rel_path, + "appended": len(content), + "size": len(new_content), + } + + +def rename_file(vault_name: str, path: str, new_name: str) -> dict[str, Any]: + """Rename a file in place (same parent directory). + + Raises: + ServiceError: ``not_found`` (404), ``read_only`` (403), + ``unsupported_extension`` (400) or ``already_exists`` (409). + """ + root = get_vault_root(vault_name) + _ensure_writable(root) + old_path = resolve_safe_path(root, path) + + if not old_path.exists() or not old_path.is_file(): + raise ServiceError( + f"File not found: {path}", + code="not_found", + status=404, + details={"vault": vault_name, "path": path}, + ) + + new_path = old_path.parent / _validate_new_name(new_name) + new_path = resolve_safe_path(root, _rel(root, new_path)) + _validate_extension(new_path) + + if new_path.exists(): + raise ServiceError( + f"Destination already exists: {new_name}", + code="already_exists", + status=409, + details={"vault": vault_name, "new_name": new_name}, + ) + + old_rel = _rel(root, old_path) + try: + old_path.rename(new_path) + except PermissionError as e: + raise ServiceError("Permission denied: cannot rename file", code="permission_denied", status=403) from e + + new_rel = _rel(root, new_path) + logger.info(f"File renamed: {vault_name}/{old_rel} -> {new_rel}") + return {"success": True, "vault": vault_name, "old_path": old_rel, "new_path": new_rel} + + +def rename_directory(vault_name: str, path: str, new_name: str) -> dict[str, Any]: + """Rename a directory in place (same parent directory). + + Raises: + ServiceError: ``not_found`` (404), ``read_only`` (403) or + ``already_exists`` (409). + """ + root = get_vault_root(vault_name) + _ensure_writable(root) + old_path = resolve_safe_path(root, path) + + if not old_path.exists() or not old_path.is_dir(): + raise ServiceError( + f"Directory not found: {path}", + code="not_found", + status=404, + details={"vault": vault_name, "path": path}, + ) + + new_path = old_path.parent / _validate_new_name(new_name) + new_path = resolve_safe_path(root, _rel(root, new_path)) + + if new_path.exists(): + raise ServiceError( + f"Destination already exists: {new_name}", + code="already_exists", + status=409, + details={"vault": vault_name, "new_name": new_name}, + ) + + old_rel = _rel(root, old_path) + try: + old_path.rename(new_path) + except PermissionError as e: + raise ServiceError("Permission denied: cannot rename directory", code="permission_denied", status=403) from e + + new_rel = _rel(root, new_path) + logger.info(f"Directory renamed: {vault_name}/{old_rel} -> {new_rel}") + return {"success": True, "vault": vault_name, "old_path": old_rel, "new_path": new_rel} + + +def move_path(vault_name: str, source_path: str, destination_dir: str = "") -> dict[str, Any]: + """Move a file or directory to another directory within the same vault. + + The item keeps its name; only its parent directory changes. + + Raises: + ServiceError: ``not_found`` (404), ``read_only`` (403), + ``unsupported_extension`` (400) or ``already_exists`` (409). + """ + root = get_vault_root(vault_name) + _ensure_writable(root) + + source = resolve_safe_path(root, source_path) + if not source.exists(): + raise ServiceError( + f"Source not found: {source_path}", + code="not_found", + status=404, + details={"vault": vault_name, "path": source_path}, + ) + + is_directory = source.is_dir() + item_name = source.name + dest_clean = (destination_dir or "").strip("/") + + if dest_clean: + dest_parent = resolve_safe_path(root, dest_clean) + if not dest_parent.exists() or not dest_parent.is_dir(): + raise ServiceError( + f"Destination directory not found: {destination_dir}", + code="not_found", + status=404, + details={"vault": vault_name, "path": destination_dir}, + ) + else: + dest_parent = root + + destination = resolve_safe_path(root, _rel(root, dest_parent / item_name)) + + if source.resolve() == destination.resolve(): + rel = _rel(root, source) + return { + "success": True, + "vault": vault_name, + "old_path": rel, + "new_path": rel, + "item_type": "directory" if is_directory else "file", + } + + if destination.exists(): + raise ServiceError( + f"A file or directory already exists at the destination: {destination.name}", + code="already_exists", + status=409, + details={"vault": vault_name, "new_path": _rel(root, destination)}, + ) + + if not is_directory: + _validate_extension(destination) + + old_rel = _rel(root, source) + try: + source.rename(destination) + except PermissionError as e: + raise ServiceError("Permission denied: cannot move item", code="permission_denied", status=403) from e + + new_rel = _rel(root, destination) + item_type = "directory" if is_directory else "file" + logger.info(f"Item moved: {vault_name}/{old_rel} -> {new_rel}") + return { + "success": True, + "vault": vault_name, + "old_path": old_rel, + "new_path": new_rel, + "item_type": item_type, + } + + +# ── D3. Find & replace ───────────────────────────────────────────────────── + + +def replace_in_files( + find: str, + replacement: str, + *, + vault: str = "all", + case_sensitive: bool = False, + whole_word: bool = False, + regex: bool = False, + include_paths: str | None = None, + exclude_paths: str | None = None, + replace_all: bool = False, + dry_run: bool = True, + is_vault_allowed: Callable[[str], bool] | None = None, +) -> dict[str, Any]: + """Find and replace text across vault files (dry-run by default). + + A backup is created before every file is rewritten. When + ``is_vault_allowed`` is provided, files from vaults it rejects are skipped + (used by the tool layer to enforce per-vault permissions and the + destructive-tools toggle). + + Returns: + ``{"matches", "total_matches"}`` in dry-run mode (with + ``"dry_run": True``) or ``{"replaced", "total_replacements"}`` when + applied. + """ + import re as re_mod + + from backend.services.search import advanced_search_vaults + + if not find: + raise ServiceError("Query is required", code="invalid_arguments", status=400) + + search_results = advanced_search_vaults( + find, + vault=vault, + case_sensitive=case_sensitive, + whole_word=whole_word, + regex=regex, + include_paths=include_paths, + exclude_paths=exclude_paths, + limit=500, + sort="relevance", + ) + + if not search_results["results"]: + return {"matches": [], "total_matches": 0, "dry_run": dry_run} + + flags = 0 if case_sensitive else re_mod.IGNORECASE + if regex: + pattern = re_mod.compile(find, flags) + elif whole_word: + pattern = re_mod.compile(rf"\b{re_mod.escape(find)}\b", flags) + else: + pattern = re_mod.compile(re_mod.escape(find), flags) + + matches: list[dict[str, Any]] = [] + total = 0 + + for result in search_results["results"]: + result_vault = result["vault"] + if is_vault_allowed is not None and not is_vault_allowed(result_vault): + continue + try: + root = get_vault_root(result_vault) + file_path = resolve_safe_path(root, result["path"]) + except ServiceError: + continue + if not file_path.exists() or not file_path.is_file(): + continue + try: + original = file_path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + + occurrences = list(pattern.finditer(original)) + if not occurrences: + continue + + if dry_run: + previews = [] + for m in occurrences[:3]: + start = max(0, m.start() - 40) + end = min(len(original), m.end() + 40) + previews.append(f"...{original[start:end]}...") + matches.append({ + "vault": result_vault, + "path": result["path"], + "title": result.get("title", result["path"]), + "match_count": len(occurrences), + "preview": previews, + }) + total += len(occurrences) + continue + + new_content, count = pattern.subn(replacement, original) + if count == 0: + continue + create_backup(file_path, result_vault, result["path"]) + try: + file_path.write_text(new_content, encoding="utf-8") + except PermissionError as e: + raise ServiceError( + f"Permission denied writing {result['path']}", + code="permission_denied", + status=403, + ) from e + matches.append({ + "vault": result_vault, + "path": result["path"], + "title": result.get("title", result["path"]), + "replacements": count, + "size": len(new_content), + }) + total += count + + if dry_run: + return {"matches": matches, "total_matches": total, "dry_run": True} + return {"replaced": matches, "total_replacements": total, "dry_run": False} + + +# ── D4. Deletion / restore ───────────────────────────────────────────────── + + +def delete_file(vault_name: str, path: str, *, backup: bool = True) -> dict[str, Any]: + """Delete a file (with a backup by default). + + Raises: + ServiceError: ``not_found`` (404) or ``read_only`` (403). + """ + root = get_vault_root(vault_name) + _ensure_writable(root) + file_path = resolve_safe_path(root, path) + + if not file_path.exists() or not file_path.is_file(): + raise ServiceError( + f"File not found: {path}", + code="not_found", + status=404, + details={"vault": vault_name, "path": path}, + ) + + rel_path = _rel(root, file_path) + if backup: + create_backup(file_path, vault_name, rel_path) + + try: + file_path.unlink() + except PermissionError as e: + raise ServiceError("Permission denied: cannot delete file", code="permission_denied", status=403) from e + + logger.info(f"File deleted: {vault_name}/{rel_path}") + return {"success": True, "vault": vault_name, "path": rel_path} + + +def delete_directory(vault_name: str, path: str, *, recursive: bool = True) -> dict[str, Any]: + """Delete a directory (recursively by default). + + Raises: + ServiceError: ``not_found`` (404), ``read_only`` (403), + ``not_empty`` (409) when non-recursive and not empty, or + ``invalid_arguments`` (400) when targeting the vault root. + """ + root = get_vault_root(vault_name) + _ensure_writable(root) + dir_path = resolve_safe_path(root, path) + + if not dir_path.exists() or not dir_path.is_dir(): + raise ServiceError( + f"Directory not found: {path}", + code="not_found", + status=404, + details={"vault": vault_name, "path": path}, + ) + + if dir_path.resolve() == root.resolve(): + raise ServiceError( + "Refusing to delete the vault root", + code="invalid_arguments", + status=400, + details={"vault": vault_name}, + ) + + file_count = sum(1 for p in dir_path.rglob("*") if p.is_file()) + + try: + if recursive: + shutil.rmtree(dir_path) + else: + if any(dir_path.iterdir()): + raise ServiceError( + f"Directory not empty: {path}", + code="not_empty", + status=409, + details={"vault": vault_name, "path": path}, + ) + dir_path.rmdir() + except PermissionError as e: + raise ServiceError("Permission denied: cannot delete directory", code="permission_denied", status=403) from e + + rel_path = _rel(root, dir_path) + logger.info(f"Directory deleted: {vault_name}/{rel_path} ({file_count} files)") + return {"success": True, "vault": vault_name, "path": rel_path, "deleted_count": file_count} + + +def restore_backup( + vault_name: str, + path: str, + version: int, + *, + backup: bool = True, +) -> dict[str, Any]: + """Restore a file from a backup version. + + The current file is backed up first (when ``backup`` is True) so the + operation is reversible. + + Raises: + ServiceError: ``not_found`` (404) when the file or backup is missing, + or ``read_only`` (403). + """ + root = get_vault_root(vault_name) + _ensure_writable(root) + file_path = resolve_safe_path(root, path) + + backup_dir = get_backup_dir(vault_name, path) + backup_path = backup_dir / f"{Path(path).name}.{version}.bak" + if not backup_path.exists(): + raise ServiceError( + f"Backup version {version} not found for {path}", + code="not_found", + status=404, + details={"vault": vault_name, "path": path, "version": version}, + ) + + # Read the target version *before* backing up the current file: both use a + # second-resolution timestamp, so a same-second backup could otherwise + # overwrite the version we are about to restore. + try: + content = backup_path.read_text(encoding="utf-8") + except OSError as e: + raise ServiceError( + f"Failed to read backup {version}: {e}", + code="read_error", + status=500, + details={"vault": vault_name, "path": path, "version": version}, + ) from e + + current_backed_up: int | None = None + if backup and file_path.exists() and file_path.is_file(): + import time as _time + + create_backup(file_path, vault_name, path) + current_backed_up = int(_time.time()) + + try: + file_path.write_text(content, encoding="utf-8") + except PermissionError as e: + raise ServiceError("Permission denied: cannot restore file", code="permission_denied", status=403) from e + + logger.info(f"File restored from backup: {vault_name}/{path} <- version {version}") + return { + "success": True, + "vault": vault_name, + "path": path, + "restored_from": version, + "current_backed_up": current_backed_up, + } diff --git a/backend/tools/context.py b/backend/tools/context.py index 3bc5342..85df083 100644 --- a/backend/tools/context.py +++ b/backend/tools/context.py @@ -139,6 +139,27 @@ class ToolContext: details={"vault": vault}, ) + def destructive_tools_enabled(self, vault: str) -> bool: + """Return whether destructive tools are allowed for *vault*. + + Controlled by the per-vault ``aiDestructiveTools`` setting (default: + enabled). Disabling it blocks delete/rename/move/find-replace tools + while keeping create/edit/append available. + """ + from backend.vault_settings import get_vault_setting + + settings = get_vault_setting(vault) or {} + return bool(settings.get("aiDestructiveTools", True)) + + def require_destructive_allowed(self, vault: str) -> None: + """Raise :class:`ToolPermissionError` if destructive tools are disabled.""" + if not self.destructive_tools_enabled(vault): + raise ToolPermissionError( + f"Destructive tools are disabled for vault '{vault}'", + code="destructive_tools_disabled", + details={"vault": vault}, + ) + def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path: """Resolve a vault-relative path, rejecting traversal outside the vault. diff --git a/backend/tools/registry.py b/backend/tools/registry.py index 28ebfec..88154ab 100644 --- a/backend/tools/registry.py +++ b/backend/tools/registry.py @@ -194,6 +194,13 @@ def call_tool( _audit(ctx, spec, arguments, ok=False, error=e.code) raise + if spec.risk == ToolRisk.DANGEROUS and vault and vault != "all": + try: + ctx.require_destructive_allowed(vault) + except ToolPermissionError as e: + _audit(ctx, spec, arguments, ok=False, error=e.code) + raise + if spec.requires_confirmation and not (confirm or ctx.confirmed): raise ToolConfirmationRequired(spec.name, arguments) diff --git a/backend/tools/schemas.py b/backend/tools/schemas.py index ae3c345..f233255 100644 --- a/backend/tools/schemas.py +++ b/backend/tools/schemas.py @@ -138,6 +138,105 @@ class ListRecentInput(BaseModel): mode: str = Field("opened", description="'opened' (history) or 'modified'") +# ── D. Mutations ─────────────────────────────────────────────────────────── + + +class CreateFileInput(BaseModel): + """Create a new text file in a vault.""" + + vault: str = Field(..., description="Vault name") + path: str = Field(..., description="Vault-relative path of the new file") + content: str = Field("", description="Initial file content") + + +class CreateDirectoryInput(BaseModel): + """Create a new directory in a vault.""" + + vault: str = Field(..., description="Vault name") + path: str = Field(..., description="Vault-relative path of the new directory") + + +class EditFileInput(BaseModel): + """Overwrite the full content of an existing file.""" + + vault: str = Field(..., description="Vault name") + path: str = Field(..., description="Vault-relative file path") + content: str = Field(..., description="New full content of the file") + + +class AppendToFileInput(BaseModel): + """Append text to the end of an existing file.""" + + vault: str = Field(..., description="Vault name") + path: str = Field(..., description="Vault-relative file path") + content: str = Field(..., description="Text to append") + + +class RenameFileInput(BaseModel): + """Rename a file in place (same parent directory).""" + + vault: str = Field(..., description="Vault name") + path: str = Field(..., description="Current vault-relative file path") + new_name: str = Field(..., description="New file name (no directory separator)") + + +class RenameDirectoryInput(BaseModel): + """Rename a directory in place (same parent directory).""" + + vault: str = Field(..., description="Vault name") + path: str = Field(..., description="Current vault-relative directory path") + new_name: str = Field(..., description="New directory name (no directory separator)") + + +class MovePathInput(BaseModel): + """Move a file or directory to another directory within the same vault.""" + + vault: str = Field(..., description="Vault name") + source_path: str = Field(..., description="Current vault-relative path of the file/directory") + destination_dir: str = Field("", description="Target directory path (empty = vault root)") + + +class ReplaceInFilesInput(BaseModel): + """Find and replace text across vault files (destructive, dry-run by default).""" + + find: str = Field(..., min_length=1, description="Text or pattern to search for") + replace: str = Field("", description="Replacement text") + vault: str = Field("all", description="Vault name or 'all'") + case_sensitive: bool = Field(False, description="Match case") + whole_word: bool = Field(False, description="Match whole words only") + regex: bool = Field(False, description="Treat 'find' as a regular expression") + include_paths: str | None = Field(None, description="Comma-separated glob patterns to include") + exclude_paths: str | None = Field(None, description="Comma-separated glob patterns to exclude") + replace_all: bool = Field(False, description="Apply the replacement (otherwise only preview)") + dry_run: bool | None = Field( + None, + description="Preview matches without writing. Defaults to the opposite of replace_all.", + ) + + +class DeleteFileInput(BaseModel): + """Delete a file from a vault (destructive).""" + + vault: str = Field(..., description="Vault name") + path: str = Field(..., description="Vault-relative file path") + + +class DeleteDirectoryInput(BaseModel): + """Delete a directory from a vault (destructive).""" + + vault: str = Field(..., description="Vault name") + path: str = Field(..., description="Vault-relative directory path") + recursive: bool = Field(True, description="Delete non-empty directories recursively") + + +class RestoreBackupInput(BaseModel): + """Restore a file from one of its backup versions.""" + + vault: str = Field(..., description="Vault name") + path: str = Field(..., description="Vault-relative file path") + version: int = Field(..., description="Backup timestamp to restore") + + class ToolResult(BaseModel): """Uniform result returned by :func:`backend.tools.registry.call_tool`.""" diff --git a/backend/tools/service.py b/backend/tools/service.py index 50069a1..be436cd 100644 --- a/backend/tools/service.py +++ b/backend/tools/service.py @@ -1,9 +1,11 @@ -"""Built-in tool services (Phase 0 + Phase C — read/search catalogue). +"""Built-in tool services (Phase 0 + Phase C read/search + Phase D mutations). These functions are the single source of truth consumed by both the in-app assistant and the MCP server. They delegate to the shared business-logic services (``backend.services``) so routes and tools never diverge. -Mutating tools (create/edit/rename/move/delete) are added in later phases. +Mutating tools (create/edit/rename/move/delete/restore) are registered with +``ToolRisk.WRITE`` or ``ToolRisk.DANGEROUS`` and gated by the registry's +confirmation mechanism (two-step propose/apply). """ from __future__ import annotations @@ -17,6 +19,39 @@ from backend.services.backups import diff_backup as _diff_backup from backend.services.backups import list_backup_files as _list_backup_files from backend.services.files import read_file_text from backend.services.graph import get_graph as _get_graph +from backend.services.mutations import ( + append_to_file as _append_to_file, +) +from backend.services.mutations import ( + create_directory as _create_directory, +) +from backend.services.mutations import ( + create_file as _create_file, +) +from backend.services.mutations import ( + delete_directory as _delete_directory, +) +from backend.services.mutations import ( + delete_file as _delete_file, +) +from backend.services.mutations import ( + edit_file as _edit_file, +) +from backend.services.mutations import ( + move_path as _move_path, +) +from backend.services.mutations import ( + rename_directory as _rename_directory, +) +from backend.services.mutations import ( + rename_file as _rename_file, +) +from backend.services.mutations import ( + replace_in_files as _replace_in_files, +) +from backend.services.mutations import ( + restore_backup as _restore_backup, +) from backend.services.recent import list_recent as _list_recent from backend.services.search import advanced_search_vaults, search_vaults from backend.services.search import list_tags as _list_tags @@ -25,7 +60,13 @@ from backend.services.vaults import browse_directory, list_accessible_vaults, li from backend.tools.context import ToolContext, ToolRisk from backend.tools.registry import tool from backend.tools.schemas import ( + AppendToFileInput, + CreateDirectoryInput, + CreateFileInput, + DeleteDirectoryInput, + DeleteFileInput, DiffBackupInput, + EditFileInput, GetBacklinksInput, GetGraphInput, ListAllFilesInput, @@ -34,8 +75,13 @@ from backend.tools.schemas import ( ListRecentInput, ListTagsInput, ListVaultsInput, + MovePathInput, ReadFileInput, ReadFileRawInput, + RenameDirectoryInput, + RenameFileInput, + ReplaceInFilesInput, + RestoreBackupInput, SearchAdvancedInput, SearchFulltextInput, SearchPathsInput, @@ -300,3 +346,161 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]: limit=params.limit, mode=params.mode, ) + + +# ── D. Mutations ─────────────────────────────────────────────────────────── + + +@tool( + name="create_file", + description="Create a new text file in a vault with optional initial content.", + input_model=CreateFileInput, + risk=ToolRisk.WRITE, + requires_vault=True, +) +def create_file(ctx: ToolContext, params: CreateFileInput) -> dict[str, Any]: + """Create a vault file (fails if it already exists).""" + return _create_file(params.vault, params.path, params.content) + + +@tool( + name="create_directory", + description="Create a new directory (and parents) in a vault.", + input_model=CreateDirectoryInput, + risk=ToolRisk.WRITE, + requires_vault=True, +) +def create_directory(ctx: ToolContext, params: CreateDirectoryInput) -> dict[str, Any]: + """Create a vault directory.""" + return _create_directory(params.vault, params.path) + + +@tool( + name="edit_file", + description="Overwrite the full content of an existing file (automatic backup).", + input_model=EditFileInput, + risk=ToolRisk.WRITE, + requires_vault=True, +) +def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]: + """Replace a vault file's content.""" + return _edit_file(params.vault, params.path, params.content) + + +@tool( + name="append_to_file", + description="Append text to the end of an existing file (automatic backup).", + input_model=AppendToFileInput, + risk=ToolRisk.WRITE, + requires_vault=True, +) +def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any]: + """Append content to a vault file.""" + return _append_to_file(params.vault, params.path, params.content) + + +@tool( + name="rename_file", + description="Rename a file in place (same parent directory). Destructive: requires confirmation.", + input_model=RenameFileInput, + risk=ToolRisk.DANGEROUS, + requires_vault=True, +) +def rename_file(ctx: ToolContext, params: RenameFileInput) -> dict[str, Any]: + """Rename a vault file.""" + return _rename_file(params.vault, params.path, params.new_name) + + +@tool( + name="rename_directory", + description="Rename a directory in place (same parent directory). Destructive: requires confirmation.", + input_model=RenameDirectoryInput, + risk=ToolRisk.DANGEROUS, + requires_vault=True, +) +def rename_directory(ctx: ToolContext, params: RenameDirectoryInput) -> dict[str, Any]: + """Rename a vault directory.""" + return _rename_directory(params.vault, params.path, params.new_name) + + +@tool( + name="move_path", + description="Move a file or directory to another directory in the same vault. Destructive: requires confirmation.", + input_model=MovePathInput, + risk=ToolRisk.DANGEROUS, + requires_vault=True, +) +def move_path(ctx: ToolContext, params: MovePathInput) -> dict[str, Any]: + """Move a vault file or directory.""" + return _move_path(params.vault, params.source_path, params.destination_dir) + + +@tool( + name="replace_in_files", + description=( + "Find and replace text across vault files. Previews by default " + "(dry_run); set replace_all=true to apply. Destructive: requires confirmation." + ), + input_model=ReplaceInFilesInput, + risk=ToolRisk.DANGEROUS, +) +def replace_in_files(ctx: ToolContext, params: ReplaceInFilesInput) -> dict[str, Any]: + """Preview or apply a find/replace, filtered to permitted vaults.""" + if params.vault and params.vault != "all": + ctx.require_vault_access(params.vault) + ctx.require_destructive_allowed(params.vault) + + dry_run = params.dry_run if params.dry_run is not None else not params.replace_all + + def _allowed(vault: str) -> bool: + return ctx.has_vault_access(vault) and ctx.destructive_tools_enabled(vault) + + return _replace_in_files( + params.find, + params.replace, + vault=params.vault, + case_sensitive=params.case_sensitive, + whole_word=params.whole_word, + regex=params.regex, + include_paths=params.include_paths, + exclude_paths=params.exclude_paths, + replace_all=params.replace_all, + dry_run=dry_run, + is_vault_allowed=_allowed, + ) + + +@tool( + name="delete_file", + description="Delete a file from a vault (automatic backup). Destructive: requires confirmation.", + input_model=DeleteFileInput, + risk=ToolRisk.DANGEROUS, + requires_vault=True, +) +def delete_file(ctx: ToolContext, params: DeleteFileInput) -> dict[str, Any]: + """Delete a vault file.""" + return _delete_file(params.vault, params.path) + + +@tool( + name="delete_directory", + description="Delete a directory (recursive by default) from a vault. Destructive: requires confirmation.", + input_model=DeleteDirectoryInput, + risk=ToolRisk.DANGEROUS, + requires_vault=True, +) +def delete_directory(ctx: ToolContext, params: DeleteDirectoryInput) -> dict[str, Any]: + """Delete a vault directory.""" + return _delete_directory(params.vault, params.path, recursive=params.recursive) + + +@tool( + name="restore_backup", + description="Restore a file from one of its backup versions (current version backed up first).", + input_model=RestoreBackupInput, + risk=ToolRisk.WRITE, + requires_vault=True, +) +def restore_backup(ctx: ToolContext, params: RestoreBackupInput) -> dict[str, Any]: + """Restore a vault file from a backup.""" + return _restore_backup(params.vault, params.path, params.version) diff --git a/docs/AI_ARCHITECTURE_GUIDE.md b/docs/AI_ARCHITECTURE_GUIDE.md index 159fe2c..853ce41 100644 --- a/docs/AI_ARCHITECTURE_GUIDE.md +++ b/docs/AI_ARCHITECTURE_GUIDE.md @@ -81,7 +81,9 @@ ObsiGate possède aujourd'hui **deux sous-systèmes IA** qui partagent la même ### 2.5 Limites de l'existant 1. **Pas de tool calling** : parsing regex fragile, pas de résultats structurés, pas de multi-étapes. + → **résolu** (phases B/C/D) : tool calling natif, catalogue lecture/recherche + mutations. 2. **2 actions seulement** (`create_file`, `create_directory`) ; ni lecture active, ni recherche, ni édition, ni suppression exposées au modèle. + → **résolu** (phases C/D) : catalogue complet (lecture, recherche, mutations avec confirmation). 3. **SSE** : ~~non réellement streaming~~ → **corrigé** (B4) : `ai_chat.stream_completion` alimente `/api/ai/bookslm/chat` token par token ; le tool-calling (`/agent`) reste non-streaming (les appels d'outils exigent la réponse complète). 4. **`PROVIDERS` chargé une seule fois** : les modèles par défaut ne sont modifiables que par variables d'environnement (pas de persistance UI). @@ -93,7 +95,7 @@ ObsiGate possède aujourd'hui **deux sous-systèmes IA** qui partagent la même Extraire la logique métier des routes de `backend/main.py` vers des fonctions réutilisables (services). Les routes REST, l'agent in-app et le serveur MCP appellent ces mêmes services. -**Nouveau module `backend/services/` (A2 lecture/recherche + C) :** +**Nouveau module `backend/services/` (A2 lecture/recherche + C + D mutations) :** ``` backend/services/ @@ -101,17 +103,21 @@ backend/services/ ├── paths.py # resolve_safe_path (anti path-traversal, source unique) ├── vaults.py # list_accessible_vaults, browse_directory, get_vault_root, list_all_files ├── files.py # read_raw_file, read_file_text (redaction + quota) +├── mutations.py # create/edit/append/rename/move/delete/restore/replace (D) ├── search.py # search_vaults (pagination), list_tags, advanced_search_vaults, search_paths -├── backups.py # get_backup_dir, list_backup_files, diff_backup +├── backups.py # get_backup_dir, create_backup, list_backup_files, diff_backup ├── graph.py # get_graph (nodes/edges, wikilinks) └── recent.py # list_recent, humanize_mtime ``` Les routes `/api/vaults`, `/api/browse/{vault}`, `/api/file/{vault}/raw`, `/api/search`, `/api/tags`, `/api/recent`, `/api/vault/{vault}/files`, `/api/file/{vault}/backups`, `/api/file/{vault}/diff`, -`/api/graph/{vault}`, `/api/tree-search` et `/api/search/advanced` en sont de simples wrappers, tout -comme les outils correspondants du catalogue (phase C). Un `ServiceError` est traduit en -`HTTPException` (handler global de `backend/main.py`) ou en `ToolError` (`backend/tools/registry.py`). +`/api/graph/{vault}`, `/api/tree-search`, `/api/search/advanced` (phase C) ainsi que +`/api/file/{vault}` (POST/PATCH/DELETE), `/api/file/{vault}/save`, `/api/file/{vault}/restore`, +`/api/directory/{vault}` (POST/PATCH/DELETE), `/api/move/{vault}` et `/api/search/replace` (phase D) +en sont de simples wrappers, tout comme les outils correspondants du catalogue. Un `ServiceError` +est traduit en `HTTPException` (handler global de `backend/main.py`) ou en `ToolError` +(`backend/tools/registry.py`). **Nouveau module `backend/tools/` :** diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a266bce..c9c9a68 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -43,11 +43,11 @@ ### 79. Assistant IA — Outils (function calling) & serveur MCP - **Effort :** 10-15 jours | **Impact :** 🟡 -- **Statut :** 🔵 Phase 0 + A2 + B1/B2/B3/B4/B5/B6/B7 + C + G livrés (2026-09-11). Détail complet : [features/ai-tools-mcp.md](./features/ai-tools-mcp.md) +- **Statut :** 🔵 Phase 0 + A2 + B1/B2/B3/B4/B5/B6/B7 + C + D + G livrés (2026-09-11). Détail complet : [features/ai-tools-mcp.md](./features/ai-tools-mcp.md) - **Description :** Transformer l'assistant BooksLM en agent (lire, chercher, lister, ouvrir, modifier) via **function calling natif**, puis exposer ObsiGate à des **clients MCP externes** (Claude Desktop, Cursor…). Les deux fronts consomment une **couche d'outils partagée**. - **Reste à faire :** - [x] **C.** Catalogue lecture & recherche (vaults, read, backlinks, backups, search, tags) - - [ ] **D.** Catalogue mutations (create/edit/append/rename/move/delete) + confirmations two-step + - [x] **D.** Catalogue mutations (create/edit/append/rename/move/delete) + confirmations two-step - [ ] **E.** Serveur MCP (Streamable HTTP `/mcp`, auth Bearer JWT, `propose`/`apply`, toggle par vault) - [ ] **F.** Durcissement (rate limiting, redaction secrets, OpenAPI + guide MCP, E2E) - **Documentation :** [AI_ARCHITECTURE_GUIDE.md](./AI_ARCHITECTURE_GUIDE.md) (architecture, catalogue d'outils, sécurité, phases). @@ -180,7 +180,7 @@ |---|---|---| | ✅ Complété | #1 → #59, #61, #63–68, #71, #72, #74–76, #78 | ~82 jours réalisés | | 🔵 P2 restant | #77 Desktop : signature code (optionnel), wizard 1er lancement (optionnel), 6 tests E2E **manuels** | ~1-2 jours | -| ⚪ P3 restant | #62 Collaboration Yjs (5-7j) · #79 Assistant IA outils + MCP (10-15j, Phase 0 + A2 + B + C + G livrés) · #80 Assistant IA UX (livré, en attente vérif) | ~15-22 jours | +| ⚪ P3 restant | #62 Collaboration Yjs (5-7j) · #79 Assistant IA outils + MCP (10-15j, Phase 0 + A2 + B + C + D + G livrés) · #80 Assistant IA UX (livré, en attente vérif) | ~15-22 jours | | ⚪ P4 restant | #69 Mobile éditeur (2-3j) · #70 Sémantique (4-5j) · #73 Sync (6-8j) | 11-16 jours | | **Total restant** | **6 items + finitions** | **~27-39 jours** | diff --git a/docs/features/ai-tools-mcp.md b/docs/features/ai-tools-mcp.md index 451aa53..b364a4e 100644 --- a/docs/features/ai-tools-mcp.md +++ b/docs/features/ai-tools-mcp.md @@ -1,6 +1,6 @@ # #79 — Assistant IA — Outils (function calling) & serveur MCP -> **Statut :** 🔵 En cours — Phase 0 + A2 + B1/B2/B3/B4/B5/B6/B7 + C + G livrés (2026-09-11) +> **Statut :** 🔵 En cours — Phase 0 + A2 + B1/B2/B3/B4/B5/B6/B7 + C + D + G livrés (2026-09-11) > **Effort :** 10-15 jours | **Impact :** 🟡 > **Références :** [Roadmap](../ROADMAP.md) · [Guide d'architecture IA](../AI_ARCHITECTURE_GUIDE.md) @@ -29,11 +29,17 @@ - [x] **C3.** Recherche : `search_fulltext`, `search_advanced`, `search_paths`, `list_tags`, `suggest_tags`, `list_recent` — extensions `backend/services/search.py` (`advanced_search_vaults`, `search_paths`) et `recent.py` ; filtrage systématique par permissions vault - Routes `/api/recent`, `/api/vault/{vault}/files`, `/api/file/{vault}/backups`, `/api/file/{vault}/diff`, `/api/graph/{vault}`, `/api/tree-search`, `/api/search/advanced` refactorées en wrappers des services ; tests `tests/test_tools.py` (+17) -## D. Catalogue d'outils — mutations (2 jours) - - [ ] **D1.** `create_file`, `create_directory` (migration du protocole texte existant) - - [ ] **D2.** `edit_file`, `append_to_file`, `rename_file`, `rename_directory`, `move_path` - - [ ] **D3.** `replace_in_files` (⚠️ confirmation two-step + backup auto) - - [ ] **D4.** `delete_file`, `delete_directory`, `restore_backup` (⚠️ confirmation two-step + backup auto, toggle par vault) +## D. Catalogue d'outils — mutations (2 jours) — ✅ livré (2026-09-11) + - [x] **D1.** `create_file`, `create_directory` (migration du protocole texte existant) + - [x] **D2.** `edit_file`, `append_to_file`, `rename_file`, `rename_directory`, `move_path` + - [x] **D3.** `replace_in_files` (⚠️ confirmation two-step + backup auto) + - [x] **D4.** `delete_file`, `delete_directory`, `restore_backup` (⚠️ confirmation two-step + backup auto, toggle par vault) + - **Détail :** service `backend/services/mutations.py` (source unique : anti path-traversal, garde + lecture seule, backup auto via `backend/services/backups.create_backup`) ; les routes REST + correspondantes délèguent à cette couche. Risques : `WRITE` (create/edit/append/restore) et + `DANGEROUS` (rename/move/replace/delete) → confirmation two-step automatique par le registry. + Toggle par vault `aiDestructiveTools` (défaut activé) pour désactiver les outils `DANGEROUS`. + Tests : `tests/test_tools_mutations.py` (40 tests). ## E. Serveur MCP (2-3 jours) - [ ] **E1.** `backend/mcp/server.py` (SDK MCP Python) enregistrant les outils depuis le registry diff --git a/tests/test_tools.py b/tests/test_tools.py index 76287a3..23b0542 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -46,6 +46,18 @@ BUILTIN_TOOLS = { "search_paths", "suggest_tags", "list_recent", + # Phase D — mutations + "create_file", + "create_directory", + "edit_file", + "append_to_file", + "rename_file", + "rename_directory", + "move_path", + "replace_in_files", + "delete_file", + "delete_directory", + "restore_backup", } diff --git a/tests/test_tools_mutations.py b/tests/test_tools_mutations.py new file mode 100644 index 0000000..2d1ef3d --- /dev/null +++ b/tests/test_tools_mutations.py @@ -0,0 +1,396 @@ +# tests/test_tools_mutations.py — Unit tests for the AI tool layer (Phase D) +"""Tests for the mutation tools: create/edit/append/rename/move/delete/restore. + +These exercise the shared tool layer end-to-end against the ``TestVault`` +fixture (``client``), including confirmation gating, backups and the +per-vault destructive-tools toggle. +""" + +from pathlib import Path + +import pytest + +from backend.tools.api import ( + ToolConfirmationRequired, + ToolContext, + ToolError, + ToolPermissionError, + ToolRisk, + call_tool, + get_tool, +) + +MUTATION_TOOLS = { + "create_file", + "create_directory", + "edit_file", + "append_to_file", + "rename_file", + "rename_directory", + "move_path", + "replace_in_files", + "delete_file", + "delete_directory", + "restore_backup", +} + +WRITE_TOOLS = {"create_file", "create_directory", "edit_file", "append_to_file", "restore_backup"} +DANGEROUS_TOOLS = { + "rename_file", + "rename_directory", + "move_path", + "replace_in_files", + "delete_file", + "delete_directory", +} + + +def _ctx(vaults=None, **kwargs) -> ToolContext: + user = {"username": "tester", "role": "admin", "vaults": vaults or ["*"]} + kwargs.setdefault("audit_enabled", False) + return ToolContext(user=user, **kwargs) + + +def _vault_path() -> Path: + from backend.indexer import get_vault_data + + return Path(get_vault_data("TestVault")["path"]) + + +# ═══════════════════════════════════════════════════════════════════ +# Registry / risk levels +# ═══════════════════════════════════════════════════════════════════ + + +class TestMutationRegistry: + def test_mutation_tools_registered(self): + for name in MUTATION_TOOLS: + assert get_tool(name) is not None, name + + @pytest.mark.parametrize("name", sorted(WRITE_TOOLS)) + def test_write_risk(self, name): + assert get_tool(name).risk is ToolRisk.WRITE + + @pytest.mark.parametrize("name", sorted(DANGEROUS_TOOLS)) + def test_dangerous_risk(self, name): + assert get_tool(name).risk is ToolRisk.DANGEROUS + + def test_all_mutations_require_confirmation(self): + for name in MUTATION_TOOLS: + assert get_tool(name).requires_confirmation is True + + +# ═══════════════════════════════════════════════════════════════════ +# Confirmation gating +# ═══════════════════════════════════════════════════════════════════ + + +class TestMutationConfirmation: + def test_create_file_requires_confirmation(self, client): + with pytest.raises(ToolConfirmationRequired): + call_tool("create_file", _ctx(), {"vault": "TestVault", "path": "n.md", "content": "x"}) + assert not (_vault_path() / "n.md").exists() + + def test_delete_file_requires_confirmation(self, client): + with pytest.raises(ToolConfirmationRequired): + call_tool("delete_file", _ctx(), {"vault": "TestVault", "path": "note1.md"}) + assert (_vault_path() / "note1.md").exists() + + +# ═══════════════════════════════════════════════════════════════════ +# D1. Creation +# ═══════════════════════════════════════════════════════════════════ + + +class TestCreate: + def test_create_file(self, client): + result = call_tool( + "create_file", + _ctx(), + {"vault": "TestVault", "path": "new/note.md", "content": "# Hi\n"}, + confirm=True, + ) + assert result.ok + assert (_vault_path() / "new" / "note.md").read_text(encoding="utf-8") == "# Hi\n" + assert result.data["path"] == "new/note.md" + + def test_create_file_already_exists(self, client): + with pytest.raises(ToolError) as exc: + call_tool("create_file", _ctx(), {"vault": "TestVault", "path": "note1.md"}, confirm=True) + assert exc.value.code == "already_exists" + + def test_create_file_unsupported_extension(self, client): + with pytest.raises(ToolError) as exc: + call_tool( + "create_file", + _ctx(), + {"vault": "TestVault", "path": "binary.exe", "content": "x"}, + confirm=True, + ) + assert exc.value.code == "unsupported_extension" + + def test_create_file_traversal_rejected(self, client): + with pytest.raises(ToolPermissionError): + call_tool( + "create_file", + _ctx(), + {"vault": "TestVault", "path": "../evil.md", "content": "x"}, + confirm=True, + ) + + def test_create_directory(self, client): + result = call_tool("create_directory", _ctx(), {"vault": "TestVault", "path": "A/B"}, confirm=True) + assert result.ok + assert (_vault_path() / "A" / "B").is_dir() + + +# ═══════════════════════════════════════════════════════════════════ +# D2. Edit / append / rename / move +# ═══════════════════════════════════════════════════════════════════ + + +class TestEditAppend: + def test_edit_file(self, client): + result = call_tool( + "edit_file", + _ctx(), + {"vault": "TestVault", "path": "note1.md", "content": "# Replaced\n"}, + confirm=True, + ) + assert result.ok + assert (_vault_path() / "note1.md").read_text(encoding="utf-8") == "# Replaced\n" + + def test_edit_file_creates_backup(self, client): + from backend.services.backups import get_backup_dir + + call_tool( + "edit_file", + _ctx(), + {"vault": "TestVault", "path": "note1.md", "content": "# Changed\n"}, + confirm=True, + ) + backups = list(get_backup_dir("TestVault", "note1.md").glob("note1.md.*.bak")) + assert backups + + def test_edit_missing_file(self, client): + with pytest.raises(ToolError) as exc: + call_tool( + "edit_file", + _ctx(), + {"vault": "TestVault", "path": "missing.md", "content": "x"}, + confirm=True, + ) + assert exc.value.code == "not_found" + + def test_append_to_file(self, client): + original = (_vault_path() / "note1.md").read_text(encoding="utf-8") + result = call_tool( + "append_to_file", + _ctx(), + {"vault": "TestVault", "path": "note1.md", "content": "APPENDED"}, + confirm=True, + ) + assert result.ok + updated = (_vault_path() / "note1.md").read_text(encoding="utf-8") + assert updated.startswith(original) + assert updated.endswith("APPENDED") + assert result.data["appended"] == len("APPENDED") + + +class TestRenameMove: + def test_rename_file(self, client): + result = call_tool( + "rename_file", + _ctx(), + {"vault": "TestVault", "path": "note1.md", "new_name": "note1_renamed.md"}, + confirm=True, + ) + assert result.ok + assert not (_vault_path() / "note1.md").exists() + assert (_vault_path() / "note1_renamed.md").exists() + assert result.data["new_path"] == "note1_renamed.md" + + def test_rename_file_rejects_path_separator(self, client): + with pytest.raises(ToolError) as exc: + call_tool( + "rename_file", + _ctx(), + {"vault": "TestVault", "path": "note1.md", "new_name": "sub/note1.md"}, + confirm=True, + ) + assert exc.value.code == "invalid_arguments" + + def test_rename_directory(self, client): + call_tool("create_directory", _ctx(), {"vault": "TestVault", "path": "OldDir"}, confirm=True) + result = call_tool( + "rename_directory", + _ctx(), + {"vault": "TestVault", "path": "OldDir", "new_name": "NewDir"}, + confirm=True, + ) + assert result.ok + assert not (_vault_path() / "OldDir").exists() + assert (_vault_path() / "NewDir").is_dir() + + def test_move_file(self, client): + result = call_tool( + "move_path", + _ctx(), + {"vault": "TestVault", "source_path": "note1.md", "destination_dir": "Projets"}, + confirm=True, + ) + assert result.ok + assert result.data["new_path"] == "Projets/note1.md" + assert (_vault_path() / "Projets" / "note1.md").exists() + + def test_move_directory(self, client): + result = call_tool( + "move_path", + _ctx(), + {"vault": "TestVault", "source_path": "Projets", "destination_dir": ""}, + confirm=True, + ) + assert result.data["item_type"] == "directory" + + +# ═══════════════════════════════════════════════════════════════════ +# D3. Find & replace +# ═══════════════════════════════════════════════════════════════════ + + +class TestReplaceInFiles: + def test_dry_run_does_not_write(self, client): + before = (_vault_path() / "note1.md").read_text(encoding="utf-8") + result = call_tool( + "replace_in_files", + _ctx(), + {"find": "Python", "replace": "Rust", "vault": "TestVault"}, + confirm=True, + ) + assert result.data["dry_run"] is True + assert result.data["total_matches"] >= 1 + assert (_vault_path() / "note1.md").read_text(encoding="utf-8") == before + + def test_apply_replaces_and_backs_up(self, client): + result = call_tool( + "replace_in_files", + _ctx(), + { + "find": "Python", + "replace": "Rust", + "vault": "TestVault", + "replace_all": True, + "dry_run": False, + }, + confirm=True, + ) + assert result.data["dry_run"] is False + assert result.data["total_replacements"] >= 1 + assert "Rust" in (_vault_path() / "note1.md").read_text(encoding="utf-8") + + def test_filters_inaccessible_vaults(self, client): + ctx = ToolContext(user={"username": "limited", "vaults": ["OtherVault"]}, audit_enabled=False) + result = call_tool( + "replace_in_files", + ctx, + {"find": "Python", "replace": "Rust", "vault": "all"}, + confirm=True, + ) + assert result.data["total_matches"] == 0 + + +# ═══════════════════════════════════════════════════════════════════ +# D4. Delete / restore +# ═══════════════════════════════════════════════════════════════════ + + +class TestDeleteRestore: + def test_delete_file_with_backup(self, client): + from backend.services.backups import get_backup_dir + + result = call_tool("delete_file", _ctx(), {"vault": "TestVault", "path": "note1.md"}, confirm=True) + assert result.ok + assert not (_vault_path() / "note1.md").exists() + assert list(get_backup_dir("TestVault", "note1.md").glob("note1.md.*.bak")) + + def test_delete_missing_file(self, client): + with pytest.raises(ToolError) as exc: + call_tool("delete_file", _ctx(), {"vault": "TestVault", "path": "missing.md"}, confirm=True) + assert exc.value.code == "not_found" + + def test_delete_directory(self, client): + result = call_tool("delete_directory", _ctx(), {"vault": "TestVault", "path": "Projets"}, confirm=True) + assert result.ok + assert result.data["deleted_count"] >= 1 + assert not (_vault_path() / "Projets").exists() + + def test_restore_backup(self, client): + from backend.services.backups import create_backup + + file_path = _vault_path() / "note1.md" + original = file_path.read_text(encoding="utf-8") + backup_path = create_backup(file_path, "TestVault", "note1.md") + assert backup_path is not None + + # Change the file directly (no extra backup), then restore the version. + file_path.write_text("# modified\n", encoding="utf-8") + assert file_path.read_text(encoding="utf-8") == "# modified\n" + + version = int(backup_path.name.split(".")[-2]) + result = call_tool( + "restore_backup", + _ctx(), + {"vault": "TestVault", "path": "note1.md", "version": version}, + confirm=True, + ) + assert result.ok + assert file_path.read_text(encoding="utf-8") == original + + +# ═══════════════════════════════════════════════════════════════════ +# Per-vault destructive-tools toggle +# ═══════════════════════════════════════════════════════════════════ + + +class TestDestructiveToggle: + def _disable(self, monkeypatch): + import backend.vault_settings as vs + + monkeypatch.setattr(vs, "get_vault_setting", lambda name: {"aiDestructiveTools": False}) + + def test_delete_blocked_when_disabled(self, client, monkeypatch): + self._disable(monkeypatch) + with pytest.raises(ToolPermissionError) as exc: + call_tool("delete_file", _ctx(), {"vault": "TestVault", "path": "note1.md"}, confirm=True) + assert exc.value.code == "destructive_tools_disabled" + assert (_vault_path() / "note1.md").exists() + + def test_rename_blocked_when_disabled(self, client, monkeypatch): + self._disable(monkeypatch) + with pytest.raises(ToolPermissionError): + call_tool( + "rename_file", + _ctx(), + {"vault": "TestVault", "path": "note1.md", "new_name": "x.md"}, + confirm=True, + ) + + def test_edit_still_allowed_when_disabled(self, client, monkeypatch): + self._disable(monkeypatch) + result = call_tool( + "edit_file", + _ctx(), + {"vault": "TestVault", "path": "note1.md", "content": "# ok\n"}, + confirm=True, + ) + assert result.ok + + def test_replace_filters_disabled_vault(self, client, monkeypatch): + self._disable(monkeypatch) + result = call_tool( + "replace_in_files", + _ctx(), + {"find": "Python", "replace": "Rust", "vault": "all", "replace_all": True, "dry_run": False}, + confirm=True, + ) + assert result.data["total_replacements"] == 0