Un asistente con IA integrado en el terminal SSH puede agilizar el diagnóstico de un error 502 de Nginx: menos copias de registros y menos cambios de ventana. Sin embargo, un terminal muestra mucho más que errores: variables de entorno, nombres de máquinas internas, archivos de configuración e incluso tokens que aparecieron por accidente en la salida de un comando.

Cuando el asistente puede leer archivos y ejecutar órdenes, ya no basta con preguntar si su diagnóstico es correcto. ¿Quién recibe el contenido del terminal y qué operaciones pueden avanzar sin pedir otra confirmación?

Hatoba permite examinar estas preguntas con un caso real de código abierto. Su presentación en Show HN describe un cliente SSH de escritorio que combina asistente de IA, una bóveda local y sincronización E2EE mediante Cloudflare Worker y D1 en una cuenta propia. Pero cifrar el contenido sincronizado no define por sí solo la privacidad de las peticiones al modelo ni la seguridad de los comandos remotos.

Cuatro conclusiones antes de entrar en el código:

  • E2EE protege el contenido de la bóveda, no todos los metadatos visibles en la nube. El Worker procesa información de autenticación, identificadores de elementos, revisiones y marcas de tiempo.
  • Una operación de solo lectura también puede enviar información a un modelo. La pantalla del terminal, los registros, los adjuntos y las respuestas de read_file pueden terminar en el contexto del modelo, fuera de la protección de la sincronización E2EE.
  • Manual no exige aprobación para cada lectura. La política distingue lectura, escritura, ejecución y reglas particulares de Auto, Bypass y MCP.
  • Que un texto cifrado sea auténtico no significa que sea la última versión. Una etiqueta AES-GCM válida no impide por sí misma repetir una versión antigua que nunca fue alterada.
RiesgoFrontera de confianzaQué confirma el código fijadoQué comprobar antes de adoptarlo
Exposición de la bóveda en la nubeBóveda local → Worker/D1Contenido cifrado en el cliente; parte de los metadatos visiblesClaves, metadatos, recuperación y copias
Registros o credenciales enviados a la IAPantalla/archivo SSH → proveedor del modeloPantalla, adjuntos y resultados pueden enviarse aparte de la sincronizaciónConfiguración de contexto, redacción, proveedor
Ejecución peligrosa por la IAHerramienta de IA → servidor SSHLecturas y modificaciones siguen ramas de aprobación distintasUsuario SSH de privilegios mínimos, prueba en Manual
Repetición de una versión antiguaWorker/D1 → bóveda localLa autenticación del cifrado no establece frescuraEstado de versión confiable y restauración

Para delimitar las afirmaciones revisé Hatoba v0.3.0 en el commit inmutable 5e9b35302: implementación criptográfica en Rust, esquema del Worker y comprobaciones de permisos. Complementé la lectura con un pequeño modelo criptográfico independiente escrito en Python. No instalé ni ejecuté Hatoba, no realicé una sesión SSH real y esto no es una auditoría de penetración.

Qué protege E2EE y qué puede seguir viendo D1

La separación comienza con las claves. En crypto.rs, Argon2id deriva master_key a partir de la contraseña maestra y HKDF-SHA256 crea claves para funciones diferentes. Dos etiquetas del código ayudan a entenderlo:

enc_key: hkdf_sha256(master_key, "hatoba/enc/v1"),
auth_key: hkdf_sha256(master_key, "hatoba/auth/v1"),

enc_key protege localmente una vault_key generada al azar; los elementos sincronizados se cifran con esa clave mediante AES-256-GCM. Los datos autenticados adicionales (AAD) vinculan cada envoltura a hatoba/item/v1/{item_id}. Por tanto, un cifrado válido de un elemento no puede presentarse bajo otro identificador sin provocar un fallo de autenticación.

La auth_key tiene otra misión. En la ruta de inicio de sesión del Worker, el cliente envía esa clave de autenticación derivada; el servicio calcula su SHA-256, lo compara con auth_hash y, si coincide, crea un token de sesión. La credencial de acceso no es la clave que descifra el contenido de la bóveda. Eso no elimina la necesidad de proteger credenciales, códigos de recuperación y sesiones abiertas.

El esquema de D1 para la tabla items deja más clara la distinción. Estas son las columnas relevantes, sin comentarios ni índices:

CREATE TABLE items (
  id         TEXT PRIMARY KEY,
  envelope   TEXT,
  revision   INTEGER NOT NULL,
  seq        INTEGER NOT NULL,
  deleted    INTEGER NOT NULL DEFAULT 0,
  updated_at INTEGER NOT NULL
);

envelope contiene el elemento cifrado. Los campos id, revision, seq, deleted y updated_at quedan accesibles al servidor para sincronización y resolución de conflictos. El número de registros, tamaños de mensajes cifrados y patrones temporales tampoco se ocultan por cifrar el contenido. El modelo de seguridad, apartado 4.5, enumera expresamente lo visible para el servidor.

E2EE ofrece confidencialidad del contenido, no invisibilidad de toda la actividad. Una copia de D1 no debería bastar para leer directamente una clave SSH correctamente cifrada, aunque sí puede revelar determinados patrones de uso y datos de sincronización.

Hay además una cuestión distinta: la versión más reciente. El modelo de amenazas admite que un Worker malicioso podría borrar, retener o devolver elementos antiguos. En el estado de implementación, rollback detection sigue siendo una tarea P2 pendiente. Integridad criptográfica y protección frente a retrocesos no son sinónimos.

Los datos de una lectura pueden salir por la petición al modelo

Imaginemos que un registro de Nginx de producción incluye una IP interna o parte de un token. SSH puede proteger la conexión y E2EE puede proteger la bóveda sincronizada. Sin embargo, si ese texto se añade a una petición al modelo, ahora existe otro destinatario: el proveedor de IA.

El README de Hatoba explica que la pantalla del terminal puede acompañar los mensajes y que ese comportamiento predeterminado es configurable. Mensajes del usuario, adjuntos y resultados de herramientas también pueden transmitirse al proveedor elegido. No significa que se suba automáticamente toda la bóveda: lo relevante es qué contenido aparece en cada petición concreta. Apagar la sincronización de Cloudflare no equivale a apagar las llamadas al modelo.

La propia lógica de aprobación lo muestra. En tools.ts, la función needsApproval() no incluye las herramientas de lectura habituales en la lista de llamadas sometidas a aprobación general:

export function needsApproval(kind: ToolKind, mode: AiPermissionMode): boolean {
  if (mode === "bypass") return false;
  return kind === "run_command" || kind === "send_input" ||
    kind === "edit_file" || kind === "write_file" ||
    kind === "fetch_url" || kind === "mcp";
}

En Manual, run_command, send_input, edit_file, write_file y fetch_url suelen requerir confirmación. Por el contrario, read_terminal, read_file, grep y glob no necesariamente solicitan permiso en cada llamada. Las excepciones guardadas pueden reducir todavía más las confirmaciones. MCP tiene reglas específicas y la función mustAsk() contempla always_ask y always_allow.

Solo lectura no significa que los datos permanezcan locales. La autorización para leer y la autorización para mandar la salida al modelo son decisiones diferentes. En servidores sensibles, comprobaría antes qué pantalla se adjunta, qué archivos pueden leerse y cómo trata los datos el proveedor, en vez de confiar únicamente en la ventana de confirmación para comandos de escritura.

Auto incorpora un juez que decide cuándo puede omitirse una aprobación. El estado del proyecto menciona pruebas unitarias, servicios simulados y verificaciones limitadas; no demuestra que se detecten todas las inyecciones de instrucciones en archivos o salidas del terminal. Bypass omite las aprobaciones comunes, aunque always_ask de MCP puede mantener excepciones. Los permisos del sistema operativo y del usuario SSH deben seguir siendo controles independientes.

Un texto cifrado válido puede estar obsoleto: seis pruebas independientes

Quería distinguir dos comportamientos: modificar un mensaje AES-GCM debe invalidar su autenticación, pero una envoltura antigua y legítima puede seguir siendo descifrable incluso si ya existe una versión más reciente.

Preparé un modelo Python separado usando Argon2id, HKDF, AES-GCM y AAD con identificador de elemento según el código fijado. No importa ni ejecuta Rust o Worker de Hatoba. La ejecución archivada empleó Python 3.13.5, argon2-cffi 25.1.0 y cryptography 46.0.4: 6 pruebas correctas, ningún fallo y código de salida 0.

Caso del modelo independienteObservación registrada
Derivación separada de enc_key y auth_keyDos claves distintas de 32 bytes
Cifrado, descifrado y búsqueda de texto claroSe recupera el contenido sintético, que no aparece en envelope
Cambio de un bit del mensaje cifradoFallo de autenticación InvalidTag
Descifrado bajo otro item_idError InvalidTag por AAD diferente
Versiones antigua y nueva del mismo elementoAmbas envolturas auténticas se descifran
Fila simulada de sincronizaciónrevision, updated_at visibles; contenido no visible

La quinta prueba es la más reveladora. Estas líneas proceden del script realmente ejecutado:

old = seal_item(self.vault_key, self.item_id, {"generation": 1})
new = seal_item(self.vault_key, self.item_id, {"generation": 2})

self.assertEqual(open_item(self.vault_key, self.item_id, new), {"generation": 2})
self.assertEqual(open_item(self.vault_key, self.item_id, old), {"generation": 1})

AES-GCM no está fallando: ambas envolturas son auténticas y sus etiquetas son correctas. La autenticación no recuerda cuál fue la última generación aceptada por el cliente. Para detectar que el servidor devuelve un elemento antiguo y legítimo hace falta algún ancla de versión confiable que el mismo atacante no pueda revertir, u otra protección contra rollback.

Texto cifrado válido ≠ dato más reciente. El modelo explica por qué Hatoba trata rollback detection como una cuestión separada. Pero no reproduce una explotación real del protocolo de sincronización del producto.

El resultado archivado de unittest termina así:

----------------------------------------------------------------------
Ran 6 tests in 0.100s

OK

El tiempo corresponde a este modelo sintético, no al rendimiento de cifrado o latencia de Hatoba. El código completo está al final del artículo.

Cuatro comprobaciones antes de permitir SSH de producción

Hatoba tiene implementación para SSH, SFTP, Vault, herramientas de archivos y sincronización, pero «existe», «se probó con mocks» y «funciona en un entorno real» son afirmaciones distintas. El documento docs/status.md de v0.3.0 recoge límites concretos:

FuncionalidadAlcance de verificación declarado
Sincronización WorkerImplementación y pruebas relacionadas; sin despliegue real de Cloudflare comprobado aquí
D1 DirectREST simulado con SQLite; no verificado contra D1 real
Proveedores de IA e inicio de sesión con ChatGPTCobertura de protocolos y mocks; falta comprobar combinaciones reales de cuentas y proveedores
Edición de archivos y UI de permisosPruebas con scripts, OpenSSH o backend simulado; sin cobertura completa del modelo en escritorio real
macOSCompilación experimental para Apple Silicon, firma ad-hoc, sin notarización y con pruebas pendientes

Si tuviera que evaluarlo para un equipo, empezaría en un entorno de bajo riesgo con cuatro controles:

  1. Aislar el servidor y la identidad. Utilizar una máquina no productiva sin datos sensibles, con un usuario SSH no root y mínimos permisos. Deshabilitar la sincronización innecesaria y comenzar con diagnósticos de solo lectura.
  2. Revisar qué información sale. Comprobar qué parte del terminal se adjunta, qué registros, archivos y resultados van a qué proveedor, y sus políticas de retención. Clasificar y anonimizar antes los datos de clientes o credenciales; si no se puede, no mandar el contexto original a un modelo externo.
  3. Probar Manual. En el host de pruebas, solicitar una modificación inocua y comprobar el diálogo, las autorizaciones de «permitir siempre» guardadas y el proceso de recuperación. Auto, Bypass, MCP y Skills externos deben revisarse por separado.
  4. Comprobar copias y recuperación. Conservar contraseña maestra y código de recuperación por separado, verificar revocación de sesiones ante pérdida de dispositivos, exportación/restauración disponible y copias sin conexión. Si se pierden ambos factores, Hatoba no puede descifrar el Vault anterior según su diseño.

Incluso cuando una escritura requiere confirmación, un archivo remoto o la salida de un comando podrían contener instrucciones maliciosas destinadas al modelo. La interfaz de permisos no sustituye las defensas contra la inyección de prompts.

La ventaja potencial de un cliente SSH con IA es reducir la fricción entre diagnóstico y acción. La decisión de adoptarlo debe basarse en quién recibe los datos, quién puede ejecutar órdenes y cómo se recupera el estado tras un fallo, no solo en una etiqueta E2EE. La matriz inicial sirve también para comparar otras herramientas de terminal con IA.

Para un enfoque alternativo donde las acciones de IA pasan por una ruta de despliegue independiente en GitHub en vez de SSH directo, puede consultarse ChatGPT Web + GitHub Self-hosted Runner.

Código completo del modelo criptográfico Python (seis pruebas)

Este script utiliza solo datos sintéticos; no se conecta a Hatoba ni a otros servicios. En un entorno Python aislado, instala argon2-cffi y cryptography, guarda el código como hatoba_crypto_boundary_fixture.py y ejecuta python3 -m unittest -v hatoba_crypto_boundary_fixture.py.

"""Isolated, source-adapted crypto boundary demonstration; NOT Hatoba execution.

Models only the publicly documented v0.3.0 key labels, algorithms and item AAD.
No Hatoba source is imported or run, no network, accounts, SSH or real data.
"""
import base64
import json
import os
import unittest
from argon2.low_level import Type, hash_secret_raw
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF


def derive_keys(password: bytes, salt: bytes):
    master_key = hash_secret_raw(
        password, salt, time_cost=3, memory_cost=64 * 1024,
        parallelism=4, hash_len=32, type=Type.ID,
    )
    def derive(info):
        return HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=info).derive(master_key)
    return derive(b"hatoba/enc/v1"), derive(b"hatoba/auth/v1")


def seal_item(vault_key: bytes, item_id: str, content: dict) -> dict:
    nonce = os.urandom(12)
    aad = ("hatoba/item/v1/" + item_id).encode()
    ciphertext = AESGCM(vault_key).encrypt(
        nonce, json.dumps(content, sort_keys=True).encode(), aad,
    )
    return {"v": 1, "n": base64.b64encode(nonce).decode(),
            "c": base64.b64encode(ciphertext).decode()}


def open_item(vault_key: bytes, item_id: str, envelope: dict) -> dict:
    nonce = base64.b64decode(envelope["n"])
    ciphertext = base64.b64decode(envelope["c"])
    aad = ("hatoba/item/v1/" + item_id).encode()
    return json.loads(AESGCM(vault_key).decrypt(nonce, ciphertext, aad))


class BoundaryFixture(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.enc_key, cls.auth_key = derive_keys(b"test-only-long-password", b"16-byte-testsalt")
        cls.vault_key = os.urandom(32)
        cls.item_id = "test-item-1"
        cls.content = {"type": "host", "hostname": "fixture.example.invalid", "secret": "synthetic-only"}

    def test_distinct_auth_and_encryption_keys(self):
        self.assertEqual(len(self.enc_key), 32)
        self.assertEqual(len(self.auth_key), 32)
        self.assertNotEqual(self.enc_key, self.auth_key)

    def test_roundtrip_and_no_plaintext_in_envelope(self):
        envelope = seal_item(self.vault_key, self.item_id, self.content)
        self.assertNotIn("fixture.example.invalid", json.dumps(envelope))
        self.assertNotIn("synthetic-only", json.dumps(envelope))
        self.assertEqual(open_item(self.vault_key, self.item_id, envelope), self.content)

    def test_tampered_ciphertext_rejected(self):
        envelope = seal_item(self.vault_key, self.item_id, self.content)
        tampered = dict(envelope)
        c = bytearray(base64.b64decode(envelope["c"]))
        c[-1] ^= 0x01
        tampered["c"] = base64.b64encode(c).decode()
        with self.assertRaises(InvalidTag):
            open_item(self.vault_key, self.item_id, tampered)

    def test_other_item_id_rejected_by_aad(self):
        envelope = seal_item(self.vault_key, self.item_id, self.content)
        with self.assertRaises(InvalidTag):
            open_item(self.vault_key, "another-item", envelope)

    def test_stale_but_valid_envelope_still_decrypts(self):
        # An authenticated envelope is not a freshness/revision check.
        old = seal_item(self.vault_key, self.item_id, {"generation": 1})
        new = seal_item(self.vault_key, self.item_id, {"generation": 2})
        self.assertEqual(open_item(self.vault_key, self.item_id, new), {"generation": 2})
        self.assertEqual(open_item(self.vault_key, self.item_id, old), {"generation": 1})

    def test_server_row_exposes_sync_metadata_not_item_plaintext(self):
        envelope = seal_item(self.vault_key, self.item_id, self.content)
        row = {"id": self.item_id, "envelope": json.dumps(envelope),
               "revision": 2, "seq": 6, "deleted": False, "updated_at": 42}
        serialized = json.dumps(row)
        self.assertIn('"revision": 2', serialized)
        self.assertIn('"updated_at": 42', serialized)
        self.assertNotIn("fixture.example.invalid", serialized)
        self.assertEqual(json.loads(row["envelope"]), envelope)


if __name__ == "__main__":
    unittest.main()