feat(ai): catalogue d'outils mutations + confirmations two-step (#79 phase D)
CI / lint (push) Successful in 59s
CI / security (push) Successful in 45s
CI / test (push) Successful in 1m22s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m37s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 59s
CI / security (push) Successful in 45s
CI / test (push) Successful in 1m22s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m37s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
This commit is contained in:
@@ -139,6 +139,27 @@ class ToolContext:
|
||||
details={"vault": vault},
|
||||
)
|
||||
|
||||
def destructive_tools_enabled(self, vault: str) -> bool:
|
||||
"""Return whether destructive tools are allowed for *vault*.
|
||||
|
||||
Controlled by the per-vault ``aiDestructiveTools`` setting (default:
|
||||
enabled). Disabling it blocks delete/rename/move/find-replace tools
|
||||
while keeping create/edit/append available.
|
||||
"""
|
||||
from backend.vault_settings import get_vault_setting
|
||||
|
||||
settings = get_vault_setting(vault) or {}
|
||||
return bool(settings.get("aiDestructiveTools", True))
|
||||
|
||||
def require_destructive_allowed(self, vault: str) -> None:
|
||||
"""Raise :class:`ToolPermissionError` if destructive tools are disabled."""
|
||||
if not self.destructive_tools_enabled(vault):
|
||||
raise ToolPermissionError(
|
||||
f"Destructive tools are disabled for vault '{vault}'",
|
||||
code="destructive_tools_disabled",
|
||||
details={"vault": vault},
|
||||
)
|
||||
|
||||
|
||||
def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
|
||||
"""Resolve a vault-relative path, rejecting traversal outside the vault.
|
||||
|
||||
@@ -194,6 +194,13 @@ def call_tool(
|
||||
_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)
|
||||
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)
|
||||
|
||||
|
||||
@@ -138,6 +138,105 @@ class ListRecentInput(BaseModel):
|
||||
mode: str = Field("opened", description="'opened' (history) or 'modified'")
|
||||
|
||||
|
||||
# ── D. Mutations ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CreateFileInput(BaseModel):
|
||||
"""Create a new text file in a vault."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the new file")
|
||||
content: str = Field("", description="Initial file content")
|
||||
|
||||
|
||||
class CreateDirectoryInput(BaseModel):
|
||||
"""Create a new directory in a vault."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the new directory")
|
||||
|
||||
|
||||
class EditFileInput(BaseModel):
|
||||
"""Overwrite the full content of an existing file."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative file path")
|
||||
content: str = Field(..., description="New full content of the file")
|
||||
|
||||
|
||||
class AppendToFileInput(BaseModel):
|
||||
"""Append text to the end of an existing file."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative file path")
|
||||
content: str = Field(..., description="Text to append")
|
||||
|
||||
|
||||
class RenameFileInput(BaseModel):
|
||||
"""Rename a file in place (same parent directory)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Current vault-relative file path")
|
||||
new_name: str = Field(..., description="New file name (no directory separator)")
|
||||
|
||||
|
||||
class RenameDirectoryInput(BaseModel):
|
||||
"""Rename a directory in place (same parent directory)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Current vault-relative directory path")
|
||||
new_name: str = Field(..., description="New directory name (no directory separator)")
|
||||
|
||||
|
||||
class MovePathInput(BaseModel):
|
||||
"""Move a file or directory to another directory within the same vault."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
source_path: str = Field(..., description="Current vault-relative path of the file/directory")
|
||||
destination_dir: str = Field("", description="Target directory path (empty = vault root)")
|
||||
|
||||
|
||||
class ReplaceInFilesInput(BaseModel):
|
||||
"""Find and replace text across vault files (destructive, dry-run by default)."""
|
||||
|
||||
find: str = Field(..., min_length=1, description="Text or pattern to search for")
|
||||
replace: str = Field("", description="Replacement text")
|
||||
vault: str = Field("all", description="Vault name or 'all'")
|
||||
case_sensitive: bool = Field(False, description="Match case")
|
||||
whole_word: bool = Field(False, description="Match whole words only")
|
||||
regex: bool = Field(False, description="Treat 'find' as a regular expression")
|
||||
include_paths: str | None = Field(None, description="Comma-separated glob patterns to include")
|
||||
exclude_paths: str | None = Field(None, description="Comma-separated glob patterns to exclude")
|
||||
replace_all: bool = Field(False, description="Apply the replacement (otherwise only preview)")
|
||||
dry_run: bool | None = Field(
|
||||
None,
|
||||
description="Preview matches without writing. Defaults to the opposite of replace_all.",
|
||||
)
|
||||
|
||||
|
||||
class DeleteFileInput(BaseModel):
|
||||
"""Delete a file from a vault (destructive)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative file path")
|
||||
|
||||
|
||||
class DeleteDirectoryInput(BaseModel):
|
||||
"""Delete a directory from a vault (destructive)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative directory path")
|
||||
recursive: bool = Field(True, description="Delete non-empty directories recursively")
|
||||
|
||||
|
||||
class RestoreBackupInput(BaseModel):
|
||||
"""Restore a file from one of its backup versions."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative file path")
|
||||
version: int = Field(..., description="Backup timestamp to restore")
|
||||
|
||||
|
||||
class ToolResult(BaseModel):
|
||||
"""Uniform result returned by :func:`backend.tools.registry.call_tool`."""
|
||||
|
||||
|
||||
+206
-2
@@ -1,9 +1,11 @@
|
||||
"""Built-in tool services (Phase 0 + Phase C — read/search catalogue).
|
||||
"""Built-in tool services (Phase 0 + Phase C read/search + Phase D mutations).
|
||||
|
||||
These functions are the single source of truth consumed by both the in-app
|
||||
assistant and the MCP server. They delegate to the shared business-logic
|
||||
services (``backend.services``) so routes and tools never diverge.
|
||||
Mutating tools (create/edit/rename/move/delete) are added in later phases.
|
||||
Mutating tools (create/edit/rename/move/delete/restore) are registered with
|
||||
``ToolRisk.WRITE`` or ``ToolRisk.DANGEROUS`` and gated by the registry's
|
||||
confirmation mechanism (two-step propose/apply).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,6 +19,39 @@ from backend.services.backups import diff_backup as _diff_backup
|
||||
from backend.services.backups import list_backup_files as _list_backup_files
|
||||
from backend.services.files import read_file_text
|
||||
from backend.services.graph import get_graph as _get_graph
|
||||
from backend.services.mutations import (
|
||||
append_to_file as _append_to_file,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
create_directory as _create_directory,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
create_file as _create_file,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
delete_directory as _delete_directory,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
delete_file as _delete_file,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
edit_file as _edit_file,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
move_path as _move_path,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
rename_directory as _rename_directory,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
rename_file as _rename_file,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
replace_in_files as _replace_in_files,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
restore_backup as _restore_backup,
|
||||
)
|
||||
from backend.services.recent import list_recent as _list_recent
|
||||
from backend.services.search import advanced_search_vaults, search_vaults
|
||||
from backend.services.search import list_tags as _list_tags
|
||||
@@ -25,7 +60,13 @@ from backend.services.vaults import browse_directory, list_accessible_vaults, li
|
||||
from backend.tools.context import ToolContext, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import (
|
||||
AppendToFileInput,
|
||||
CreateDirectoryInput,
|
||||
CreateFileInput,
|
||||
DeleteDirectoryInput,
|
||||
DeleteFileInput,
|
||||
DiffBackupInput,
|
||||
EditFileInput,
|
||||
GetBacklinksInput,
|
||||
GetGraphInput,
|
||||
ListAllFilesInput,
|
||||
@@ -34,8 +75,13 @@ from backend.tools.schemas import (
|
||||
ListRecentInput,
|
||||
ListTagsInput,
|
||||
ListVaultsInput,
|
||||
MovePathInput,
|
||||
ReadFileInput,
|
||||
ReadFileRawInput,
|
||||
RenameDirectoryInput,
|
||||
RenameFileInput,
|
||||
ReplaceInFilesInput,
|
||||
RestoreBackupInput,
|
||||
SearchAdvancedInput,
|
||||
SearchFulltextInput,
|
||||
SearchPathsInput,
|
||||
@@ -300,3 +346,161 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]:
|
||||
limit=params.limit,
|
||||
mode=params.mode,
|
||||
)
|
||||
|
||||
|
||||
# ── D. Mutations ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@tool(
|
||||
name="create_file",
|
||||
description="Create a new text file in a vault with optional initial content.",
|
||||
input_model=CreateFileInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def create_file(ctx: ToolContext, params: CreateFileInput) -> dict[str, Any]:
|
||||
"""Create a vault file (fails if it already exists)."""
|
||||
return _create_file(params.vault, params.path, params.content)
|
||||
|
||||
|
||||
@tool(
|
||||
name="create_directory",
|
||||
description="Create a new directory (and parents) in a vault.",
|
||||
input_model=CreateDirectoryInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def create_directory(ctx: ToolContext, params: CreateDirectoryInput) -> dict[str, Any]:
|
||||
"""Create a vault directory."""
|
||||
return _create_directory(params.vault, params.path)
|
||||
|
||||
|
||||
@tool(
|
||||
name="edit_file",
|
||||
description="Overwrite the full content of an existing file (automatic backup).",
|
||||
input_model=EditFileInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]:
|
||||
"""Replace a vault file's content."""
|
||||
return _edit_file(params.vault, params.path, params.content)
|
||||
|
||||
|
||||
@tool(
|
||||
name="append_to_file",
|
||||
description="Append text to the end of an existing file (automatic backup).",
|
||||
input_model=AppendToFileInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any]:
|
||||
"""Append content to a vault file."""
|
||||
return _append_to_file(params.vault, params.path, params.content)
|
||||
|
||||
|
||||
@tool(
|
||||
name="rename_file",
|
||||
description="Rename a file in place (same parent directory). Destructive: requires confirmation.",
|
||||
input_model=RenameFileInput,
|
||||
risk=ToolRisk.DANGEROUS,
|
||||
requires_vault=True,
|
||||
)
|
||||
def rename_file(ctx: ToolContext, params: RenameFileInput) -> dict[str, Any]:
|
||||
"""Rename a vault file."""
|
||||
return _rename_file(params.vault, params.path, params.new_name)
|
||||
|
||||
|
||||
@tool(
|
||||
name="rename_directory",
|
||||
description="Rename a directory in place (same parent directory). Destructive: requires confirmation.",
|
||||
input_model=RenameDirectoryInput,
|
||||
risk=ToolRisk.DANGEROUS,
|
||||
requires_vault=True,
|
||||
)
|
||||
def rename_directory(ctx: ToolContext, params: RenameDirectoryInput) -> dict[str, Any]:
|
||||
"""Rename a vault directory."""
|
||||
return _rename_directory(params.vault, params.path, params.new_name)
|
||||
|
||||
|
||||
@tool(
|
||||
name="move_path",
|
||||
description="Move a file or directory to another directory in the same vault. Destructive: requires confirmation.",
|
||||
input_model=MovePathInput,
|
||||
risk=ToolRisk.DANGEROUS,
|
||||
requires_vault=True,
|
||||
)
|
||||
def move_path(ctx: ToolContext, params: MovePathInput) -> dict[str, Any]:
|
||||
"""Move a vault file or directory."""
|
||||
return _move_path(params.vault, params.source_path, params.destination_dir)
|
||||
|
||||
|
||||
@tool(
|
||||
name="replace_in_files",
|
||||
description=(
|
||||
"Find and replace text across vault files. Previews by default "
|
||||
"(dry_run); set replace_all=true to apply. Destructive: requires confirmation."
|
||||
),
|
||||
input_model=ReplaceInFilesInput,
|
||||
risk=ToolRisk.DANGEROUS,
|
||||
)
|
||||
def replace_in_files(ctx: ToolContext, params: ReplaceInFilesInput) -> dict[str, Any]:
|
||||
"""Preview or apply a find/replace, filtered to permitted vaults."""
|
||||
if params.vault and params.vault != "all":
|
||||
ctx.require_vault_access(params.vault)
|
||||
ctx.require_destructive_allowed(params.vault)
|
||||
|
||||
dry_run = params.dry_run if params.dry_run is not None else not params.replace_all
|
||||
|
||||
def _allowed(vault: str) -> bool:
|
||||
return ctx.has_vault_access(vault) and ctx.destructive_tools_enabled(vault)
|
||||
|
||||
return _replace_in_files(
|
||||
params.find,
|
||||
params.replace,
|
||||
vault=params.vault,
|
||||
case_sensitive=params.case_sensitive,
|
||||
whole_word=params.whole_word,
|
||||
regex=params.regex,
|
||||
include_paths=params.include_paths,
|
||||
exclude_paths=params.exclude_paths,
|
||||
replace_all=params.replace_all,
|
||||
dry_run=dry_run,
|
||||
is_vault_allowed=_allowed,
|
||||
)
|
||||
|
||||
|
||||
@tool(
|
||||
name="delete_file",
|
||||
description="Delete a file from a vault (automatic backup). Destructive: requires confirmation.",
|
||||
input_model=DeleteFileInput,
|
||||
risk=ToolRisk.DANGEROUS,
|
||||
requires_vault=True,
|
||||
)
|
||||
def delete_file(ctx: ToolContext, params: DeleteFileInput) -> dict[str, Any]:
|
||||
"""Delete a vault file."""
|
||||
return _delete_file(params.vault, params.path)
|
||||
|
||||
|
||||
@tool(
|
||||
name="delete_directory",
|
||||
description="Delete a directory (recursive by default) from a vault. Destructive: requires confirmation.",
|
||||
input_model=DeleteDirectoryInput,
|
||||
risk=ToolRisk.DANGEROUS,
|
||||
requires_vault=True,
|
||||
)
|
||||
def delete_directory(ctx: ToolContext, params: DeleteDirectoryInput) -> dict[str, Any]:
|
||||
"""Delete a vault directory."""
|
||||
return _delete_directory(params.vault, params.path, recursive=params.recursive)
|
||||
|
||||
|
||||
@tool(
|
||||
name="restore_backup",
|
||||
description="Restore a file from one of its backup versions (current version backed up first).",
|
||||
input_model=RestoreBackupInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def restore_backup(ctx: ToolContext, params: RestoreBackupInput) -> dict[str, Any]:
|
||||
"""Restore a vault file from a backup."""
|
||||
return _restore_backup(params.vault, params.path, params.version)
|
||||
|
||||
Reference in New Issue
Block a user