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
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:
@@ -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),
|
||||
})
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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>"
|
||||
Reference in New Issue
Block a user