feat: intégration TikTak #197-200 + scoped tokens, webhooks filtrés, listes mtime/size ; fix e2e-server BUG-112
CI / lint (push) Successful in 3m17s
CI / security (push) Failing after 1m51s
CI / test (push) Successful in 5m14s
CI / build (push) Successful in 1m50s
CI / e2e (push) Successful in 18m18s

This commit is contained in:
2026-10-14 11:42:53 -04:00
parent 2be0b15bcf
commit f783394f09
59 changed files with 3233 additions and 77 deletions
+37 -7
View File
@@ -215,15 +215,37 @@ def _save_api_tokens(data: dict):
tmp.replace(API_TOKENS_FILE)
def create_api_token(user: dict, name: str, expiry_key: str) -> tuple[dict, str]:
#: Endpoint perimeters accepted for scoped API tokens (#199). ``"full"``
#: keeps today's behaviour ; ``"files"`` restricts the token to the file
#: read/write surface (enforced by ``TokenScopeMiddleware`` on REST and by
#: the tool registry on MCP/agent calls).
API_TOKEN_SCOPES = ("full", "files")
def create_api_token(
user: dict,
name: str,
expiry_key: str,
vaults: list[str] | None = None,
scope: str = "full",
) -> tuple[dict, str]:
"""Create a persistent API/MCP token. Returns (record, jwt_string).
``expiry_key`` must be one of API_TOKEN_EXPIRY_CHOICES; "never" omits the
``exp`` claim (valid until explicitly revoked).
#199 (synchro TikTak) — optional least-privilege restriction:
``vaults`` limits the token to the given vaults (``None`` = snapshot of
the creator's vaults, i.e. today's behaviour) and ``scope`` limits the
endpoint perimeter (``"full"`` or ``"files"``). Both are embedded in the
JWT so enforcement stays stateless; the record keeps them for display.
"""
if expiry_key not in API_TOKEN_EXPIRY_CHOICES:
raise ValueError("Expiration invalide")
if scope not in API_TOKEN_SCOPES:
raise ValueError("Portée invalide (full | files)")
seconds = API_TOKEN_EXPIRY_CHOICES[expiry_key]
resolved_vaults = list(vaults) if vaults is not None else list(user.get("vaults", []))
with _api_tokens_lock:
data = _load_api_tokens()
tokens = data["tokens"]
@@ -235,11 +257,12 @@ def create_api_token(user: dict, name: str, expiry_key: str) -> tuple[dict, str]
payload = {
"sub": user["username"],
"role": user.get("role", "user"),
"vaults": user.get("vaults", []),
"vaults": resolved_vaults,
"jti": jti,
"iat": now,
"type": "access",
"api": True,
"scope": scope,
}
if seconds is not None:
payload["exp"] = now + seconds
@@ -252,6 +275,8 @@ def create_api_token(user: dict, name: str, expiry_key: str) -> tuple[dict, str]
"expires_at": payload.get("exp"),
"expiry_key": expiry_key,
"last_used_at": None,
"vaults": resolved_vaults,
"scope": scope,
}
tokens[jti] = record
_save_api_tokens(data)
@@ -262,11 +287,16 @@ def list_api_tokens(username: str) -> list[dict]:
"""Token metadata for one user, newest first."""
data = _load_api_tokens()
now = int(time.time())
items = [
{**t, "expired": t.get("expires_at") is not None and t["expires_at"] < now}
for t in data["tokens"].values()
if t["username"] == username
]
items = []
for t in data["tokens"].values():
if t["username"] != username:
continue
# #199 : les clés créées avant la portée limitée n'ont ni scope ni
# vaults stockés → portée pleine, comportement inchangé.
entry = {**t, "expired": t.get("expires_at") is not None and t["expires_at"] < now}
entry.setdefault("scope", "full")
entry.setdefault("vaults", None)
items.append(entry)
return sorted(items, key=lambda t: t["created_at"], reverse=True)
+3
View File
@@ -115,6 +115,9 @@ def get_current_user(
user["_token_vaults"] = payload.get("vaults", [])
# Attach the token id for per-token rate limiting (AI tool layer).
user["_token_jti"] = payload.get("jti")
# #199 : périmètre d'endpoints du token (défaut "full" = comportement
# actuel, y compris pour les clés créées avant la portée limitée).
user["_token_scope"] = payload.get("scope", "full")
# Feature #107: track last usage of user-managed API/MCP tokens
# (throttled write — this dependency runs on both REST and /mcp paths).
if payload.get("api"):
+28 -2
View File
@@ -1003,6 +1003,8 @@ async def delete_user_endpoint(
class CreateApiTokenRequest(BaseModel):
name: str
expiry: str # 1d | 30d | 180d | 365d | never
vaults: list[str] | None = None # #199 : None = vaults du créateur (comportement actuel)
scope: str = "full" # #199 : "full" | "files"
@router.get("/tokens")
@@ -1020,9 +1022,33 @@ async def create_user_token(
request: Request,
current_user=Depends(require_auth),
):
"""Create a long-lived API/MCP token. The raw JWT is returned ONCE."""
"""Create a long-lived API/MCP token. The raw JWT is returned ONCE.
#199 : portée optionnelle — ``vaults`` (sous-ensemble des vaults du
créateur, défaut = tous) et ``scope`` (``"full"`` ou ``"files"``).
"""
from backend.auth.jwt_handler import API_TOKEN_SCOPES
from backend.auth.middleware import check_vault_access
from backend.indexer import index
scope = (req.scope or "full").strip().lower()
if scope not in API_TOKEN_SCOPES:
raise HTTPException(400, "Portée invalide (full | files)")
vaults: list[str] | None = None
if req.vaults is not None:
cleaned = sorted({v.strip() for v in req.vaults if isinstance(v, str) and v.strip()})
if not cleaned:
raise HTTPException(400, "Liste de vaults vide")
unknown = [v for v in cleaned if v not in index]
if unknown:
raise HTTPException(400, f"Vaults inconnus : {', '.join(unknown)}")
denied = [v for v in cleaned if not check_vault_access(v, current_user)]
if denied:
raise HTTPException(403, f"Accès refusé aux vaults : {', '.join(denied)}")
vaults = cleaned
try:
record, token = create_api_token(current_user, req.name.strip(), req.expiry)
record, token = create_api_token(current_user, req.name.strip(), req.expiry,
vaults=vaults, scope=scope)
except ValueError as e:
raise HTTPException(400, str(e))
from backend.audit import log_config_change
+141
View File
@@ -0,0 +1,141 @@
# backend/auth/scope.py
# Scoped API/MCP tokens (#199, synchro TikTak — Gitea #2).
#
# A token may carry an endpoint perimeter in its ``scope`` JWT claim:
# - ``"full"`` (default, and the case of every token issued before #199):
# no restriction — today's behaviour, unchanged ;
# - ``"files"``: file read/write surface only (vault file CRUD, listings,
# search, backups read/restore). Everything else (admin, config, AI,
# webhooks, shares, auth management…) answers **403**.
#
# Vault restriction is orthogonal and needs no new machinery: a scoped token
# embeds the allowed vault list in its ``vaults`` JWT claim, so the existing
# ``check_vault_access`` (REST routes) and ``ToolContext.require_vault_access``
# (MCP/agent tools) refuse other vaults with 403 — for REST *and* MCP alike.
"""Endpoint perimeter for scoped API tokens."""
from __future__ import annotations
from typing import Any
SCOPE_FULL = "full"
SCOPE_FILES = "files"
KNOWN_SCOPES = (SCOPE_FULL, SCOPE_FILES)
# (methods, path prefix or exact path). ``methods`` None = any method.
# Keep this list tight: a sync client (TikTak) needs file CRUD, listings,
# search and backup read/restore — nothing else.
_RULES_FOR_FILES: tuple[tuple[frozenset[str] | None, str, bool], ...] = (
# (methods, path, exact_only)
(frozenset({"GET"}), "/api/health", False),
(frozenset({"GET"}), "/api/auth/me", True),
(frozenset({"GET"}), "/api/vaults", True),
(None, "/api/browse/", False),
(None, "/api/file/", False),
(None, "/api/media/", False),
(None, "/api/directory/", False),
(None, "/api/move/", False),
(None, "/api/vault/", False), # {vault}/files, {vault}/paths, batch-upload…
(frozenset({"GET"}), "/api/search", False),
(frozenset({"GET"}), "/api/tree-search", True),
(frozenset({"GET"}), "/api/suggest", False),
(frozenset({"GET"}), "/api/tags", False),
(frozenset({"GET"}), "/api/graph/", False),
(frozenset({"GET"}), "/api/backups", False),
(frozenset({"GET"}), "/docs", False),
(frozenset({"GET"}), "/redoc", True),
(frozenset({"GET"}), "/openapi.json", True),
(None, "/mcp", False),
)
def is_path_allowed_for_scope(scope: str | None, method: str, path: str) -> bool:
"""True when a token with *scope* may call (*method*, *path*).
- ``None``/``"full"`` → everything allowed (backward compatible).
- ``"files"`` → allow-list above (API surface only ; static assets and
public pages carry no data and are handled by the caller — the
middleware only gates ``/api/*`` and ``/mcp*``).
- unknown scope → fail-closed (deny).
"""
if scope in (None, SCOPE_FULL):
return True
if scope != SCOPE_FILES:
return False
if not (path.startswith("/api/") or path.startswith("/mcp")):
return True
method = (method or "GET").upper()
for methods, rule_path, exact in _RULES_FOR_FILES:
if methods is not None and method not in methods:
continue
if exact:
if path == rule_path:
return True
elif path == rule_path or path.startswith(rule_path):
return True
return False
def scope_of_user(user: dict[str, Any] | None) -> str:
"""Endpoint scope attached to an authenticated user (default ``"full"``)."""
if not user:
return SCOPE_FULL
return user.get("_token_scope") or SCOPE_FULL
class TokenScopeMiddleware:
"""Pure-ASGI middleware enforcing the endpoint perimeter of scoped tokens.
Denies out-of-perimeter calls made with a scoped token (**403**) before
they reach the routes. Only *adds* restrictions: the ``scope`` claim is
read unverified here, and the signature is still verified downstream by
``get_current_user`` (an invalid token is rejected with 401 a step later
anyway). Full-scope and scopeless tokens pass through untouched.
"""
def __init__(self, app: Any):
self.app = app
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
if scope.get("type") == "http":
method = scope.get("method", "GET")
path = scope.get("path", "")
if path.startswith("/api/") or path.startswith("/mcp"):
token_scope = _scope_from_asgi(scope)
if token_scope not in (None, SCOPE_FULL) and not is_path_allowed_for_scope(
token_scope, method, path
):
from starlette.responses import JSONResponse
response = JSONResponse(
{"detail": "Token à portée limitée : endpoint hors périmètre 'files'"},
status_code=403,
)
await response(scope, receive, send)
return
await self.app(scope, receive, send)
def _scope_from_asgi(asgi_scope: Any) -> str | None:
"""Extract the unverified ``scope`` claim from an ASGI scope's auth."""
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in asgi_scope.get("headers", [])}
token: str | None = None
auth = headers.get("authorization", "")
if auth.lower().startswith("bearer "):
token = auth[7:].strip()
else:
for part in headers.get("cookie", "").split(";"):
name, _, value = part.strip().partition("=")
if name.strip() == "access_token" and value.strip():
token = value.strip()
break
if not token:
return None
try:
from jose import jwt as _jwt
claims = _jwt.get_unverified_claims(token)
except Exception:
return None
token_scope = (claims or {}).get("scope")
return token_scope if isinstance(token_scope, str) and token_scope else None
+11
View File
@@ -1229,6 +1229,13 @@ async def update_single_file(vault_name: str, abs_file_path: str) -> dict[str, A
if not vault_path:
return None
# #197 : les routes passent historiquement un chemin relatif au vault
# (``result["path"]``) alors que la docstring exigeait un absolu — le
# rafraîchissement était un no-op silencieux (couvert en prod par le
# watcher, mais pas dans les tests ni en polling lent). On accepte les deux.
if not Path(abs_file_path).is_absolute():
abs_file_path = str(Path(vault_path) / abs_file_path)
# Get vault configuration for hidden files handling
vault_cfg = vault_data.get("config") or vault_config.get(vault_name, {})
@@ -1272,6 +1279,10 @@ async def remove_single_file(vault_name: str, abs_file_path: str) -> dict[str, A
if not vault_path:
return None
# #197 : même tolérance relatif/absolu que ``update_single_file``.
if not Path(abs_file_path).is_absolute():
abs_file_path = str(Path(vault_path) / abs_file_path)
try:
rel_path = str(Path(abs_file_path).relative_to(vault_path)).replace("\\", "/")
except ValueError:
+7
View File
@@ -486,6 +486,13 @@ app.add_middleware(SSESafeGZipMiddleware, minimum_size=1000)
# Security headers on all responses
app.add_middleware(SecurityHeadersMiddleware)
# Scoped API tokens (#199) : les clés à portée "files" sont confinées à la
# surface fichiers côté REST (403 hors périmètre). Placé avant CORS pour que
# le refus s'applique aussi aux requêtes out-of-scope.
from backend.auth.scope import TokenScopeMiddleware
app.add_middleware(TokenScopeMiddleware)
# Explicit same-origin CORS policy (#87 T8 — finit BUG-034).
# `allow_origins=[]` : le navigateur n'émet aucun `Access-Control-Allow-*`,
# donc toute lecture cross-origin est refusée (défense explicite, plus
+20
View File
@@ -43,6 +43,7 @@ from backend.auth.middleware import get_current_user, is_auth_enabled
from backend.services.files import read_file_text
from backend.services.vaults import list_accessible_vaults
from backend.tools.api import (
FILES_SCOPE_TOOLS,
ToolContext,
ToolError,
ToolMode,
@@ -191,6 +192,14 @@ def _tool_definitions() -> list[types.Tool]:
return definitions
def _mcp_base_name(mcp_name: str) -> str:
"""Strip the ``propose_``/``apply_`` prefix back to the registry tool name."""
for prefix in ("propose_", "apply_"):
if mcp_name.startswith(prefix):
return mcp_name[len(prefix):]
return mcp_name
def _parse_vault_uri(uri: str) -> tuple[str, str]:
"""Split ``vault://name/path`` into ``(vault, path)``."""
parsed = urlparse(uri)
@@ -208,6 +217,10 @@ def build_server() -> Server:
# ── Tools ──────────────────────────────────────────────────────────
@server.list_tools()
async def _list_tools() -> list[types.Tool]:
# #199 : un token à portée "files" ne voit que les outils fichiers.
user = _user_from_context(server)
if (user.get("_token_scope") or "full") == "files":
return [t for t in _tool_definitions() if _mcp_base_name(t.name) in FILES_SCOPE_TOOLS]
return _tool_definitions()
@server.call_tool()
@@ -262,6 +275,7 @@ def build_server() -> Server:
user = _user_from_context(server)
vault, path = _parse_vault_uri(str(uri))
ctx = ToolContext(user=user, mode=ToolMode.MCP)
ctx.require_tool_allowed("read_file") # #199 : portée inconnue → refus
ctx.require_vault_access(vault)
data = read_file_text(vault, path, redact=True, max_bytes=MAX_RESOURCE_BYTES)
mime = "text/markdown" if Path(path).suffix.lower() == ".md" else "text/plain"
@@ -352,6 +366,12 @@ def _propose(user: dict[str, Any], tool: str, arguments: dict[str, Any]) -> list
params = spec.input_model.model_validate(arguments)
vault = getattr(params, "vault", None)
ctx = ToolContext(user=user, mode=ToolMode.MCP)
# #199 : les tokens à portée limitée ne peuvent proposer que les outils
# fichiers (même garde que call_tool, qui couvre apply_).
try:
ctx.require_tool_allowed(tool)
except ToolError as e:
return _text(e.to_dict())
if vault and vault != "all":
ctx.require_vault_access(vault)
if spec.risk == ToolRisk.DANGEROUS:
+2 -2
View File
@@ -242,8 +242,8 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
"response": {"id": "abc…", "token": "abc…", "vault": "TestVault", "path": "notes/Accueil.md", "url": "/s/abc…"},
},
("post", "/api/webhooks"): {
"request": {"name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "secret": "s3cr3t"},
"response": {"id": "3f2c…", "name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "enabled": True},
"request": {"name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "secret": "s3cr3t", "vault": "TikTak", "path_prefix": "Inbox"},
"response": {"id": "3f2c…", "name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "enabled": True, "vault": "TikTak", "path_prefix": "Inbox"},
},
}
+9
View File
@@ -115,6 +115,15 @@ async def api_file_save(
content = body.get("content", "")
result = service_edit_file(vault_name, path, content, backup=backup)
# #197 (synchro TikTak) : rafraîchir l'index de façon synchrone pour une
# cohérence read-after-write — un client qui liste juste après le save
# doit voir le nouveau mtime/size sans attendre le watcher.
await update_single_file(vault_name, result["path"])
# #198 : la sauvegarde texte émet file_modified (événement clé pour tirer
# les retours de synchro côté TikTak en phase 3).
await dispatch_webhooks("file_modified", {"vault": vault_name, "path": result["path"]})
# Audit log
client_ip = current_user.get("_request_ip", "unknown")
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
+3 -1
View File
@@ -34,7 +34,9 @@ async def api_webhooks_create(body: dict = Body(...), current_user=Depends(requi
secret = body.get("secret")
if not url:
raise HTTPException(400, "URL is required")
return create_webhook(name, url, events, secret)
# #198 : filtres optionnels vault / path_prefix (None = tout recevoir).
return create_webhook(name, url, events, secret,
vault=body.get("vault"), path_prefix=body.get("path_prefix"))
@router.patch("/{webhook_id}", response_model=WebhookModel)
+7
View File
@@ -300,6 +300,8 @@ class BrowseItem(BaseModel):
children_count: int | None = Field(default=None, description="Number of children (directories only)")
size: int | None = Field(default=None, description="File size in bytes")
extension: str | None = Field(default=None, description="File extension")
modified: float | None = Field(default=None, description="Modification time, epoch seconds (#197)")
modified_iso: str | None = Field(default=None, description="Modification time, ISO 8601 (#197)")
class BrowseResponse(BaseModel):
@@ -667,6 +669,9 @@ class VaultPathEntry(BaseModel):
path: str = Field(description="Full relative path")
name: str = Field(description="File or directory name")
type: str = Field(description="'file' or 'directory'")
size: int | None = Field(default=None, description="File size in bytes (#197, files only)")
mtime: float | None = Field(default=None, description="Modification time, epoch seconds (#197, files only)")
mtime_iso: str | None = Field(default=None, description="Modification time, ISO 8601 (#197, files only)")
class VaultPathsResponse(BaseModel):
@@ -1042,6 +1047,8 @@ class WebhookModel(BaseModel):
enabled: bool = True
created_at: str | None = None
last_fired_at: str | None = None
vault: str | None = Field(default=None, description="#198 vault filter (None = all vaults)")
path_prefix: str | None = Field(default=None, description="#198 path-prefix filter (None = whole vault)")
class ShareModel(BaseModel):
+34 -6
View File
@@ -198,23 +198,51 @@ def list_paths(vault: str, limit: int = 5000) -> dict[str, Any]:
once lets the client filter files/directories instantly instead of issuing
a request per keystroke.
#197 (synchro TikTak) : chaque entrée fichier expose aussi ``size``,
``mtime`` (epoch) et ``mtime_iso`` — un client externe peut détecter les
fichiers modifiés depuis un horodatage en **un seul appel**, sans relire
chaque fichier. Les valeurs viennent de l'index (source unique, même
contrat que ``list_all_files``) ; les répertoires portent ``None``.
Args:
vault: Vault name.
limit: Maximum number of entries returned.
Returns:
``{"vault", "count", "results": [{vault, path, name, type}]}``.
``{"vault", "count", "results": [{vault, path, name, type, size?,
mtime?, mtime_iso?}]}``.
"""
from backend.indexer import path_index
from datetime import datetime
from backend.indexer import get_vault_data, path_index
entries = path_index.get(vault, [])
results = [
{
meta: dict[str, dict[str, Any]] = {}
data = get_vault_data(vault)
if data:
for info in data.get("files", []):
meta[info["path"]] = info
results = []
for entry in entries[: max(0, limit)]:
item: dict[str, Any] = {
"vault": vault,
"path": entry["path"],
"name": entry["name"],
"type": entry["type"],
"size": None,
"mtime": None,
"mtime_iso": None,
}
for entry in entries[: max(0, limit)]
]
info = meta.get(entry["path"])
if info is not None:
item["size"] = info.get("size")
iso = info.get("modified")
item["mtime_iso"] = iso
if iso:
try:
item["mtime"] = datetime.fromisoformat(iso).timestamp()
except (ValueError, TypeError):
item["mtime"] = None
results.append(item)
return {"vault": vault, "count": len(results), "results": results}
+6 -1
View File
@@ -49,6 +49,8 @@ def get_vault_root(vault_name: str) -> Path:
def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
"""Return the direct children of a vault directory (directories first)."""
from datetime import datetime, timezone
from backend.indexer import SUPPORTED_EXTENSIONS
from backend.vault_settings import get_vault_setting
@@ -89,11 +91,14 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
"children_count": file_count,
})
elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"):
stat = entry.stat()
items.append({
"name": entry.name,
"path": rel,
"type": "file",
"size": entry.stat().st_size,
"size": stat.st_size,
"modified": stat.st_mtime,
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"extension": entry.suffix.lower(),
})
except PermissionError:
+2
View File
@@ -19,6 +19,7 @@ from backend.tools import service as _service # noqa: F401 (registers tools)
from backend.tools import spreadsheets as _spreadsheets # noqa: F401 (registers existing-workbook tools #153 A6)
from backend.tools import web as _web # noqa: F401 (registers web tools)
from backend.tools.context import (
FILES_SCOPE_TOOLS,
ToolConfirmationRequired,
ToolContext,
ToolError,
@@ -52,6 +53,7 @@ from backend.tools.registry import (
from backend.tools.schemas import ToolResult
__all__ = [
"FILES_SCOPE_TOOLS",
"ToolConfirmationRequired",
"ToolContext",
"ToolError",
+49
View File
@@ -117,6 +117,29 @@ class ToolRateLimitError(ToolError):
self.retry_after = retry_after
#: Tools a ``files``-scoped API token (#199, synchro TikTak) may invoke :
#: vault file read/write surface only — no external calls (git/web), no
#: notifications, no scheduler, no cross-vault analysis beyond file content.
FILES_SCOPE_TOOLS: frozenset[str] = frozenset({
# Lecture / recherche de fichiers
"list_vaults", "list_directory", "list_all_files",
"read_file", "read_file_raw", "get_backlinks",
"get_graph", "search_fulltext", "search_advanced", "search_paths",
"list_tags", "suggest_tags", "list_recent",
"list_backups", "diff_backup", "restore_backup",
"find_duplicates",
# Mutations de fichiers
"create_file", "create_directory", "edit_file", "append_to_file",
"rename_file", "rename_directory", "move_path", "replace_in_files",
"delete_file", "delete_directory", "merge_duplicate_notes",
# Tableurs & documents (création/édition de fichiers du vault)
"create_xlsx", "create_docx", "create_csv", "create_pdf",
"list_xlsx_sheets", "xlsx_to_markdown", "search_workbook",
"analyze_range", "update_xlsx_cells", "append_xlsx_rows",
"edit_xlsx_structure",
})
@dataclass
class ToolContext:
"""Carries the caller identity and execution options for a tool call.
@@ -190,6 +213,32 @@ class ToolContext:
details={"vault": vault},
)
def require_tool_allowed(self, tool: str) -> None:
"""Raise :class:`ToolPermissionError` when the caller's token scope
forbids *tool* (#199).
A ``files``-scoped token is confined to :data:`FILES_SCOPE_TOOLS`
(vault file surface). Full-scope and scopeless callers (session
tokens, pre-#199 API keys) are unaffected ; an unknown scope fails
closed.
"""
token_scope = self.user.get("_token_scope") or "full"
if token_scope == "full":
return
if token_scope == "files":
if tool not in FILES_SCOPE_TOOLS:
raise ToolPermissionError(
f"Token à portée limitée : outil '{tool}' hors périmètre 'files'",
code="token_scope_denied",
details={"tool": tool, "scope": "files"},
)
return
raise ToolPermissionError(
"Token à portée inconnue : appel refusé",
code="token_scope_denied",
details={"tool": tool},
)
def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
"""Resolve a vault-relative path, rejecting traversal outside the vault.
+8
View File
@@ -216,6 +216,14 @@ def call_tool(
_audit(ctx, spec, arguments, ok=False, error=e.code)
raise
# #199 : les tokens à portée limitée sont confinés à la surface fichiers,
# y compris pour les outils sans vault (git, web, notify…).
try:
ctx.require_tool_allowed(name)
except ToolPermissionError as e:
_audit(ctx, spec, arguments, ok=False, error=e.code)
raise
if spec.risk == ToolRisk.DANGEROUS and vault and vault != "all":
try:
ctx.require_destructive_allowed(vault)
+11 -4
View File
@@ -123,10 +123,17 @@ def list_vaults(ctx: ToolContext, _params: ListVaultsInput) -> list[dict[str, An
def list_directory(ctx: ToolContext, params: ListDirectoryInput) -> list[dict[str, Any]]:
"""Return the entries of a vault directory (direct children only)."""
data = browse_directory(params.vault, params.path)
return [
{"name": item["name"], "path": item["path"], "type": item["type"]}
for item in data["items"]
]
# #197 : même contrat que la REST — la taille et le mtime permettent la
# réconciliation externe sans relire chaque fichier.
out = []
for item in data["items"]:
entry = {"name": item["name"], "path": item["path"], "type": item["type"]}
if item["type"] == "file":
entry["size"] = item.get("size")
entry["mtime"] = item.get("modified")
entry["mtime_iso"] = item.get("modified_iso")
out.append(entry)
return out
@tool(
+89 -3
View File
@@ -16,6 +16,9 @@ Security (BUG-026):
Events: file_created, file_deleted, file_modified, file_renamed,
directory_created, directory_deleted, directory_renamed
(#198 : chaque webhook peut porter un filtre optionnel ``vault`` et/ou
``path_prefix`` — seuls les événements correspondants sont livrés ; la
charge utile inclut toujours ``vault`` et le(s) chemin(s)).
"""
import asyncio
@@ -120,6 +123,65 @@ def is_safe_target(url: str) -> bool:
return True
def _normalize_vault_filter(vault: str | None) -> str | None:
"""Normalize an optional vault filter (``None``/empty = no filtering)."""
if not vault or not isinstance(vault, str):
return None
cleaned = vault.strip()
return cleaned or None
def _normalize_prefix_filter(prefix: str | None) -> str | None:
"""Normalize an optional path-prefix filter (``None``/empty = no filtering)."""
if not prefix or not isinstance(prefix, str):
return None
cleaned = prefix.strip().strip("/")
return cleaned or None
def _event_paths(data: dict) -> list[str]:
"""Vault-relative paths carried by a webhook payload.
File/directory events use ``path``; renames and moves carry
``old_path``/``new_path`` instead.
"""
paths = []
for key in ("path", "old_path", "new_path"):
value = data.get(key)
if isinstance(value, str) and value:
paths.append(value.strip("/"))
return paths
def webhook_matches(wh: dict, event_type: str, data: dict) -> bool:
"""True when webhook *wh* must receive (*event_type*, *data*).
Pure function (no I/O) so the filtering is unit-testable :
- event subscription (as before) ;
- #198 optional ``vault`` filter — exact match on ``data["vault"]`` ;
- #198 optional ``path_prefix`` filter — matches when any carried path
equals the prefix or lies underneath it (``Docs`` matches ``Docs`` and
``Docs/a.md``, not ``Docs2/b.md``).
Webhooks created before #198 carry neither key and keep receiving
everything they subscribe to (backward compatible).
"""
if event_type not in wh.get("events", []):
return False
vault_filter = _normalize_vault_filter(wh.get("vault"))
if vault_filter and data.get("vault") != vault_filter:
return False
prefix = _normalize_prefix_filter(wh.get("path_prefix"))
if prefix:
wanted = prefix + "/"
for candidate in _event_paths(data):
if candidate == prefix or candidate.startswith(wanted):
break
else:
return False
return True
def _read() -> list:
if not WEBHOOKS_FILE.exists():
return []
@@ -195,7 +257,14 @@ def get_webhooks() -> list:
return [_public_view(wh) for wh in _read()]
def create_webhook(name: str, url: str, events: list[str], secret: str | None = None) -> dict:
def create_webhook(
name: str,
url: str,
events: list[str],
secret: str | None = None,
vault: str | None = None,
path_prefix: str | None = None,
) -> dict:
validate_webhook_url(url)
with _lock:
webhooks = _read()
@@ -208,6 +277,9 @@ def create_webhook(name: str, url: str, events: list[str], secret: str | None =
"enabled": True,
"created_at": datetime.now(timezone.utc).isoformat(),
"last_fired_at": None,
# #198 : filtres optionnels (None = pas de filtre, reçoit tout).
"vault": _normalize_vault_filter(vault),
"path_prefix": _normalize_prefix_filter(path_prefix),
}
webhooks.append(wh)
_write(webhooks)
@@ -226,6 +298,13 @@ def update_webhook(wh_id: str, updates: dict) -> dict | None:
validate_webhook_url(updates["url"])
if "secret" in updates:
_store_secret(wh_id, updates["secret"])
# #198 : normaliser les filtres ("" réinitialise le filtre).
for key, normalizer in (
("vault", _normalize_vault_filter),
("path_prefix", _normalize_prefix_filter),
):
if key in updates:
updates[key] = normalizer(updates[key])
safe_updates = {
k: v for k, v in updates.items()
if k not in ("id", "secret")
@@ -250,9 +329,16 @@ def delete_webhook(wh_id: str) -> bool:
async def dispatch_webhooks(event_type: str, data: dict):
"""Fire all enabled webhooks subscribed to event_type."""
"""Fire all enabled webhooks subscribed to event_type.
#198 : les webhooks portant un filtre ``vault``/``path_prefix`` ne sont
livrés que pour les événements correspondants (voir :func:`webhook_matches`).
"""
webhooks = _read()
targets = [wh for wh in webhooks if wh.get("enabled", True) and event_type in wh.get("events", [])]
targets = [
wh for wh in webhooks
if wh.get("enabled", True) and webhook_matches(wh, event_type, data)
]
if not targets:
return