Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/app/services/ai/ai_tool_security.py
2026-08-21 00:15:57 +00:00

207 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Security-first candidate filter — hard boundary, not a ranking signal.
Permission/tenant در Registry اعمال می‌شود.
این ماژول روی مجموعهٔ از قبل مجاز، side_effect و execution_mode را اعمال می‌کند
تا Tool خطرناک با Intent عمومی وارد Candidate نشود.
UNKNOWN → NOT CANDIDATE (fail-closed) مگر نام در Manifest نباشد و
unknown_policy=allow (فقط برای fillerهای تست ranking).
"""
from __future__ import annotations
import re
from enum import Enum
from typing import AbstractSet, Iterable, List, Optional, Set
from app.services.ai.ai_execution_policy import (
EXECUTION_MODE_ANALYZER,
resolve_execution_mode,
)
from app.services.ai.ai_tool_index import get_tool_index
from app.services.ai.ai_tool_manifest import get_manifest_entry
from app.services.ai.ai_tool_spec import ToolSideEffect
class QueryMutation(str, Enum):
READ = "read"
WRITE = "write"
DESTRUCTIVE = "destructive"
EXPORT = "export"
EXECUTE = "execute"
UNKNOWN = "unknown"
class ToolSecurityClass(str, Enum):
READ_ONLY = "read_only"
WRITE = "write"
DESTRUCTIVE = "destructive"
HIGH_RISK = "high_risk"
UNKNOWN = "unknown"
_DESTRUCTIVE_QUERY = re.compile(
r"حذف|پاک(?:\s*کن)?|delete\b|remove\b|فراموش(?:ش)?\s*کن|forget\b",
re.IGNORECASE,
)
_EXPORT_QUERY = re.compile(
r"خروجی|export\b|اکسل|excel|دانلود\s*لیست",
re.IGNORECASE,
)
_EXECUTE_QUERY = re.compile(
r"اجرای\s*(?:workflow|اتوماسیون|گردش[\s\-]*کار)|execute_workflow|"
r"اتوماسیون\s*را\s*اجرا|گردش[\s\-]*کار\s*را\s*اجرا|"
r"workflow\s*را\s*اجرا|run this workflow|test this automation|"
r"اتوماسیون\s*را\s*تست|تست\s*(?:این\s*)?(?:اتوماسیون|گردش[\s\-]*کار|workflow)",
re.IGNORECASE,
)
# بدون «حذف» — آن Destructive است
_WRITE_QUERY = re.compile(
r"ثبت|ایجاد|اضافه|بساز|بزن|ویرایش|create|update|\badd\b|new\s+invoice|"
r"مشتری\s+جدید|کالای\s+جدید|به\s*خاطر\s*بسپار|یادت\s*باشه|remember\b|"
r"بروزرسانی|به‌?روز(?:رسانی)?|عوض\s*کن|اصلاح\s*کن",
re.IGNORECASE,
)
_LEADING_GREETING = re.compile(
r"^(سلام|درود|صبح\s*بخیر|عصر\s*بخیر|وقت\s*بخیر|hello|hi|hey)[\s,،!؛:.]*",
re.IGNORECASE,
)
def _strip_greeting(text: str) -> str:
stripped = _LEADING_GREETING.sub("", (text or "").strip(), count=1).strip()
return stripped or (text or "").strip()
def classify_query_mutation(
user_query: Optional[str],
history_messages: Optional[List[dict]] = None,
) -> QueryMutation:
"""کلاس جهش مورد نیاز کاربر — جدا از دامنهٔ مالی/انبار."""
text = _strip_greeting(user_query or "")
classified = _classify_text(text)
if classified != QueryMutation.UNKNOWN and classified != QueryMutation.READ:
return classified
short = len(text) < 40
if not history_messages or (classified == QueryMutation.READ and not short):
return classified
for msg in reversed(history_messages[-8:]):
if msg.get("role") != "user":
continue
prior = _strip_greeting(str(msg.get("content") or ""))
if len(prior) < 8:
continue
prior_class = _classify_text(prior)
if prior_class in (
QueryMutation.DESTRUCTIVE,
QueryMutation.EXECUTE,
QueryMutation.EXPORT,
QueryMutation.WRITE,
):
return prior_class
return classified
def _classify_text(text: str) -> QueryMutation:
q = (text or "").replace("\u200c", "").replace("\u200d", "").strip()
if len(q) < 2:
return QueryMutation.UNKNOWN
if _DESTRUCTIVE_QUERY.search(q):
return QueryMutation.DESTRUCTIVE
if _EXECUTE_QUERY.search(q):
return QueryMutation.EXECUTE
if _EXPORT_QUERY.search(q):
return QueryMutation.EXPORT
if _WRITE_QUERY.search(q):
return QueryMutation.WRITE
return QueryMutation.READ
def classify_tool_security(name: str) -> ToolSecurityClass:
"""طبقه‌بندی از Manifest — prefix نام فقط از طریق side_effect ذخیره‌شده."""
if not name:
return ToolSecurityClass.UNKNOWN
entry = get_manifest_entry(name)
if entry is None or not entry.enabled:
return ToolSecurityClass.UNKNOWN
side = entry.side_effect or ToolSideEffect.NONE.value
if side == ToolSideEffect.DELETE.value:
return ToolSecurityClass.DESTRUCTIVE
if side in (ToolSideEffect.EXECUTE.value, ToolSideEffect.EXPORT.value):
return ToolSecurityClass.HIGH_RISK
if side == ToolSideEffect.WRITE.value or entry.intent_write:
return ToolSecurityClass.WRITE
if side == ToolSideEffect.NONE.value:
return ToolSecurityClass.READ_ONLY
return ToolSecurityClass.UNKNOWN
def tool_side_effect(name: str) -> str:
idx = get_tool_index()
if name in idx.side_effects:
return idx.side_effects[name]
entry = get_manifest_entry(name)
if entry is None:
return ""
return entry.side_effect
def mutation_allows_tool(
name: str,
mutation: QueryMutation,
*,
execution_mode: Optional[str] = None,
) -> bool:
"""Hard filter: True فقط اگر Tool برای این mutation و mode مجاز باشد."""
mode = resolve_execution_mode(execution_mode) if execution_mode else None
cls = classify_tool_security(name)
if cls == ToolSecurityClass.UNKNOWN:
return False
if mode == EXECUTION_MODE_ANALYZER:
return cls == ToolSecurityClass.READ_ONLY
if cls == ToolSecurityClass.READ_ONLY:
return True
if mutation in (QueryMutation.READ, QueryMutation.UNKNOWN):
return False
if mutation == QueryMutation.WRITE:
return cls == ToolSecurityClass.WRITE
if mutation == QueryMutation.DESTRUCTIVE:
return cls == ToolSecurityClass.DESTRUCTIVE
if mutation == QueryMutation.EXPORT:
return tool_side_effect(name) == ToolSideEffect.EXPORT.value
if mutation == QueryMutation.EXECUTE:
return tool_side_effect(name) == ToolSideEffect.EXECUTE.value
return False
def filter_security_candidates(
names: Iterable[str],
user_query: Optional[str],
*,
execution_mode: Optional[str] = None,
forced_names: Optional[AbstractSet[str]] = None,
history_messages: Optional[List[dict]] = None,
unknown_policy: str = "deny",
) -> Set[str]:
"""مجموعهٔ مجاز برای Discovery/Ranking. Security score وجود ندارد.
unknown_policy:
deny — نام خارج از Manifest حذف می‌شود (مسیر production)
allow — نام ناشناخته نگه داشته می‌شود (filler تست ranking)
"""
forced = {n for n in (forced_names or ()) if n}
mutation = classify_query_mutation(user_query, history_messages)
allowed: Set[str] = set()
for name in names:
if not name:
continue
if name in forced:
allowed.add(name)
continue
cls = classify_tool_security(name)
if cls == ToolSecurityClass.UNKNOWN:
if unknown_policy == "allow":
allowed.add(name)
continue
if mutation_allows_tool(name, mutation, execution_mode=execution_mode):
allowed.add(name)
return allowed