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,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
|
||||
Reference in New Issue
Block a user