feat(ai): phase 0 couche d'outils partagee (backend/tools) + tests
CI / lint (push) Successful in 57s
CI / security (push) Successful in 39s
CI / test (push) Failing after 41s
CI / build (push) Skipped
CI / e2e (push) Skipped
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

- 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)
This commit is contained in:
2026-09-11 12:03:21 -04:00
parent a3642caa3d
commit 5c1823d6d2
7 changed files with 988 additions and 2 deletions
+54
View File
@@ -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),
})
+169
View File
@@ -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
+203
View File
@@ -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)
+52
View File
@@ -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
+166
View File
@@ -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])]
+64 -2
View File
@@ -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 <JWT>` → `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.
+280
View File
@@ -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>"