Is an AI SSH Client Safe? Hatoba's E2EE, Model Data Flows, and Command Approval
An AI assistant that can diagnose an Nginx 502 error right inside an SSH terminal sounds useful. You avoid copying logs between windows. But a terminal also contains environment variables, internal hostnames, configuration files, and sometimes a token accidentally printed in an error message.
Once an assistant can read files and run commands, the question is no longer only whether its diagnosis is correct. Who receives the terminal data, and which tool calls can proceed without asking again?
Hatoba offers a concrete example. Its Show HN launch presents an open-source SSH desktop client combining an AI assistant, a local vault, and end-to-end-encrypted sync through a Cloudflare Worker and D1 database in your own account. That last claim is useful, but encryption of the sync channel does not define the privacy or execution behavior of the assistant.
Four findings up front:
- E2EE protects vault contents, not all cloud-visible information. The Worker still processes authentication material and metadata such as item IDs, sequence numbers, revisions and timestamps.
- Read-only tools can still send data to a model. Terminal screens, logs, attachments and file-tool output may become prompt context; Cloudflare’s sync encryption does not cover this separate transmission.
- Manual mode does not prompt for every read. The approval policy treats reads differently from writes and commands, with distinct behavior for Auto, Bypass and MCP.
- Authenticated ciphertext is not necessarily fresh. An AES-GCM tag does not, by itself, tell a client that an older valid version has been replayed.
| Risk | Trust boundary | What the pinned source establishes | What to check before adoption |
|---|---|---|---|
| Compromised cloud vault | Local vault → Worker/D1 | Item payloads are encrypted client-side; some synchronization metadata remains visible | Keys, metadata, recovery and backups |
| Production logs or credentials reach a model | SSH screen/files → model provider | Selected screens, attachments and tool responses may be forwarded independently of sync | Context settings, redaction, provider handling |
| Dangerous AI tool execution | AI tool → remote SSH host | Ordinary reads and mutating commands follow different approval branches; Bypass relaxes normal prompts | Least-privilege SSH identity, Manual-mode test |
| Replay of an older item | Worker/D1 → local vault | Ciphertext authentication does not establish version freshness; upstream rollback detection is unfinished | Trusted version anchors and recovery |
I inspected Hatoba v0.3.0 at immutable commit 5e9b35302, then exercised the underlying cryptographic distinction with a separate Python model. This is a source-level architectural assessment, not an installation, live SSH test, or penetration test of Hatoba.
What E2EE keeps local—and what the D1 database still knows
Start with the keys. The pinned Rust crypto.rs derives a master_key from the master password using Argon2id, then uses HKDF-SHA256 to separate its purposes. Two labels in the actual source are especially revealing:
enc_key: hkdf_sha256(master_key, "hatoba/enc/v1"),
auth_key: hkdf_sha256(master_key, "hatoba/auth/v1"),
The enc_key wraps a randomly generated vault_key locally. Individual items use that vault key with AES-256-GCM. Associated authenticated data (AAD) binds an envelope to hatoba/item/v1/{item_id}, so an otherwise valid envelope cannot simply be moved to a different item identity without authentication failing.
The other derived key, auth_key, has a different job. In the Worker login route, the client sends this derived authentication secret; the server compares its SHA-256 with a stored auth_hash and issues a session token after a successful check. The distinction matters: a login credential is not the same as the vault-decryption key. It does not mean sessions, recovery codes or authentication secrets are unimportant.
Now inspect the D1 items schema. These are the relevant columns, without the source comments and indexes:
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
);
The envelope holds the encrypted item payload. The id, revision, seq, deleted and updated_at fields remain accessible because synchronization and conflict handling need them. Record counts, ciphertext sizes, device/session records and timing patterns are separate from the business content inside the envelope. The upstream security model, section 4.5, makes that boundary explicit.
E2EE is a content-confidentiality design, not a promise to hide every trace of usage. Possession of a D1 export should not immediately reveal an SSH private key stored inside a correctly encrypted item. At the same time, synchronization activity and session management still have observable surfaces.
Encryption also does not guarantee data availability or freshness. Hatoba’s threat model acknowledges that a malicious Worker could delete, withhold or roll back data; its implementation status lists rollback detection as unfinished P2 work. More on that in the independent experiment below.
A read operation can leave the device through the AI request
Imagine troubleshooting a production web server. Its error log contains an internal address or part of a credential. SSH transport can be encrypted, and cloud vault sync can be E2EE, yet the moment that log text is included in a model request, a separate recipient enters the picture.
Hatoba’s README security disclosure notes that terminal-screen text may accompany a message, with that default configurable. User messages, attachments and tool results can also be sent to a chosen AI provider. This is not a claim that the application automatically uploads the entire vault. The question is which information enters the specific model context on a specific turn. Turning off Cloudflare sync alone does not switch off model requests.
The frontend approval policy further illustrates the distinction. In tools.ts, needsApproval(), ordinary reads are absent from the default approval list:
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";
}
Under Manual mode, operations such as run_command, send_input, edit_file, write_file and fetch_url normally require an approval decision. Ordinary read_terminal, read_file, grep and glob calls are not all gated by a per-call prompt. Previously saved allowances can also change whether a prompt reappears. MCP permissions need their own treatment: the same file’s mustAsk() distinguishes always_ask and always_allow rules.
Permission to read is not a guarantee that the contents remain local. Read access is one policy decision. Including a tool result in a message to a model provider is another. On a system that might expose customer data, I would inspect the screen-context setting and provider behavior before treating a Manual-mode confirmation dialog as sufficient protection.
Auto and Bypass are not equivalent. Auto introduces a judge to decide whether certain proposed calls can skip approval. The project status table describes unit, mocked-service and limited evaluations, not conclusive defenses against prompt injection from hostile files or terminal output. Bypass relaxes ordinary confirmations; MCP tools configured with always_ask can still be exceptions. In either case, OS permissions and SSH-account scope remain independent controls.
Valid ciphertext can be stale: six independent Python checks
The most useful distinction to isolate was simple: modifying encrypted data should invalidate its authentication tag, while an older envelope that has never been modified can remain perfectly authentic.
I wrote a standalone Python model using the same classes of primitives and labels shown in the pinned source—Argon2id, HKDF, AES-GCM and item-ID AAD. It does not import Hatoba’s Rust or Worker implementation. The archived execution used Python 3.13.5, argon2-cffi 25.1.0 and cryptography 46.0.4; all six unittest cases passed, with zero failures and exit code 0.
| Standalone model check | Recorded observation |
|---|---|
| Derive separate encryption/authentication keys | Two different 32-byte keys |
| Encrypt/decrypt and inspect the envelope | Original synthetic content round-trips; test plaintext is absent from the envelope |
| Flip one bit of AES-GCM ciphertext | Decryption fails with InvalidTag |
Decrypt under another item_id | AAD mismatch raises InvalidTag |
| Create old and new authentic envelopes for one item | Both generations can still decrypt |
| Wrap the envelope in a simulated synchronization row | revision and updated_at remain visible; the item payload does not |
The fifth case gets to the point. These assertions come from the script actually executed:
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})
Nothing is wrong with AES-GCM here. Both envelopes are genuine and pass authentication. Authentication alone has no memory of the highest generation a client already accepted. To detect a server returning an old, valid item, a system needs some trusted state, version anchor or other rollback defense that the same adversary cannot silently rewind.
Valid ciphertext ≠ latest data. This explains the separate rollback-detection work noted by Hatoba. It does not establish a live exploitable rollback bug in Hatoba: the exercise used a deliberately separate model, not the product’s sync client or protocol.
The archived unittest output ends as follows:
----------------------------------------------------------------------
Ran 6 tests in 0.100s
OK
That duration belongs to this small standalone fixture, not to Hatoba’s encryption throughput or AI latency. The full source is included at the end so the assertions can be inspected.
Before I let an AI assistant use production SSH
Hatoba is a project worth examining further, but the source inspection is not a basis for replacing a production SSH workflow. The v0.3.0 status document draws useful distinctions between implemented features, mocked integrations and tests that have not yet been performed against live systems.
| Capability | Recorded validation boundary |
|---|---|
| Worker-based sync | Implementation and related tests exist; this article has no first-hand Cloudflare deployment receipt |
| D1 Direct | REST-style behavior exercised with a SQLite simulation, not verified against actual D1 |
| AI providers and ChatGPT sign-in | Protocol and mock-based coverage; real account/provider combinations still need verification |
| AI file operations and approval UI | Script/OpenSSH or mock-backend coverage; actual model-driven desktop combinations remain incompletely tested |
| macOS | Experimental Apple Silicon build, ad-hoc signed and not notarized; real-device gaps remain |
If I were evaluating it for a team, I would expand permissions only after four controlled checks:
- Isolate host and identity. Use a disposable, nonproduction machine with no sensitive datasets and a non-root, least-privilege SSH user. Disable unnecessary cloud sync; start with limited read-only diagnostics.
- Inspect what leaves the machine. Check whether the screen is attached by default; review logs, attachments and tool outputs going to the selected provider and its retention policy. Classify and redact customer data or credentials first. If that is not workable, do not send the raw context to an external model.
- Verify Manual approval. On the test host, propose an innocuous file write and observe the confirmation. Check for mistakenly saved “always allow” grants and test the recovery path. Evaluate Auto, Bypass, MCP and third-party skills separately.
- Verify recovery and backups. Store the master password and recovery code appropriately and separately, confirm session revocation when devices are lost, and validate an export/recovery or offline backup where supported. With both the master password and recovery code gone, Hatoba’s design cannot recover the previous encrypted vault.
Even when a write command requires confirmation, terminal output or a file can still contain malicious instructions intended for the model. Authorization dialogs and prompt-injection defenses are not interchangeable.
The appeal of these tools is real: less log copying, less window switching, and faster movement between diagnosis and action. The adoption decision, though, comes down to who receives the data, who can execute commands, and how you recover when state goes wrong—not merely whether a product page says E2EE.
If your concern is isolating AI-generated actions behind an independent deployment boundary rather than granting direct SSH permissions, I cover an alternative in ChatGPT Web + GitHub Self-hosted Runner. You can also save the risk matrix above and compare it with the pinned sources and real test records of any other AI terminal tool.
Full Python source of the six independent crypto checks
This is a synthetic-data fixture. It does not connect to Hatoba or an external service. In an isolated Python environment with argon2-cffi and cryptography, save the code as hatoba_crypto_boundary_fixture.py and run 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()