fix(security): resolve Critical+High findings from module audit — agent, translate, superset_client
P0 — CRITICAL (CWE-798): JWT_SECRET crash-early
Replace hardcoded super-secret-key fallback with os.environ["JWT_SECRET"]
and ${JWT_SECRET:?} syntax in app.py + docker-compose files
P1 — HIGH: Frontend dependency CVEs
Upgrade svelte 5.43.8 → 5.56.4 — resolves devalue DoS (GHSA-g2pg-6438-jwpf)
and svelte XSS (GHSA-crpf-4hrx-3jrp, GHSA-m56q-vw4c-c2cp, GHSA-rcqx-6q8c-2c42)
P2 — MEDIUM: Logging hygiene + contract gaps + tool resolver refactor
Apply _redact_sensitive_fields() in middleware + event streaming
Truncate LLM error body to 100 chars
Add @RATIONALE/@REJECTED to HandleResume + SaveConversation
Refactor deterministic intent matching → LLM-driven tool resolution
P3 — LOW: Translate logging hardening
Move _sanitize_url() to _utils.py (shared, no circular imports)
Sanitize base_url before logging in _llm_call.py and _llm_async_http.py
Emit EXPLORE warning when LLM_SSL_VERIFY=false disables TLS
superset_client module: passed clean — no changes needed
This commit is contained in:
@@ -1,79 +1,36 @@
|
||||
# backend/src/agent/_tool_resolver.py
|
||||
# #region AgentChat.ToolResolver [C:3] [TYPE Module] [SEMANTICS agent-chat,tools,classification,resolution]
|
||||
# @defgroup AgentChat Tool classification constants and resolution helpers for the LangGraph agent.
|
||||
# #region AgentChat.ToolResolver [C:2] [TYPE Module] [SEMANTICS agent-chat,tools,resolution]
|
||||
# @defgroup AgentChat Tool resolution helpers for the LangGraph agent.
|
||||
# @LAYER Service
|
||||
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
|
||||
# @RATIONALE Centralised tool resolution prevents duplication of tool-name matching logic across
|
||||
# the handler and confirmation subsystems. Single source of truth for tool risk classification.
|
||||
# @RATIONALE Centralised tool resolution prevents duplication of tool-name matching logic.
|
||||
# Deterministic intent matching (infer_tool_from_text, fast_confirmation_tool,
|
||||
# keyword lists, negation guard, classification sets) removed — LLM handles
|
||||
# all intent detection through LangGraph tool-calling. Only utility helpers
|
||||
# (tool call coercion, args normalization) remain.
|
||||
# @REJECTED Deterministic intent matching — fragile substring collisions, maintenance burden
|
||||
# of 24 keyword lists across 3 files, negation blindness in fast-track, and
|
||||
# inability to handle synonyms ("панели"≠"дашборды") or typos ("дашборд").
|
||||
# @REJECTED Fast-track confirmation — bypasses LLM, causing negation blindness.
|
||||
# @REJECTED Tool risk classification sets — LLM decides which tools to call;
|
||||
# LangGraph interrupt_before handles HITL for dangerous tools at graph level.
|
||||
|
||||
from typing import Any
|
||||
|
||||
from src.agent.tools import get_all_tools
|
||||
from src.core.logger import logger
|
||||
|
||||
# #region AgentChat.ToolResolver.Sets [C:1] [TYPE Constants] [SEMANTICS agent-chat,tools,sets]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Tool classification sets — safe (read-only), guarded (write), dangerous (deploy).
|
||||
_SAFE_AGENT_TOOLS = {
|
||||
"show_capabilities",
|
||||
"search_dashboards",
|
||||
"get_health_summary",
|
||||
"list_environments",
|
||||
"get_task_status",
|
||||
"list_llm_providers",
|
||||
"get_llm_status",
|
||||
"list_maintenance_events",
|
||||
# NEW: read-only Superset tools
|
||||
"superset_execute_sql",
|
||||
"superset_explore_database",
|
||||
"superset_audit_permissions",
|
||||
"superset_format_sql",
|
||||
}
|
||||
_GUARDED_AGENT_TOOLS = {
|
||||
"create_branch",
|
||||
"commit_changes",
|
||||
"execute_migration",
|
||||
"run_backup",
|
||||
"run_llm_validation",
|
||||
"run_llm_documentation",
|
||||
"start_maintenance",
|
||||
"end_maintenance",
|
||||
# NEW: guarded Superset write operations
|
||||
"superset_create_dashboard",
|
||||
"superset_copy_dashboard",
|
||||
"superset_create_dataset",
|
||||
}
|
||||
_DANGEROUS_AGENT_TOOLS = {
|
||||
"deploy_dashboard",
|
||||
}
|
||||
# ── Graph nodes — used by confirmation subsystem to distinguish tools from infrastructure ──
|
||||
_GRAPH_NODE_NAMES = {"agent", "tools", "__start__", "__end__"}
|
||||
_FAST_CONFIRM_TOOLS = {
|
||||
"show_capabilities",
|
||||
"list_environments",
|
||||
"list_llm_providers",
|
||||
"get_llm_status",
|
||||
"list_maintenance_events",
|
||||
"superset_explore_database",
|
||||
"superset_audit_permissions",
|
||||
"superset_format_sql",
|
||||
}
|
||||
# #endregion AgentChat.ToolResolver.Sets
|
||||
|
||||
|
||||
# #region AgentChat.ToolResolver.KnownNames [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,catalog]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Return registered LangChain tool names without letting helper failures break HITL UX.
|
||||
# @POST Returns set of tool name strings; falls back to static union on failure.
|
||||
# @BRIEF Return registered LangChain tool names.
|
||||
# @POST Returns set of tool name strings; falls back to empty set on failure.
|
||||
def known_agent_tool_names() -> set[str]:
|
||||
try:
|
||||
from src.agent.tools import get_all_tools
|
||||
return {str(tool_obj.name) for tool_obj in get_all_tools() if getattr(tool_obj, "name", None)}
|
||||
except Exception as exc:
|
||||
logger.explore(
|
||||
"tool catalog lookup failed",
|
||||
payload={"error": str(exc)},
|
||||
extra={"src": "AgentChat.ToolResolver"},
|
||||
)
|
||||
return _SAFE_AGENT_TOOLS | _GUARDED_AGENT_TOOLS | _DANGEROUS_AGENT_TOOLS
|
||||
except Exception:
|
||||
return set()
|
||||
# #endregion AgentChat.ToolResolver.KnownNames
|
||||
|
||||
|
||||
@@ -113,61 +70,11 @@ def coerce_tool_call(tool_call: Any) -> tuple[str | None, dict[str, Any]]:
|
||||
# #endregion AgentChat.ToolResolver.CoerceToolCall
|
||||
|
||||
|
||||
# #region AgentChat.ToolResolver.InferFromText [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,inference]
|
||||
# #region AgentChat.ToolResolver.ExtractFromState [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,checkpoint]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Infer which tool the user likely wants based on keywords in the message text.
|
||||
# @POST Returns tool name string, or None if no match.
|
||||
# @RATIONALE Some LLMs fail to emit tool calls even when instructed. This fallback
|
||||
# uses keyword matching to guess the user's intent and auto-trigger HITL.
|
||||
def infer_tool_from_text(text: str) -> str | None:
|
||||
lowered = (text or "").lower()
|
||||
inferred: str | None = None
|
||||
if any(word in lowered for word in ["окруж", "environment", "env"]):
|
||||
inferred = "list_environments"
|
||||
elif any(word in lowered for word in ["maintenance", "обслуж", "баннер"]):
|
||||
if any(word in lowered for word in ["start", "созда", "запусти", "начни"]):
|
||||
inferred = "start_maintenance"
|
||||
elif any(word in lowered for word in ["end", "закрой", "заверши", "останов"]):
|
||||
inferred = "end_maintenance"
|
||||
else:
|
||||
inferred = "list_maintenance_events"
|
||||
elif any(word in lowered for word in ["дашборд", "dashboard", "dashboards", "дашборды"]):
|
||||
inferred = "search_dashboards"
|
||||
elif any(word in lowered for word in ["здоров", "health", "статус системы", "system status"]):
|
||||
inferred = "get_health_summary"
|
||||
elif any(word in lowered for word in ["задач", "task", "таск"]):
|
||||
inferred = "get_task_status"
|
||||
elif any(word in lowered for word in ["llm", "provider", "провайдер", "модель"]):
|
||||
inferred = "list_llm_providers"
|
||||
elif any(word in lowered for word in ["branch", "ветк"]):
|
||||
inferred = "create_branch"
|
||||
elif any(word in lowered for word in ["commit", "коммит"]):
|
||||
inferred = "commit_changes"
|
||||
elif any(word in lowered for word in ["deploy", "депло", "разверн"]):
|
||||
inferred = "deploy_dashboard"
|
||||
elif any(word in lowered for word in ["миграц", "migration", "migrate"]):
|
||||
inferred = "execute_migration"
|
||||
elif any(word in lowered for word in ["backup", "бэкап", "резерв"]):
|
||||
inferred = "run_backup"
|
||||
elif any(word in lowered for word in ["валидац", "validation", "validate"]):
|
||||
inferred = "run_llm_validation"
|
||||
elif any(word in lowered for word in ["документ", "documentation", "docs"]):
|
||||
inferred = "run_llm_documentation"
|
||||
elif any(word in lowered for word in ["инструмент", "tool", "capabilit", "умеешь", "можешь"]):
|
||||
inferred = "show_capabilities"
|
||||
|
||||
if inferred:
|
||||
logger.reason("Tool inferred from user text",
|
||||
payload={"tool": inferred, "text_preview": (text or "")[:80]},
|
||||
extra={"src": "AgentChat.ToolResolver.InferFromText"})
|
||||
return inferred
|
||||
# #endregion AgentChat.ToolResolver.InferFromText
|
||||
|
||||
|
||||
# #region AgentChat.ToolResolver.ExtractFromState [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,checkpoint]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Extract pending tool name and args from the LangGraph checkpoint; infer only as last resort.
|
||||
# @BRIEF Extract pending tool name and args from the LangGraph checkpoint.
|
||||
# @POST Returns (tool_name, args) tuple; (None, {}) if nothing found.
|
||||
# @RATIONALE LLM handles intent — no fallback to keyword inference.
|
||||
def extract_tool_call_from_state(state, user_text: str = "") -> tuple[str | None, dict[str, Any]]:
|
||||
known_tools = known_agent_tool_names()
|
||||
try:
|
||||
@@ -177,26 +84,14 @@ def extract_tool_call_from_state(state, user_text: str = "") -> tuple[str | None
|
||||
tool_name, tool_args = coerce_tool_call(msg.tool_calls[0])
|
||||
if tool_name:
|
||||
return (str(tool_name), tool_args)
|
||||
except Exception as exc:
|
||||
logger.explore(
|
||||
"tool_call extraction failed",
|
||||
payload={"error": str(exc)},
|
||||
extra={"src": "AgentChat.ToolResolver"},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if getattr(state, "next", None):
|
||||
node_or_tool = str(state.next[0])
|
||||
if node_or_tool in known_tools and node_or_tool not in _GRAPH_NODE_NAMES:
|
||||
return (node_or_tool, {})
|
||||
|
||||
inferred_tool = infer_tool_from_text(user_text)
|
||||
if inferred_tool:
|
||||
logger.explore(
|
||||
"tool_call inferred from user text",
|
||||
payload={"tool": inferred_tool},
|
||||
extra={"src": "AgentChat.ToolResolver"},
|
||||
)
|
||||
return (inferred_tool, {})
|
||||
return (None, {})
|
||||
# #endregion AgentChat.ToolResolver.ExtractFromState
|
||||
|
||||
@@ -206,16 +101,8 @@ def extract_tool_call_from_state(state, user_text: str = "") -> tuple[str | None
|
||||
# @BRIEF Find a registered LangChain tool by name.
|
||||
# @POST Returns tool object or None if not found.
|
||||
def find_tool(tool_name: str):
|
||||
from src.agent.tools import get_all_tools
|
||||
return next((tool_obj for tool_obj in get_all_tools() if getattr(tool_obj, "name", None) == tool_name), None)
|
||||
# #endregion AgentChat.ToolResolver.FindTool
|
||||
|
||||
|
||||
# #region AgentChat.ToolResolver.FastConfirm [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,fast-path]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Check if user text maps to a tool eligible for fast-track HITL confirmation.
|
||||
# @POST Returns tool name if match, None otherwise.
|
||||
def fast_confirmation_tool(text: str) -> str | None:
|
||||
tool_name = infer_tool_from_text(text)
|
||||
return tool_name if tool_name in _FAST_CONFIRM_TOOLS else None
|
||||
# #endregion AgentChat.ToolResolver.FastConfirm
|
||||
# #endregion AgentChat.ToolResolver
|
||||
|
||||
Reference in New Issue
Block a user