# Is an AI SSH Client Safe? Hatoba's E2EE, Model Data Flows, and Command Approval

> Canonical: https://vxi.me/en/posts/ai-ssh-client-security-hatoba-e2ee/
> Locale: en-us
> Published: 2026-10-15
> Updated: 2026-10-15
> Markdown: https://vxi.me/en/posts/ai-ssh-client-security-hatoba-e2ee.md

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](https://github.com/scarletkc/Hatoba) offers a concrete example. Its [Show HN launch](https://news.ycombinator.com/item?id=50076388) 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`](https://github.com/scarletkc/Hatoba/tree/5e9b353020a43fbb7560dcb0abf5e071c986ee7a)**, 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`](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/crates/hatoba-core/src/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:

```rust
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](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/workers/sync/src/routes/auth.ts), 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](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/workers/sync/migrations/0001_init.sql). These are the relevant columns, without the source comments and indexes:

```sql
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](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/hatoba-spec.md), 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](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/hatoba-spec.md) acknowledges that a malicious Worker could delete, withhold or roll back data; its [implementation status](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/status.md) 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](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/README.md) 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()`](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/apps/desktop/src/features/ai/tools.ts), ordinary reads are absent from the default approval list:

```ts
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](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/status.md) 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:

```python
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:

```text
----------------------------------------------------------------------
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](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/status.md) 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:

1. **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.
2. **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.
3. **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.
4. **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](/en/posts/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.

<details>
<summary>Full Python source of the six independent crypto checks</summary>

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`.

```python
"""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()
```

</details>

## Verification context

- Level: source-inspected
- Verified: 2026-10-15T00:00:00.000Z
- Environment: Hatoba v0.3.0 pinned source 5e9b353
- Environment: Independent Python 3.13.5 synthetic 6/6
- Evidence mode: mixed
- Evidence receipt: https://vxi.me/evidence/ai-ssh-client-security-hatoba-e2ee.json
