From 5c1823d6d29b49b8640bf8e30eac3f514b4d558d Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 11 Sep 2026 12:03:21 -0400 Subject: [PATCH] feat(ai): phase 0 couche d'outils partagee (backend/tools) + tests - backend/tools/: ToolContext, registry @tool + schemas JSON, audit ai_tool_call - Services lecture/recherche: list_vaults, list_directory, read_file, search_fulltext, list_tags - Permissions check_vault_access + resolve_safe_path, confirmation gating (two-step) - Redaction des secrets, limites de taille, audit JSONL (args sensibles resumes) - tests/test_tools.py: 30 tests (registry, contexte, execution, confirmation, audit) - ROADMAP: item #79 phase A livree (A2 partiel) --- backend/tools/audit.py | 54 ++++++++ backend/tools/context.py | 169 +++++++++++++++++++++++ backend/tools/registry.py | 203 +++++++++++++++++++++++++++ backend/tools/schemas.py | 52 +++++++ backend/tools/service.py | 166 ++++++++++++++++++++++ docs/ROADMAP.md | 66 ++++++++- tests/test_tools.py | 280 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 988 insertions(+), 2 deletions(-) create mode 100644 backend/tools/audit.py create mode 100644 backend/tools/context.py create mode 100644 backend/tools/registry.py create mode 100644 backend/tools/schemas.py create mode 100644 backend/tools/service.py create mode 100644 tests/test_tools.py diff --git a/backend/tools/audit.py b/backend/tools/audit.py new file mode 100644 index 0000000..cf39f16 --- /dev/null +++ b/backend/tools/audit.py @@ -0,0 +1,54 @@ +"""Audit logging for AI tool calls. + +Reuses the application audit log (``data/audit.log``, JSON lines) and adds an +``ai_tool_call`` action. Argument values that may contain sensitive payloads +(file content, prompts) are summarized rather than stored verbatim. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from backend.audit import _write_entry + +# Argument keys whose values may contain secrets or large payloads. +_SENSITIVE_ARG_KEYS = {"content", "text", "body"} +_MAX_ARG_CHARS = 200 + + +def _sanitize_arguments(arguments: dict[str, Any] | None) -> dict[str, Any]: + """Return a log-safe view of tool arguments.""" + safe: dict[str, Any] = {} + for key, value in (arguments or {}).items(): + if key in _SENSITIVE_ARG_KEYS: + safe[key] = f"<{len(str(value))} chars>" + else: + safe[key] = str(value)[:_MAX_ARG_CHARS] + return safe + + +def log_tool_call( + *, + username: str, + mode: str, + tool: str, + arguments: dict[str, Any] | None = None, + ok: bool = True, + vault: str | None = None, + ip: str | None = None, + error: str | None = None, +) -> None: + """Append an ``ai_tool_call`` entry to the audit log.""" + _write_entry({ + "timestamp": datetime.now(timezone.utc).isoformat(), + "action": "ai_tool_call", + "username": username, + "ip": ip or "unknown", + "mode": mode, + "tool": tool, + "vault": vault, + "ok": ok, + "error": error, + "arguments": _sanitize_arguments(arguments), + }) diff --git a/backend/tools/context.py b/backend/tools/context.py new file mode 100644 index 0000000..54a7a61 --- /dev/null +++ b/backend/tools/context.py @@ -0,0 +1,169 @@ +"""Execution context and shared primitives for the AI tool layer. + +The tool layer is deliberately transport-agnostic: the same tool functions are +invoked by the in-app assistant (function calling) and by the MCP server. This +module holds the context object that carries the caller identity, the +permission helpers, and the domain error types shared across the layer. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any + +from backend.auth.middleware import check_vault_access + +logger = logging.getLogger("obsigate.tools") + + +class ToolMode(str, Enum): + """How a tool was invoked.""" + + IN_APP = "in_app" + MCP = "mcp" + + +class ToolScope(str, Enum): + """Which front a tool is exposed to.""" + + IN_APP = "in_app" + MCP = "mcp" + + +class ToolRisk(str, Enum): + """Risk level of a tool. + + ``READ`` tools run without confirmation. ``WRITE`` and ``DANGEROUS`` tools + require an explicit confirmation (two-step ``propose``/``apply`` for MCP, + an "Apply" card in the in-app UI). + """ + + READ = "read" + WRITE = "write" + DANGEROUS = "dangerous" + + +class ToolError(Exception): + """Base class for tool execution errors. + + Carries a stable, machine-readable ``code`` so callers (agent loop, MCP + server) can react without parsing the message. + """ + + code = "tool_error" + + def __init__(self, message: str, *, code: str | None = None, details: dict[str, Any] | None = None): + super().__init__(message) + self.message = message + if code: + self.code = code + self.details = details or {} + + def to_dict(self) -> dict[str, Any]: + return {"ok": False, "error": {"code": self.code, "message": self.message, "details": self.details}} + + +class ToolNotFoundError(ToolError): + code = "not_found" + + +class ToolPermissionError(ToolError): + code = "permission_denied" + + +class ToolValidationError(ToolError): + code = "invalid_arguments" + + +class ToolConfirmationRequired(ToolError): + """Raised when a mutating tool is invoked without confirmation. + + This is the hook for the two-step ``propose``/``apply`` mechanism: the + ``propose`` phase surfaces this payload to the user, and the ``apply`` + phase re-invokes the tool with ``confirm=True``. + """ + + code = "confirmation_required" + + def __init__(self, tool: str, arguments: dict[str, Any], message: str | None = None): + super().__init__(message or f"Confirmation required for '{tool}'", code="confirmation_required") + self.tool = tool + self.arguments = arguments + + def to_dict(self) -> dict[str, Any]: + payload = super().to_dict() + payload["error"]["tool"] = self.tool + payload["error"]["arguments"] = self.arguments + return payload + + +@dataclass +class ToolContext: + """Carries the caller identity and execution options for a tool call. + + Attributes: + user: Authenticated user dict (as returned by ``get_current_user``). + mode: Whether the call originates from the in-app assistant or MCP. + confirmed: True once a mutating action has been approved. + ip: Optional client IP for auditing. + audit_enabled: Set to False to skip audit logging (tests, dry runs). + metadata: Free-form caller metadata (conversation id, client name…). + """ + + user: dict[str, Any] + mode: ToolMode = ToolMode.IN_APP + confirmed: bool = False + ip: str | None = None + audit_enabled: bool = True + metadata: dict[str, Any] = field(default_factory=dict) + + @property + def username(self) -> str: + return self.user.get("username", "unknown") + + def has_vault_access(self, vault: str) -> bool: + """Return True if the caller may access *vault*.""" + return bool(vault) and check_vault_access(vault, self.user) + + def require_vault_access(self, vault: str) -> None: + """Raise :class:`ToolPermissionError` unless the caller can access *vault*.""" + if not self.has_vault_access(vault): + raise ToolPermissionError( + f"Access denied to vault '{vault}'", + code="vault_access_denied", + details={"vault": vault}, + ) + + +def resolve_safe_path(vault_root: Path, relative_path: str) -> Path: + """Resolve a vault-relative path, rejecting traversal outside the vault. + + Mirrors ``backend.main._resolve_safe_path`` but raises a domain error + instead of ``HTTPException`` so the tool layer stays transport-agnostic. + + Raises: + ToolPermissionError: When the resolved path escapes the vault root. + ToolError: When the path cannot be resolved. + """ + full_path = vault_root / (relative_path or "") + try: + resolved = full_path.resolve(strict=False) + root = vault_root.resolve(strict=False) + except Exception as e: + raise ToolError(f"Path resolution error: {e}", code="path_error") from e + + try: + resolved.relative_to(root) + except ValueError: + # Case-insensitive fallback for Windows / Docker path casing. + if not str(resolved).lower().startswith(str(root).lower()): + logger.warning(f"Path outside vault: vault={root}, requested={relative_path}") + raise ToolPermissionError( + "Access denied: path outside vault", + code="path_outside_vault", + details={"path": relative_path}, + ) from None + return resolved diff --git a/backend/tools/registry.py b/backend/tools/registry.py new file mode 100644 index 0000000..7e441c6 --- /dev/null +++ b/backend/tools/registry.py @@ -0,0 +1,203 @@ +"""Tool registry — declarative registration and uniform execution. + +Tools are registered with the :func:`tool` decorator. Each tool declares its +input model (used both for JSON Schema exposure and argument validation), its +risk level, and the fronts (in-app / MCP) it is exposed to. + +Execution goes through :func:`call_tool`, which centralizes argument +validation, vault permission checks, confirmation gating, and audit logging. +""" + +from __future__ import annotations + +import logging +import time +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from pydantic import BaseModel, ValidationError + +from backend.tools.audit import log_tool_call +from backend.tools.context import ( + ToolConfirmationRequired, + ToolContext, + ToolError, + ToolNotFoundError, + ToolPermissionError, + ToolRisk, + ToolScope, + ToolValidationError, +) +from backend.tools.schemas import ToolResult + +logger = logging.getLogger("obsigate.tools.registry") + + +@dataclass(frozen=True) +class ToolSpec: + """Static description of a registered tool.""" + + name: str + description: str + input_model: type[BaseModel] + handler: Callable[[ToolContext, Any], Any] + risk: ToolRisk = ToolRisk.READ + scopes: tuple[ToolScope, ...] = (ToolScope.IN_APP, ToolScope.MCP) + requires_vault: bool = False + + @property + def requires_confirmation(self) -> bool: + """Mutating tools always require an explicit confirmation.""" + return self.risk != ToolRisk.READ + + def parameters_schema(self) -> dict[str, Any]: + """JSON Schema of the tool arguments (for LLM function calling).""" + schema = self.input_model.model_json_schema() + schema.pop("title", None) + return schema + + def openai_schema(self) -> dict[str, Any]: + """OpenAI-compatible function-calling schema.""" + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": self.parameters_schema(), + }, + } + + +_REGISTRY: dict[str, ToolSpec] = {} + + +def tool( + *, + name: str, + description: str, + input_model: type[BaseModel], + risk: ToolRisk = ToolRisk.READ, + scopes: tuple[ToolScope, ...] = (ToolScope.IN_APP, ToolScope.MCP), + requires_vault: bool = False, +) -> Callable[[Callable[[ToolContext, Any], Any]], Callable[[ToolContext, Any], Any]]: + """Register a tool. Returns the original handler unchanged.""" + + def decorator(func: Callable[[ToolContext, Any], Any]) -> Callable[[ToolContext, Any], Any]: + if name in _REGISTRY: + raise ValueError(f"Duplicate tool name: {name}") + _REGISTRY[name] = ToolSpec( + name=name, + description=description, + input_model=input_model, + handler=func, + risk=risk, + scopes=scopes, + requires_vault=requires_vault, + ) + return func + + return decorator + + +def get_tool(name: str) -> ToolSpec | None: + """Return the spec for *name*, or ``None``.""" + return _REGISTRY.get(name) + + +def list_tools(*, scope: ToolScope | None = None) -> list[ToolSpec]: + """Return registered tools, optionally filtered by scope.""" + specs = list(_REGISTRY.values()) + if scope is not None: + specs = [s for s in specs if scope in s.scopes] + return specs + + +def get_tool_schemas(*, scope: ToolScope | None = None) -> list[dict[str, Any]]: + """Return OpenAI-compatible schemas for registered tools.""" + return [spec.openai_schema() for spec in list_tools(scope=scope)] + + +def _audit(ctx: ToolContext, spec: ToolSpec, arguments: dict[str, Any], *, ok: bool, error: str | None = None) -> None: + if not ctx.audit_enabled: + return + try: + log_tool_call( + username=ctx.username, + mode=ctx.mode.value, + tool=spec.name, + arguments=arguments, + ok=ok, + vault=arguments.get("vault"), + ip=ctx.ip, + error=error, + ) + except Exception as e: + logger.debug(f"Tool audit failed for '{spec.name}': {e}") + + +def call_tool( + name: str, + ctx: ToolContext, + arguments: dict[str, Any] | None = None, + *, + confirm: bool = False, +) -> ToolResult: + """Validate, authorize, execute and audit a tool call. + + Args: + name: Registered tool name. + ctx: Caller context (identity, mode, confirmation state). + arguments: Raw arguments to validate against the tool input model. + confirm: Explicit one-shot confirmation (two-step ``apply`` phase). + + Raises: + ToolNotFoundError: Unknown tool. + ToolValidationError: Arguments fail validation. + ToolPermissionError: Vault access denied or path escapes the vault. + ToolConfirmationRequired: Mutating tool invoked without confirmation. + ToolError: Any other execution error. + """ + spec = _REGISTRY.get(name) + if spec is None: + raise ToolNotFoundError(f"Unknown tool: {name}", details={"tool": name}) + + arguments = arguments or {} + + try: + params = spec.input_model.model_validate(arguments) + except ValidationError as e: + error = ToolValidationError( + f"Invalid arguments for '{name}'", + details={"errors": e.errors(include_url=False)}, + ) + _audit(ctx, spec, arguments, ok=False, error=error.code) + raise error from e + + vault = getattr(params, "vault", None) + if spec.requires_vault and vault: + try: + ctx.require_vault_access(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) + + started = time.perf_counter() + try: + data = spec.handler(ctx, params) + except ToolError as e: + _audit(ctx, spec, arguments, ok=False, error=e.code) + raise + except Exception as e: + logger.error(f"Tool '{name}' failed: {e}") + exec_error = ToolError(f"Tool '{name}' failed: {e}", code="tool_execution_error") + _audit(ctx, spec, arguments, ok=False, error=exec_error.code) + raise exec_error from e + + duration_ms = round((time.perf_counter() - started) * 1000, 2) + logger.debug(f"Tool '{name}' ok in {duration_ms}ms") + _audit(ctx, spec, arguments, ok=True) + return ToolResult(ok=True, data=data) diff --git a/backend/tools/schemas.py b/backend/tools/schemas.py new file mode 100644 index 0000000..8f295d9 --- /dev/null +++ b/backend/tools/schemas.py @@ -0,0 +1,52 @@ +"""Pydantic input/output models for the AI tool layer. + +Input models double as JSON Schemas advertised to LLMs (via +``model_json_schema``) and as validation for arguments received over MCP. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class ListVaultsInput(BaseModel): + """No parameters — lists the vaults the caller can access.""" + + +class ListDirectoryInput(BaseModel): + """Browse a directory inside a vault.""" + + vault: str = Field(..., description="Vault name") + path: str = Field("", description="Vault-relative directory path (empty = vault root)") + + +class ReadFileInput(BaseModel): + """Read a text file's content from a vault.""" + + vault: str = Field(..., description="Vault name") + path: str = Field(..., description="Vault-relative file path") + + +class SearchFulltextInput(BaseModel): + """Full-text search across one or all accessible vaults.""" + + q: str = Field(..., min_length=1, description="Search query") + vault: str = Field("all", description="Vault name or 'all'") + tag: str | None = Field(None, description="Optional comma-separated tag filter") + limit: int = Field(50, ge=1, le=200, description="Maximum number of results") + + +class ListTagsInput(BaseModel): + """List tags and their occurrence counts.""" + + vault: str | None = Field(None, description="Vault name or 'all' (default: all accessible)") + + +class ToolResult(BaseModel): + """Uniform result returned by :func:`backend.tools.registry.call_tool`.""" + + ok: bool = True + data: Any = None + error: str | None = None diff --git a/backend/tools/service.py b/backend/tools/service.py new file mode 100644 index 0000000..3ac5157 --- /dev/null +++ b/backend/tools/service.py @@ -0,0 +1,166 @@ +"""Built-in tool services (Phase 0 — read/search). + +These functions are the single source of truth consumed by both the in-app +assistant and the MCP server. They delegate to the existing core modules +(``backend.indexer``, ``backend.search``) rather than duplicating route logic. +Mutating tools (create/edit/rename/move/delete) are added in later phases. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from backend.tools.context import ( + ToolContext, + ToolError, + ToolNotFoundError, + ToolRisk, + resolve_safe_path, +) +from backend.tools.registry import tool +from backend.tools.schemas import ( + ListDirectoryInput, + ListTagsInput, + ListVaultsInput, + ReadFileInput, + SearchFulltextInput, +) + +logger = logging.getLogger("obsigate.tools.service") + +# Maximum file size returned by ``read_file`` (bytes). +TOOL_MAX_READ_BYTES = 200_000 + + +def _vault_data_or_raise(vault: str) -> dict[str, Any]: + from backend.indexer import get_vault_data + + data = get_vault_data(vault) + if not data: + raise ToolNotFoundError(f"Vault '{vault}' not found", details={"vault": vault}) + return data + + +@tool( + name="list_vaults", + description="List the vaults the current user is allowed to access.", + input_model=ListVaultsInput, + risk=ToolRisk.READ, +) +def list_vaults(ctx: ToolContext, _params: ListVaultsInput) -> list[dict[str, Any]]: + """Return accessible vaults with a file count.""" + from backend.indexer import get_vault_data, get_vault_names + + vaults: list[dict[str, Any]] = [] + for name in get_vault_names(): + if not ctx.has_vault_access(name): + continue + data = get_vault_data(name) or {} + vaults.append({"name": name, "file_count": len(data.get("files", []))}) + return vaults + + +@tool( + name="list_directory", + description="List files and subdirectories of a directory inside a vault.", + input_model=ListDirectoryInput, + risk=ToolRisk.READ, + requires_vault=True, +) +def list_directory(ctx: ToolContext, params: ListDirectoryInput) -> list[dict[str, Any]]: + """Return the entries of a vault directory (direct children only).""" + data = _vault_data_or_raise(params.vault) + root = Path(data["path"]) + target = resolve_safe_path(root, params.path) if params.path else root.resolve() + + if not target.exists() or not target.is_dir(): + raise ToolNotFoundError(f"Directory not found: {params.path}", details={"vault": params.vault, "path": params.path}) + + from backend.vault_settings import get_vault_setting + + hide_hidden = (get_vault_setting(params.vault) or {}).get("hideHiddenFiles", False) + + items: list[dict[str, Any]] = [] + for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())): + if hide_hidden and entry.name.startswith("."): + continue + rel = str(entry.relative_to(root)).replace("\\", "/") + items.append({ + "name": entry.name, + "path": rel, + "type": "directory" if entry.is_dir() else "file", + }) + return items + + +@tool( + name="read_file", + description="Read the text content of a file inside a vault (secrets redacted).", + input_model=ReadFileInput, + risk=ToolRisk.READ, + requires_vault=True, +) +def read_file(ctx: ToolContext, params: ReadFileInput) -> dict[str, Any]: + """Return the (redacted) content of a vault file.""" + data = _vault_data_or_raise(params.vault) + root = Path(data["path"]) + target = resolve_safe_path(root, params.path) + + if not target.exists() or not target.is_file(): + raise ToolNotFoundError(f"File not found: {params.path}", details={"vault": params.vault, "path": params.path}) + + size = target.stat().st_size + if size > TOOL_MAX_READ_BYTES: + raise ToolError( + f"File too large ({size} bytes > {TOOL_MAX_READ_BYTES})", + code="file_too_large", + details={"vault": params.vault, "path": params.path, "size": size}, + ) + + content = target.read_text(encoding="utf-8", errors="replace") + + from backend.secret_redactor import redact_file_content + + content = redact_file_content(content, params.path) + return {"vault": params.vault, "path": params.path, "size": size, "content": content} + + +@tool( + name="search_fulltext", + description="Full-text search across one vault or all accessible vaults.", + input_model=SearchFulltextInput, + risk=ToolRisk.READ, +) +def search_fulltext(ctx: ToolContext, params: SearchFulltextInput) -> list[dict[str, Any]]: + """Return ranked search results, filtered to accessible vaults.""" + from backend.search import search + + results = search(params.q, vault_filter=params.vault, tag_filter=params.tag, limit=params.limit) + return [r for r in results if ctx.has_vault_access(r["vault"])] + + +@tool( + name="list_tags", + description="List tags with their occurrence counts for a vault or all accessible vaults.", + input_model=ListTagsInput, + risk=ToolRisk.READ, +) +def list_tags(ctx: ToolContext, params: ListTagsInput) -> list[dict[str, Any]]: + """Return tags sorted by descending count.""" + from backend.search import get_all_tags + + if params.vault and params.vault != "all": + ctx.require_vault_access(params.vault) + return [{"tag": tag, "count": count} for tag, count in get_all_tags(params.vault).items()] + + from backend.indexer import get_vault_names + + merged: dict[str, int] = {} + for name in get_vault_names(): + if not ctx.has_vault_access(name): + continue + for tag, count in get_all_tags(name).items(): + merged[tag] = merged.get(tag, 0) + count + return [{"tag": tag, "count": count} for tag, count in sorted(merged.items(), key=lambda x: -x[1])] diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 44b0c02..26753af 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -685,6 +685,68 @@ --- +### 79. Assistant IA — Outils (function calling) & serveur MCP + +- **Effort :** 10-15 jours | **Impact :** 🟡 | **Statut :** 🔵 En cours — Phase 0 livrée (2026-09-11) +- **Description :** Transformer l'assistant BooksLM (protocole texte `obsigate-action` limité à `create_file`/`create_directory`) en un agent capable de lire, chercher, lister, ouvrir et 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** — source unique de vérité. +- **Documentation :** [AI_ARCHITECTURE_GUIDE.md](./AI_ARCHITECTURE_GUIDE.md) (architecture, catalogue d'outils, sécurité, phases). +- **Sous-tâches :** + +##### A. Fondations — couche d'outils partagée (2-3 jours) — ✅ livré (2026-09-11) + - [x] **A1.** Créer `backend/tools/` : `context.py` (`ToolContext` : user, allowed_vaults, mode, confirmed), `registry.py` (décorateur `@tool` + schéma JSON), `schemas.py`, `service.py`, `audit.py` + - [ ] **A2.** Extraire la logique métier des routes de `backend/main.py` en fonctions de service réutilisables (les routes deviennent des wrappers) — *partiel : services lecture/recherche livrés (`list_vaults`, `list_directory`, `read_file`, `search_fulltext`, `list_tags`) ; refactor des routes `main.py` à suivre* + - [x] **A3.** Contexte de permissions : chaque outil applique `check_vault_access` + `resolve_safe_path` + - [x] **A4.** Audit : journalisation JSONL de chaque appel d'outil (qui, quoi, vault, résultat) — action `ai_tool_call`, arguments sensibles résumés + - [x] **A5.** Tests unitaires du registry + services (sans IA) — `tests/test_tools.py` (30 tests) + +##### B. Function calling in-app (3-4 jours) + - [ ] **B1.** Abstraction tool-calling provider-agnostique : `_call_deepseek_openrouter` (`ai.py:115`) + `_call_gemini` (`ai.py:156`) + Ollama — payload `tools`/`tool_choice`, parsing `tool_calls` / `functionCall` + - [ ] **B2.** Agent loop `backend/agent/loop.py` : boucle tool→résultat→tool, limite d'itérations (10) + budget tokens + - [ ] **B3.** Fallback protocole texte `obsigate-action` si le modèle ne supporte pas les tools + - [ ] **B4.** SSE réellement streaming (corriger `bookslm_routes.py:211`) + - [ ] **B5.** Confirmations UI : outils `read` auto, outils `write` via carte Apply (`bookslm.js:515`), aperçu diff pour `edit_file` + - [ ] **B6.** Outils de navigation in-app : `open_file`, `reveal_in_tree` (événement `obsigate:open-file`) + - [ ] **B7.** Tests : agent loop (LLM mocké), confirmations, permissions + +##### C. Catalogue d'outils — lecture & recherche (1-2 jours) + - [ ] **C1.** Vaults/navigation : `list_vaults`, `list_directory`, `list_all_files` + - [ ] **C2.** Lecture : `read_file`, `read_file_raw`, `get_backlinks`, `list_backups`, `diff_backup`, `get_graph` + - [ ] **C3.** Recherche : `search_fulltext`, `search_advanced`, `search_paths`, `list_tags`, `suggest_tags`, `list_recent` + +##### 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) + +##### E. Serveur MCP (2-3 jours) + - [ ] **E1.** `backend/mcp/server.py` (SDK MCP Python) enregistrant les outils depuis le registry + - [ ] **E2.** Mapping des primitives : Tools (mutations + recherche), Resources (vaults/fichiers en lecture `vault://`), Prompts (templates) + - [ ] **E3.** Transport **Streamable HTTP** : endpoint `/mcp` dans FastAPI, auth `Authorization: Bearer ` → `get_current_user` (décision 2026-09-11). `stdio` optionnel plus tard + - [ ] **E4.** Confirmations : **two-step `propose_*`/`apply_*`** avec token signé (usage unique, TTL) ; élicitation optionnelle si le client l'annonce (décision 2026-09-11) + - [ ] **E5.** Toggle par vault pour désactiver les outils destructifs (défaut : activés) + - [ ] **E6.** Tests : handshake MCP, permissions par vault, mapping des ressources, anti-rejeu du token + +##### F. Durcissement & documentation (1 jour) + - [ ] **F1.** Rate limiting par token/outil + quotas (`BOOKSLM_MAX_*`) + - [ ] **F2.** Redaction des secrets avant retour au LLM + - [ ] **F3.** Documentation OpenAPI (`backend/openapi_docs.py`) + guide MCP + - [ ] **F4.** Tests E2E de bout en bout + +##### G. Sélection fournisseur/modèle par défaut (adresse le point 4) + - [x] **G1.** Persistance `ai_default_provider` + `ai_default_models` dans `data/config.json` (`_DEFAULT_CONFIG`) + - [x] **G2.** Lecture + rechargement à chaud dans `backend/ai.py` (`get_default_provider`, `reload_ai_config`) + - [x] **G3.** UI : sélecteurs « Fournisseur par défaut » + « Modèle par défaut » dans `#cfg-ai` (`frontend/index.html`, `frontend/js/config.js`) + - [x] **G4.** i18n FR/EN + +- **Décisions :** + - ✅ Transport MCP : **Streamable HTTP** (2026-09-11) + - ✅ Confirmation MCP : **two-step `propose`/`apply`** (2026-09-11) + - ✅ Périmètre des mutations externes : **toutes autorisées** (create/edit/rename/move/delete) — encadrées par confirmation + backup auto + audit + toggle par vault (2026-09-11) + - Détail et justification dans le guide §8. + +--- + ## ⚪ Backlog — Priorité 4 (P4) ### 67. Notifications web — Push API @@ -916,9 +978,9 @@ |---|---|---| | ✅ Complété | #1 → #59, #63-68, #71, #72 (OpenAPI 3.1), #74, #75, #76, #78 (58 E2E, 59 offline, 63 i18n, 64 MFA TOTP+WebAuthn, 65 thèmes, 66 export, 67 push, 68 health, 71 admin, 72 OpenAPI, 74 PDF, 75 split view, 76 BooksLM, 78 Excalidraw) | ~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) | ~5-7 jours | +| ⚪ P3 restant | #62 Collaboration Yjs (5-7j) · #79 Assistant IA outils + MCP (10-15j, G livré) | ~15-22 jours | | ⚪ P4 restant | #69 Mobile éditeur (2-3j) · #70 Sémantique (4-5j) · #73 Sync (6-8j) | 11-16 jours | -| **Total restant** | **5 items + finitions** | **~17-25 jours** | +| **Total restant** | **6 items + finitions** | **~27-39 jours** | #75 : E2E matriciels 37/37 verts (split-view.spec.js + split-view-matrix.spec.js) — 2026-09-08. diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..582800f --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,280 @@ +# tests/test_tools.py — Unit tests for the AI tool layer (Phase 0) +"""Tests for backend.tools: registry, context, permissions, execution, audit. + +The index-dependent tests reuse the ``client`` fixture from conftest, which +builds the in-memory index for a ``TestVault``. +""" + +import json +from pathlib import Path + +import pytest + +from backend.tools import ( + ToolConfirmationRequired, + ToolContext, + ToolError, + ToolNotFoundError, + ToolPermissionError, + ToolRisk, + ToolScope, + ToolValidationError, + call_tool, + get_tool, + get_tool_schemas, + list_tools, + resolve_safe_path, +) +from backend.tools.registry import ToolSpec +from backend.tools.schemas import ListVaultsInput + +BUILTIN_TOOLS = {"list_vaults", "list_directory", "read_file", "search_fulltext", "list_tags"} + + +def _ctx(vaults=None, **kwargs) -> ToolContext: + user = {"username": "tester", "role": "admin", "vaults": vaults or ["*"]} + kwargs.setdefault("audit_enabled", False) + return ToolContext(user=user, **kwargs) + + +# ═══════════════════════════════════════════════════════════════════ +# Registry +# ═══════════════════════════════════════════════════════════════════ + + +class TestRegistry: + def test_builtin_tools_registered(self): + names = {s.name for s in list_tools()} + assert BUILTIN_TOOLS <= names + + def test_get_tool_returns_spec(self): + spec = get_tool("read_file") + assert spec is not None + assert spec.risk is ToolRisk.READ + assert spec.requires_vault is True + + def test_get_tool_unknown_returns_none(self): + assert get_tool("does_not_exist") is None + + def test_schemas_have_openai_shape(self): + schemas = {s["function"]["name"]: s for s in get_tool_schemas()} + read = schemas["read_file"] + assert read["type"] == "function" + props = read["function"]["parameters"]["properties"] + assert "vault" in props and "path" in props + + def test_duplicate_tool_name_raises(self): + from backend.tools.registry import tool + + with pytest.raises(ValueError): + + @tool(name="list_vaults", description="dup", input_model=ListVaultsInput) + def _dup(ctx, params): # pragma: no cover - never registered + return None + + def test_scope_filter(self, monkeypatch): + from backend.tools import registry + + spec = ToolSpec( + name="_mcp_only", + description="mcp only", + input_model=ListVaultsInput, + handler=lambda ctx, params: None, + scopes=(ToolScope.MCP,), + ) + monkeypatch.setitem(registry._REGISTRY, "_mcp_only", spec) + + mcp_names = {s.name for s in list_tools(scope=ToolScope.MCP)} + in_app_names = {s.name for s in list_tools(scope=ToolScope.IN_APP)} + assert "_mcp_only" in mcp_names + assert "_mcp_only" not in in_app_names + + +# ═══════════════════════════════════════════════════════════════════ +# Context & path safety +# ═══════════════════════════════════════════════════════════════════ + + +class TestContext: + def test_wildcard_access(self): + ctx = ToolContext(user={"vaults": ["*"]}) + assert ctx.has_vault_access("Anything") + + def test_specific_access(self): + ctx = ToolContext(user={"vaults": ["V1"]}) + assert ctx.has_vault_access("V1") + assert not ctx.has_vault_access("V2") + + def test_require_vault_access_denied(self): + ctx = ToolContext(user={"vaults": ["V1"]}) + with pytest.raises(ToolPermissionError): + ctx.require_vault_access("V2") + + def test_token_vaults_take_precedence(self): + ctx = ToolContext(user={"vaults": ["*"], "_token_vaults": ["V1"]}) + assert ctx.has_vault_access("V1") + assert not ctx.has_vault_access("V2") + + def test_resolve_safe_path_inside(self, tmp_path): + resolved = resolve_safe_path(tmp_path, "sub/note.md") + assert str(resolved).startswith(str(tmp_path.resolve())) + + def test_resolve_safe_path_traversal(self, tmp_path): + with pytest.raises(ToolPermissionError): + resolve_safe_path(tmp_path, "../evil.md") + + +# ═══════════════════════════════════════════════════════════════════ +# Execution (index-backed) +# ═══════════════════════════════════════════════════════════════════ + + +class TestCallTool: + def test_unknown_tool(self, client): + with pytest.raises(ToolNotFoundError): + call_tool("nope", _ctx(), {}) + + def test_invalid_arguments(self, client): + with pytest.raises(ToolValidationError): + call_tool("read_file", _ctx(), {"vault": "TestVault"}) + + def test_list_vaults(self, client): + result = call_tool("list_vaults", _ctx(), {}) + assert result.ok + names = {v["name"] for v in result.data} + assert "TestVault" in names + + def test_list_vaults_filters_by_permission(self, client): + ctx = ToolContext(user={"username": "limited", "vaults": ["OtherVault"]}, audit_enabled=False) + result = call_tool("list_vaults", ctx, {}) + assert result.data == [] + + def test_list_directory(self, client): + result = call_tool("list_directory", _ctx(), {"vault": "TestVault"}) + names = {e["name"] for e in result.data} + assert "note1.md" in names + assert "Projets" in names + projets = next(e for e in result.data if e["name"] == "Projets") + assert projets["type"] == "directory" + + def test_list_directory_not_found(self, client): + with pytest.raises(ToolNotFoundError): + call_tool("list_directory", _ctx(), {"vault": "TestVault", "path": "nope"}) + + def test_read_file(self, client): + result = call_tool("read_file", _ctx(), {"vault": "TestVault", "path": "note1.md"}) + assert result.ok + assert "Python" in result.data["content"] + assert result.data["path"] == "note1.md" + + def test_read_file_not_found(self, client): + with pytest.raises(ToolNotFoundError): + call_tool("read_file", _ctx(), {"vault": "TestVault", "path": "missing.md"}) + + def test_read_file_too_large(self, client): + from backend.indexer import get_vault_data + from backend.tools.service import TOOL_MAX_READ_BYTES + + vault_path = Path(get_vault_data("TestVault")["path"]) + big = vault_path / "big.txt" + big.write_text("x" * (TOOL_MAX_READ_BYTES + 10), encoding="utf-8") + try: + with pytest.raises(ToolError) as exc: + call_tool("read_file", _ctx(), {"vault": "TestVault", "path": "big.txt"}) + assert exc.value.code == "file_too_large" + finally: + big.unlink() + + def test_read_file_redacts_secrets(self, client): + from backend.indexer import get_vault_data + + vault_path = Path(get_vault_data("TestVault")["path"]) + secret = vault_path / "secret.md" + fake_jwt = "eyJ" + "a" * 30 + "." + "b" * 30 + "." + "c" * 30 + secret.write_text(f"token: {fake_jwt}\n", encoding="utf-8") + try: + result = call_tool("read_file", _ctx(), {"vault": "TestVault", "path": "secret.md"}) + assert "[JWT MASQUÉ]" in result.data["content"] + finally: + secret.unlink() + + def test_search_fulltext(self, client): + result = call_tool("search_fulltext", _ctx(), {"q": "Python"}) + assert result.ok + assert len(result.data) >= 1 + assert all(r["vault"] == "TestVault" for r in result.data) + + def test_list_tags(self, client): + result = call_tool("list_tags", _ctx(), {}) + tags = {t["tag"] for t in result.data} + assert "python" in tags + + def test_vault_permission_denied(self, client): + ctx = ToolContext(user={"username": "limited", "vaults": ["OtherVault"]}, audit_enabled=False) + with pytest.raises(ToolPermissionError): + call_tool("list_directory", ctx, {"vault": "TestVault"}) + + +# ═══════════════════════════════════════════════════════════════════ +# Confirmation gating +# ═══════════════════════════════════════════════════════════════════ + + +class TestConfirmation: + def _register_write_tool(self, monkeypatch): + from backend.tools import registry + + spec = ToolSpec( + name="_tmp_write", + description="write tool for tests", + input_model=ListVaultsInput, + handler=lambda ctx, params: {"done": True}, + risk=ToolRisk.WRITE, + ) + monkeypatch.setitem(registry._REGISTRY, "_tmp_write", spec) + + def test_write_tool_requires_confirmation(self, client, monkeypatch): + self._register_write_tool(monkeypatch) + with pytest.raises(ToolConfirmationRequired): + call_tool("_tmp_write", _ctx(), {}) + + def test_write_tool_runs_when_confirmed(self, client, monkeypatch): + self._register_write_tool(monkeypatch) + result = call_tool("_tmp_write", _ctx(), {}, confirm=True) + assert result.ok + assert result.data == {"done": True} + + def test_context_confirmed_flag(self, client, monkeypatch): + self._register_write_tool(monkeypatch) + result = call_tool("_tmp_write", _ctx(confirmed=True), {}) + assert result.ok + + +# ═══════════════════════════════════════════════════════════════════ +# Audit +# ═══════════════════════════════════════════════════════════════════ + + +class TestToolAudit: + def test_tool_call_is_audited(self, client, monkeypatch, tmp_path): + from backend import audit as audit_mod + + log_file = tmp_path / "audit.log" + monkeypatch.setattr(audit_mod, "AUDIT_LOG_FILE", log_file) + + ctx = ToolContext(user={"username": "tester", "vaults": ["*"]}, audit_enabled=True) + call_tool("list_vaults", ctx, {}) + + assert log_file.exists() + entries = [json.loads(line) for line in log_file.read_text(encoding="utf-8").splitlines() if line.strip()] + assert entries[-1]["action"] == "ai_tool_call" + assert entries[-1]["tool"] == "list_vaults" + assert entries[-1]["ok"] is True + + def test_sanitize_arguments_masks_content(self): + from backend.tools.audit import _sanitize_arguments + + safe = _sanitize_arguments({"vault": "V", "path": "a.md", "content": "x" * 500}) + assert safe["vault"] == "V" + assert safe["path"] == "a.md" + assert safe["content"] == "<500 chars>"