diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2f15a89 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,97 @@ +name: CI + +# Quality gate only — no deploy job here on purpose. Deploy stays manual +# on Coolify for now; this workflow's job is to make sure nothing merges +# that fails tests, lint, type-check, or the Docker build. + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Lint and typecheck are platform-independent — running once keeps this + # fast, and a formatting or typing error isn't OS-specific. + static: + name: Lint & Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: | + requirements.txt + requirements-dev.txt + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install -r requirements.txt -r requirements-dev.txt + pip install ruff pyright + + - name: Ruff lint + run: ruff check . + + - name: Ruff format check + run: ruff format --check . + + - name: Pyright type check + run: pyright + + # Single Python version, single OS: this app only ever runs as a Linux + # container in production (see Dockerfile — python:3.12-slim-bookworm), + # unlike a published library that has to support whatever environment + # its consumers pick. A cross-platform/version matrix here would add CI + # time without covering any code path that actually ships. + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: | + requirements.txt + requirements-dev.txt + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install -r requirements.txt -r requirements-dev.txt + + - name: Pytest + run: pytest -v + + # Proves the actual production artifact builds — the Dockerfile is what + # deploys (Coolify), and until this job existed nothing in CI verified + # it still builds after a change. Build only, never pushed anywhere. + docker-build: + name: Docker Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image (no push) + uses: docker/build-push-action@v6 + with: + context: . + push: false + tags: nullain-agent:ci + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/agent/attachments_store.py b/agent/attachments_store.py index a150db0..2fc3135 100644 --- a/agent/attachments_store.py +++ b/agent/attachments_store.py @@ -9,6 +9,7 @@ from __future__ import annotations +import logging import os import re import threading @@ -18,6 +19,8 @@ from agent.file_parse import ParsedAttachment +logger = logging.getLogger("nullain.attachments_store") + # Cache em memória (dev / fallback quando Neon off). # LRU com tamanho máximo — evita leak em processo longo sem Neon. _MEMORY_MAX_ENTRIES = 256 @@ -28,6 +31,11 @@ _pool: Any = None _pool_lock = threading.Lock() +# Mantém referência forte às tasks fire-and-forget de save_attachment() — +# sem isso o event loop pode coletar a task antes dela terminar (RUF006). +# Cada task se auto-remove do set no done_callback. +_persist_tasks: set[Any] = set() + def _memory_set(key: str, value: dict[str, Any]) -> None: with _MEMORY_LOCK: @@ -72,10 +80,10 @@ async def _get_pool(): return _pool try: from psycopg_pool import AsyncConnectionPool - except ImportError: + except ImportError as exc: raise RuntimeError( - "psycopg não instalado. Rode: pip install 'psycopg[binary]'" - ) + "psycopg-pool não instalado. Rode: pip install psycopg-pool" + ) from exc url = _get_database_url() _pool = AsyncConnectionPool( conninfo=url, @@ -91,25 +99,23 @@ async def _get_pool(): async def _execute(sql: str, params: list[Any] | None = None) -> list[dict[str, Any]]: """Executa query e retorna linhas como dicts.""" pool = await _get_pool() - async with pool.connection() as conn: - async with conn.cursor() as cur: - await cur.execute(sql, params or []) - try: - rows = await cur.fetchall() - except Exception: # noqa: BLE001 - return [] - if not rows: - return [] - cols = [desc[0] for desc in cur.description or []] - return [dict(zip(cols, row)) for row in rows] + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute(sql, params or []) + try: + rows = await cur.fetchall() + except Exception: # noqa: BLE001 + return [] + if not rows: + return [] + cols = [desc[0] for desc in cur.description or []] + return [dict(zip(cols, row, strict=False)) for row in rows] async def _execute_write(sql: str, params: list[Any] | None = None) -> None: """Executa INSERT/UPDATE/DELETE.""" pool = await _get_pool() - async with pool.connection() as conn: - async with conn.cursor() as cur: - await cur.execute(sql, params or []) + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute(sql, params or []) def save_attachment( @@ -150,20 +156,22 @@ def save_attachment( if not neon_configured(): payload["warning"] = ( - "Neon não configurado — arquivo parseado em memória " - "(não persistido no banco)." + "Neon não configurado — arquivo parseado em memória (não persistido no banco)." ) return payload # Agenda persistência async no event loop try: loop = asyncio.get_running_loop() - loop.create_task(_persist_async(file_id, parsed, user_id, thread_id)) + task = loop.create_task(_persist_async(file_id, parsed, user_id, thread_id)) + _persist_tasks.add(task) + task.add_done_callback(_persist_tasks.discard) payload["persisted"] = True except RuntimeError: # Sem event loop (ex.: CLI mode) — faz sync via thread try: import asyncio as _asyncio + _asyncio.run(_persist_async(file_id, parsed, user_id, thread_id)) payload["persisted"] = True except Exception as exc: # noqa: BLE001 @@ -236,8 +244,17 @@ def fetch_attachments_by_ids( for item in result: _memory_set(str(item["fileId"]), item) out.append(item) - except Exception: # noqa: BLE001 - pass + except Exception: + # Read failure: the turn still has whatever was cached in + # memory, so degrade instead of failing the whole /api/chat + # request — but a silent drop here previously left no trace + # if Neon was actually down/misconfigured. + logger.warning( + "Falha ao buscar anexos no Neon (missing=%s, user_id=%s)", + missing, + user_id, + exc_info=True, + ) # preserva ordem pedida by_id = {str(x.get("fileId")): x for x in out} @@ -255,15 +272,20 @@ async def _fetch_from_neon( # psycopg não suporta IN com lista dinâmica facilmente via parâmetros # Usamos ANY($1::uuid[]) com cast placeholders = ", ".join(f"${i + 1}" for i in range(len(file_ids))) - params = file_ids + [user_id] + params = [*file_ids, user_id] rows = await _execute( + # S608: the f-string only interpolates `$1, $2, ...` positional + # placeholders and a param count — never `file_ids`/`user_id` + # content, which travel through `params` below and stay + # properly parameterized. Nothing attacker-controlled reaches + # the SQL string itself. f""" SELECT id, filename, file_type, file_size, mime_type, extracted_text, char_count, is_truncated, storage_path, user_id FROM chat_attachments WHERE id IN ({placeholders}) AND user_id = ${len(file_ids) + 1} - """, + """, # noqa: S608 params, ) @@ -310,6 +332,17 @@ async def delete_attachments_by_thread(thread_id: str, user_id: str) -> None: "DELETE FROM chat_attachments WHERE thread_id = $1 AND user_id = $2", [thread_id, user_id], ) - except Exception: # noqa: BLE001 - pass - + except Exception: + # A failed delete leaves attachments orphaned in Neon with no + # trace anywhere — log before re-raising so the failure is at + # least visible. The caller (web/server.py's delete_thread + # route) already wraps this call in its own try/except and + # always returns {"ok": True} regardless, so re-raising here + # does not change observable API behavior. + logger.error( + "Falha ao deletar anexos no Neon (thread_id=%s, user_id=%s)", + thread_id, + user_id, + exc_info=True, + ) + raise diff --git a/agent/bridge.py b/agent/bridge.py index b8264b7..5fcc9e4 100644 --- a/agent/bridge.py +++ b/agent/bridge.py @@ -47,7 +47,9 @@ # por agent/external_mcp.py e agent/zuckpay.py para tokens/config) já é o # padrão para estado persistente deste app — um subdiretório próprio evita # misturar arquivos livres do agente com esse estado sensível. -_AGENT_WORKSPACE_ROOT_PATH = Path(os.getenv("NULLAIN_DATA_DIR", "data")).resolve() / "agent_workspace" +_AGENT_WORKSPACE_ROOT_PATH = ( + Path(os.getenv("NULLAIN_DATA_DIR", "data")).resolve() / "agent_workspace" +) _AGENT_WORKSPACE_ROOT_PATH.mkdir(parents=True, exist_ok=True) _AGENT_WORKSPACE_ROOT = str(_AGENT_WORKSPACE_ROOT_PATH) @@ -428,9 +430,7 @@ async def stream_bridge_events( delimitadores `<<>>`, então isso é seguro mesmo indo como parte do mesmo prompt. """ - agent = await get_or_build_bridge_agent( - thread_id, settings, user_email=user_email, model=model - ) + agent = await get_or_build_bridge_agent(thread_id, settings, user_email=user_email, model=model) ctx = (system_context or "").strip() full_message = f"{ctx}\n\n{message}" if ctx else message diff --git a/agent/bridge_tools.py b/agent/bridge_tools.py index e37e015..6c9b8f6 100644 --- a/agent/bridge_tools.py +++ b/agent/bridge_tools.py @@ -107,9 +107,7 @@ def _registered_tool_from_pydantic( ) -def register_zuckpay_tools( - registry: ToolRegistry, *, user_email: str | None -) -> list[str]: +def register_zuckpay_tools(registry: ToolRegistry, *, user_email: str | None) -> list[str]: """Registra as 4 tools ZuckPay, com `user_email` capturado por closure. Reaproveita as funções `_create_pix`/`_create_spei`/`_payment_status`/ diff --git a/agent/config.py b/agent/config.py index a350ffe..c8f902f 100644 --- a/agent/config.py +++ b/agent/config.py @@ -118,11 +118,7 @@ def sandbox_enabled(self) -> bool: @property def sandbox_allowed_package_set(self) -> frozenset[str]: raw = self.sandbox_allowed_packages or "" - return frozenset( - p.strip().lower() - for p in raw.split(",") - if p.strip() - ) + return frozenset(p.strip().lower() for p in raw.split(",") if p.strip()) @property def sandbox_aio_enabled(self) -> bool: @@ -148,8 +144,7 @@ def llm_api_key(self) -> str: if self.llm_provider == "xai": if not self.xai_api_key: raise ValueError( - "XAI_API_KEY não definida. " - "Crie uma em https://console.x.ai e coloque no .env" + "XAI_API_KEY não definida. Crie uma em https://console.x.ai e coloque no .env" ) return self.xai_api_key if self.llm_provider == "openai": @@ -160,8 +155,7 @@ def llm_api_key(self) -> str: ) return self.openai_api_key raise ValueError( - f"LLM_PROVIDER inválido: {self.llm_provider!r}. " - "Use 'ollama', 'xai' ou 'openai'." + f"LLM_PROVIDER inválido: {self.llm_provider!r}. Use 'ollama', 'xai' ou 'openai'." ) @property @@ -194,9 +188,7 @@ def get_settings() -> Settings: "OLLAMA_BASE_URL", defaults.get("base_url") or "https://ollama.com/v1", ).strip(), - composio_mcp_url=os.getenv( - "COMPOSIO_MCP_URL", "https://connect.composio.dev/mcp" - ).strip(), + composio_mcp_url=os.getenv("COMPOSIO_MCP_URL", "https://connect.composio.dev/mcp").strip(), composio_consumer_api_key=consumer_key, composio_user_id=os.getenv("COMPOSIO_USER_ID", "default").strip(), # Apps/MCPs ativos no Composio (ícones no rail direito da UI) @@ -213,20 +205,14 @@ def get_settings() -> Settings: wavespeed_api_key=os.getenv("WAVESPEED_API_KEY", "").strip(), # Groq Vision (Llama 3.2 11B) — pre-pass de imagens anexadas groq_api_key=os.getenv("GROQ_API_KEY", "").strip(), - groq_vision_model=os.getenv( - "GROQ_VISION_MODEL", "llama-3.2-11b-vision-preview" - ).strip(), + groq_vision_model=os.getenv("GROQ_VISION_MODEL", "llama-3.2-11b-vision-preview").strip(), # Sandbox code interpreter sandbox_provider=os.getenv("SANDBOX_PROVIDER", "").strip().lower(), sandbox_e2b_api_key=os.getenv("E2B_API_KEY", "").strip(), - sandbox_docker_image=os.getenv( - "SANDBOX_DOCKER_IMAGE", "python:3.11-slim" - ).strip(), + sandbox_docker_image=os.getenv("SANDBOX_DOCKER_IMAGE", "python:3.11-slim").strip(), sandbox_timeout_sec=int(os.getenv("SANDBOX_TIMEOUT_SEC", "120")), sandbox_max_file_size_mb=int(os.getenv("SANDBOX_MAX_FILE_SIZE_MB", "50")), - sandbox_allowed_packages=os.getenv( - "SANDBOX_ALLOWED_PACKAGES", "" - ).strip(), + sandbox_allowed_packages=os.getenv("SANDBOX_ALLOWED_PACKAGES", "").strip(), # AIO Sandbox (SANDBOX_PROVIDER=aio) — endpoint HTTP do container sandbox_aio_url=os.getenv("SANDBOX_URL", "http://localhost:8080").strip(), sandbox_aio_api_key=os.getenv("SANDBOX_API_KEY", "").strip(), diff --git a/agent/external_mcp.py b/agent/external_mcp.py index f4eb4fe..1420f13 100644 --- a/agent/external_mcp.py +++ b/agent/external_mcp.py @@ -8,8 +8,10 @@ from __future__ import annotations import base64 +import contextlib import hashlib import json +import logging import os import secrets import threading @@ -21,6 +23,8 @@ import httpx from langchain_mcp_adapters.client import MultiServerMCPClient +logger = logging.getLogger("nullain.external_mcp") + # ── paths ──────────────────────────────────────────────── _DATA_DIR = Path(os.getenv("NULLAIN_DATA_DIR", "data")).resolve() @@ -41,9 +45,7 @@ HIGGSFIELD_TOKEN = f"{HIGGSFIELD_AUTH_SERVER}/oauth2/token" HIGGSFIELD_REGISTER = f"{HIGGSFIELD_AUTH_SERVER}/oauth2/register" HIGGSFIELD_SCOPES = "openid email offline_access" -HIGGSFIELD_LOGO = ( - "https://www.google.com/s2/favicons?domain=higgsfield.ai&sz=128" -) +HIGGSFIELD_LOGO = "https://www.google.com/s2/favicons?domain=higgsfield.ai&sz=128" # ── Semrush constants ──────────────────────────────────── # MCP oficial da Semrush (streamable HTTP; o servidor não fala SSE puro). @@ -101,10 +103,8 @@ def _save_oauth_pending(pending: dict[str, dict[str, Any]]) -> None: json.dumps(pruned, ensure_ascii=False), encoding="utf-8", ) - try: + with contextlib.suppress(OSError, NotImplementedError): tmp.chmod(0o600) - except (OSError, NotImplementedError): - pass tmp.replace(_OAUTH_PENDING_PATH) @@ -276,17 +276,17 @@ def save_store(store: dict[str, Any]) -> None: encoding="utf-8", ) # Permissões restritivas (apenas owner lê/escreve) - try: + # Windows não suporta chmod POSIX + with contextlib.suppress(OSError, NotImplementedError): tmp.chmod(0o600) - except (OSError, NotImplementedError): - pass # Windows não suporta chmod POSIX tmp.replace(_STORE_PATH) def _load_store_decrypted() -> dict[str, Any]: """Carrega o store e descriptografa tokens em memória.""" store = load_store() - mcps = store.get("mcps") if isinstance(store.get("mcps"), dict) else {} + mcps_raw = store.get("mcps") + mcps: dict[str, Any] = mcps_raw if isinstance(mcps_raw, dict) else {} for item in mcps.values(): if isinstance(item, dict): _decrypt_secrets_in_place(item) @@ -399,7 +399,9 @@ def is_external_mcp_active(mcp_id: str) -> bool: return False if item.get("auth_type") == "api_key": return bool(semrush_api_key()) if mcp_id == SEMRUSH_ID else False - return bool((item.get("access_token") or "").strip() or (item.get("refresh_token") or "").strip()) + return bool( + (item.get("access_token") or "").strip() or (item.get("refresh_token") or "").strip() + ) # ── OAuth PKCE ─────────────────────────────────────────── @@ -434,9 +436,7 @@ async def ensure_oauth_client_id(redirect_uri: str) -> str: async with httpx.AsyncClient(timeout=30.0) as client: res = await client.post(HIGGSFIELD_REGISTER, json=payload) if res.status_code >= 400: - raise RuntimeError( - f"Falha ao registrar OAuth client: {res.status_code} {res.text[:300]}" - ) + raise RuntimeError(f"Falha ao registrar OAuth client: {res.status_code} {res.text[:300]}") data = res.json() client_id = str(data.get("client_id") or "").strip() if not client_id: @@ -459,7 +459,6 @@ async def start_higgsfield_oauth( `user_email` é amarrado ao state para o callback validar ownership. """ - from agent.security import is_safe_slug, safe_error_message # Valida redirect_uri (defesa contra SSRF/open redirect via callback) if not isinstance(redirect_uri, str) or not redirect_uri.startswith(("http://", "https://")): @@ -519,7 +518,6 @@ async def complete_higgsfield_oauth(code: str, state: str) -> dict[str, Any]: é single-use no provedor, mas o pop atômico garante que só UM callback chegue ao token endpoint com o code_verifier válido. """ - from agent.security import safe_error_message if not isinstance(code, str) or not code.strip(): raise RuntimeError("Authorization code ausente.") @@ -586,9 +584,7 @@ async def refresh_higgsfield_token_if_needed() -> str | None: access = (item.get("access_token") or "").strip() refresh = (item.get("refresh_token") or "").strip() expires_at = item.get("expires_at") - still_valid = bool(access) and ( - not expires_at or time.time() < float(expires_at) - 60 - ) + still_valid = bool(access) and (not expires_at or time.time() < float(expires_at) - 60) if still_valid: return access if not refresh: @@ -684,8 +680,11 @@ async def load_higgsfield_tools() -> list[Any]: return [] try: + # build_higgsfield_server_config returns a plain dict, structurally + # compatible with langchain_mcp_adapters' Connection TypedDict but + # not nominally typed as one. client = MultiServerMCPClient( - {"higgsfield": build_higgsfield_server_config(token)} + {"higgsfield": build_higgsfield_server_config(token)} # type: ignore[reportArgumentType] ) tools = await client.get_tools() # Prefixa descrição para o modelo priorizar certo @@ -693,11 +692,13 @@ async def load_higgsfield_tools() -> list[Any]: desc = str(getattr(t, "description", "") or "") if "Higgsfield" not in desc: try: - t.description = ( - f"[Higgsfield MCP] {desc}".strip() + t.description = f"[Higgsfield MCP] {desc}".strip() + except Exception: + # Cosmetic only, tool stays usable without the prefix; + # logged for visibility. + logger.debug( + "Não deu para prefixar descrição da tool Higgsfield", exc_info=True ) - except Exception: # noqa: BLE001 - pass update_mcp(HIGGSFIELD_ID, last_error=None) return list(tools) except Exception as exc: # noqa: BLE001 @@ -739,8 +740,9 @@ async def load_semrush_tools() -> list[Any]: api_key = semrush_api_key() try: + # See load_higgsfield_tools() above for why this needs a type: ignore. client = MultiServerMCPClient( - {"semrush": build_semrush_server_config(api_key)} + {"semrush": build_semrush_server_config(api_key)} # type: ignore[reportArgumentType] ) tools = await client.get_tools() # Marca a origem: sem isso o modelo confunde estas tools com as de @@ -750,8 +752,10 @@ async def load_semrush_tools() -> list[Any]: if "Semrush" not in desc: try: t.description = f"[Semrush MCP] {desc}".strip() - except Exception: # noqa: BLE001 - pass + except Exception: + # Cosmetic only, tool stays usable without the prefix; + # logged for visibility. + logger.debug("Não deu para prefixar descrição da tool Semrush", exc_info=True) update_mcp(SEMRUSH_ID, last_error=None, connected_at=time.time()) return list(tools) except Exception as exc: # noqa: BLE001 diff --git a/agent/file_parse.py b/agent/file_parse.py index 18d8120..327724c 100644 --- a/agent/file_parse.py +++ b/agent/file_parse.py @@ -64,9 +64,7 @@ def sniff_file_type(data: bytes, filename: str, declared_mime: str | None) -> st """ ext = _ext_of(filename) if ext not in ALLOWED_EXT: - raise ValueError( - f"Extensão não suportada: .{ext or '?'}. Use .txt, .md ou .pdf." - ) + raise ValueError(f"Extensão não suportada: .{ext or '?'}. Use .txt, .md ou .pdf.") mime = (declared_mime or "").split(";")[0].strip().lower() if mime and mime not in ALLOWED_MIME[ext] and mime != "application/octet-stream": @@ -76,13 +74,14 @@ def sniff_file_type(data: bytes, filename: str, declared_mime: str | None) -> st # PDF: magic %PDF if ext == "pdf": if not data.startswith(b"%PDF"): - raise ValueError( - "Arquivo .pdf inválido: conteúdo não parece PDF (magic bytes)." - ) - if mime and mime not in ALLOWED_MIME["pdf"]: - # browser às vezes manda octet-stream — ok se magic bateu - if mime not in {"application/octet-stream", ""}: - raise ValueError(f"MIME incompatível com PDF: {mime}") + raise ValueError("Arquivo .pdf inválido: conteúdo não parece PDF (magic bytes).") + # browser às vezes manda octet-stream — ok se magic bateu + if ( + mime + and mime not in ALLOWED_MIME["pdf"] + and mime not in {"application/octet-stream", ""} + ): + raise ValueError(f"MIME incompatível com PDF: {mime}") return "pdf" # TXT / MD: precisa ser texto decodificável (utf-8 / latin-1) @@ -101,12 +100,17 @@ def sniff_file_type(data: bytes, filename: str, declared_mime: str | None) -> st except UnicodeDecodeError as exc: raise ValueError("Não foi possível ler o arquivo como texto.") from exc - if mime and mime not in ALLOWED_MIME[ext]: - if not mime.startswith("text/") and mime not in { + if ( + mime + and mime not in ALLOWED_MIME[ext] + and not mime.startswith("text/") + and mime + not in { "application/octet-stream", "", - }: - raise ValueError(f"MIME incompatível com .{ext}: {mime}") + } + ): + raise ValueError(f"MIME incompatível com .{ext}: {mime}") return ext @@ -130,9 +134,7 @@ def extract_text(data: bytes, file_type: str) -> str: try: from pypdf import PdfReader except ImportError as exc: - raise RuntimeError( - "Dependência pypdf ausente. Instale: pip install pypdf" - ) from exc + raise RuntimeError("Dependência pypdf ausente. Instale: pip install pypdf") from exc reader = PdfReader(BytesIO(data)) parts: list[str] = [] for page in reader.pages: @@ -145,8 +147,7 @@ def extract_text(data: bytes, file_type: str) -> str: text = "\n\n".join(parts).strip() if not text: raise ValueError( - "PDF sem texto extraível (pode ser scan/imagem). " - "Use um PDF com texto selecionável." + "PDF sem texto extraível (pode ser scan/imagem). Use um PDF com texto selecionável." ) # normaliza espaços excessivos text = re.sub(r"[ \t]+\n", "\n", text) @@ -167,9 +168,7 @@ def parse_upload( if not data: raise ValueError("Arquivo vazio.") if len(data) > MAX_FILE_BYTES: - raise ValueError( - f"Arquivo excede 10 MB ({len(data) / (1024 * 1024):.1f} MB)." - ) + raise ValueError(f"Arquivo excede 10 MB ({len(data) / (1024 * 1024):.1f} MB).") # Sanitiza nome (remove path traversal, chars de controle, etc.) safe_filename = sanitize_filename(filename or "file")[:240] diff --git a/agent/integrations.py b/agent/integrations.py index 268ece8..69ae2eb 100644 --- a/agent/integrations.py +++ b/agent/integrations.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging import os import threading import time @@ -12,6 +13,8 @@ from agent.config import Settings +logger = logging.getLogger("nullain.integrations") + # Metadados de exibição (slug → label + logo CDN oficial do Composio) TOOLKIT_META: dict[str, dict[str, str]] = { "composio": { @@ -264,11 +267,7 @@ def _slug_aliases(slug: str) -> str: def parse_active_toolkits(raw: str | None = None) -> list[str]: - text = ( - raw - if raw is not None - else os.getenv("COMPOSIO_ACTIVE_TOOLKITS", _DEFAULT_ACTIVE) - ) + text = raw if raw is not None else os.getenv("COMPOSIO_ACTIVE_TOOLKITS", _DEFAULT_ACTIVE) if not text or not str(text).strip(): text = _DEFAULT_ACTIVE seen: set[str] = set() @@ -530,8 +529,11 @@ def list_active_integrations( "connected": zp_status["connected"], } ) - except Exception: # noqa: BLE001 - pass + except Exception: + # ZuckPay item just doesn't appear in the integrations rail; the + # rest of the list still builds. Logged so a real ZuckPay outage + # isn't invisible. + logger.debug("Falha ao buscar status do ZuckPay para o rail", exc_info=True) hub = TOOLKIT_META["composio"] items.append( @@ -634,8 +636,11 @@ def list_active_integrations( from agent.external_mcp import rail_items_for_external items.extend(rail_items_for_external()) - except Exception: # noqa: BLE001 - pass + except Exception: + # External-MCP items just don't appear in the rail; the rest of + # the list still builds. Logged so a real external_mcp failure + # isn't invisible. + logger.debug("Falha ao buscar itens de MCP externo para o rail", exc_info=True) list_active_integrations.last_error = live_err # type: ignore[attr-defined] list_active_integrations.last_source = source # type: ignore[attr-defined] diff --git a/agent/metering.py b/agent/metering.py index 7787084..8d77a92 100644 --- a/agent/metering.py +++ b/agent/metering.py @@ -12,7 +12,7 @@ from dataclasses import dataclass from typing import Any -from agent.redis_client import redis_get, redis_hget, redis_hset, redis_set +from agent.redis_client import redis_get, redis_set logger = logging.getLogger("nullain.metering") @@ -91,7 +91,9 @@ async def record_tenant_usage( # Expira o registro em 30 dias (2592000s) await redis_set(redis_key, updated_data, ttl_seconds=2592000) - logger.debug("Uso atualizado para tenant %s: tokens=%d, tools=%d", tenant_id, new_tokens, new_tools) + logger.debug( + "Uso atualizado para tenant %s: tokens=%d, tools=%d", tenant_id, new_tokens, new_tools + ) return updated_data @@ -109,9 +111,17 @@ async def check_tenant_quota(tenant_id: str, plan_tier: str = "pro") -> tuple[bo tool_calls = usage.get("tool_calls", 0) if tokens >= limits.max_tokens_per_day: - return False, f"Limite diário de tokens atingido ({tokens}/{limits.max_tokens_per_day}) no plano {limits.name}." + return ( + False, + f"Limite diário de tokens atingido " + f"({tokens}/{limits.max_tokens_per_day}) no plano {limits.name}.", + ) if tool_calls >= limits.max_tool_calls_per_day: - return False, f"Limite diário de chamadas de ferramentas atingido ({tool_calls}/{limits.max_tool_calls_per_day}) no plano {limits.name}." + return ( + False, + f"Limite diário de chamadas de ferramentas atingido " + f"({tool_calls}/{limits.max_tool_calls_per_day}) no plano {limits.name}.", + ) return True, None diff --git a/agent/models.py b/agent/models.py index 4e74af8..d75a65d 100644 --- a/agent/models.py +++ b/agent/models.py @@ -14,6 +14,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import TypedDict from agent.config import Settings @@ -22,6 +23,17 @@ DEFAULT_OLLAMA_MODEL = "glm-5.2:cloud" +class ModelOptionDict(TypedDict): + """Formato de `ModelOption.to_dict()` — mirrors web/server.py's + ModelOptionResponse field-by-field, only `badge` is nullable.""" + + id: str + label: str + tagline: str + badge: str | None + badge_tone: str + + @dataclass(frozen=True) class ModelOption: """Um cérebro selecionável, já no formato que a UI consome.""" @@ -34,7 +46,7 @@ class ModelOption: # Tom do pill — casa com as variantes .nl-badge--* do tema badge_tone: str = "warning" - def to_dict(self) -> dict[str, str | None]: + def to_dict(self) -> ModelOptionDict: return { "id": self.id, "label": self.label, diff --git a/agent/rag_tools.py b/agent/rag_tools.py index 58d1818..6829603 100644 --- a/agent/rag_tools.py +++ b/agent/rag_tools.py @@ -69,9 +69,7 @@ def _get_cuckoo_filter() -> CuckooFilter: return _cuckoo_filter -def register_rag_tools( - registry: ToolRegistry, settings: Settings, *, tenant_id: str -) -> list[str]: +def register_rag_tools(registry: ToolRegistry, settings: Settings, *, tenant_id: str) -> list[str]: """Registra `rag_remember`/`rag_search` se `settings.rag_enabled` (QDRANT_URL + QDRANT_API_KEY configuradas). `tenant_id` (normalmente `user_email`) é capturado por closure — mesmo padrão de diff --git a/agent/redis_client.py b/agent/redis_client.py index c8bbf33..10b5b77 100644 --- a/agent/redis_client.py +++ b/agent/redis_client.py @@ -11,11 +11,11 @@ import json import logging import time +from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from typing import Any, AsyncGenerator +from typing import Any import redis.asyncio as aioredis -from redis.exceptions import RedisError from agent.config import get_settings @@ -37,7 +37,9 @@ async def get_redis_client() -> aioredis.Redis | None: settings = get_settings() if not settings.redis_enabled: - logger.warning("REDIS_URL não configurada. Funcionalidades distribuídas operando em modo fallback.") + logger.warning( + "REDIS_URL não configurada. Funcionalidades distribuídas operando em modo fallback." + ) return None try: @@ -51,9 +53,14 @@ async def get_redis_client() -> aioredis.Redis | None: ) await client.ping() _redis_client = client - logger.info("Conexão Redis estabelecida com sucesso: %s", settings.redis_url.split("@")[-1]) + logger.info( + "Conexão Redis estabelecida com sucesso: %s", settings.redis_url.split("@")[-1] + ) return _redis_client - except Exception as exc: + except Exception as exc: # noqa: BLE001 — Redis is meant to degrade + # gracefully everywhere in this module: every caller already + # checks the returned bool/default, and every except here logs + # at error level with context before returning it. logger.error("Falha ao conectar no Redis Cloud/Upstash: %s", exc) return None @@ -70,7 +77,7 @@ async def redis_set(key: str, value: Any, ttl_seconds: int | None = None) -> boo else: await client.set(key, val_str) return True - except Exception as exc: + except Exception as exc: # noqa: BLE001 — see get_redis_client() above logger.error("Erro no redis_set(%s): %s", key, exc) return False @@ -88,7 +95,7 @@ async def redis_get(key: str, default: Any = None) -> Any: return json.loads(val) except (json.JSONDecodeError, TypeError): return val - except Exception as exc: + except Exception as exc: # noqa: BLE001 — see get_redis_client() above logger.error("Erro no redis_get(%s): %s", key, exc) return default @@ -101,7 +108,7 @@ async def redis_delete(key: str) -> bool: try: await client.delete(key) return True - except Exception as exc: + except Exception as exc: # noqa: BLE001 — see get_redis_client() above logger.error("Erro no redis_delete(%s): %s", key, exc) return False @@ -113,9 +120,12 @@ async def redis_hset(name: str, key: str, value: Any) -> bool: return False try: val_str = json.dumps(value) if not isinstance(value, str) else value - await client.hset(name, key, val_str) + # redis-py's stubs type client.hset()'s return as ResponseT (a union + # including non-awaitable literals, for its sync pipeline builder + # pattern) — with the real async client here it is awaitable. + await client.hset(name, key, val_str) # type: ignore[reportGeneralTypeIssues] return True - except Exception as exc: + except Exception as exc: # noqa: BLE001 — see get_redis_client() above logger.error("Erro no redis_hset(%s, %s): %s", name, key, exc) return False @@ -126,14 +136,15 @@ async def redis_hget(name: str, key: str, default: Any = None) -> Any: if not client: return default try: - val = await client.hget(name, key) + # See redis_hset() above for why this needs a type: ignore. + val = await client.hget(name, key) # type: ignore[reportGeneralTypeIssues] if val is None: return default try: return json.loads(val) except (json.JSONDecodeError, TypeError): return val - except Exception as exc: + except Exception as exc: # noqa: BLE001 — see get_redis_client() above logger.error("Erro no redis_hget(%s, %s): %s", name, key, exc) return default @@ -144,7 +155,8 @@ async def redis_hgetall(name: str) -> dict[str, Any]: if not client: return {} try: - raw_dict = await client.hgetall(name) + # See redis_hset() above for why this needs a type: ignore. + raw_dict = await client.hgetall(name) # type: ignore[reportGeneralTypeIssues] result: dict[str, Any] = {} for k, v in raw_dict.items(): try: @@ -152,7 +164,7 @@ async def redis_hgetall(name: str) -> dict[str, Any]: except (json.JSONDecodeError, TypeError): result[k] = v return result - except Exception as exc: + except Exception as exc: # noqa: BLE001 — see get_redis_client() above logger.error("Erro no redis_hgetall(%s): %s", name, exc) return {} @@ -185,7 +197,10 @@ async def distributed_lock(name: str, timeout: float = 10.0) -> AsyncGenerator[b current_token = await client.get(lock_key) if current_token == token: await client.delete(lock_key) - except Exception: # noqa: BLE001 + except Exception: # noqa: BLE001, S110 + # Best-effort release: the lock still expires via its own + # `ex=timeout` TTL set at acquire time, so a failed delete + # here self-heals instead of leaking the lock forever. pass @@ -207,7 +222,7 @@ async def check_redis_health() -> dict[str, Any]: "connected": True, "latency_ms": round(latency, 2), } - except Exception as exc: + except Exception as exc: # noqa: BLE001 — see get_redis_client() above return { "status": "unhealthy", "connected": False, diff --git a/agent/sandbox.py b/agent/sandbox.py index 293e656..e98f594 100644 --- a/agent/sandbox.py +++ b/agent/sandbox.py @@ -7,11 +7,8 @@ from __future__ import annotations import asyncio -import base64 -import hashlib -import os +import contextlib import time -from collections.abc import AsyncIterator from dataclasses import dataclass from pathlib import Path from typing import Any @@ -32,7 +29,7 @@ class ExecResult: stderr: str = "" exit_code: int = 0 success: bool = True - files: list[dict[str, Any]] = None # noqa: RUF013 + files: list[dict[str, Any]] | None = None duration_ms: int = 0 error: str | None = None @@ -64,7 +61,9 @@ def __init__( self.last_used = time.monotonic() self._lock = asyncio.Lock() - async def exec_code(self, code: str, language: str = "python", timeout: int | None = None) -> ExecResult: + async def exec_code( + self, code: str, language: str = "python", timeout: int | None = None + ) -> ExecResult: """Executa código no sandbox.""" async with self._lock: start = time.monotonic() @@ -77,7 +76,7 @@ async def exec_code(self, code: str, language: str = "python", timeout: int | No language=language, timeout=effective_timeout, ) - except asyncio.TimeoutError: + except TimeoutError: return ExecResult( success=False, error=f"Execução excedeu o limite de {self.timeout_sec}s.", @@ -104,7 +103,7 @@ async def exec_bash(self, command: str, timeout: int | None = None) -> ExecResul command=command, timeout=effective_timeout, ) - except asyncio.TimeoutError: + except TimeoutError: return ExecResult( success=False, error=f"Execução excedeu o limite de {effective_timeout}s.", @@ -128,10 +127,10 @@ async def upload_file(self, filename: str, data: bytes, dest_dir: str = "/worksp """ if len(data) > self.max_file_size_mb * 1024 * 1024: raise ValueError( - f"Arquivo excede {self.max_file_size_mb}MB " - f"({len(data) / (1024 * 1024):.1f}MB)" + f"Arquivo excede {self.max_file_size_mb}MB ({len(data) / (1024 * 1024):.1f}MB)" ) import posixpath + # Normaliza dest_dir e valida confinamento dest = (dest_dir or "/workspace").strip() if not dest.startswith("/"): @@ -160,6 +159,7 @@ async def download_file(self, path: str) -> bytes: Segurança: valida confinamento em /workspace. """ import posixpath + raw = (path or "").strip() if not raw: raise ValueError("Path vazio.") @@ -182,6 +182,7 @@ async def list_files(self, path: str = "/workspace") -> list[FileInfo]: Segurança: valida confinamento em /workspace. """ import posixpath + raw = (path or "/workspace").strip() if not raw.startswith("/"): raw = f"/workspace/{raw}" @@ -284,19 +285,21 @@ def _build_driver(self) -> Any: """Factory do driver conforme provider.""" if self._provider == "e2b": from agent.sandbox_e2b import E2BDriver + return E2BDriver(api_key=self._settings.sandbox_e2b_api_key) if self._provider == "docker": from agent.sandbox_docker import DockerDriver + return DockerDriver(image=self._settings.sandbox_docker_image) if self._provider == "aio": from agent.sandbox_aio import AIODriver + return AIODriver( base_url=self._settings.sandbox_aio_url, api_key=self._settings.sandbox_aio_api_key, ) raise ValueError( - f"SANDBOX_PROVIDER inválido: {self._provider!r}. " - "Use 'e2b', 'docker' ou 'aio'." + f"SANDBOX_PROVIDER inválido: {self._provider!r}. Use 'e2b', 'docker' ou 'aio'." ) async def start(self) -> None: @@ -308,10 +311,8 @@ async def stop(self) -> None: """Para o cleanup e fecha todos os sandboxes.""" if self._cleanup_task and not self._cleanup_task.done(): self._cleanup_task.cancel() - try: + with contextlib.suppress(asyncio.CancelledError): await self._cleanup_task - except asyncio.CancelledError: - pass await self.close_all() async def _cleanup_loop(self) -> None: @@ -346,7 +347,7 @@ async def get_or_create(self, thread_id: str) -> SandboxSession: print(f"🚀 Criando sandbox para thread {thread_id[:8]}… ({self._provider})") try: handle = await self._driver.create() - except Exception as exc: # noqa: BLE001 + except Exception as exc: raise RuntimeError(f"Falha ao criar sandbox: {exc}") from exc session = SandboxSession( diff --git a/agent/sandbox_aio.py b/agent/sandbox_aio.py index 8fe93c0..4aa3995 100644 --- a/agent/sandbox_aio.py +++ b/agent/sandbox_aio.py @@ -180,7 +180,7 @@ async def create(self) -> Any: session_id = f"nullain-{time.monotonic_ns():x}" try: await client.shell.create_session(id=session_id, exec_dir=AIO_WORKSPACE) - except Exception as exc: # noqa: BLE001 + except Exception as exc: # Três causas, em ordem de frequência, e todas parecem "offline" # daqui: (1) o container não subiu; (2) subiu mas publicado só em # 127.0.0.1, então quem está fora da máquina não alcança; @@ -304,7 +304,7 @@ async def download(self, handle: dict[str, Any], path: str) -> bytes: try: async for chunk in client.file.download_file(path=aio_path): chunks.append(chunk) - except Exception as exc: # noqa: BLE001 + except Exception as exc: raise RuntimeError(f"Não foi possível ler {path} no AIO: {exc}") from exc return b"".join(chunks) @@ -315,9 +315,7 @@ async def list_files(self, handle: dict[str, Any], path: str = "/workspace") -> client: Any = handle["client"] aio_path = _to_aio_path(path) try: - result = await client.file.list_path( - path=aio_path, recursive=False, include_size=True - ) + result = await client.file.list_path(path=aio_path, recursive=False, include_size=True) except Exception: # noqa: BLE001 return [] if _is_file_error(result) or result is None: diff --git a/agent/sandbox_docker.py b/agent/sandbox_docker.py index 1b01ab3..09d52d0 100644 --- a/agent/sandbox_docker.py +++ b/agent/sandbox_docker.py @@ -9,22 +9,25 @@ import asyncio import hashlib import io +import logging import secrets import tarfile import time from pathlib import Path from typing import Any +logger = logging.getLogger("nullain.sandbox_docker") + def _docker_client() -> Any: """Retorna client docker-py ou None se indisponível.""" try: import docker + return docker.from_env() - except Exception as exc: # noqa: BLE001 + except Exception as exc: raise RuntimeError( - f"Docker não disponível: {exc}. " - "Certifique-se de que o Docker daemon está rodando." + f"Docker não disponível: {exc}. Certifique-se de que o Docker daemon está rodando." ) from exc @@ -85,7 +88,9 @@ def _create(): # (/tmp, /run). /workspace é volume rw persistente. read_only=True, tmpfs={ - "/tmp": "rw,noexec,nosuid,nodev,size=64m", + "/tmp": "rw,noexec,nosuid,nodev,size=64m", # noqa: S108 — + # this is a hardened tmpfs mount spec (noexec/nosuid/ + # nodev, size-capped), not insecure temp-file usage. "/run": "rw,noexec,nosuid,nodev,size=16m", }, # Não permite escalonamento de privilégios @@ -172,13 +177,18 @@ def _run(): duration_ms = int((time.monotonic() - start) * 1000) # Detecta arquivos gerados em /workspace/output + # NOTE: `files` stays [] unconditionally today — see issue + # tracking that ls_result is computed but never parsed into + # it (generated-file detection in this driver is dead). files = [] try: out_dir = "/workspace/output" _ = container.exec_run(f"mkdir -p {out_dir}") - ls_result = container.exec_run(f"ls -la {out_dir}") - except Exception: # noqa: BLE001 - pass + container.exec_run(f"ls -la {out_dir}") + except Exception: + # File-listing is best-effort; a failure here + # shouldn't fail the whole execution result. + logger.debug("Falha ao listar %s no container", out_dir, exc_info=True) return ExecResult( stdout=stdout, @@ -197,18 +207,20 @@ def _run(): ) # Usa asyncio.wait_for para respeitar timeout - loop = asyncio.get_running_loop() + asyncio.get_running_loop() try: return await asyncio.wait_for( asyncio.to_thread(_run), timeout=timeout, ) - except asyncio.TimeoutError: + except TimeoutError: # Tenta matar o exec no container (melhor esforço) try: container.exec_run("pkill -f '__nullain_'", demux=True) - except Exception: # noqa: BLE001 - pass + except Exception: + # Best-effort kill after a timeout that's already being + # reported below regardless. + logger.debug("Falha ao matar processo no container após timeout", exc_info=True) return ExecResult( success=False, error=f"Execução excedeu {timeout}s — processo terminado.", @@ -216,6 +228,7 @@ def _run(): async def upload(self, container: Any, path: str, data: bytes) -> None: """Escreve bytes em arquivo no container via put_archive.""" + def _upload(): # Cria tar em memória tar_stream = io.BytesIO() @@ -235,10 +248,11 @@ def _upload(): async def download(self, container: Any, path: str) -> bytes: """Lê arquivo do container via get_archive.""" + def _download(): try: stream, _ = container.get_archive(path) - except Exception as exc: # noqa: BLE001 + except Exception as exc: raise RuntimeError(f"Não foi possível ler {path}: {exc}") from exc # get_archive retorna chunks; monta tar tar_bytes = b"".join(chunk for chunk in stream) @@ -281,7 +295,7 @@ def _list(): ftype, size_str, name = parts if name in (".", ".."): continue - is_dir = (ftype == "d") + is_dir = ftype == "d" full_path = f"{path.rstrip('/')}/{name}" try: size_int = int(size_str) @@ -303,6 +317,7 @@ def _list(): async def destroy(self, container: Any) -> None: """Remove container e volumes.""" + def _destroy(): try: container.remove(force=True, v=True) diff --git a/agent/sandbox_e2b.py b/agent/sandbox_e2b.py index 476f0c4..f6476c4 100644 --- a/agent/sandbox_e2b.py +++ b/agent/sandbox_e2b.py @@ -9,10 +9,8 @@ from __future__ import annotations import asyncio -import base64 import json import time -from pathlib import Path from typing import Any @@ -30,6 +28,7 @@ def _ensure_sdk(self): try: # e2b-code-interpreter v2+ usa Sandbox, não CodeInterpreter from e2b_code_interpreter import Sandbox + self._sdk = Sandbox except ImportError as exc: raise RuntimeError( @@ -91,25 +90,33 @@ def _run(): if hasattr(execution, "results") and execution.results: for result in execution.results: # E2B v2: result.type ou result.format - result_type = getattr(result, "type", None) or getattr(result, "format", "text") + result_type = getattr(result, "type", None) or getattr( + result, "format", "text" + ) if result_type == "text" or result_type == "text/plain": stdout += str(getattr(result, "text", "")) + "\n" elif result_type == "image" or result_type == "image/png": img_data = getattr(result, "png", None) or getattr(result, "data", None) if img_data: - files.append({ - "path": "/workspace/output/chart.png", - "mime": "image/png", - "base64": img_data, - }) + files.append( + { + "path": "/workspace/output/chart.png", + "mime": "image/png", + "base64": img_data, + } + ) elif result_type == "image/jpeg": - img_data = getattr(result, "jpeg", None) or getattr(result, "data", None) + img_data = getattr(result, "jpeg", None) or getattr( + result, "data", None + ) if img_data: - files.append({ - "path": "/workspace/output/chart.jpg", - "mime": "image/jpeg", - "base64": img_data, - }) + files.append( + { + "path": "/workspace/output/chart.jpg", + "mime": "image/jpeg", + "base64": img_data, + } + ) elif result_type == "json" or result_type == "application/json": json_data = getattr(result, "json", None) if json_data: @@ -198,6 +205,7 @@ def _run(): async def upload(self, handle: Any, path: str, data: bytes) -> None: """Escreve arquivo no sandbox E2B.""" + def _upload(): # E2B v2: sandbox.files.write(path, content) handle.files.write(path, data) @@ -206,6 +214,7 @@ def _upload(): async def download(self, handle: Any, path: str) -> bytes: """Lê arquivo do sandbox E2B.""" + def _download(): # E2B v2: sandbox.files.read(path) return handle.files.read(path) @@ -238,6 +247,7 @@ def _list(): async def destroy(self, handle: Any) -> None: """Fecha e libera o sandbox E2B.""" + def _destroy(): try: # E2B v2: sandbox.close() diff --git a/agent/sandbox_tools.py b/agent/sandbox_tools.py index d423bb9..c54bee6 100644 --- a/agent/sandbox_tools.py +++ b/agent/sandbox_tools.py @@ -11,9 +11,7 @@ from __future__ import annotations -import base64 import contextvars -import time from typing import Any from langchain_core.tools import StructuredTool @@ -86,7 +84,6 @@ async def _run_python( - Cap timeout_override em MAX_SANDBOX_TIMEOUT_SEC (anti DoS). - Limita tamanho do código (defense in depth). """ - from agent.security import safe_error_message # Valida tipo e tamanho do código if not isinstance(code, str): @@ -104,9 +101,7 @@ async def _run_python( manager = _get_manager(settings) session = await manager.get_or_create(tid) - effective_timeout = _cap_timeout( - timeout_override, settings.sandbox_timeout_sec - ) + effective_timeout = _cap_timeout(timeout_override, settings.sandbox_timeout_sec) result = await session.exec_code( code, language="python", @@ -145,6 +140,7 @@ async def _run_python( # ── Tool: bash_sandbox ────────────────────────────────────────────── + async def _run_bash( command: str, *, @@ -175,9 +171,7 @@ async def _run_bash( manager = _get_manager(settings) session = await manager.get_or_create(tid) - effective_timeout = _cap_timeout( - timeout_override, settings.sandbox_timeout_sec - ) + effective_timeout = _cap_timeout(timeout_override, settings.sandbox_timeout_sec) result = await session.exec_bash( command, timeout=effective_timeout, @@ -212,6 +206,7 @@ async def _run_bash( # ── Tool: sandbox_files ───────────────────────────────────────────── + async def _manage_files( action: str, *, @@ -225,7 +220,7 @@ async def _manage_files( e sanitiza o nome de arquivo. O container já é read-only exceto /workspace e tmpfs, mas validamos no nível da tool como defense in depth. """ - from agent.security import sanitize_filename, safe_error_message + from agent.security import safe_error_message, sanitize_filename tid = _get_thread_id() settings = _settings or _guess_settings() @@ -246,6 +241,7 @@ async def _manage_files( raw_path = f"/workspace/{raw_path}" # Normaliza .. e . — usando posixpath sem permitir escape import posixpath + norm = posixpath.normpath(raw_path) # Bloqueia escape de /workspace if not (norm == "/workspace" or norm.startswith("/workspace/")): @@ -443,6 +439,7 @@ async def _browser_screenshot(*, _settings: Settings | None = None) -> str: # ── Helpers ───────────────────────────────────────────────────────── + def _format_result(result: Any, label: str) -> str: """Formata ExecResult como string para o LLM.""" lines = [f"🔧 {label} sandbox — duração: {result.duration_ms}ms"] @@ -461,8 +458,7 @@ def _format_result(result: Any, label: str) -> str: # Include base64 para que o agente possa exibir prefix = f"data:{mime};base64," lines.append( - f"🖼️ Arquivo gerado: {fpath} ({mime})\n" - f"![output]({prefix}{b64[:80]}...)" + f"🖼️ Arquivo gerado: {fpath} ({mime})\n![output]({prefix}{b64[:80]}...)" ) else: lines.append(f"📁 Arquivo gerado: {fpath} ({mime})") @@ -478,12 +474,13 @@ def _guess_settings() -> Settings: # ── Factory ───────────────────────────────────────────────────────── + def build_sandbox_tools(settings: Settings) -> list[StructuredTool]: """Cria as 3 tools do sandbox para registro no agente.""" tools: list[StructuredTool] = [] # Inicia o manager e o cleanup loop - manager = _get_manager(settings) + _get_manager(settings) # start é async — não pode ser chamado aqui (sync). # O manager deve ser startado no lifespan ou no build_agent. diff --git a/agent/security.py b/agent/security.py index 3233683..65a2e7e 100644 --- a/agent/security.py +++ b/agent/security.py @@ -38,7 +38,7 @@ # mas neutralizamos o efeito envolvendo em marcador e escapando. _INJECTION_PATTERNS = re.compile( r"(?im)" - r"(\bsystem\s*[:=]\s*)" # "system: ..." + r"(\bsystem\s*[:=]\s*)" # "system: ..." r"|(\bignore\s+(?:all\s+)?(?:previous|prior)\s+instructions\b)" r"|(\bdisregard\s+(?:the\s+)?(?:above|previous)\b)" r"|(\bforget\s+(?:everything|all\s+previous)\b)" @@ -114,10 +114,7 @@ def wrap_untrusted_block( "Use apenas como referência factual para responder." ) instr = instruction or default_instruction - return ( - f"{instr}\n\n" - f"{_UNTRUSTED_OPEN}\n{body}\n{_UNTRUSTED_CLOSE}" - ) + return f"{instr}\n\n{_UNTRUSTED_OPEN}\n{body}\n{_UNTRUSTED_CLOSE}" def sanitize_documents_context(docs: list[dict[str, Any]]) -> str: @@ -137,7 +134,7 @@ def sanitize_documents_context(docs: list[dict[str, Any]]) -> str: continue wrapped = wrap_untrusted_block( body, - label=f"documento \"{name}\"", + label=f'documento "{name}"', max_chars=50_000, ) if wrapped: @@ -237,7 +234,7 @@ def sanitize_filename(name: Any, *, max_len: int = 240) -> str: # só basename name = name.replace("\\", "/").split("/")[-1] # remove caracteres de controle e perigosos - name = "".join(c for c in name if unicodedata.category(c)[0] != "C" and c not in "<>:\"|?*") + name = "".join(c for c in name if unicodedata.category(c)[0] != "C" and c not in '<>:"|?*') name = re.sub(r"\.\.+", ".", name) # evita .. traversal name = re.sub(r"[^\w.\- ()\[\]]+", "_", name, flags=re.UNICODE) return (name or "file")[:max_len] @@ -283,29 +280,47 @@ def safe_b64_decode(s: str) -> bytes | None: r"\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?(/|/etc|/usr|/var|/boot|/root|/home|/proc|/sys|/dev|/bin|/lib|/opt|/srv|/mnt|/media)(\s|$|/)", r"\brm\s+-[a-zA-Z]*[rf][a-zA-Z]*\s+/", # rm -rf anything starting with / r"\brm\s+-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*\s+/", # rm -fr / - r"\bmkfs(?:\.\w+)?\b", # formatar - r"\bdd\s+.*\bof\s*=\s*/dev/", # dd para device - r"\bshred\b", # shred + r"\bmkfs(?:\.\w+)?\b", # formatar + r"\bdd\s+.*\bof\s*=\s*/dev/", # dd para device + r"\bshred\b", # shred # Fork bomb: :(){ :|:& };: (vários formatos) r":\s*\(\s*\)\s*\{\s*:[^}]*;\s*\}\s*:", r":\s*\(\s*\)\s*\{[^}]*&[^}]*\}", - r"\bwhile\s+true\s*;?\s*do\s*", # loop infinito óbvio + r"\bwhile\s+true\s*;?\s*do\s*", # loop infinito óbvio # Escalação de privilégios / escape - r"\bsudo\b", r"\bsu\b", r"\bdoas\b", - r"\bnsenter\b", r"\bunshare\b", r"\bsetcap\b", r"\bchroot\b", - r"\bmount\b", r"\bumount\b", - r"\bchmod\s+[0-7]*[67][0-7]{2}\s+/", # setuid/setgid em / - r"\bchown\b", r"\bchgrp\b", + r"\bsudo\b", + r"\bsu\b", + r"\bdoas\b", + r"\bnsenter\b", + r"\bunshare\b", + r"\bsetcap\b", + r"\bchroot\b", + r"\bmount\b", + r"\bumount\b", + r"\bchmod\s+[0-7]*[67][0-7]{2}\s+/", # setuid/setgid em / + r"\bchown\b", + r"\bchgrp\b", # Container escape / proc - r"/proc/1/root", r"/proc/self", r"\bcgroup\b", - r"\bsystemctl\b", r"\bservice\b", r"\binvoke-rc\.d\b", + r"/proc/1/root", + r"/proc/self", + r"\bcgroup\b", + r"\bsystemctl\b", + r"\bservice\b", + r"\binvoke-rc\.d\b", # Shellcode / binary injection - r"\bperl\s+-e\b", r"\bpython\s+-c\b.*\b__import__\s*\(", # python -c com import - r"\bnmap\b", r"\bhydra\b", r"\bjohn\b", r"\bhashcat\b", + r"\bperl\s+-e\b", + r"\bpython\s+-c\b.*\b__import__\s*\(", # python -c com import + r"\bnmap\b", + r"\bhydra\b", + r"\bjohn\b", + r"\bhashcat\b", # Persistência - r"\bcrontab\b", r"\bsystemd\b", + r"\bcrontab\b", + r"\bsystemd\b", # Tentativa de desativar defesas - r"\bkill\s+-9\s+1\b", r"\bkillall\b", r"\bpkill\s+-f\s*nullain", + r"\bkill\s+-9\s+1\b", + r"\bkillall\b", + r"\bpkill\s+-f\s*nullain", ) ) @@ -368,10 +383,7 @@ def _fernet_key() -> bytes | None: # Se vier como base64 de 32 bytes, usa direto. Senão, deriva via SHA-256. try: decoded = base64.urlsafe_b64decode(raw) - if len(decoded) == 32: - key = decoded - else: - key = hashlib.sha256(raw.encode("utf-8")).digest() + key = decoded if len(decoded) == 32 else hashlib.sha256(raw.encode("utf-8")).digest() except Exception: # noqa: BLE001 key = hashlib.sha256(raw.encode("utf-8")).digest() @@ -446,7 +458,7 @@ def decrypt_secret(stored: str | None) -> str | None: if f is None: # criptografado mas sem chave disponível — não devolve nada útil return None - token = s[len(_ENC_PREFIX):] + token = s[len(_ENC_PREFIX) :] try: return f.decrypt(token.encode("ascii")).decode("utf-8") except Exception: # noqa: BLE001 @@ -471,7 +483,7 @@ def encryption_status() -> dict[str, str | bool]: r"(?:api[_-]?key|token|secret|password|bearer|authorization)[\s:=]+[A-Za-z0-9_\-\.]{8,}", r"sk-[A-Za-z0-9]{10,}", r"eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}", # JWT - r"/[A-Za-z0-9_\-]{20,}/", # paths longos suspeitos + r"/[A-Za-z0-9_\-]{20,}/", # paths longos suspeitos r"Traceback \(most recent call last\):[\s\S]*?\n[A-Za-z_][A-Za-z0-9_]*Error:", ) ) @@ -495,4 +507,4 @@ def safe_error_message(exc: BaseException | str, *, fallback: str = "Erro intern # Se ainda parecer uma traceback, descarta if "Traceback" in msg and "Error:" in msg: return fallback - return msg \ No newline at end of file + return msg diff --git a/agent/vision_attach.py b/agent/vision_attach.py index 4f6439a..8fb1df0 100644 --- a/agent/vision_attach.py +++ b/agent/vision_attach.py @@ -84,17 +84,16 @@ async def describe_image_via_sdk( Levanta `VisionError` (nullain.errors) em falha — nunca deixa exceção crua do provider escapar. """ + from nullain.config.settings import RouterConfig, TierConfig from nullain.errors import VisionError as SdkVisionError from nullain.llm import OpenAICompatibleProvider - from nullain.config.settings import RouterConfig, TierConfig from nullain.ports.vision import ModelRouterVisionProvider from nullain.router.router import ModelRouter api_key = (settings.groq_api_key or "").strip() if not api_key: raise SdkVisionError( - "GROQ_API_KEY não configurada. " - "Sem ela a Nullain não consegue ler imagens anexadas." + "GROQ_API_KEY não configurada. Sem ela a Nullain não consegue ler imagens anexadas." ) model = (settings.groq_vision_model or "llama-3.2-11b-vision-preview").strip() @@ -118,9 +117,7 @@ async def describe_image_via_sdk( llm = OpenAICompatibleProvider(api_key=api_key, base_url="https://api.groq.com/openai") provider = ModelRouterVisionProvider(router, llm) - text = await provider.describe_image( - image_bytes, mime_type=mime_type, hint=hint or None - ) + text = await provider.describe_image(image_bytes, mime_type=mime_type, hint=hint or None) text = (text or "").strip() if not text: raise SdkVisionError("Vision provider retornou descrição vazia.") diff --git a/agent/wavespeed_tools.py b/agent/wavespeed_tools.py index 3d422f9..feed177 100644 --- a/agent/wavespeed_tools.py +++ b/agent/wavespeed_tools.py @@ -12,13 +12,14 @@ from __future__ import annotations +import logging import time from typing import Any, Literal import httpx from pydantic import BaseModel, Field -from agent.config import Settings +logger = logging.getLogger("nullain.wavespeed_tools") RESULT_URL_TMPL = "https://api.wavespeed.ai/api/v3/predictions/{id}/result" @@ -228,9 +229,7 @@ def _is_valid_image_url(u: str) -> bool: return False # Descarta apenas URLs que terminam exatamente em /result ou /status (sem arquivo) c = clean.rstrip("/") - if c.endswith("/result") or c.endswith("/status"): - return False - return True + return not (c.endswith("/result") or c.endswith("/status")) def _add_url(u: Any) -> None: if isinstance(u, str) and _is_valid_image_url(u): @@ -254,13 +253,16 @@ def _add_url(u: Any) -> None: if not urls: import json import re + try: payload_str = json.dumps(payload) found = re.findall(r'https?://[^\s"\'\}]+', payload_str) for f in found: _add_url(f) - except Exception: # noqa: BLE001 - pass + except Exception: + # Last-resort URL scrape; worst case is zero URLs found + # instead of some, not data loss. + logger.debug("Falha no fallback de scan de URL no payload Wavespeed", exc_info=True) # Deduplicate while preserving order deduped: list[str] = [] @@ -468,9 +470,7 @@ def _generate_image_wavespeed_sync( if status not in {"completed", "succeeded"} or not urls: result_url = "" - urls_obj = ( - payload.get("urls") if isinstance(payload.get("urls"), dict) else {} - ) + urls_obj = payload.get("urls") if isinstance(payload.get("urls"), dict) else {} if isinstance(urls_obj, dict): result_url = str(urls_obj.get("get") or "").strip() pred_id = str(payload.get("id") or "").strip() @@ -542,9 +542,7 @@ async def _generate_image_wavespeed_async( ) try: - async with httpx.AsyncClient( - timeout=httpx.Timeout(150.0, connect=15.0) - ) as client: + async with httpx.AsyncClient(timeout=httpx.Timeout(150.0, connect=15.0)) as client: res = await client.post(submit_url, headers=_headers(key), json=body) if res.status_code == 401: return "Erro Wavespeed: API key inválida ou ausente (401)." @@ -558,9 +556,7 @@ async def _generate_image_wavespeed_async( if status not in {"completed", "succeeded"} or not urls: result_url = "" - urls_obj = ( - payload.get("urls") if isinstance(payload.get("urls"), dict) else {} - ) + urls_obj = payload.get("urls") if isinstance(payload.get("urls"), dict) else {} if isinstance(urls_obj, dict): result_url = str(urls_obj.get("get") or "").strip() pred_id = str(payload.get("id") or "").strip() @@ -617,7 +613,6 @@ def _format_success(meta, model_id, body, prompt, urls) -> str: return "\n".join(lines) - def choice_protocol_hint() -> str: """Texto de ajuda para o system prompt do agente.""" return """ diff --git a/agent/zuckpay.py b/agent/zuckpay.py index e2cafce..a9e5a4f 100644 --- a/agent/zuckpay.py +++ b/agent/zuckpay.py @@ -34,6 +34,7 @@ from __future__ import annotations import base64 +import contextlib import json import os import threading @@ -68,7 +69,7 @@ _HTTP_TIMEOUT = httpx.Timeout(30.0, connect=10.0) -class ZuckPayNotConnected(RuntimeError): +class ZuckPayNotConnectedError(RuntimeError): """Usuário do turno não tem conta ZuckPay conectada.""" @@ -82,7 +83,7 @@ class ZuckPayCredentials: client_secret: str def basic_auth_header(self) -> str: - raw = f"{self.client_id}:{self.client_secret}".encode("utf-8") + raw = f"{self.client_id}:{self.client_secret}".encode() return "Basic " + base64.b64encode(raw).decode("ascii") @@ -116,10 +117,9 @@ def _save_raw(store: dict[str, Any]) -> None: with _STORE_LOCK: tmp = _STORE_PATH.with_suffix(".tmp") tmp.write_text(json.dumps(store, ensure_ascii=False, indent=2), encoding="utf-8") - try: + # Windows não tem chmod POSIX + with contextlib.suppress(OSError, NotImplementedError): tmp.chmod(0o600) - except (OSError, NotImplementedError): - pass # Windows não tem chmod POSIX tmp.replace(_STORE_PATH) @@ -228,13 +228,13 @@ def resolve_credentials(user_email: str | None) -> tuple[str, ZuckPayCredentials """Resolve a credencial do usuário informado. Falha fechado.""" user = _norm_user(user_email) if not user: - raise ZuckPayNotConnected( + raise ZuckPayNotConnectedError( "Não consegui identificar o usuário desta conversa, então não " "acesso nenhuma conta ZuckPay." ) creds = get_credentials(user) if creds is None: - raise ZuckPayNotConnected( + raise ZuckPayNotConnectedError( "Sua conta ZuckPay não está conectada. Abra o painel de " "integrações, clique em ZuckPay e informe client_id e " "client_secret para liberar as ações de pagamento." @@ -329,9 +329,7 @@ def validate_splits(splits: list[dict[str, Any]] | None) -> list[dict[str, Any]] if not isinstance(splits, list): raise ValueError("splits deve ser uma lista.") if not 2 <= len(splits) <= 10: - raise ValueError( - f"Split exige de 2 a 10 recebedores — recebi {len(splits)}." - ) + raise ValueError(f"Split exige de 2 a 10 recebedores — recebi {len(splits)}.") total = 0.0 clean: list[dict[str, Any]] = [] for part in splits: @@ -341,16 +339,16 @@ def validate_splits(splits: list[dict[str, Any]] | None) -> list[dict[str, Any]] if not email: raise ValueError("Item de split sem email.") try: - pct = float(part.get("percentage")) + # part.get("percentage") can be None (missing key) — float(None) + # raises TypeError at runtime, caught below. Intentional. + pct = float(part.get("percentage")) # type: ignore[reportArgumentType] except (TypeError, ValueError) as exc: raise ValueError(f"Percentual inválido para {email}.") from exc total += pct clean.append({"email": email, "percentage": pct}) # Tolerância de centésimo: 33.33+33.33+33.34 é intenção legítima. if abs(total - 100.0) > 0.01: - raise ValueError( - f"A soma dos percentuais do split precisa dar 100 — deu {total:.2f}." - ) + raise ValueError(f"A soma dos percentuais do split precisa dar 100 — deu {total:.2f}.") return clean diff --git a/agent/zuckpay_tools.py b/agent/zuckpay_tools.py index 629d094..916aafb 100644 --- a/agent/zuckpay_tools.py +++ b/agent/zuckpay_tools.py @@ -46,7 +46,7 @@ def _fmt_money(value: Any, currency: str = "BRL") -> str: def _err(exc: Exception) -> str: """Erro legível para o modelo, sem stack trace nem corpo cru da API.""" - if isinstance(exc, zp.ZuckPayNotConnected): + if isinstance(exc, zp.ZuckPayNotConnectedError): return f"⚠️ {exc}" if isinstance(exc, (zp.ZuckPayError, ValueError)): return f"⚠️ ZuckPay: {exc}" @@ -63,9 +63,7 @@ class CreatePixInput(BaseModel): email: str = Field(description="E-mail do pagador.") telefone: str = Field(description="Telefone do pagador.") descricao: str = Field(default="", description="Descrição da cobrança.") - external_id_client: str = Field( - default="", description="ID do pedido no sistema do vendedor." - ) + external_id_client: str = Field(default="", description="ID do pedido no sistema do vendedor.") urlnoty: str = Field(default="", description="URL https de webhook.") splits: list[dict] | None = Field( default=None, @@ -158,9 +156,7 @@ class CreateSpeiInput(BaseModel): email: str = Field(description="E-mail do pagador.") telefone: str = Field(default="", description="Telefone do pagador.") descricao: str = Field(default="", description="Descrição do pagamento.") - external_id_client: str = Field( - default="", description="ID do pedido; garante idempotência." - ) + external_id_client: str = Field(default="", description="ID do pedido; garante idempotência.") urlnoty: str = Field(default="", description="URL https de webhook.") @@ -234,9 +230,7 @@ async def _create_spei( class PaymentStatusInput(BaseModel): - transaction_id: str = Field( - default="", description="ID da transação retornado na criação." - ) + transaction_id: str = Field(default="", description="ID da transação retornado na criação.") external_id_client: str = Field( default="", description="Alternativa: o ID do pedido no seu sistema." ) @@ -253,8 +247,7 @@ async def _payment_status( ext = (external_id_client or "").strip() if not tid and not ext: return ( - "⚠️ ZuckPay: informe transaction_id ou external_id_client " - "para consultar o status." + "⚠️ ZuckPay: informe transaction_id ou external_id_client para consultar o status." ) params = {"transactionId": tid} if tid else {"external_id_client": ext} data = await zp.request( diff --git a/docs/AUDIT_REPORT.md b/docs/AUDIT_REPORT.md index e6feb9e..08f8f1c 100644 --- a/docs/AUDIT_REPORT.md +++ b/docs/AUDIT_REPORT.md @@ -57,7 +57,7 @@ except Exception: E em `config.py`: ```python -redis_url=(os.getenv("REDIS_URL") or "").strip(), +redis_url = ((os.getenv("REDIS_URL") or "").strip(),) ``` 3. Colocar `REDIS_URL` só no `.env` / secrets do host (já existe no `.env`, que está corretamente no `.gitignore`). @@ -81,7 +81,8 @@ redis_url=(os.getenv("REDIS_URL") or "").strip(), # dentro de chat_stream, depois de validar image_b64 if image_b64: from agent.redis_client import redis_incr_with_ttl # criar helper - key = f"nullain:rl:vision:{user_id}:{int(time.time()//60)}" + + key = f"nullain:rl:vision:{user_id}:{int(time.time() // 60)}" count = await redis_incr_with_ttl(key, ttl=60) if count > 10: # 10 imagens/min raise HTTPException(status_code=429, detail="Limite de imagens por minuto excedido.") @@ -103,6 +104,7 @@ raise HTTPException(status_code=500, detail=f"Falha ao transcrever áudio: {str( ```python from agent.security import safe_error_message + raise HTTPException( status_code=500, detail=safe_error_message(exc, fallback="Falha ao transcrever áudio."), diff --git "a/docs/hacking-advanced-modules/06_buffer_overflow_e_explora\303\247\303\243o_de_mem\303\263ria.md" "b/docs/hacking-advanced-modules/06_buffer_overflow_e_explora\303\247\303\243o_de_mem\303\263ria.md" index f8c7e9f..69f53e5 100644 --- "a/docs/hacking-advanced-modules/06_buffer_overflow_e_explora\303\247\303\243o_de_mem\303\263ria.md" +++ "b/docs/hacking-advanced-modules/06_buffer_overflow_e_explora\303\247\303\243o_de_mem\303\263ria.md" @@ -22,8 +22,9 @@ Um buffer overflow ocorre quando dados além da capacidade alocada são escritos ```python # Passo 1: Encontrar offset para EIP/RIP com padrão cíclico from pwn import * + cyclic(200) # Gera padrão único -cyclic_find(0x6161616c) # Encontra offset após crash +cyclic_find(0x6161616C) # Encontra offset após crash # Passo 2: Verificar controle de EIP payload = b"A" * offset + b"B" * 4 # EIP deve ser 0x42424242 @@ -37,6 +38,7 @@ badchars = b"\x00\x0a\x0d" # Passo 5: Shellcode e exploit final from pwn import * + shellcode = asm(shellcraft.sh()) # Gera shellcode com pwntools nop_sled = b"\x90" * 16 payload = b"A" * offset + p32(jmp_esp_addr) + nop_sled + shellcode @@ -56,13 +58,14 @@ payload = b"A" * offset + p32(jmp_esp_addr) + nop_sled + shellcode ```python from pwn import * -elf = ELF('./binario') + +elf = ELF("./binario") rop = ROP(elf) # Encontrar gadgets -rop.raw(rop.find_gadget(['pop rdi', 'ret'])[0]) -rop.raw(next(elf.search(b'/bin/sh\x00'))) -rop.raw(elf.plt['system']) +rop.raw(rop.find_gadget(["pop rdi", "ret"])[0]) +rop.raw(next(elf.search(b"/bin/sh\x00"))) +rop.raw(elf.plt["system"]) print(rop.dump()) ``` diff --git a/docs/hacking-advanced.md b/docs/hacking-advanced.md index 730a982..f648344 100644 --- a/docs/hacking-advanced.md +++ b/docs/hacking-advanced.md @@ -300,8 +300,9 @@ Um buffer overflow ocorre quando dados além da capacidade alocada são escritos ```python # Passo 1: Encontrar offset para EIP/RIP com padrão cíclico from pwn import * + cyclic(200) # Gera padrão único -cyclic_find(0x6161616c) # Encontra offset após crash +cyclic_find(0x6161616C) # Encontra offset após crash # Passo 2: Verificar controle de EIP payload = b"A" * offset + b"B" * 4 # EIP deve ser 0x42424242 @@ -315,6 +316,7 @@ badchars = b"\x00\x0a\x0d" # Passo 5: Shellcode e exploit final from pwn import * + shellcode = asm(shellcraft.sh()) # Gera shellcode com pwntools nop_sled = b"\x90" * 16 payload = b"A" * offset + p32(jmp_esp_addr) + nop_sled + shellcode @@ -334,13 +336,14 @@ payload = b"A" * offset + p32(jmp_esp_addr) + nop_sled + shellcode ```python from pwn import * -elf = ELF('./binario') + +elf = ELF("./binario") rop = ROP(elf) # Encontrar gadgets -rop.raw(rop.find_gadget(['pop rdi', 'ret'])[0]) -rop.raw(next(elf.search(b'/bin/sh\x00'))) -rop.raw(elf.plt['system']) +rop.raw(rop.find_gadget(["pop rdi", "ret"])[0]) +rop.raw(next(elf.search(b"/bin/sh\x00"))) +rop.raw(elf.plt["system"]) print(rop.dump()) ``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1ec1a18 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +# Base set mirrors nullain-agent-sdk's own selection (E,F,W,I,N,UP,B,S,A,C4,SIM,RUF) +# for consistency across the ecosystem, with two adjustments measured against +# this repo's actual code (see PR description for the measurement): +# - B008 dropped: 100% of hits here are FastAPI's own Depends()/File() idiom +# (function-call-in-default-argument IS how FastAPI's DI works) — the rule +# structurally conflicts with the framework, not a real bug in this repo. +# - BLE001 added (flake8-blind-except, not in the SDK's set): this repo +# already annotated 105 broad `except Exception` blocks with `# noqa: BLE001` +# before this rule was ever turned on — the intent was already there, +# turning the rule on just makes it real instead of a dead trail. +select = [ + "E", "F", "W", "I", "N", "UP", "B", "S", "A", "C4", "SIM", "RUF", "BLE", +] +ignore = [ + "S101", # allow assert in tests (see per-file-ignores below) + "B008", # Depends()/File() in FastAPI signatures — not a bug, it's the framework's DI mechanism + "RUF001", # ambiguous-unicode-character-string — this app's UI/docs are pt-BR; + "RUF002", # ambiguous-unicode-character-docstring — "×" and "ℹ" here are content, not typos. +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101", "S105", "S106"] +# choice_protocol_hint() embeds a literal [[NULLAIN_CHOICE]] JSON template +# the LLM must reproduce verbatim in its output — it can't be reflowed +# without changing what the model is instructed to emit, and it's string +# content, so an inline `# noqa` isn't possible there. +"agent/wavespeed_tools.py" = ["E501"] + +[tool.pyright] +pythonVersion = "3.12" +typeCheckingMode = "basic" +include = ["agent", "web", "main.py"] +venvPath = "." +venv = ".venv" diff --git a/requirements.txt b/requirements.txt index 54ea71f..aa65a5f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -104,6 +104,13 @@ agent-sandbox>=0.0.30,<0.1.0 # Anexos de chat (PDF/TXT/MD) + Neon PostgreSQL pypdf>=5.0.0,<6.0.0 psycopg[binary]>=3.2.0,<4.0.0 +# psycopg-pool é um pacote PyPI separado de psycopg[binary] (não vem +# incluído) — agent/attachments_store.py's AsyncConnectionPool precisa +# dele. Sem esta linha, o import sempre falhava em produção e a +# persistência de anexos no Neon caía silenciosamente no fallback em +# memória (achado via pyright — issue #XX). Mesmo range de versão do +# psycopg pinado acima; ambos seguem o mesmo cadence de release. +psycopg-pool>=3.2.0,<4.0.0 # Redis para cache distribuído, rate limit e job queue redis>=5.0.0,<6.0.0 diff --git a/tests/test_sandbox_aio.py b/tests/test_sandbox_aio.py index 86e22ad..dfef40f 100644 --- a/tests/test_sandbox_aio.py +++ b/tests/test_sandbox_aio.py @@ -9,6 +9,7 @@ import base64 from types import SimpleNamespace +from typing import ClassVar from unittest.mock import AsyncMock import pytest @@ -105,7 +106,7 @@ def __init__(self): ) ) - def download_file(self, *, path): # noqa: ARG002 — assinatura espelha o SDK real + def download_file(self, *, path): async def _gen(): for chunk in (b"ol", b"a!"): yield chunk @@ -426,7 +427,7 @@ async def test_browser_navigate_uses_title_from_navigate_when_present(driver_wit async def test_browser_screenshot_concatenates_bytes(driver_with_fake_client): driver, fake = driver_with_fake_client - def _screenshot(**kwargs): # noqa: ARG001 + def _screenshot(**kwargs): async def _gen(): for chunk in (b"\x89PNG", b"...rest"): yield chunk @@ -570,13 +571,13 @@ async def test_sandbox_status_online_when_api_responds(monkeypatch): monkeypatch.setattr(ws, "_settings", get_settings()) class _FakeAsyncClient: - def __init__(self, *a, **kw): # noqa: ARG002 + def __init__(self, *a, **kw): pass async def __aenter__(self): return self - async def __aexit__(self, *a): # noqa: ARG002 + async def __aexit__(self, *a): return False async def get(self, url, headers=None): @@ -602,16 +603,16 @@ async def test_sandbox_status_offline_when_key_rejected(monkeypatch): monkeypatch.setattr(ws, "_settings", get_settings()) class _FakeAsyncClient401: - def __init__(self, *a, **kw): # noqa: ARG002 + def __init__(self, *a, **kw): pass async def __aenter__(self): return self - async def __aexit__(self, *a): # noqa: ARG002 + async def __aexit__(self, *a): return False - async def get(self, url, headers=None): # noqa: ARG002 + async def get(self, url, headers=None): return SimpleNamespace(status_code=401) monkeypatch.setattr(ws.httpx, "AsyncClient", _FakeAsyncClient401) @@ -630,16 +631,16 @@ async def test_sandbox_status_offline_when_connection_fails(monkeypatch): monkeypatch.setattr(ws, "_settings", get_settings()) class _FakeAsyncClientRefused: - def __init__(self, *a, **kw): # noqa: ARG002 + def __init__(self, *a, **kw): pass async def __aenter__(self): return self - async def __aexit__(self, *a): # noqa: ARG002 + async def __aexit__(self, *a): return False - async def get(self, url, headers=None): # noqa: ARG002 + async def get(self, url, headers=None): raise ConnectionError("refused") monkeypatch.setattr(ws.httpx, "AsyncClient", _FakeAsyncClientRefused) @@ -698,17 +699,17 @@ async def test_sandbox_vnc_asset_proxies_upstream_content(monkeypatch): class _FakeResp: status_code = 200 - headers = {"content-type": "application/javascript"} + headers: ClassVar[dict[str, str]] = {"content-type": "application/javascript"} content = b"export default class RFB {}" class _FakeAsyncClient: - def __init__(self, *a, **kw): # noqa: ARG002 + def __init__(self, *a, **kw): pass async def __aenter__(self): return self - async def __aexit__(self, *a): # noqa: ARG002 + async def __aexit__(self, *a): return False async def get(self, url): @@ -744,20 +745,20 @@ async def test_sandbox_vnc_asset_propagates_upstream_error_status(monkeypatch): class _FakeResp: status_code = 404 - headers = {} + headers: ClassVar[dict[str, str]] = {} content = b"" class _FakeAsyncClient: - def __init__(self, *a, **kw): # noqa: ARG002 + def __init__(self, *a, **kw): pass async def __aenter__(self): return self - async def __aexit__(self, *a): # noqa: ARG002 + async def __aexit__(self, *a): return False - async def get(self, url): # noqa: ARG002 + async def get(self, url): return _FakeResp() monkeypatch.setattr(ws.httpx, "AsyncClient", _FakeAsyncClient) @@ -776,16 +777,16 @@ async def test_sandbox_vnc_asset_502_when_upstream_unreachable(monkeypatch): monkeypatch.setattr(ws, "_settings", get_settings()) class _FakeAsyncClientRefused: - def __init__(self, *a, **kw): # noqa: ARG002 + def __init__(self, *a, **kw): pass async def __aenter__(self): return self - async def __aexit__(self, *a): # noqa: ARG002 + async def __aexit__(self, *a): return False - async def get(self, url): # noqa: ARG002 + async def get(self, url): raise ConnectionError("refused") monkeypatch.setattr(ws.httpx, "AsyncClient", _FakeAsyncClientRefused) @@ -812,7 +813,7 @@ async def send(self, data): async def __aenter__(self): return self - async def __aexit__(self, *a): # noqa: ARG002 + async def __aexit__(self, *a): self.closed = True return False @@ -919,9 +920,7 @@ def _fake_connect(url, **kwargs): monkeypatch.setattr(ws.ws_client, "connect", _fake_connect) - client_ws = _FakeClientWS( - session={"user": {"email": "test@test.com"}}, subprotocols=["binary"] - ) + client_ws = _FakeClientWS(session={"user": {"email": "test@test.com"}}, subprotocols=["binary"]) client_ws.set_incoming([b"client-msg"]) await ws.sandbox_vnc_socket(client_ws) diff --git a/tests/test_vision_attach_sdk.py b/tests/test_vision_attach_sdk.py index 8a66b5d..f45a90a 100644 --- a/tests/test_vision_attach_sdk.py +++ b/tests/test_vision_attach_sdk.py @@ -110,26 +110,28 @@ async def test_describe_image_via_sdk_without_api_key_raises_vision_error(): async def test_describe_image_via_sdk_invalid_base64_raises_vision_error(): settings = _settings() with pytest.raises(VisionError): - await describe_image_via_sdk( - settings, image_b64="::: não é base64 :::", mime="image/png" - ) + await describe_image_via_sdk(settings, image_b64="::: não é base64 :::", mime="image/png") async def test_describe_image_via_sdk_empty_response_raises_vision_error(): settings = _settings() - with patch( - "httpx.AsyncClient.post", - new=AsyncMock(return_value=_openai_response("")), + with ( + patch( + "httpx.AsyncClient.post", + new=AsyncMock(return_value=_openai_response("")), + ), + pytest.raises(VisionError), ): - with pytest.raises(VisionError): - await describe_image_via_sdk(settings, image_b64=_TINY_PNG, mime="image/png") + await describe_image_via_sdk(settings, image_b64=_TINY_PNG, mime="image/png") async def test_describe_image_via_sdk_provider_http_error_raises_vision_error(): settings = _settings() - with patch( - "httpx.AsyncClient.post", - new=AsyncMock(return_value=httpx.Response(401, text="invalid key")), + with ( + patch( + "httpx.AsyncClient.post", + new=AsyncMock(return_value=httpx.Response(401, text="invalid key")), + ), + pytest.raises(VisionError), ): - with pytest.raises(VisionError): - await describe_image_via_sdk(settings, image_b64=_TINY_PNG, mime="image/png") + await describe_image_via_sdk(settings, image_b64=_TINY_PNG, mime="image/png") diff --git a/web/auth.py b/web/auth.py index fc8f958..6fade1a 100644 --- a/web/auth.py +++ b/web/auth.py @@ -3,7 +3,8 @@ Credenciais são lidas de variáveis de ambiente: - AUTH_ADMIN_EMAIL — email da conta admin - - AUTH_ADMIN_PASSWORD_HASH — hex de pbkdf2_hmac(sha256, senha, AUTH_PASSWORD_SALT, AUTH_PBKDF2_ROUNDS) + - AUTH_ADMIN_PASSWORD_HASH — hex de pbkdf2_hmac(sha256, senha, + AUTH_PASSWORD_SALT, AUTH_PBKDF2_ROUNDS) - AUTH_PASSWORD_SALT — salt binário (string; codificado para bytes) - AUTH_PBKDF2_ROUNDS — default 200_000 (OWASP 2023) @@ -23,6 +24,8 @@ import logging import os import secrets +import threading +import time from typing import Any from fastapi import HTTPException, Request @@ -44,9 +47,7 @@ def _is_production() -> bool: if val in {"true", "1", "yes"}: return True # RENDER se seta como "true"; Fly injeta FLY_*; Railway injeta RAILWAY_* - if os.getenv("RENDER_HOST") or os.getenv("FLY_REGION"): - return True - return False + return bool(os.getenv("RENDER_HOST") or os.getenv("FLY_REGION")) def get_admin_email() -> str: @@ -94,9 +95,7 @@ def auth_fully_configured() -> bool: return False # Validação extra: hash PBKDF2 SHA-256 deve ser hex de 64 chars. # Se não for, mesmo com a env setada, o sistema não está pronto. - if not _is_hex_64(get_admin_password_hash()): - return False - return True + return _is_hex_64(get_admin_password_hash()) def session_secret() -> str: @@ -106,25 +105,19 @@ def session_secret() -> str: Em produção: AUTH_SECRET (ou SESSION_SECRET) é OBRIGATÓRIO — fail-fast. Em dev: permite fallback, mas emite warning. """ - secret = ( - os.getenv("AUTH_SECRET") - or os.getenv("SESSION_SECRET") - or "" - ).strip() + secret = (os.getenv("AUTH_SECRET") or os.getenv("SESSION_SECRET") or "").strip() if secret: return secret if _is_production(): raise RuntimeError( "AUTH_SECRET (ou SESSION_SECRET) não definida em produção. " - "Gere um segredo longo (ex.: `python -c \"import secrets; " - "print(secrets.token_urlsafe(64))\"`) e defina no ambiente do host." + 'Gere um segredo longo (ex.: `python -c "import secrets; ' + 'print(secrets.token_urlsafe(64))"`) e defina no ambiente do host.' ) # Dev only - print( - "[WARNING] AUTH_SECRET não definida — usando fallback de DEV (NÃO usar em prod)." - ) + print("[WARNING] AUTH_SECRET não definida — usando fallback de DEV (NÃO usar em prod).") return "nullain-dev-session-secret-change-me" @@ -212,14 +205,12 @@ def logout_user(request: Request) -> None: # Gerado na primeira leitura e armazenado na sessão. Clientes devem # enviar de volta no header X-CSRF-Token em todas as mutações. -import secrets as _secrets - def ensure_csrf_token(request: Request) -> str: """Garante um CSRF token na sessão. Idempotente.""" token = request.session.get("_csrf") if not isinstance(token, str) or not token: - token = _secrets.token_urlsafe(32) + token = secrets.token_urlsafe(32) request.session["_csrf"] = token return token @@ -234,7 +225,7 @@ def verify_csrf_token(request: Request, provided: str | None) -> bool: expected = request.session.get("_csrf") if not isinstance(expected, str) or not expected: return False - return _secrets.compare_digest(provided, expected) + return secrets.compare_digest(provided, expected) def verify_origin_or_referer(request: Request) -> bool: @@ -250,14 +241,17 @@ def verify_origin_or_referer(request: Request) -> bool: expected_host = "" try: expected_host = (request.url.hostname or "").lower() - except Exception: # noqa: BLE001 - pass + except Exception: # noqa: BLE001 — security-relevant (CSRF + # origin check below fails open to True when expected_host is + # empty), so log at warning rather than debug. + _security_log.warning("Falha ao resolver hostname esperado da request", exc_info=True) origin = (request.headers.get("origin") or "").strip().lower() if origin: # Origin é "scheme://host[:port]" — compara só o host try: from urllib.parse import urlparse + parsed = urlparse(origin) origin_host = (parsed.hostname or "").lower() except Exception: # noqa: BLE001 @@ -270,6 +264,7 @@ def verify_origin_or_referer(request: Request) -> bool: if referer: try: from urllib.parse import urlparse + parsed = urlparse(referer) ref_host = (parsed.hostname or "").lower() except Exception: # noqa: BLE001 @@ -320,15 +315,12 @@ def require_csrf(request: Request) -> None: # In-process: o lockout é por IP + email. Em multi-worker, trocar # por Redis para compartilhar o estado. -import time as _time -import threading as _threading - _LOGIN_LOCKOUT_THRESHOLD = 10 # falhas antes do lockout _LOGIN_LOCKOUT_WINDOW_SEC = 900 # janela: 15 min _LOGIN_LOCKOUT_DURATION_SEC = 900 # bloqueio dura 15 min _login_attempts: dict[tuple[str, str], list[float]] = {} _login_lockouts: dict[tuple[str, str], float] = {} -_LOGIN_LOCK = _threading.Lock() +_LOGIN_LOCK = threading.Lock() def _client_id_from_request(request: Request) -> str: @@ -347,7 +339,7 @@ def is_login_locked(request: Request, email: str) -> bool: # 1. Tenta checagem rápida em memória primeiro with _LOGIN_LOCK: - now = _time.monotonic() + now = time.monotonic() for k, t in list(_login_lockouts.items()): if t < now: _login_lockouts.pop(k, None) @@ -357,15 +349,20 @@ def is_login_locked(request: Request, email: str) -> bool: # 2. Redis check try: from agent.config import get_settings + if get_settings().redis_enabled: import redis + settings = get_settings() r = redis.Redis.from_url(settings.redis_url, socket_timeout=1.0) redis_key = f"nullain:login_locked:{ip}:{normalized_email}" if r.get(redis_key): return True - except Exception: # noqa: BLE001 - pass + except Exception: # noqa: BLE001 — security-relevant: a Redis + # failure silently skips the distributed lockout check (falls + # through to the local in-memory check already done above), so + # log at warning rather than debug. + _security_log.warning("Falha ao checar lockout de login no Redis", exc_info=True) return False @@ -382,7 +379,7 @@ def record_login_attempt(request: Request, email: str, success: bool) -> None: key = (ip, normalized_email) with _LOGIN_LOCK: - now = _time.monotonic() + now = time.monotonic() if success: _login_attempts.pop(key, None) _login_lockouts.pop(key, None) @@ -398,8 +395,10 @@ def record_login_attempt(request: Request, email: str, success: bool) -> None: # Registro distribuído no Redis try: from agent.config import get_settings + if get_settings().redis_enabled: import redis + settings = get_settings() r = redis.Redis.from_url(settings.redis_url, socket_timeout=1.0) fail_key = f"nullain:login_fail:{ip}:{normalized_email}" @@ -408,10 +407,15 @@ def record_login_attempt(request: Request, email: str, success: bool) -> None: if success: r.delete(fail_key, lock_key) else: - fails = r.incr(fail_key) + # redis-py's stubs type .incr()'s return as ResponseT (a + # union including the pipeline-builder's own type) — this + # is a plain sync client call, always returns int here. + fails = int(r.incr(fail_key)) # type: ignore[reportGeneralTypeIssues] if fails == 1: r.expire(fail_key, _LOGIN_LOCKOUT_WINDOW_SEC) if fails >= _LOGIN_LOCKOUT_THRESHOLD: r.setex(lock_key, _LOGIN_LOCKOUT_DURATION_SEC, "1") - except Exception: # noqa: BLE001 - pass + except Exception: # noqa: BLE001 — security-relevant: a Redis + # failure here silently drops the failed-attempt counter, so log + # at warning rather than debug. + _security_log.warning("Falha ao registrar tentativa de login no Redis", exc_info=True) diff --git a/web/server.py b/web/server.py index a08d9af..4deada1 100644 --- a/web/server.py +++ b/web/server.py @@ -11,6 +11,7 @@ # mas o FastAPI não consegue resolver ForwardRef em runtime se annotations for lazy. import asyncio as _asyncio +import contextlib import json import logging import os @@ -18,13 +19,13 @@ import threading import time import uuid as _uuid +from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from pathlib import Path from typing import Any import httpx import websockets as ws_client -from websockets.exceptions import ConnectionClosed as _WSConnectionClosed from fastapi import ( Depends, FastAPI, @@ -46,21 +47,7 @@ from slowapi.util import get_remote_address from starlette.middleware.base import BaseHTTPMiddleware from starlette.types import ASGIApp - -# ── Security audit logging ───────────────────────────────────────── -_security_log = logging.getLogger("nullain.security") -_security_log.setLevel(logging.WARNING) -if not _security_log.handlers: - _h = logging.StreamHandler() - _h.setFormatter(logging.Formatter( - '{"ts":"%(asctime)s","level":"%(levelname)s","event":%(message)s}', - datefmt="%Y-%m-%dT%H:%M:%S", - )) - _security_log.addHandler(_h) - -# ── Body size limit (anti OOM DoS) ───────────────────────────────── -# 12 MB — cobre o pior caso: imagem base64 ~8MB + texto + attachments -MAX_BODY_BYTES = 12 * 1024 * 1024 +from websockets.exceptions import ConnectionClosed as _WSConnectionClosed from agent.bridge import new_thread_id, stream_bridge_events from agent.config import Settings, get_settings @@ -80,6 +67,25 @@ verify_origin_or_referer, ) +logger = logging.getLogger("nullain.server") + +# ── Security audit logging ───────────────────────────────────────── +_security_log = logging.getLogger("nullain.security") +_security_log.setLevel(logging.WARNING) +if not _security_log.handlers: + _h = logging.StreamHandler() + _h.setFormatter( + logging.Formatter( + '{"ts":"%(asctime)s","level":"%(levelname)s","event":%(message)s}', + datefmt="%Y-%m-%dT%H:%M:%S", + ) + ) + _security_log.addHandler(_h) + +# ── Body size limit (anti OOM DoS) ───────────────────────────────── +# 12 MB — cobre o pior caso: imagem base64 ~8MB + texto + attachments +MAX_BODY_BYTES = 12 * 1024 * 1024 + def _safe_client_key(request: Request) -> str: """ @@ -99,8 +105,10 @@ def _safe_client_key(request: Request) -> str: user = current_user(request) if user and user.get("email"): return f"user:{user['email']}" - except Exception: # noqa: BLE001 - pass + except Exception: + # Falls through to the IP-based key below either way, so this is + # a real fallback, not data loss. + logger.debug("Falha ao resolver usuário para chave de rate limit", exc_info=True) # Fallback: IP da conexão TCP (não do header spoofável) client = getattr(request, "client", None) if client is not None: @@ -116,19 +124,23 @@ def _safe_client_key(request: Request) -> str: # Suporta Redis Cloud / Upstash para escalabilidade distribuída multi-worker. _redis_url_env = (os.getenv("REDIS_URL") or "").strip() try: - limiter = Limiter(key_func=_safe_client_key, storage_uri=_redis_url_env if _redis_url_env else "memory://") + limiter = Limiter( + key_func=_safe_client_key, storage_uri=_redis_url_env if _redis_url_env else "memory://" + ) except Exception: + # Falls back to in-memory rate limiting — a real fallback, not silent + # data loss. + logger.warning("Falha ao configurar Limiter com Redis, caindo para memory://", exc_info=True) limiter = Limiter(key_func=_safe_client_key, storage_uri="memory://") def _is_prod_env() -> bool: import os + for v in ("RENDER", "RAILWAY", "FLY", "DYNO"): if os.getenv(v, "").strip().lower() in {"true", "1", "yes"}: return True - if os.getenv("RENDER_HOST") or os.getenv("FLY_REGION"): - return True - return False + return bool(os.getenv("RENDER_HOST") or os.getenv("FLY_REGION")) _local_vision_rate_limit: dict[str, list[float]] = {} @@ -157,7 +169,8 @@ async def _check_vision_rate_limit(user_key: str, max_per_min: int = 10) -> bool _security_log.warning('"vision_rate_limit_exceeded","user":"%s"', user_key) return False return True - except Exception as exc: + except Exception as exc: # noqa: BLE001 — falls through to the + # in-memory fallback below, a real fallback, already logged. _security_log.debug("Erro ao checar rate limit de visão no Redis: %s", exc) # 2. Fallback local em memória (sliding window 60s) @@ -184,6 +197,7 @@ async def dispatch(self, request, call_next): try: if int(content_length) > self.max_bytes: from fastapi.responses import JSONResponse + return JSONResponse( status_code=413, content={"detail": "Request body too large."}, @@ -251,9 +265,7 @@ async def dispatch(self, request, call_next): response.headers["Content-Security-Policy"] = self.csp # HSTS só em prod (em HTTP local quebraria dev) if self.is_prod and "Strict-Transport-Security" not in response.headers: - response.headers["Strict-Transport-Security"] = ( - "max-age=31536000; includeSubDomains" - ) + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" return response @@ -294,16 +306,8 @@ def _build_csp(is_prod: bool) -> str: "https://cdnjs.cloudflare.com " "data:" ) - img_src = ( - "'self' " - "data: " - "https: " - "http: " - ) - connect_src = ( - "'self' " - "https://cdn.jsdelivr.net" - ) + img_src = "'self' data: https: http: " + connect_src = "'self' https://cdn.jsdelivr.net" # iframes: same-origin (saída inline do sandbox de código). O painel # "computador ao vivo" NÃO embeda um iframe do sandbox — o desktop VNC # é renderizado via proxy same-origin (/api/sandbox/vnc-assets, @@ -314,7 +318,7 @@ def _build_csp(is_prod: bool) -> str: form_action = "'self'" directives = [ - f"default-src 'self'", + "default-src 'self'", f"script-src {script_src}", f"style-src {style_src}", f"font-src {font_src}", @@ -329,6 +333,7 @@ def _build_csp(is_prod: bool) -> str: ] return "; ".join(directives) + STATIC_DIR = Path(__file__).resolve().parent / "static" # Estado global do processo. `_agent`/`_ready`/`_boot_error` (LangGraph @@ -376,6 +381,7 @@ async def lifespan(app: FastAPI): if _settings and _settings.sandbox_enabled: try: from agent.sandbox_tools import _get_manager + sb_manager = _get_manager(_settings) await sb_manager.start() print("🧪 Sandbox manager iniciado (cleanup loop).\n") @@ -390,6 +396,7 @@ async def lifespan(app: FastAPI): # atrasam/impedem o encerramento limpo do processo. try: from agent.bridge import close_all_bridge_agents + await close_all_bridge_agents() print("🧹 AgentBridge: conexões fechadas.") except Exception as exc: # noqa: BLE001 @@ -399,6 +406,7 @@ async def lifespan(app: FastAPI): if _settings and _settings.sandbox_enabled: try: from agent.sandbox_tools import _get_manager + sb_manager = _get_manager(_settings) await sb_manager.stop() print("🧹 Sandbox manager parado.") @@ -408,9 +416,7 @@ async def lifespan(app: FastAPI): # Habilita /docs e /openapi.json só se explicitamente permitido. # Em prod, deixa desligado por padrão (expõe superfície extra). -_ENABLE_DOCS = (os.getenv("ENABLE_API_DOCS") or "").strip().lower() in { - "1", "true", "yes" -} +_ENABLE_DOCS = (os.getenv("ENABLE_API_DOCS") or "").strip().lower() in {"1", "true", "yes"} app = FastAPI( title="Nullain Agent", @@ -427,9 +433,11 @@ async def lifespan(app: FastAPI): # CORSMiddleware não adiciona headers Access-Control-Allow-Origin, então # o browser bloqueia requests cross-origin automaticamente. _allowed_origins_env = (os.getenv("CORS_ALLOWED_ORIGINS") or "").strip() -_allowed_origins = [ - o.strip() for o in _allowed_origins_env.split(",") if o.strip() -] if _allowed_origins_env else [] +_allowed_origins = ( + [o.strip() for o in _allowed_origins_env.split(",") if o.strip()] + if _allowed_origins_env + else [] +) if _allowed_origins: # Só configura o middleware se houver origens explícitas. # Sem origens configuradas, same-origin é o default e nenhum @@ -461,7 +469,13 @@ async def lifespan(app: FastAPI): # Rate limiter state (slowapi) app.state.limiter = limiter -app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) +# slowapi's handler is typed against the general FastAPI ExceptionHandler +# signature but is only ever invoked for RateLimitExceeded — a known +# upstream typing mismatch between slowapi and FastAPI's stubs. +app.add_exception_handler( + RateLimitExceeded, + _rate_limit_exceeded_handler, # type: ignore[reportArgumentType] +) @app.middleware("http") @@ -474,7 +488,6 @@ async def add_no_cache_static_headers(request: Request, call_next): return response - class ChatRequest(BaseModel): message: str = Field(default="", max_length=32000) thread_id: str | None = None @@ -585,6 +598,7 @@ async def system_health_report( ) -> dict[str, Any]: """Relatório completo de integridade e diagnósticos do sistema (Redis, Postgres, LLM, MCP).""" from agent.resilience import get_system_health + return await get_system_health() @@ -596,12 +610,12 @@ async def system_metering_report( """Métricas de uso de tokens e chamadas de ferramentas por tenant.""" from agent.metering import get_tenant_limits from agent.redis_client import redis_get - + tenant_id = _user.get("email", "default") today_str = time.strftime("%Y-%m-%d") usage = await redis_get(f"nullain:metering:{tenant_id}:{today_str}", default={}) limits = get_tenant_limits("pro") - + return { "tenant_id": tenant_id, "date": today_str, @@ -752,9 +766,7 @@ async def sandbox_status( # mostrar o estado vazio com a instrução de start-sandbox.sh. base = _settings.sandbox_aio_url.rstrip("/") headers = ( - {"X-AIO-API-Key": _settings.sandbox_aio_api_key} - if _settings.sandbox_aio_api_key - else {} + {"X-AIO-API-Key": _settings.sandbox_aio_api_key} if _settings.sandbox_aio_api_key else {} ) # Sonda um endpoint de API de verdade (com a chave), não só /v1/docs: é o # mesmo caminho que o driver usa, então "online" aqui significa "o agente @@ -811,7 +823,7 @@ async def sandbox_vnc_asset( try: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get(upstream_url) - except Exception as exc: # noqa: BLE001 + except Exception as exc: raise HTTPException(status_code=502, detail="Sandbox inalcançável.") from exc if resp.status_code != 200: @@ -835,26 +847,35 @@ async def sandbox_vnc_asset( def _sandbox_ws_upstream_url() -> str: base = (_settings.sandbox_aio_url if _settings else "").rstrip("/") if base.startswith("https://"): - return "wss://" + base[len("https://"):] + "/websockify" + return "wss://" + base[len("https://") :] + "/websockify" if base.startswith("http://"): - return "ws://" + base[len("http://"):] + "/websockify" + return "ws://" + base[len("http://") :] + "/websockify" return base + "/websockify" @app.websocket("/api/sandbox/vnc-socket") async def sandbox_vnc_socket(websocket: WebSocket) -> None: """Proxy bidirecional do WebSocket do VNC — a chave nunca vai ao navegador.""" - user = current_user(websocket) # SessionMiddleware popula .session em WS também - + # SessionMiddleware popula .session em WebSocket também; current_user + # only touches .session, so this works at runtime despite WebSocket + # and Request not sharing a type hierarchy. + user = current_user(websocket) # type: ignore[reportArgumentType] + # Em produção (Railway/reverse proxy), websocket.url.hostname pode ser o IP interno # do container (0.0.0.0 / 127.0.0.1), enquanto o navegador envia o domínio público # em X-Forwarded-Host, Host e Origin. fw_host = ( - websocket.headers.get("x-forwarded-host") - or websocket.headers.get("host") - or websocket.url.hostname - or "" - ).split(",")[0].split(":")[0].strip().lower() + ( + websocket.headers.get("x-forwarded-host") + or websocket.headers.get("host") + or websocket.url.hostname + or "" + ) + .split(",")[0] + .split(":")[0] + .strip() + .lower() + ) url_host = (websocket.url.hostname or "").lower() valid_hosts = {url_host, fw_host, "localhost", "127.0.0.1"} @@ -865,9 +886,8 @@ async def sandbox_vnc_socket(websocket: WebSocket) -> None: from urllib.parse import urlparse as _urlparse origin_host = (_urlparse(origin).hostname or "").lower() - origin_ok = ( - origin_host in valid_hosts - or (url_host in ("localhost", "127.0.0.1") and origin_host in ("localhost", "127.0.0.1")) + origin_ok = origin_host in valid_hosts or ( + url_host in ("localhost", "127.0.0.1") and origin_host in ("localhost", "127.0.0.1") ) if not user or not origin_ok or not _settings or not _settings.sandbox_aio_enabled: @@ -909,7 +929,12 @@ async def client_to_upstream() -> None: except (WebSocketDisconnect, _WSConnectionClosed): pass except Exception: - pass + # A broken pipe on either side of this proxy pump is + # expected/frequent (VNC session ends, tab closed); + # logged at debug so a genuinely unexpected failure + # (auth, protocol) is still traceable instead of + # indistinguishable from a normal close. + logger.debug("client_to_upstream encerrou por exceção", exc_info=True) async def upstream_to_client() -> None: try: @@ -921,7 +946,8 @@ async def upstream_to_client() -> None: except _WSConnectionClosed: pass except Exception: - pass + # See client_to_upstream above. + logger.debug("upstream_to_client encerrou por exceção", exc_info=True) tasks = [ _asyncio.create_task(client_to_upstream()), @@ -937,10 +963,11 @@ async def upstream_to_client() -> None: except Exception as exc: # noqa: BLE001 print(f"⚠️ Erro ao conectar ao WebSocket VNC upstream ({upstream_url}): {exc}") finally: - try: + # Idempotent cleanup; the socket may already be closed (client + # disconnect, error path above already handled) — closing an + # already-closed socket is expected, not a failure worth logging. + with contextlib.suppress(Exception): await websocket.close() - except Exception: - pass def _public_base_url(request: Request) -> str: @@ -964,7 +991,6 @@ def _public_base_url(request: Request) -> str: - Quando PROXY_ALLOWED_HOSTS é setado, faz match exact/suffix. """ import os - from agent.security import safe_error_message env = (os.getenv("PUBLIC_BASE_URL") or os.getenv("NULLAIN_PUBLIC_URL") or "").strip() if env: @@ -979,9 +1005,7 @@ def _public_base_url(request: Request) -> str: raise HTTPException(status_code=500, detail="PUBLIC_BASE_URL com caracteres inválidos.") return env.rstrip("/") - allow_proxy = os.getenv("ALLOW_PROXY_HOSTS", "").strip().lower() in { - "1", "true", "yes" - } + allow_proxy = os.getenv("ALLOW_PROXY_HOSTS", "").strip().lower() in {"1", "true", "yes"} if not allow_proxy: # Sem permissão explícita, usa só a base_url observada pelo uvicorn # (em prod atrás de proxy, isso requer ALLOW_PROXY_HOSTS=true + proxy-headers) @@ -992,7 +1016,9 @@ def _public_base_url(request: Request) -> str: # hijack (atacante injeta host malicioso no callback URL). allow_list_raw = (os.getenv("PROXY_ALLOWED_HOSTS") or "").strip() if not allow_list_raw: - _security_log.error('"proxy_hosts_misconfigured","msg":"ALLOW_PROXY_HOSTS=true sem PROXY_ALLOWED_HOSTS"') + _security_log.error( + '"proxy_hosts_misconfigured","msg":"ALLOW_PROXY_HOSTS=true sem PROXY_ALLOWED_HOSTS"' + ) raise HTTPException( status_code=500, detail="ALLOW_PROXY_HOSTS=true exige PROXY_ALLOWED_HOSTS definido.", @@ -1015,8 +1041,8 @@ def _public_base_url(request: Request) -> str: # rejeita caracteres não-ASCII (IDN spoofing / confusables) try: host.encode("ascii") - except UnicodeEncodeError: - raise HTTPException(status_code=400, detail="Host header não-ASCII é proibido.") + except UnicodeEncodeError as exc: + raise HTTPException(status_code=400, detail="Host header não-ASCII é proibido.") from exc # limita tamanho (headers gigantes são suspeitos) if len(host) > 253: # RFC 1035 max hostname raise HTTPException(status_code=400, detail="Host header muito longo.") @@ -1074,7 +1100,7 @@ async def external_mcp_toggle( item = set_mcp_enabled(mcp_id, body.enabled) try: await rebuild_agent(reason=f"toggle {mcp_id}={body.enabled}") - except Exception as exc: # noqa: BLE001 + except Exception as exc: raise HTTPException( status_code=500, detail=f"Toggle ok, rebuild falhou: {safe_error_message(exc)}", @@ -1128,10 +1154,8 @@ async def zuckpay_connect( save_credentials(email, body.client_id, body.client_secret) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc - except Exception as exc: # noqa: BLE001 - raise HTTPException( - status_code=500, detail=safe_error_message(exc) - ) from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=safe_error_message(exc)) from exc # Sem rebuild do agente: as tools da ZuckPay já estão registradas e # resolvem a credencial por chamada. Reconstruir aqui derrubaria as @@ -1171,9 +1195,12 @@ async def higgsfield_connect_start( redirect_uri = f"{base}/api/external-mcp/higgsfield/callback" try: data = await start_higgsfield_oauth(redirect_uri, user_email=_user.get("email")) - except Exception as exc: # noqa: BLE001 + except Exception as exc: from agent.security import safe_error_message - raise HTTPException(status_code=502, detail=safe_error_message(exc, fallback="Falha ao iniciar OAuth.")) from exc + + raise HTTPException( + status_code=502, detail=safe_error_message(exc, fallback="Falha ao iniciar OAuth.") + ) from exc return { "ok": True, "authorize_url": data["authorize_url"], @@ -1222,7 +1249,7 @@ async def higgsfield_oauth_callback( # Valida formato básico para evitar injeção no log/página if len(state) > 128 or len(code) > 512: return _oauth_result_page(False, "Parâmetros de callback inválidos.") - if any(c in state for c in "\r\n\t<>" ): + if any(c in state for c in "\r\n\t<>"): return _oauth_result_page(False, "state inválido.") owner = get_pending_oauth_owner(state) @@ -1263,7 +1290,9 @@ async def higgsfield_oauth_callback( ) return _oauth_result_page(True, "Higgsfield conectado e ativo na Nullain.") except Exception as exc: # noqa: BLE001 - return _oauth_result_page(False, safe_error_message(exc, fallback="Falha ao concluir o OAuth.")) + return _oauth_result_page( + False, safe_error_message(exc, fallback="Falha ao concluir o OAuth.") + ) @app.post("/api/external-mcp/higgsfield/disconnect") @@ -1277,7 +1306,7 @@ async def higgsfield_disconnect( disconnect_higgsfield() try: await rebuild_agent(reason="higgsfield disconnected") - except Exception as exc: # noqa: BLE001 + except Exception as exc: raise HTTPException(status_code=500, detail=safe_error_message(exc)) from exc pub = next( (x for x in list_external_mcps_public() if x["id"] == "higgsfield"), @@ -1287,9 +1316,9 @@ async def higgsfield_disconnect( def _oauth_result_page(ok: bool, message: str): - from fastapi.responses import HTMLResponse from html import escape as _html_escape - from urllib.parse import urlparse + + from fastapi.responses import HTMLResponse title = "conectado" if ok else "falha" # Escapa a mensagem para evitar XSS (mensagem vem de fontes não confiáveis @@ -1342,7 +1371,10 @@ def _oauth_result_page(ok: bool, message: str): }} var h = bg.replace("#", ""); if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; - var lin = function (c) {{ c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }}; + var lin = function (c) {{ + c /= 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }}; var lum = 0.2126 * lin(parseInt(h.slice(0, 2), 16)) + 0.7152 * lin(parseInt(h.slice(2, 4), 16)) + 0.0722 * lin(parseInt(h.slice(4, 6), 16)); @@ -1419,21 +1451,25 @@ async def delete_thread( from agent.attachments_store import delete_attachments_by_thread await delete_attachments_by_thread(thread_id=tid, user_id=user_id) - except Exception: # noqa: BLE001 + except Exception: # noqa: BLE001, S110 — delete_attachments_by_thread + # already logs the failure at error level before re-raising; + # the route intentionally always returns {"ok": True} to the + # client either way (deleting a thread that's already gone, or + # whose attachments failed to clean up, isn't a client error). pass return {"ok": True} - async def _read_file_safe(file: UploadFile, max_bytes: int) -> bytes: """Lê um UploadFile em chunks seguros com teto estrito de bytes para evitar OOM DoS.""" buf = bytearray() while chunk := await file.read(64 * 1024): buf.extend(chunk) if len(buf) > max_bytes: + max_mb = max_bytes // (1024 * 1024) raise HTTPException( status_code=413, - detail=f"Tamanho do arquivo excede o limite máximo permitido ({max_bytes // (1024 * 1024)} MB).", + detail=f"Tamanho do arquivo excede o limite máximo permitido ({max_mb} MB).", ) return bytes(buf) @@ -1472,7 +1508,7 @@ async def chat_upload( ) except ValueError as exc: raise HTTPException(status_code=400, detail=safe_error_message(exc)) from exc - except Exception as exc: # noqa: BLE001 + except Exception as exc: # Não vaza stack trace / internals raise HTTPException( status_code=400, @@ -1505,8 +1541,10 @@ def _get_whisper_model() -> Any: if _whisper_model is None: try: from faster_whisper import WhisperModel + _whisper_model = WhisperModel("base", device="cpu", compute_type="int8") - except Exception as exc: + except Exception as exc: # noqa: BLE001 — logged; callers + # handle a None model (transcription unavailable). logging.warning("Não foi possível carregar faster-whisper: %s", exc) return None return _whisper_model @@ -1531,6 +1569,7 @@ async def transcribe_audio( raise HTTPException(status_code=400, detail="Áudio excede o limite de 25 MB.") import tempfile + ext = Path(file.filename or "audio.webm").suffix or ".webm" with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp: tmp.write(raw) @@ -1539,7 +1578,9 @@ async def transcribe_audio( try: model = _get_whisper_model() if model is None: - raise HTTPException(status_code=500, detail="Whisper open-source não disponível no servidor.") + raise HTTPException( + status_code=500, detail="Whisper open-source não disponível no servidor." + ) lang_arg = language if language and language != "auto" else None segments, info = model.transcribe( @@ -1561,6 +1602,7 @@ async def transcribe_audio( except Exception as exc: logging.error("Erro na transcrição Whisper: %s", exc) from agent.security import safe_error_message + raise HTTPException( status_code=500, detail=safe_error_message(exc, fallback="Falha ao transcrever áudio."), @@ -1569,7 +1611,9 @@ async def transcribe_audio( try: if os.path.exists(tmp_path): os.unlink(tmp_path) - except Exception: # noqa: BLE001 + except Exception: # noqa: BLE001, S110 — best-effort temp-file + # cleanup; worst case is a leaked temp file, not a functional + # failure worth surfacing. pass @@ -1620,7 +1664,7 @@ async def chat_stream( att_inline = body.attachments or [] # Valida thread_id (UUID) se fornecido — anti injeção no checkpointer - from agent.security import normalize_uuid, normalize_mime, is_probably_base64_image + from agent.security import is_probably_base64_image, normalize_mime, normalize_uuid if body.thread_id is not None: tid = normalize_uuid(body.thread_id) @@ -1661,22 +1705,17 @@ async def chat_stream( chosen_model = resolve_model_id(_settings, body.model) if _settings else None - if image_b64: - if not await _check_vision_rate_limit(user_id, max_per_min=10): - raise HTTPException( - status_code=429, - detail="Limite de envio de imagens por minuto excedido (máximo 10/min).", - ) + if image_b64 and not await _check_vision_rate_limit(user_id, max_per_min=10): + raise HTTPException( + status_code=429, + detail="Limite de envio de imagens por minuto excedido (máximo 10/min).", + ) async def event_generator(): - yield _sse( - {"type": "meta", "data": {"thread_id": thread_id, "model": chosen_model}} - ) + yield _sse({"type": "meta", "data": {"thread_id": thread_id, "model": chosen_model}}) agent_message = message or ( - "Analise a imagem anexada." - if image_b64 - else "Analise o(s) documento(s) anexado(s)." + "Analise a imagem anexada." if image_b64 else "Analise o(s) documento(s) anexado(s)." ) # ── Documentos: monta system context ── @@ -1732,9 +1771,10 @@ async def event_generator(): try: if _settings and _settings.groq_vision_enabled: yield _sse({"type": "status", "data": "vision:descrevendo imagem…"}) - from agent.vision_attach import describe_image_via_sdk from nullain.errors import VisionError + from agent.vision_attach import describe_image_via_sdk + description = await describe_image_via_sdk( _settings, image_b64=image_b64, @@ -1743,9 +1783,11 @@ async def event_generator(): ) except VisionError as exc: from agent.security import safe_error_message as _safe + print(f"⚠️ Vision provider (SDK) falhou: {_safe(exc)}") except Exception as exc: # noqa: BLE001 from agent.security import safe_error_message as _safe + print(f"⚠️ Vision provider (SDK) falhou (erro inesperado): {_safe(exc)}") if description: @@ -1787,7 +1829,9 @@ async def event_generator(): ) -async def _stream_with_keepalive(events_aiter: Any, interval_sec: float = 10.0) -> Any: +async def _stream_with_keepalive( + events_aiter: Any, interval_sec: float = 10.0 +) -> AsyncGenerator[str, None]: """ Empacota o stream de eventos assíncronos emitindo `: ping\n\n` a cada `interval_sec` segundos quando nenhum evento for gerado, @@ -1848,7 +1892,10 @@ def main() -> None: if str(root) not in sys.path: sys.path.insert(0, str(root)) - host = os.getenv("HOST", "0.0.0.0") + host = os.getenv("HOST", "0.0.0.0") # noqa: S104 — this is how the app + # is meant to run: always inside a container (Dockerfile CMD invokes + # this), where 0.0.0.0 is the correct bind address, not a host-network + # exposure mistake. port = int(os.getenv("PORT", "8000")) # forwarded_allow_ips: quem pode setar X-Forwarded-* que o uvicorn confia.