Arquitectura de seguridad multicapa para un asistente IA con datos PII
Llevo meses con un asistente de IA corriendo sobre infraestructura propia: notas, servidores, tareas, automatizaciones. Funciona bien. Demasiado bien, de hecho. Tan bien que llegó el momento de añadirle la agenda de contactos personales. Y ahí me detuve.
Un contacto no es una nota técnica. Tiene nombre, teléfono, dirección. Datos que no son solo míos — son también de las personas que me los dieron. Antes de seguir adelante decidí que el sistema tenía que garantizar algo concreto: el asistente solo puede acceder a esos datos cuando yo le doy permiso explícito, y en ningún otro momento.
Este artículo documenta las decisiones de arquitectura para conseguirlo. No es teoría — es lo que hay montado.
Stack: vault de notas Markdown + Meilisearch + scripts Bash/Python + GPG + KDE + LLM vía MCP
🎯 Modelo de amenaza
Antes de escribir una línea de código, definí los vectores reales a mitigar. No los teóricos — los que podían ocurrir con mi setup concreto:
| Amenaza | Vector | Severidad |
|---|---|---|
| El LLM lee contactos sin autorización explícita | MCP sin control de acceso | Alta |
| Los contactos se indexan en claro en Meilisearch | Indexador sin filtrado | Alta |
| La contraseña queda en el historial del chat | Input de texto en conversación | Alta |
| Datos recuperables tras borrado | rm estándar |
Media |
| Master key de Meilisearch en el repo | Documentación en vault | Media |
| Credenciales persistentes en memoria del asistente | Sistema de memoria | Media |
Con el mapa claro, diseñé seis capas independientes. Si una falla, las demás aguantan.
🔐 Capa 1 — Cifrado en reposo con GPG AES-256
La unidad de confidencialidad es el directorio del vault. Cada directorio sensible puede abrirse y cerrarse de forma independiente. En reposo, los ficheros .md son ficheros .md.gpg — ruido ininteligible sin la passphrase.
# Cerrar: cifrar y destruir el original
gpg --batch --yes --quiet \
--symmetric --cipher-algo AES256 \
--passphrase "$PASS" \
-o "${f}.gpg" "$f"
shred -u "$f" # sobreescritura segura: 3 pasadas, sin recuperación forense
# Abrir: descifrar a .md temporal
gpg --batch --yes --quiet \
--passphrase "$PASS" \
--decrypt -o "$out" "$f"Por qué cifrado simétrico y no asimétrico: el caso de uso es un único propietario. GPG asimétrico añade gestión de claves sin ningún beneficio real aquí.
Por qué shred y no rm: en sistemas de ficheros con journaling (ext4, btrfs), rm no garantiza la eliminación física de los datos. shred -u sobreescribe antes de desvincular.
🚪 Capa 2 — Contraseña fuera del canal del asistente
Este es el punto donde la mayoría de implementaciones fallan. Si le dices la contraseña al asistente en el chat, esa contraseña queda en el historial de conversación, potencialmente en logs, potencialmente en memoria persistente.
El flujo que implementé separa completamente los canales:
Usuario → kdialog (diálogo gráfico nativo KDE)
↓
/tmp/.harper_pass (chmod 600, vida < 5 segundos)
↓
script de cifrado / descifrado
kdialog se lanza mediante un atajo de teclado global registrado en el sistema operativo — sin pasar por el asistente en ningún momento. La contraseña llega al script a través de un fichero temporal con permisos restrictivos que el script elimina tras leer.
printf '%s' "$pass" > /tmp/.harper_pass
chmod 600 /tmp/.harper_pass
# El script lee y destruye inmediatamente
rm -f /tmp/.harper_passPor qué no variables de entorno: las variables de entorno son visibles en /proc/<pid>/environ para cualquier proceso del mismo usuario. El fichero temporal con chmod 600 y vida corta es más seguro en este contexto.
🤖 Capa 3 — MCP con detección dinámica de directorios restringidos
El servidor MCP expone herramientas de lectura, escritura y búsqueda en el vault. Sin protección, el LLM podría leer ficheros descifrados durante una sesión abierta. La protección no es una lista estática que hay que mantener a mano — eso se queda desactualizado.
La detección es dinámica: un directorio está restringido si contiene algún fichero .md.gpg.
def _get_restricted_dirs() -> set:
"""Directorios con .md.gpg presentes = cifrados = fuera de límites."""
return {p.parent.name for p in VAULT.rglob("*.md.gpg")}
def _is_restricted(path: Path) -> bool:
try:
parts = path.relative_to(VAULT).parts
return bool(parts and parts[0] in _get_restricted_dirs())
except ValueError:
return FalseEsta función se evalúa en cada llamada a herramienta. El coste es despreciable (glob local). La ventaja: cualquier nuevo directorio cifrado queda protegido automáticamente sin tocar el código. Todas las herramientas que acceden a ficheros pasan por _is_restricted() antes de operar.
🔍 Capa 4 — Indexador Meilisearch con filtrado automático
El indexador mantiene el motor de búsqueda actualizado. El problema: si se ejecuta mientras un directorio cifrado está temporalmente abierto, los datos PII acaban en el índice en claro.
La misma lógica dinámica del MCP se aplica al indexador:
def _get_restricted_dirs() -> set:
return {
os.path.basename(os.path.dirname(p))
for p in glob.glob(f"{VAULT_DIR}/**/*.md.gpg", recursive=True)
}
# En index_all():
restricted = _get_restricted_dirs()
for path in glob.glob(f"{VAULT_DIR}/**/*.md", recursive=True):
if _is_restricted(path):
continue # nunca indexar, ni siquiera en claro
# En index_file():
if _is_restricted(path):
print(f"✗ Rechazado: directorio cifrado — {rel}")
returnLa propiedad clave: mientras exista al menos un .md.gpg en el directorio, todos los .md de ese directorio quedan excluidos del índice. No hay ventana de vulnerabilidad entre el descifrado y la indexación.
🗝️ Capa 5 — Gestión de claves en Meilisearch
Meilisearch usa un modelo de claves derivadas de una master key. Definí tres claves con ámbitos distintos:
| Key | Permisos | Almacenamiento |
|---|---|---|
| master | Administración total | Solo gestor de contraseñas — nunca en vault ni scripts |
| search-only | Solo búsqueda | Servidor MCP (riesgo aceptado: red privada) |
| indexer | documents.add, indexes.get | Script indexador (riesgo aceptado: red privada) |
Nota: la master key no vive en ningún fichero del repositorio, ni en comentarios, ni en documentación. Si se compromete, cualquiera puede crear claves de administración con acceso total a todos los índices.
🧹 Capa 6 — Exclusión de git y memoria del asistente
# .gitignore
directorio_sensible/
El directorio de datos sensibles no se versiona. Ni los .md.gpg. Esto elimina el riesgo de exposición vía historial de git o repositorio remoto — incluso si el repo acaba siendo público por error.
El sistema de memoria persistente del asistente tiene una regla explícita: las contraseñas no se persisten nunca. El protocolo de cierre de sesión incluye verificación activa de que no queden credenciales almacenadas.
✅ Propiedades de seguridad resultantes
| Propiedad | Garantía |
|---|---|
| Confidencialidad en reposo | GPG AES-256; datos ilegibles sin passphrase |
| Separación de canales | Contraseña nunca en historial de conversación |
| Mínimo privilegio | El LLM solo accede cuando la sesión está abierta explícitamente |
| Eliminación segura | shred -u; sin recuperación forense básica |
| Detección automática | Sin listas estáticas; basada en presencia de .md.gpg |
| Sin rastro en índice | Meilisearch excluye dirs cifrados independientemente del estado |
🔜 Pendientes y mejoras futuras
- TLS para Meilisearch: actualmente HTTP en red privada. Un reverse proxy Nginx o certificado self-signed lo resolverían.
- Rotación de claves: no hay proceso definido para rotar las claves search-only e indexer.
- Audit log: no hay registro de cuándo se abre o cierra un directorio cifrado.
La arquitectura no es perfecta. Pero el modelo de amenaza está cubierto donde más importa: el asistente no puede leer lo que no debe, la contraseña nunca toca el canal del chat y los datos no aparecen donde no tienen que aparecer.