CreaRack-SL

Auditoría Suprema 2 · Cola config-ia: IA del workspace, drivers CNS y saneado de prompts

Cuándo

05-09-2026 · commit f5792359 (PR #506, v1.114.0). Cola MEDIA/BAJA del dominio config-ia en la Auditoría Suprema 2 (task #286): de 19 hallazgos del catálogo, 17 se cierran en este commit, 1 ya estaba cerrado en v1.112.0 (#16, whitelist Junos) y 1 se descarta tras verificación (#1). Implementado por un agente Opus en worktree con las decisiones fijadas por hallazgo; revisado, ajustado y verificado por Claude Fable 5.1 — el agente no pudo ejecutar nada por un fallo de aislamiento del harness, así que ruff, pytest y mypy los corrió la sesión principal sobre el worktree. 48 tests nuevos en tests/config_ia/test_cola_auditoria_config_ia.py.

Síntomas visibles

  1. La síntesis de IA del panel interno (informe de rendimiento, informe FinOps) devolvía la frase fija “AI analysis temporarily unavailable” como si fuera un veredicto real: nada distinguía un fallo del proveedor de un análisis genuino que dijera “todo va bien” (#5).
  2. Si un helper del informe de rendimiento o de costes fallaba a mitad de recogida, el except lo tragaba en silencio y el informe salía incompleto sin avisar (#3).
  3. Los dos SDK de la cadena de respaldo de IA (google-genai, Anthropic) no llevaban timeout: un proveedor colgado se llevaba consigo, sin límite de tiempo, la petición web que lo había invocado (#6).
  4. El log de fallo de esos mismos SDK podía arrastrar la URL con la API key o el cuerpo de la petición (#7).
  5. El bloque monitors.alerts del endpoint de métricas del workspace no reflejaba alertas reales (#8).
  6. Con Valkey caído, las llamadas de pago del workspace a Gemma corrían sin ningún tope de gasto (#11).
  7. Una clave o contraseña escrita al estilo de la consola de un equipo de red — enable secret 5 <hash>, username admin password 7 <hash>, snmp-server community <valor> — llegaba íntegra al LLM: el filtro de secretos solo cubría el formato clave=valor (#12).
  8. Una recomendación de la IA de diagnóstico marcada como riesgo ALTO, con comandos de escritura y sin comandos de vuelta atrás, se guardaba igual que una completa: la ausencia solo restaba 0,05 al score de validación (#13).
  9. config/wsgi.py apuntaba a un módulo de settings (config.settings.prod) que nunca existió: un arranque WSGI sin la variable de entorno puesta a mano reventaba con ModuleNotFoundError (#2).
  10. config/settings/production.py pisaba con 10 MB el límite de 100 MB que fija base.py para el tamaño de una petición sin ficheros — un cuerpo JSON/base64 grande pasaba en desarrollo y moría en producción con RequestDataTooBig (#4).

Causa raíz

  • AIOperations._call_ai convertía el fallo del proveedor en una cadena de texto en vez de propagar la excepción: el llamador no podía distinguir “no hay análisis” de “el análisis dice que todo va bien”.
  • Los recolectores del informe (_collect() y sus helpers) no separaban “no se pudo recoger esta parte” de “no había nada que recoger”.
  • Ningún cliente SDK de la cadena de respaldo llevaba timeout ni límite de reintentos, y sus mensajes de error se logueaban sin pasar por el redactor de secretos ya existente en el proyecto.
  • El filtro de secretos (redact_secrets) solo reconocía el formato clave: valor / clave=valor; la sintaxis de consola de red, con clave y valor separados por un espacio, quedaba fuera.
  • El validador de diagnósticos trataba la ausencia de rollback en una recomendación de riesgo ALTO como un aviso menor, no como motivo para invalidarla.
  • core/ratelimit.py::fixed_window_exceeded fallaba siempre abierto (deja pasar la petición) cuando el caché no respondía — correcto para topes de disponibilidad, pero no para un tope que protege gasto real de dinero.
  • config/wsgi.py y config/settings/production.py arrastraban un nombre de módulo que nunca existió y un valor que pisaba en silencio al de base.py, sin que nadie los hubiera revisado desde que se escribieron.

Fix aplicado

Commit f5792359 (PR #506):

  • AIOperations._call_ai propaga AIProviderError en vez de devolver una cadena; core/workspace_api.py la captura y responde con ai_analysis: null + ai_error de mensaje fijo, dejando el detalle real en el log redactado.
  • Los recolectores del informe de rendimiento/FinOps mueven su except a _collect(), que loguea el motivo redactado y añade partial: true + errors: [<helper>] al payload.
  • core/services/ai_providers/router.py: nueva constante AI_TIMEOUT_SECONDS = 30; Anthropic con timeout/max_retries=1, google-genai con HttpOptions(timeout=ms); los mensajes de error de ambos SDK pasan por redact_secrets antes de loguearse.
  • monitors.alerts del endpoint de métricas del workspace pasa a construirse desde AlertEvent sin resolved_at a nivel plataforma; el payload declara en unavailable las claves de API que Pro no puede medir.
  • Los prompts de AIOperations envuelven los datos con wrap_untrusted — misma defensa que ya tenían los prompts de CNS —; se retiran executive_summary y classify_incident, sin llamadores en el resto del repo.
  • core/ratelimit.py::fixed_window_exceeded admite el parámetro fail_open: bool; el cap de llamadas de pago del workspace lo pasa a False — con Valkey caído, el gasto se bloquea en vez de correr sin tope. El resto de llamadores del proyecto sigue fail-open.
  • monitoring/services/ai_providers/sanitize.py::redact_secrets añade tres patrones para el formato CLI con espacio: password|secret|psk|… <valor> (con el prefijo numérico de cifrado tipo Cisco opcional), tacacs-server key / radius key / isakmp key, y ntp authentication-key N md5 <valor>. La palabra key suelta se deja sin redactar a propósito, para no tapar frases como “key features”.
  • monitoring/services/ai_providers/diagnosis_validator.py: una recomendación HIGH con comandos y sin rollback pasa a errors (marca is_valid=False), resta 0,3 al score y vacía adjustments["commands"] — porque create_insight persiste el insight aunque no sea válido. MEDIUM sigue como aviso, pero con −0,15 en vez de −0,05.
  • Los modelos Pydantic de diagnóstico normalizan confidence_score a [0, 1] y risk_level a mayúsculas validado contra LOW/MEDIUM/HIGH; parse_llm_json cuenta solo comillas no escapadas al reparar JSON truncado; los drivers claude.py y ollama.py adoptan parse_llm_json con guard de forma, como ya tenía google_genai.py; gemini.py redacta sus logs y tolera response.text=None.
  • is_on_topic (filtro de temas del Tutor): los términos de 3 caracteres o menos casan por palabra completa vía regex precompilada, así que una frase como “el mapa del tesoro” deja de colar por contener “ap”.
  • config/wsgi.py apunta a config.settings.production (el módulo prod nunca existió); config/settings/production.py deja de redefinir DATA_UPLOAD_MAX_MEMORY_SIZE, así que prevalece el valor de 100 MB de base.py.

Lecciones

  • Un helper que “no puede fallar” — _call_ai devolviendo un string fijo en vez de propagar la excepción — es la forma más barata de que un fallo de proveedor externo se disfrace de veredicto: cuando la IA falla, el llamador necesita SABER que falló, no recibir una frase que parece una respuesta.
  • Un filtro de secretos escrito para un formato (clave=valor) no cubre gratis el formato hermano (clave valor) de otra fuente de datos; cada sintaxis de entrada nueva necesita su propio patrón.
  • Fail-open es la opción correcta por defecto porque prioriza disponibilidad, pero cualquier tope que protege GASTO real necesita fail-closed explícito — es una decisión por endpoint, no una política global.
  • Una constante de configuración que pisa a otra en un módulo distinto (production.py sobre base.py) queda invisible hasta que el límite real hace falta en producción; el mismo valor en un solo sitio evita la sorpresa.

Preventivos futuros

  • Descartado por un verificador: #1 (GUC de RLS en WebSocket/Huey) — falla cerrado y el rol ya lleva bypass explícito, no era una brecha real.
  • Ya resuelto antes de este PR: #16 (delete interfaces en la whitelist Junos, v1.112.0).
  • Límite honesto declarado en el propio CHANGELOG del commit: los patrones nuevos de redact_secrets usan \s+, que también casa un salto de línea — una palabra clave al final de una línea puede tapar la primera palabra de la siguiente.
  • Sin migraciones ni cambios en static/js en este commit.

Véase también

  • [[feature—config-ia—auditoria-suprema-etapa-3]]
  • [[decision—20260611—auditoria-suprema-etapa-3-cierre-findings-m1-b6-e]]
  • [[feature—monitoring—auditoria-suprema-sa3-cns-insights-ia]]
  • [[incident—20260904—auditoria-suprema-2-cola-monitoring-a-sondas-y-targets]]
  • [[incident—20260904—auditoria-suprema-2-cola-monitoring-d-deep-discovery-wireless-ups]]
  • [[incident—20260904—auditoria-suprema-2-cola-racks-libreria-backup-restore]]
  • [[incident—20260901—auditoria-suprema-2-tanda8-workspace-token-doctor-sin-acotar]]
  • [[entity—core—middleware—rate-limit]]
  • [[workspace—seguridad—auditoria-suprema-informe]]
  • [[concept—auditoria-suprema—plan-y-metodo]]