Seguridad¶
Esta página es lo que se entrega a una revisión de seguridad con forma de HIPAA o SOC 2: las fronteras de confianza, qué datos salen del deployment y hacia quién, qué se cifra y dónde, y una matriz de controles que nombra — para cada control — el mecanismo de este código que lo satisface y el test que lo sostiene.
Describe lo que hay, no lo que estaría bien tener. Una fila sin mecanismo lo
dice y enlaza la issue que lo construiría. Para cómo informar de una
vulnerabilidad y la lista de endurecimiento para producción, consulta
SECURITY.md en
la raíz del repositorio; esta página es todo lo demás, en una sola copia.
Dos páginas vecinas responden a las preguntas que una revisión hace a continuación y no se repiten aquí: Protección de datos para dónde viven los datos personales, qué alcanza realmente un borrado y qué huecos siguen abiertos, y Licencias para cada componente de terceros que envían las imágenes.
Modelo de amenazas¶
La plataforma es autoalojada y multi-tenant. La suposición de diseño es que la infraestructura del operador es de confianza y que cada petición que entra en ella no lo es, así que las fronteras que importan son las que una petición cruza de camino a los datos.
| Frontera | Qué la cruza | ¿De confianza al otro lado? |
|---|---|---|
| Navegador → BFF (los route handlers de Next.js) | Una cookie de sesión, la cabecera de organización, la entrada del formulario | No — pero el BFF no verifica la cookie: lee el agenticos_access_token httpOnly y lo reenvía como cabecera bearer (frontend/src/lib/platform-proxy.ts). Es una frontera que reenvía una credencial; la verificación es trabajo de la API |
| BFF → API (FastAPI) | Un JWT ligado a una sesión en la base de datos, la cabecera X-Organization-Id |
No — el token se verifica en cada petición, la sesión se comprueba por si fue revocada y la organización se resuelve desde el token |
| API → PostgreSQL / Redis | Consultas y lecturas de caché, sobre TLS cuando está configurado | Sí — el almacén es del operador; lo que protege en reposo está bajo «Qué se cifra y dónde» |
| API / worker → proveedores de modelos, canales, servidores MCP, proveedores de búsqueda, Logfire | Prompts, llamadas a herramientas, consultas, respuestas, trazas | No — son terceros; lo que les llega es una decisión por agent, salvo el tracing a nivel de deployment (abajo) |
| Navegador ↔ proveedor realtime (una llamada de voz sobre WebRTC) | El audio del micrófono de quien llama y la respuesta hablada del agent | No — el navegador no tiene ninguna credencial del proveedor: el servidor responde a su oferta SDP, una oferta que abre un canal de datos se rechaza y nunca se emite un client secret. Las herramientas, las aprobaciones y las transcripciones corren en el sideband del servidor |
| Worker → conectores (Google Drive, S3, …) | Credenciales desselladas del vault, documentos descargados | No — la credencial de un conector es un secreto del vault referenciado por id |
| Un visual o una app → la consola | Una petición para abrir un enlace o para enviar un mensaje de seguimiento en nombre de la persona | No — la página es script escrito por el modelo que puede haber leído contenido hostil. Un enlace se abre y un seguimiento se envía solo después de que una persona lo lea y haga clic, en el propio DOM de la consola; un seguimiento sin interacción del usuario detrás se descarta |
Worker → sitios web públicos (el conector web) |
Peticiones GET de páginas, sitemaps y robots.txt; sin credencial | No — la URL de inicio la escribe un tenant y cada enlace posterior lo elige el sitio, así que cada petición y cada redirección pasan la comprobación SSRF, se conectan a la dirección comprobada (app/core/pinned_http.py) y se quedan en un único host |
La autoridad dentro de un tenant nunca es un nombre de rol en una ruta: es una
fila de membresía más el catálogo de permisos (app/core/permissions.py),
resuelto por recurso. Dos llamantes con el mismo rol pueden alcanzar filas
distintas, porque un grant sobre un recurso amplía lo que el rol permite sin
ascender al miembro.
Qué sale del deployment¶
Nada llama a casa. Cada llamada saliente es una que el deployment configuró, y cada una es una frontera por la que preguntará la revisión de un cliente.
| Hacia | Qué | Cuándo |
|---|---|---|
| El proveedor de modelos configurado | El prompt, la salida del modelo, los argumentos y resultados de las herramientas | Cada run — salvo que el modelo corra en la infraestructura del propio operador, en cuyo caso no sale nada |
| El canal configurado (Slack, Teams, Telegram, Mattermost) | Las respuestas generadas por el agent — texto, imágenes y adjuntos | Siempre que un agent esté expuesto por ese canal; cada send_message publica en el proveedor (app/services/channels/) |
| Logfire | Trazas, que llevan prompts y salidas salvo que el agent diga otra cosa | Dos caminos independientes. Un token de observabilidad por agent traza ese agent, y su modo content decide cuánto lleva el span - none lo reduce a tiempo, tokens, coste y nombres de herramienta (#1413). Un LOGFIRE_TOKEN a nivel de deployment instrumenta todos los runs, tanto en la API como en el worker (app/core/logfire_setup.py), así que con él puesto sale el contenido de todo agent que no haya pedido none; el que sí lo pidió queda fijado a una instrumentación sin contenido también en ese tracer (suppress_content), de modo que el modo se sostiene en ambos caminos, y un especialista de ese agent lo hereda - escrito inline o inventado a mitad del run. Un hueco que no cubre: una fijación que falla, que se registra y se deja. Ninguno de los dos caminos está activo por defecto. No hay término medio filtrado a propósito - una exportación parcialmente depurada es una garantía que nadie puede auditar (#1616) |
| Servidores MCP | Llamadas a herramientas y sus argumentos | Solo para las herramientas a las que el agent está ligado |
| Un proveedor de búsqueda web (Tavily, DuckDuckGo) | La consulta de búsqueda | Solo cuando se concede la capability de búsqueda |
| Un proveedor de embeddings | El texto del documento, en la ingesta | Solo para una base de conocimiento cuyo proveedor sea remoto |
El servidor de teselas que nombra VISUAL_MAP_TILES |
Desde este despliegue, nunca desde una página: las coordenadas de cada tesela que muestra un mapa y que aún no está en caché | Solo cuando el ajuste está activo y alguien abre un mapa. Una página envía al despliegue tres números, cada uno comprobado contra su zoom; la dirección y la clave del servidor se quedan en el servidor (Apps) |
| Un proveedor de voz realtime (OpenAI, Azure OpenAI, Google, xAI) | Durante una llamada de voz: el audio de quien llama, las instrucciones del agent, las definiciones y los resultados de las herramientas, y los turnos anteriores de la conversación como texto cuando una llamada empieza o se reconecta | Solo para un agent cuya sección Voice nombra ese proveedor, mientras haya una llamada abierta. Aquí no se guarda audio; lo que conserva el proveedor lo rige tu acuerdo con él |
| Un proveedor de speech-to-text | Una grabación: una nota de voz de un canal, un dictado en /chat, o la de un visitante en una página alojada o un widget en server |
Cada vez que se transcribe una. El dictado de un visitante es anónimo y queda fuera de todo budget de modelo, así que lo que lo limita es el rate limit del enlace por el dictado más largo. Por defecto un dictado es PCM en crudo cuyas muestras se cuentan y que se rechaza por encima del límite antes de enviarlo, así que ese límite es exacto; un despliegue que acepta audio comprimido lee la duración de la cabecera del archivo, que un archivo hecho a mano puede declarar por debajo - canales calcula el peor caso en ambos casos. Con browser, el navegador del visitante envía el audio a su propio fabricante en su lugar, y nada pasa por aquí |
Qué se cifra y dónde¶
Hay un mecanismo de cifrado a nivel de aplicación, y es deliberadamente el único:
el vault (app/core/vault.py). Cada credencial de conector y de API en reposo
está sellada en un sobre por propietario cuya clave de envoltura se deriva, por
HKDF, de la organización (o el usuario) a la que pertenece — de modo que un texto
cifrado copiado a la fila de otra organización no se puede desellar. La clave
maestra es rotable sin volver a cifrar los payloads.
No todo lo que la plataforma almacena es una credencial del vault, y esto se dice sin rodeos porque una revisión lo va a encontrar:
- Tokens bearer de vida corta — invitaciones a organizaciones
(
OrganizationInvitation.token), solicitudes de vinculación de canal (ChannelLinkRequest.token) y enlaces de conversación compartida (ConversationShare.share_token) — son columnasString(64)aleatorias buscadas por igualdad, no selladas en el vault. Quien tiene el valor puede usarlo, así que los protege la caducidad y el uso único, no el cifrado. Los refresh tokens de sesión son la excepción que sí se hashea en reposo (sessions.refresh_token_hash). - Los archivos subidos y los del chat están donde los ponga
FILE_STORAGE_BACKEND(app/services/file_storage.py). Conlocal, el valor por defecto, eso es en claro en el sistema de archivos del contenedor de la API, protegido solo por el cifrado del volumen. Cons3son objetos en un bucket que nombra el despliegue, y lo que pide cada escritura lo decideFILE_STORAGE_S3_ENCRYPTION:sse-s3por defecto osse-kmsbajo una clave que controla el cliente — onone, que no envía ninguna cabecera de cifrado y existe para un almacén compatible con S3 sin KMS detrás. Connonelos objetos están tan cifrados como los cifre ese almacén, y nada más.agenticos cmd doctorindica en qué backend está un despliegue en marcha. - Los cuerpos de los mensajes,
rag_documentsy sus vectores, y los workspaces del sandbox se guardan como columnas en texto plano, filas de pgvector y archivos del workspace. El vault sella credenciales, no contenido; la protección en reposo de esto es a nivel de disco.
El backend S3 es una elección del momento del despliegue y no migra lo que el local ya tiene; los ajustes están en la configuración y el razonamiento en procesamiento de archivos.
Qué se guarda sobre una persona, y qué ocurre con ello¶
El art. 15 del RGPD pregunta qué guardas sobre alguien y el art. 17 pide retirarlo. A ambos responde esta tabla, a propósito: un inventario que enumera una tabla para la exportación y la olvida para el borrado es peor que ninguno, porque se lee como completo.
La línea que traza es sobre alguien frente a creado por alguien. Lo que es sobre una persona se va con ella; lo que creó para la organización — un agent sobre el que trabaja el equipo, una base de conocimiento, una credencial guardada — se traspasa, porque borrar la cuenta de un colega no puede borrar el agent del que depende el equipo.
| Tabla | Al borrar | En la exportación | Por qué |
|---|---|---|---|
users |
Se borra | El perfil, sin el hash de la contraseña | La cuenta misma |
conversations, messages, tool_calls |
Cascada, salvo los chats de un proyecto, que se quedan con el equipo | Los hilos que iniciaron, con cada turno; en el chat de un proyecto, dondequiera que escribieron, solo sus turnos y las respuestas a ellos | Suyos, y una transcripción sin un turno de cada dos no responde a nada. El chat de un proyecto es de varias personas, y los turnos de los demás miembros son suyos |
chat_files |
Cascada; los bytes se desvinculan tras el commit | No se enumera | La fila cascadea desde el mensaje y el fichero en disco no, así que las rutas se recogen antes del borrado y se desvinculan tras su commit; de otro modo los bytes sobreviven a la petición que pedía quitarlos: inalcanzables para nada y borrados por nadie. La transcripción del export nombra el adjunto; los bytes se descargan mientras la cuenta exista, no se empaquetan dentro |
message_ratings |
Cascada | Sí | Una opinión que expresaron |
sessions |
Cascada | Dispositivo, dirección y horas — nunca la credencial, que es un hash | Dónde iniciaron sesión |
api_keys, oauth_grants |
Cascada, junto con los códigos y tokens por los que se canjeó un consentimiento | Las claves que emitieron y los clientes a los que dejaron actuar en su nombre: nombre, prefijo, permisos y fechas — nunca una clave, un hash ni un token | Una credencial que actúa en su nombre es suya para verla; las claves propias del asistente que la plataforma emite por ejecución no se listan |
conversation_favourites, dashboard_layouts, dashboard_presets, user_slash_commands |
Cascada | Disposiciones y atajos | Ajustes personales, sin sentido para nadie más |
agent_memory_files (owner_key = person:<id>) |
Purgado explícitamente, bajo un bloqueo que también toma una escritura concurrente | Sí | Una clave de texto sin clave foránea: nada cascadeaba, así que cada nota sobrevivía a la cuenta — y una ejecución que escribiera durante el borrado la habría recreado |
agent_workspaces (scope = user) |
Purgado explícitamente | No | owner_ref es texto por la misma razón que owner_key, así que ninguna cascada lo sigue, y un workspace respaldado por estado guarda los propios ficheros |
organization_members |
Cascada | A qué organizaciones pertenece, como qué y desde cuándo | Una fila que la nombra directamente y que un administrador ya ve |
channel_identities |
Purgado explícitamente | Sí | SET NULL dejaba la fila con un id de Slack, un nombre de usuario y un nombre visible de alguien que ya no está, vinculada a nadie |
agent_runs |
SET NULL — se conservan |
Runs que iniciaron y lo que costó cada uno | El gasto es el registro de la organización; un run anónimo sigue contando para el mes |
agents, knowledge_bases, skills, contexts, agent_triggers, agent_environments, agent_exposures, local_services |
SET NULL — se conservan |
No | Creados para la organización. Quitarlos se llevaría el trabajo del equipo con la persona |
organization_secrets |
SET NULL, y un secreto privado asciende a la organización |
No | Una credencial sobre la que corre la organización. El ascenso es lo que impide que un secreto sin dueño bloquee el borrado |
organizations |
La org personal se borra; una compartida que crearon pasa a otro propietario | No | Nadie puede quedarse con una organización sin propietario |
resource_grants |
Cascada en quien lo recibe; SET NULL en quien lo concedió |
No | Un permiso para ellos es suyo; uno que concedieron es el registro de la organización de quién puede qué |
app_admin_audit_logs |
Se conservan, con el id del actor | No | El registro de la organización de lo que se hizo en ella. Una entrada que nombra una cuenta borrada es honesta; una con el actor retirado es peor que inútil, y no es de la persona para llevársela |
embed_visitors |
Sin tocar | No | Un visitante es una clave de navegador, nunca una cuenta — aquí no hay nada sobre una persona con login |
Lo que el borrado no alcanza — y lo dice la documentación, no el despliegue al descubrirlo
Un almacén de memoria externo configurado. mem0 guarda lo que un agent
recordó en el sistema de un tercero, y este despliegue solo puede pedir que
lo olvide mientras tenga la credencial de la organización.
MemoryService.forget_person lo hace para una persona que sigue siendo
miembro; las notas de una cuenta borrada en un almacén externo son del
operador.
Las copias de seguridad. Un borrado quita filas de la base de datos viva. El calendario de copias que ejecute un despliegue las conserva hasta su rotación, y ningún borrado a nivel de aplicación cambia eso.
La retención propia de un proveedor de modelos. Lo que se envió para responder a un turno queda sujeto a la política del proveedor, que cubre modelos y que este despliegue no controla.
Autoservicio, y qué añade un administrador¶
Una persona no necesita un administrador para los casos ordinarios.
GET /me/data/export es toda la tabla anterior en un documento JSON, limitado por
hora y registrado en el rastro de auditoría — también cuando alguien se exporta a
sí mismo, porque una exportación tiene la forma de una fuga cuando quien llama no
es quien dice ser.
Se arma y se serializa entero, así que está acotado:
PERSONAL_DATA_EXPORT_MAX_CHARS (16 millones de caracteres por defecto) es
cuánto texto de conversación puede llevar un documento, y una cuenta que tiene
más recibe un rechazo con ambas cifras en lugar de un documento silenciosamente
parcial. Producir ese es tarea del operador, desde la base de datos. DELETE /conversations/{id} quita un hilo, sus turnos y los
ficheros que llegaron con ellos, comprobado contra la propiedad de quien llama
(FA-015).
Un administrador añade dos cosas y ambas se auditan:
GET /admin/users/{id}/export, que exige un motivo — leer todo el historial
de conversaciones de un colega es legítimo unas dos veces al año y serio cada vez
— y DELETE /admin/users/{id}, donde el motivo es opcional porque una salida
ordinaria no suele tener nada que explicar.
Matriz de controles¶
Una fila por control, el mecanismo que lo satisface y el test que lo sostiene. Encuadrado frente a las salvaguardas técnicas de HIPAA §164.312 y SOC 2 CC6–CC8.
Control de acceso · HIPAA §164.312(a) · SOC 2 CC6¶
| Control | Mecanismo | Sostenido por |
|---|---|---|
| Aislamiento entre tenants, incluso cuando el llamante es dueño de la fila | resolve_access rechaza un recurso cuyo organization_id difiere antes de comprobar la propiedad (app/services/access.py) |
test_resource_access.py::TestTenantBoundary, test_conversation_tenant_isolation.py, test_platform_flows.py |
| Un permiso en cada ruta de colección | Dependencia de ruta require(*perms) en las rutas de listado, creación y catálogo (app/api/deps.py), catálogo en app/core/permissions.py |
test_platform_routes.py::TestEachRouteDemandsItsOwnPermission |
| Las rutas por recurso autorizan en el servicio, no en la ruta | Una ruta que actúa sobre un agent, un skill o una colección no lleva gate require() — un gate de rol rechazaría al portador de un grant antes de que el grant se aplicara — y en su lugar llama a resolve_access (app/services/access.py) |
test_platform_routes.py::TestEveryPlatformRouteIsGuarded (toda ruta está gateada o la decide el servicio) |
| Un grant amplía el acceso sin ascender al miembro | El resolve_access por recurso toma max(alcance del rol, grant) (app/services/access.py) |
test_resource_access.py::TestGrantsWidenAccess, ::TestPermissionsGrantsCannotWiden |
| Una mención en un canal se ejecuta como el remitente, no como el bot | Se usa el AuthContext propio de un remitente vinculado y activo (app/services/channels/mentions.py) |
test_channel_mentions.py::TestAnswer::test_the_run_carries_the_senders_own_role |
Autenticación · HIPAA §164.312(d) · SOC 2 CC6¶
| Control | Mecanismo | Sostenido por |
|---|---|---|
| JWT (HS256), contraseñas con bcrypt | app/core/security.py — verify_token, get_password_hash |
test_security.py, test_auth.py |
| Claves de API comparadas en tiempo constante | secrets.compare_digest (app/api/deps.py) |
test_auth.py, las comprobaciones HMAC de webhooks en los adaptadores de canal |
| Sesiones en base de datos con revocación | Tabla sessions + SessionService; token ligado a un claim sid (app/services/session.py, app/api/routes/v1/sessions.py) |
test_session_verify.py, test_session_revocation.py |
| Inicio de sesión único contra el propio proveedor de identidad del despliegue | OIDC genérico por discovery — authorization code con PKCE, email_verified obligatorio, la cuenta indexada por sub (app/core/oauth.py, app/api/routes/v1/oauth.py). Entra ID, Okta, Keycloak; se configura en Inicio de sesión único |
test_oidc_sign_in.py |
| La política de registro controla el SSO igual que el formulario | check_may_register dentro de get_or_create_oauth_user — invite_only y la lista de dominios permitidos también rechazan un acceso por proveedor (app/services/user.py) |
test_oidc_sign_in.py::TestTheRoundTrip, test_signup_policy.py |
| Inicio de sesión con una cuenta del directorio | El bind LDAP estándar en dos pasos - buscar como la cuenta de servicio y luego hacer bind como la cuenta con la contraseña tecleada; una contraseña vacía nunca llega a un bind, el nombre de usuario se escapa al insertarlo en el filtro, dos cuentas coincidentes son un rechazo, TLS se verifica y ldap:// sin StartTLS se rechaza al arrancar (app/services/directory/ldap_directory.py). Véase Inicio de sesión con el directorio |
test_ldap_directory.py, test_directory_settings.py |
| Inicio de sesión integrado de Windows | SPNEGO con el keytab de este servicio; solo se acepta un ticket aceptado en un único paso, y su principal se resuelve a través del directorio a la misma cuenta a la que llega la contraseña (app/services/directory/kerberos.py, app/api/routes/v1/directory_auth.py) |
test_kerberos_acceptor.py, test_directory_auth_routes.py |
| Los grupos del directorio deciden las membresías | Mapeos de grupos del directorio, aplicados en cada inicio de sesión por LDAP, Kerberos u OIDC. La sincronización reescribe solo las membresías que ella misma creó, nunca degrada ni elimina a un propietario (owner), nunca incorpora a nadie a una organización personal y no puede mapear un rol que su autor no podría asignar; un exceso de grupos (group overage) de Entra ID se rechaza (app/services/directory/sync.py, app/services/directory/mappings.py, groups_claim en app/core/oauth.py) |
tests/integration/test_directory_groups.py, test_oidc_groups.py |
| Concesiones a grupos | Una concesión tiene exactamente un sujeto, una persona o un grupo (ck_resource_grant_one_subject); una concesión a un grupo alcanza a quien esté en el grupo cuando se comprueba el acceso, y solo a través de grupos de la misma organización (app/repositories/resource_grant.py) |
tests/integration/test_directory_groups.py, test_resource_grant_repo.py |
| SAML, SCIM, una nueva comprobación del directorio entre inicios de sesión | Todavía no — la sesión abierta de una cuenta desactivada en el directorio dura hasta que caduca su refresh token, salvo que un administrador la desactive. Véase Lo que esto todavía no hace | — |
| Límite de peticiones en el login | enforce_auth_limit (app/api/deps.py) |
test_auth_rate_limit.py |
| Una dirección de correo cambiada se demuestra antes de que el correo la siga | PATCH /users/me deja la dirección en users.pending_email y le envía un enlace de un solo uso válido una hora; hasta que ese enlace vuelve, la cuenta sigue recibiéndolo todo en su dirección actual, y esa dirección recibe aviso de que se ha pedido un cambio. El enlace lleva la versión de credenciales de la cuenta, así que cambiar o restablecer la contraseña — lo que ese aviso pide hacer — lo revoca, y que quien administra repare la dirección borra el cambio pendiente. Volver a pedir la dirección ya pendiente no envía nada, y el número de direcciones distintas por cuenta y hora está limitado. Tanto la petición como la confirmación quedan auditadas (app/services/user.py, POST /auth/email-change/confirm) |
test_email_change.py |
| Un refresh token reproducido termina su cadena y queda registrado | La rotación conserva el hash que sustituyó; un refresh que coincida con él es el caso de reutilización de la RFC 6819 §5.2.2.3 y cierra esa sesión con una entrada de auditoría (SessionService.detect_refresh_reuse). Un token presentado dentro de REFRESH_REUSE_GRACE_SECONDS (60 por defecto) desde su rotación es una respuesta perdida o una petición de una ráfaga con la misma cookie, no una reproducción, y recibe el sucesor que la fila ya guarda, reconstruido a partir del token gastado, de modo que cada petición de la ráfaga obtiene el mismo token (SessionService.claim_refresh_grace, reissue_within_grace); esa ventana es el coste aceptado |
test_session_revocation.py::TestReusingASpentRefreshToken, TestTheReuseGraceWindow |
| Claves de API de la organización | La autoridad de un miembro, limitada a los permisos con los que se emitió la clave y releída de la membresía del emisor en cada petición; SHA-256 en reposo, se muestra una vez, caducidad opcional, revocable, limitada por clave, aceptada solo en rutas públicas y nombrada por prefijo en las entradas de auditoría de la petición (app/services/api_key.py, app/api/public_api.py) |
tests/integration/test_api_keys.py, test_api_key_service.py |
| OAuth 2.1 para clientes MCP | Solo clientes públicos, PKCE (S256) obligatorio, redirect URI comparada exactamente; el consentimiento en la consola rechaza una sesión de suplantación y los permisos que el miembro no tiene; los códigos son de un solo uso, los refresh tokens rotan, y reutilizar uno revoca la concesión con cada token emitido; un token se consume en una única actualización condicional, así que dos intercambios simultáneos no pueden emitir ambos; el registro abierto se limita por dirección y los clientes sin usar se eliminan al cabo de un día (app/services/oauth_server.py, app/services/platform_mcp/_oauth.py) |
tests/integration/test_mcp_oauth.py |
| Eventos de cambios en vivo | Publicados solo después de que una escritura a través de la API pública haya tenido éxito y se haya confirmado; el acceso de cada suscriptor se vuelve a leer en cada evento, así que una fila que no puede leer, el cambio de otra organización o la eliminación de un tipo que su rol no ve completo nunca le llegan, y un socket cuya sesión, clave o membresía terminó se cierra (app/services/change_feed.py) |
tests/integration/test_change_feed.py |
Controles de auditoría · HIPAA §164.312(b) · SOC 2 CC7¶
| Control | Mecanismo | Sostenido por |
|---|---|---|
| Las mutaciones relevantes para la governance quedan registradas, dentro de la transacción de la petición | record_audit (app/core/audit.py) en el servicio que muta — rotación de secretos, vinculación de skill / sincronización / MCP, membresía, compartición, aprobaciones, exportaciones y más; escrito en app_admin_audit_logs. No es cobertura general de toda escritura (el CRUD de la base de conocimiento, por ejemplo, no se audita) |
test_skill_binding_audit.py, test_sync_source_audit.py |
| El rastro es legible por un auditor | GET /audit, gateado en audit:read (app/services/audit.py) |
test_audit_service.py |
| Exportar el rastro (CSV/JSONL) | GET /audit/export sobre una ventana, con puerta en audit:read, registrando su propia lectura en el rastro; las exportaciones de runs, aprobaciones y gasto hacen lo mismo (#1422) |
test_exporting.py (la exportación y su propia entrada de auditoría) |
| Un periodo de auditoría que una organización puede alargar y nunca acortar | Un suelo de todo el despliegue (seis años por defecto, HIPAA §164.316(b)(2)); un periodo más corto se rechaza en vez de subirse. El barrido no borra entradas de auditoría: la cadena de hashes y su checkpoint se apoyan en que las entradas se quedan, así que una entrada no se puede retirar sin romper la evidencia de que el resto está intacto (app/core/retention.py). Véase Retención |
test_retention.py::TestWhichNumberWins, ::test_audit_resolves_to_a_period_and_is_still_not_swept |
| Una persona puede leer y borrar sus propios datos | GET /me/data/export (limitado por hora, auditado incluso cuando es la propia persona) y DELETE /conversations/{id} acotado a la propiedad de quien llama; la exportación de un administrador exige un motivo (app/services/personal_data.py) |
test_personal_data.py |
| Evidencia de manipulación (una cadena de hashes) | Cada entrada se suma a la cadena de hashes de su organización, y cada cadena lleva un checkpoint en su marca más alta, así que una entrada reescrita, una cola truncada y una cadena borrada se detectan por separado; agenticos cmd audit-verify las recorre y sale con código distinto de cero ante una rotura (app/core/audit.py, app/commands/audit_verify.py). Detección, no prevención: quien tenga las credenciales de la propia base de datos puede reforjar una cadena o quitar el trigger del checkpoint |
test_audit_record.py, test_audit_chain_backfill.py, test_audit_verify_command.py, test_audit_checkpoint_migration.py |
Integridad · HIPAA §164.312(c) · SOC 2 CC8 (gestión del cambio)¶
| Control | Mecanismo | Sostenido por |
|---|---|---|
| Un spec se rechaza al publicar, nunca en tiempo de ejecución | validate_spec (app/services/agent_registry.py) — capability desconocida, scope no concedido, secret_id de tipo equivocado o de otra organización, una conexión MCP personal |
test_agent_registry.py, test_capability_secrets.py::TestPublishValidation |
| El budget se comprueba antes de la petición al modelo, y el coste se registra incluso si falla | BudgetGuard.wrap_model_request gatea antes de la llamada (app/agents/capabilities/budget/); el coste del run se escribe en un finally terminal (app/services/agent_runner.py) |
test_spend.py::TestBudgetGuard, test_agent_runner.py::…::test_a_failed_run_still_records_its_cost |
| Una aprobación se decide exactamente una vez | ApprovalService.decide rechaza una fila que no esté pendiente leída for_update (app/services/approvals.py) |
test_approvals_queue.py::TestDecidingTwiceIsRefused |
| Una aprobación en una llamada de voz ejecuta exactamente los argumentos mostrados, una vez, y solo quien llama la decide | El puente de la llamada escribe la fila, muestra los argumentos exactos, espera 60 s y concede por id de llamada a herramienta (app/services/voice/approvals.py); la ruta de la cola rechaza las filas de una llamada en curso |
test_voice_approvals.py::test_an_approval_grants_exactly_the_shown_arguments_once, tests/integration/test_voice_approvals_queue.py |
| Una llamada de voz se detiene cuando se pierde el acceso, y se cuelga en cada salida | Cada heartbeat de 15 segundos vuelve a comprobar la sesión, la membresía, el estado publicado del agent y el interruptor de la organización; cada salida pasa por un único teardown protegido que registra el fin de la llamada y después cuelga la llamada del proveedor; un barrido cada minuto y al arrancar cuelga las llamadas cuyo heartbeat quedó obsoleto (app/services/voice/session.py, app/services/voice/reaper.py) |
test_voice_session.py::test_a_call_stops_being_allowed_when_access_goes, ::test_every_exit_path_hangs_up_the_provider_call, test_voice_reaper.py::test_a_call_whose_heartbeat_stopped_is_hung_up_and_its_run_closed |
| El análisis estático alcanza el cambio que introduce el hallazgo | CodeQL (security-extended) en cada pull request para Python, JavaScript/TypeScript, Rust y los workflows, más una pasada completa semanal (.github/workflows/codeql.yml). El merge lo rechaza la protección de merge por code scanning en el ruleset de main, no el estado del propio job — véase ramas |
test_codeql_workflow.py |
| Una dependencia con una vulnerabilidad conocida hace fallar el pull request | make audit sobre backend/uv.lock y make audit-frontend sobre frontend/bun.lock, ambos en el trabajo Security Scan y en make check |
test_ci_parity.py |
| Lo que contiene una versión puede leerse sin compilarla | Un SBOM CycloneDX por imagen, generado desde el manifiesto publicado y adjuntado a la release; el inventario de componentes es el índice legible | test_images_workflow.py::TestTheReleaseCarriesAnInventory |
Confidencialidad de las credenciales · HIPAA §164.312(a)(2)(iv)¶
| Control | Mecanismo | Sostenido por |
|---|---|---|
| Ningún secreto en claro en una respuesta de la API ni en una entrada de auditoría | SealedStr/CredentialStr enmascaran cada repr; las pistas son solo los últimos 4 caracteres (app/core/secret_kinds.py, app/core/vault.py) |
test_no_secret_escapes.py (barre toda la superficie de OpenAPI), test_capability_secrets.py::TestInjection |
| Los logs no forman parte de esa garantía | Una respuesta de token MCP OAuth mal formada llega a los logs a través de un ValidationError de Pydantic que repite su entrada — un hueco conocido, #1626 |
test_mcp_connections.py::test_an_unreadable_token_response_does_not_echo_its_input (documenta que el token acaba en caplog) |
| Enlaces firmados en los logs de acceso | El filtro de redacción cubre, además de los logs de la aplicación, los logs de acceso y de errores de uvicorn (app/core/logging.py), así que un ?token= firmado - el de un socket embed o el de un enlace a medios generados - se escribe redactado allí; el nginx/nginx.conf incluido registra las rutas sin su query string; el log de acceso de Traefik (traefik/traefik.yml), de otro proxy inverso o del servidor del frontend queda fuera de la aplicación, registra esos tokens de la consulta en claro y debe guardarse y compartirse como un log que contiene credenciales vigentes de corta duración |
test_logging.py::test_a_signed_link_in_the_access_log_is_redacted |
| Ninguna credencial del proveedor llega al navegador de una llamada de voz | El servidor responde a la oferta SDP y mantiene el sideband; una oferta con canal de datos se rechaza; los client secrets nunca se emiten; la clave se desella para una llamada, y para colgar solo a través de OrganizationSecretService.voice_call_key, cuyo texto en claro va al endpoint de colgado del proveedor y a ningún otro sitio (app/services/voice/transport.py, app/services/voice/hangup.py) |
test_voice_session.py::test_no_frame_carries_the_provider_key, test_voice_transport.py::test_an_offer_with_a_data_channel_is_refused |
| Una credencial está ligada a su organización en reposo | Sobre HKDF por propietario (app/core/vault.py); el alcance son las credenciales de conectores y de API — para los tokens bearer que no cubre, ver «Qué se cifra y dónde» |
test_secret_tenant_isolation.py, test_vault.py |
Seguridad en la transmisión · HIPAA §164.312(e) · SOC 2 CC6¶
| Control | Mecanismo | Sostenido por |
|---|---|---|
| TLS hacia PostgreSQL y Redis | POSTGRES_SSLMODE, REDIS_SSL (app/core/config.py); doctor informa del estado en vivo de Postgres desde pg_stat_ssl |
Postgres, sobre una conexión real: test_store_tls.py; Redis, al construir la URL y en doctor: test_config.py, test_doctor_sandbox.py |
| Cabeceras de framing y MIME en cada respuesta; CSP en todas salvo los endpoints de la referencia de la API | SecurityHeadersMiddleware (app/core/middleware.py), cuyos exclude_paths quitan la CSP — no el framing ni el MIME — para OpenAPI, Swagger y ReDoc; más la CSP propia del frontend por deployment (frontend/src/middleware.ts), cuyo script-src lleva un nonce por petición y 'strict-dynamic' en vez de 'unsafe-inline' |
test_security_headers.py, incluido test_an_excluded_path_keeps_its_framing_but_drops_the_csp; csp.test.ts, middleware.test.ts |
| HTTPS y HSTS | Terminados en el reverse proxy — el nginx/nginx.conf incluido pone HSTS; la aplicación no, por diseño |
Asunto del deployment; ver la lista de endurecimiento |
| Las páginas escritas por agents no pueden alcanzar la consola | Una aplicación publicada se sirve desde una ruta sin cookies tras un token firmado de vida corta, bajo Content-Security-Policy: sandbox sin allow-same-origin — un origen opaco — ni allow-popups, más connect-src 'none', ninguna fuente remota salvo la ruta de bibliotecas del propio despliegue, y frame-ancestors nombrando solo la consola (y, para una página pública que se puede incrustar, el documento de incrustación y los sitios listados); un enlace de la página se abre solo después de que una persona confirme su dirección, y un seguimiento que pide se envía solo después de que una persona lo lea y haga clic en Enviar; la única excepción para imágenes es la propia ruta de teselas del despliegue, que toma tres números comprobados y trae la tesela del servidor que nombra VISUAL_MAP_TILES, así que una página nunca llega a ese servidor; opcionalmente desde su propio dominio registrable (ARTIFACT_ORIGIN) |
test_artifact_routes.py::TestTheContentRoute, test_artifact_service.py::TestThePolicy, test_artifact_pages.py, test_map_tiles.py, test_tile_proxy.py, artifacts.test.tsx, visual-frame.test.tsx, visual-panel.test.tsx, csp.test.ts |
| Límites de peticiones en las superficies públicas | Límites sobre Redis en la API de runs, el widget embed, las páginas alojadas, los enlaces públicos de aplicaciones y sus direcciones de contenido firmadas, y los enlaces firmados a medios generados que recibe el visitante de una página alojada, contados por enlace (app/services/rate_limit.py); límites por remitente en los bots de canal (app/services/channels/router.py) |
test_rate_limited_surfaces.py; el límite del bot de canal está implementado, pero poco testeado |
| Un enlace de embed filtrado acota lo que pueden costar las llamadas | Los inicios de llamada de voz se limitan por dirección, por enlace y por sujeto jwt, como mucho hay VOICE_MAX_CALLS_PER_EMBED llamadas abiertas en un embed, y cada una está acotada por el tope de llamada y el budget (app/services/rate_limit.py, app/services/voice/starter.py) |
test_rate_limit.py, test_voice_starter.py, tests/integration/test_voice_call_slot.py |
| Un dictado cuesta como mucho su duración máxima | Por defecto solo WAV PCM de 16 bits, mono y 16 kHz: leído de forma estricta, con sus muestras contadas y rechazado por encima de dictation_max_seconds antes de llamar al proveedor, y enviado tras una cabecera que construye el servidor (app/services/ml/pcm_wav.py, MLService.transcribe_dictation). WebM, Ogg y MP4 solo cuando un app admin activa dictation_compressed_audio, tras un diálogo que nombra el riesgo |
test_pcm_wav.py, test_ml_service.py, dictation-audio-card.integration.test.tsx |
Los rechazos como conjunto¶
Los tests de rechazo de arriba llevan el marcador security. make test-security
ejecuta todo el conjunto, y CI publica la lista recogida como artefacto
security-tests.txt en cada ejecución del backend (#1417) — así los rechazos se
pueden contar y leer, en vez de darlos por supuestos. Un test cuyo nombre o módulo
mencione un tenant, un permiso, un budget, una aprobación, un secreto o texto en
claro y no lleve el marcador hace fallar tests/test_security_marker.py, que
mantiene la lista completa a medida que crece la suite.
El perfil HIPAA, y lo que no afirma¶
Una revisión de seguridad no pregunta «¿es este software conforme?». HHS no certifica software alguno y la OCR no reconoce certificaciones privadas. Pregunta ¿podemos ejecutar esto dentro de nuestro entorno conforme, y podéis demostrarlo? — y la respuesta es una configuración que viene con el producto más un comando que comprueba un despliegue en marcha contra ella (#1448).
Una fila por control, cada una nombrando el ajuste que lo satisface o el que no,
y salida distinta de cero ante cualquier fallo, para que corra en la CI del
propio cliente. La configuración es deploy/profiles/hipaa/: una superposición
de compose que se niega a arrancar sin los ajustes que no puede poner por
defecto, y un archivo env comentado.
Este párrafo se lee del tirón junto al perfil. Responde a las salvaguardas técnicas, §164.312, y solo a ellas. Las salvaguardas administrativas (§164.308 —análisis de riesgos, formación del personal, política sancionadora, plan de contingencia, acuerdos de business associate) y las físicas (§164.310) son de la operadora y lo seguirán siendo. Un perfil que insinuara otra cosa sería una afirmación que nadie puede sostener.
La hoja¶
| Control | Salvaguarda | Lo satisface |
|---|---|---|
postgres-tls |
§164.312(e)(1) | POSTGRES_SSLMODE=verify-full. require cifra y no verifica certificado alguno, así que el perfil no lo acepta |
redis-tls |
§164.312(e)(1) | REDIS_SSL=true. Ambos almacenes han de ser los tuyos: los db y redis incluidos no tienen listener TLS, así que la superposición se niega a arrancar sin POSTGRES_HOST y REDIS_HOST |
browser-tls |
§164.312(e)(1) | FRONTEND_URL y PUBLIC_BASE_URL en https, con tu proxy inverso terminándolo. Atestiguado, y una dirección http aquí se rechaza: cualquier otro control puede pasar mientras un inicio de sesión cruza la frontera del cliente en texto plano |
vault-key |
§164.312(a)(2)(iv) | Una clave maestra del vault de al menos 64 caracteres. HKDF deriva de cualquier cosa una clave de envoltura del tamaño correcto y no puede añadir entropía a un secreto adivinable |
content-at-rest |
§164.312(a)(2)(iv) | De la operadora. Los datos de Postgres, el volumen de medios y la raíz de workspaces del sandbox los cifra un volumen o un disco, no esta aplicación |
local-model |
§164.312(e)(1) | Todo perfil de modelo servido desde tu propia red. Se analiza el nombre de host —una dirección privada, localhost, un ollama/litellm/vllm a secas, o un nombre .internal/.local/.svc—, así que https://ollama.vendor.example no es local, y uno sin base_url es, por definición, la API pública del proveedor |
traces-local |
§164.312(e)(1) | LOGFIRE_TOKEN sin definir y ningún agente publicado ni entorno con nombre que lleve su propio token de trazas: cada uno engancha su exportador, y observability.content es full por defecto |
sso |
§164.312(d) | OIDC_ISSUER, configurado solo por discovery: el issuer es la única URL, y los endpoints de autorización, token y JWKS salen de su .well-known/openid-configuration - o KERBEROS_ENABLED, el inicio de sesión con el propio ticket del dominio. Sin ninguno de los dos, el control está incumplido y la hoja lo dice: con LDAP_URL solo, dice que las contraseñas son las del directorio pero se teclean aquí sin inicio de sesión único, y si no, que la gente entra con contraseñas que este despliegue guarda. La autenticación multifactor es del proveedor de identidad, y la hoja no se la atribuye en ningún sentido |
signup |
§164.312(a)(1) | invite_only o closed |
audit-retention |
§164.312(b) | Un suelo de auditoría de al menos 2190 días — los seis años de §164.316(b)(2) |
audit-chain |
§164.312(c)(1) | La cadena de hashes y su checkpoint. Detección, no prevención — véase Controles de auditoría |
Tres resultados, y el del medio pesa. ok y !! son respuestas de este código.
-- es un control que de verdad es de la operadora, nombrado en vez de
aprobado en silencio —una hoja que se saltara lo que no puede ver se leería como
completa y no lo estaría— y no hace fallar el comando, porque un control que
desde aquí nadie puede evidenciar es uno que nadie podría aprobar nunca.
Quién es la business associate¶
Una clienta que ejecuta esto en su propia infraestructura recibe software. Nadie de aquí toca su PHI y no hace falta acuerdo alguno. Un despliegue que otra parte opere para ella convierte a esa parte en business associate, y eso es un contrato, no una casilla de configuración.
Por qué el perfil usa por defecto un modelo local¶
Un modelo alojado se lleva el contenido de cada ejecución, así que usarlo implica
un acuerdo con ese proveedor — y esos acuerdos son más estrechos de lo que la
gente supone. Una organización con HIPAA habilitado en un proveedor grande suele
excluir la ejecución de código y la descarga web, que es exactamente la forma de
las capabilities sandbox, code_execution y web_fetch. Quien firma uno y
luego construye un agente sobre ellas se entera durante un incidente.
La inferencia local elimina la pregunta, y por eso es el valor por defecto del perfil y no una sugerencia.
Resumen¶
- Confía en la infraestructura del operador; no confíes en ninguna petición que entre en ella. Las fronteras que importan son navegador → BFF → API → almacén, y API/worker → terceros. El BFF reenvía la cookie de sesión; la API es donde se verifica una petición.
- Los únicos datos que salen son los que el deployment configuró para que
salieran — proveedores de modelos, canales, servidores MCP, proveedores de
búsqueda y de embeddings, y Logfire — que es opcional y, en cuanto se pone un
token a nivel de deployment, traza todos los runs que sirve el proceso de la
API, con el contenido de todo agent que no haya pedido
none. - Las credenciales de conectores y de API están selladas por organización en el
único vault; los tokens bearer de vida corta y el resto del contenido en reposo
(mensajes, RAG, sandboxes) no lo están. Los archivos subidos son lo que se ha
movido:
FILE_STORAGE_BACKEND=s3los pone en un almacén de objetos que cifra cada escritura. - Cada control de la matriz nombra un mecanismo y un test, y nombra sus huecos en la misma frase — la evidencia de manipulación enlaza la issue que la construiría.
- Informa de vulnerabilidades y ejecuta la lista de endurecimiento desde
SECURITY.md; lee Protección de datos y Licencias junto a esta página.