localharness 0.55.0

Agents that own themselves: one Rust crate that's both an agent SDK (streaming, tools, hooks, policies, triggers, MCP) and a wallet-owning, self-sovereign agent that runs in the browser.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
//! Transparent at-rest encryption wrapper over any [`Filesystem`].
//!
//! [`EncryptedFilesystem`] wraps an inner filesystem (OPFS in the browser
//! app, [`super::NativeFilesystem`] in the unit tests) and seals every
//! `write_atomic` with AES-256-GCM under a caller-supplied 32-byte key —
//! in the browser app that key is derived from the master wallet seed via
//! [`crate::wallet::at_rest_key_from_entropy`] (tag
//! `localharness/v0/opfs-at-rest`), so a stolen browser profile yields
//! only ciphertext for agent data (conversation history, system prompt,
//! lessons, working files).
//!
//! ## File format
//!
//! ```text
//! "LHE1" (4 bytes) || nonce (12 bytes) || AES-256-GCM ciphertext+tag (n+16)
//! ```
//!
//! ## Transparent migration — plaintext stays readable FOREVER
//!
//! `read` sniffs the magic: present → decrypt (GCM auth failure is a
//! **clear error**, never silent garbage); absent → the bytes pass through
//! unchanged. Pre-existing plaintext files therefore keep working with no
//! flag-day, and re-encrypt naturally on their next write. The one edge:
//! a legacy *plaintext* file that happens to start with the 4 bytes
//! `LHE1` AND is ≥ 32 bytes long would be misread as ciphertext (and
//! error). No localharness-written file matches that shape.
//!
//! ## Exemptions — the identity/boot files are NEVER encrypted
//!
//! [`EXEMPT_FILES`] (matched on the file name, path-independent) skip
//! encryption on write. `.lh_wallet` is the decryption ROOT — sealing it
//! under a key derived from itself bricks the identity (the 2026-06-05
//! reset-brick class of bug), and the boot path must read `.lh_owner` /
//! `.lh_linked_owner` / `.lh_device_key` before a wallet exists. The two
//! local-model artifacts are public CDN downloads (~550 MB of Gemma
//! weights) — nothing secret, and far too large to round-trip through an
//! in-memory AEAD on every read.
//!
//! ## Threat model
//!
//! Confidentiality of OPFS contents at rest (stolen profile directory,
//! disk inspection, OPFS-scoped export/extension channels). It does NOT
//! defend against code running in the origin (which can load the seed and
//! derive the key), and the GCM tag authenticates file *contents*, not
//! file *names* — ciphertexts can be swapped between paths by an attacker
//! with write access (out of scope; write access also allows deletion).

use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
use aes_gcm::{Aes256Gcm, Nonce};
use async_trait::async_trait;

use super::{file_name, DirEntry, Filesystem, Metadata, SharedFilesystem, WalkEntry};
use crate::error::{Error, Result};

/// Magic prefix marking a sealed file. Version byte folded into the tag
/// (`LHE1` = localharness encryption, format v1).
pub const MAGIC: [u8; 4] = *b"LHE1";

/// AES-GCM nonce length (96-bit, the GCM standard).
const NONCE_LEN: usize = 12;

/// AES-GCM authentication tag length.
const TAG_LEN: usize = 16;

/// The shortest possible sealed file: magic + nonce + empty ciphertext + tag.
const MIN_SEALED_LEN: usize = MAGIC.len() + NONCE_LEN + TAG_LEN;

/// File names that are NEVER encrypted (matched on the final path
/// component). Three classes:
///
/// - **Identity / pre-wallet boot files** — `.lh_wallet` is the seed the
///   key derives FROM (encrypting it bricks the identity); `.lh_owner`,
///   `.lh_linked_owner`, and `.lh_device_key` are read by the mount path
///   before (or without) a master wallet existing.
/// - **Public local-model artifacts** — `.lh_local_model.safetensors` /
///   `.lh_local_tokenizer.json` are ~550 MB of public Gemma weights from
///   the HF CDN: nothing secret, too large for in-memory AEAD round-trips.
/// - **Notification inbox files** — `.lh_notif_pending.json` is written by
///   BOTH `web/sw.js` (a plain SERVICE WORKER with no seed, so no cipher)
///   and the Rust app via [`crate::app::shared_opfs`]; sealing the Rust
///   writes makes sw.js's `JSON.parse` of the `LHE1…` bytes throw, which
///   silently clobbers the stash down to one entry — closed-tab pushes then
///   never reach the in-app bell (#35 inbox-not-displaying bug). Keep this
///   file family plaintext so the two writers share ONE on-disk format;
///   `.lh_notif_inbox.json` is the merged log of that same plaintext-origin
///   data (kept format-uniform to avoid the inverse hazard).
///
/// Pinned by `exempt_list_is_pinned` — removing `.lh_wallet` from this
/// list is an identity-bricking change.
pub const EXEMPT_FILES: &[&str] = &[
    ".lh_wallet",
    ".lh_owner",
    ".lh_linked_owner",
    ".lh_device_key",
    ".lh_local_model.safetensors",
    ".lh_local_tokenizer.json",
    ".lh_notif_pending.json",
    ".lh_notif_inbox.json",
];

/// At-rest encryption wrapper implementing [`Filesystem`] around an inner
/// filesystem. See the module docs for format, migration, and exemptions.
pub struct EncryptedFilesystem {
    inner: SharedFilesystem,
    cipher: Aes256Gcm,
}

impl std::fmt::Debug for EncryptedFilesystem {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Never Debug-print key material.
        f.debug_struct("EncryptedFilesystem")
            .field("inner", &self.inner)
            .field("key", &"<redacted>")
            .finish()
    }
}

impl EncryptedFilesystem {
    /// Wrap `inner` with at-rest AES-256-GCM under `key` (32 bytes — in
    /// the browser app, [`crate::wallet::at_rest_key_from_entropy`]).
    pub fn new(inner: SharedFilesystem, key: &[u8; 32]) -> Self {
        Self {
            inner,
            cipher: Aes256Gcm::new(key.into()),
        }
    }

    /// Whether `path`'s file name is on the never-encrypt list.
    pub fn is_exempt(path: &str) -> bool {
        EXEMPT_FILES.contains(&file_name(path))
    }

    /// Whether `bytes` carry the sealed-file shape (magic + minimum length).
    pub fn looks_sealed(bytes: &[u8]) -> bool {
        bytes.len() >= MIN_SEALED_LEN && bytes[..MAGIC.len()] == MAGIC
    }

    /// `MAGIC || nonce || ct+tag` with a fresh random nonce per call.
    fn seal(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
        let ct = self
            .cipher
            .encrypt(&nonce, plaintext)
            .map_err(|_| Error::other("at-rest encrypt failed"))?;
        let mut out = Vec::with_capacity(MAGIC.len() + NONCE_LEN + ct.len());
        out.extend_from_slice(&MAGIC);
        out.extend_from_slice(&nonce);
        out.extend_from_slice(&ct);
        Ok(out)
    }

    /// Decrypt bytes that passed [`Self::looks_sealed`]. A GCM auth
    /// failure (wrong key OR tampered ciphertext) is a clear error —
    /// never silently-returned garbage.
    fn open(&self, path: &str, sealed: &[u8]) -> Result<Vec<u8>> {
        let nonce_start = MAGIC.len();
        let ct_start = nonce_start + NONCE_LEN;
        let mut nonce = [0u8; NONCE_LEN];
        nonce.copy_from_slice(&sealed[nonce_start..ct_start]);
        let nonce = Nonce::from(nonce);
        self.cipher.decrypt(&nonce, &sealed[ct_start..]).map_err(|_| {
            Error::other(format!(
                "at-rest decrypt failed for '{path}': wrong key or tampered ciphertext (GCM auth)"
            ))
        })
    }
}

#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl Filesystem for EncryptedFilesystem {
    async fn read(&self, path: &str) -> Result<Vec<u8>> {
        let bytes = self.inner.read(path).await?;
        if Self::looks_sealed(&bytes) {
            self.open(path, &bytes)
        } else {
            // Legacy plaintext (or an exempt file) — pass through as-is.
            Ok(bytes)
        }
    }

    async fn write_atomic(&self, path: &str, bytes: &[u8]) -> Result<()> {
        if Self::is_exempt(path) {
            return self.inner.write_atomic(path, bytes).await;
        }
        let sealed = self.seal(bytes)?;
        self.inner.write_atomic(path, &sealed).await
    }

    async fn metadata(&self, path: &str) -> Result<Option<Metadata>> {
        // Sizes reflect the on-disk (sealed) byte count — documented
        // divergence; the fs tools only branch on kind/existence.
        self.inner.metadata(path).await
    }

    async fn read_dir(&self, path: &str) -> Result<Vec<DirEntry>> {
        self.inner.read_dir(path).await
    }

    async fn walk(&self, path: &str, max_depth: Option<usize>) -> Result<Vec<WalkEntry>> {
        self.inner.walk(path, max_depth).await
    }

    async fn delete(&self, path: &str) -> Result<()> {
        self.inner.delete(path).await
    }

    async fn rename(&self, from: &str, to: &str) -> Result<()> {
        // Move the raw bytes verbatim — ciphertext is not path-bound, so
        // a sealed file stays decryptable at its new name with no
        // decrypt/re-encrypt round-trip.
        self.inner.rename(from, to).await
    }
}

#[cfg(all(test, feature = "native"))]
mod tests {
    use std::sync::Arc;

    use super::*;
    use crate::filesystem::NativeFilesystem;

    const KEY: [u8; 32] = [7u8; 32];

    fn setup() -> (tempfile::TempDir, EncryptedFilesystem, Arc<NativeFilesystem>) {
        let dir = tempfile::tempdir().expect("tempdir");
        let raw = Arc::new(NativeFilesystem::new());
        let enc = EncryptedFilesystem::new(raw.clone(), &KEY);
        (dir, enc, raw)
    }

    fn p(dir: &tempfile::TempDir, name: &str) -> String {
        dir.path().join(name).to_string_lossy().into_owned()
    }

    /// Round trip: the wrapper writes ciphertext (magic present, plaintext
    /// absent on the raw filesystem) and reads the plaintext back.
    #[tokio::test]
    async fn round_trip_seals_at_rest_and_reads_back() {
        let (dir, enc, raw) = setup();
        let path = p(&dir, ".lh_history.json");
        let plain = b"the conversation history nobody should read at rest";

        enc.write_atomic(&path, plain).await.unwrap();

        let on_disk = raw.read(&path).await.unwrap();
        assert!(EncryptedFilesystem::looks_sealed(&on_disk), "missing LHE1 framing");
        assert!(
            !on_disk
                .windows(plain.len())
                .any(|w| w == plain.as_slice()),
            "plaintext leaked into the at-rest bytes"
        );
        assert_eq!(on_disk.len(), MAGIC.len() + NONCE_LEN + plain.len() + TAG_LEN);

        assert_eq!(enc.read(&path).await.unwrap(), plain);
    }

    /// Transparent migration: a pre-existing plaintext file (no magic)
    /// reads through unchanged — old profiles stay readable forever.
    #[tokio::test]
    async fn legacy_plaintext_reads_through_unchanged() {
        let (dir, enc, raw) = setup();
        let path = p(&dir, ".lh_system_prompt.txt");
        let legacy = b"You are a helpful agent.";
        raw.write_atomic(&path, legacy).await.unwrap();

        assert_eq!(enc.read(&path).await.unwrap(), legacy);
    }

    /// A magic-prefixed file that is too short to be ours passes through
    /// as plaintext instead of erroring.
    #[tokio::test]
    async fn short_magic_prefixed_plaintext_passes_through() {
        let (dir, enc, raw) = setup();
        let path = p(&dir, "notes.txt");
        let almost = b"LHE1 but actually just a short note"; // < MIN? no — long enough...
        // Use a genuinely-too-short payload for the length branch:
        let tiny = b"LHE1tiny";
        assert!(tiny.len() < MIN_SEALED_LEN);
        raw.write_atomic(&path, tiny).await.unwrap();
        assert_eq!(enc.read(&path).await.unwrap(), tiny);

        // And document the known edge: a ≥32-byte plaintext starting with
        // LHE1 IS treated as ciphertext (GCM rejects it with a clear error).
        raw.write_atomic(&path, almost).await.unwrap();
        assert!(enc.read(&path).await.is_err());
    }

    /// Tamper rejection: flipping one ciphertext byte fails GCM auth with
    /// a CLEAR error naming the path — never silent garbage bytes.
    #[tokio::test]
    async fn tampered_ciphertext_is_rejected_with_clear_error() {
        let (dir, enc, raw) = setup();
        let path = p(&dir, "secret.txt");
        enc.write_atomic(&path, b"integrity matters").await.unwrap();

        let mut sealed = raw.read(&path).await.unwrap();
        let last = sealed.len() - 1;
        sealed[last] ^= 0x01;
        raw.write_atomic(&path, &sealed).await.unwrap();

        let err = enc.read(&path).await.expect_err("tamper must not decrypt");
        let msg = err.to_string();
        assert!(
            msg.contains("at-rest decrypt failed") && msg.contains("secret.txt"),
            "unclear tamper error: {msg}"
        );
    }

    /// Wrong key (e.g. a different seed) fails cleanly, not with garbage.
    #[tokio::test]
    async fn wrong_key_is_rejected_not_garbage() {
        let (dir, enc, raw) = setup();
        let path = p(&dir, "secret.txt");
        enc.write_atomic(&path, b"sealed under key A").await.unwrap();

        let other = EncryptedFilesystem::new(raw.clone(), &[8u8; 32]);
        assert!(other.read(&path).await.is_err());
    }

    /// The identity/boot files are written PLAINTEXT through the wrapper —
    /// `.lh_wallet` is the decryption root (sealing it bricks identity).
    #[tokio::test]
    async fn exempt_identity_files_stay_plaintext_on_disk() {
        let (dir, enc, raw) = setup();
        for name in EXEMPT_FILES {
            let path = p(&dir, name);
            let body = format!("contents of {name}");
            enc.write_atomic(&path, body.as_bytes()).await.unwrap();
            assert_eq!(
                raw.read(&path).await.unwrap(),
                body.as_bytes(),
                "{name} must NEVER be encrypted at rest"
            );
            // Reading back through the wrapper also returns the plaintext.
            assert_eq!(enc.read(&path).await.unwrap(), body.as_bytes());
        }
    }

    /// PINNED exemption list. Removing `.lh_wallet` (the seed — the key
    /// derives FROM it) would brick every identity; the others are
    /// pre-wallet boot reads or public model artifacts. Adding entries is
    /// fine; update this pin deliberately.
    #[test]
    fn exempt_list_is_pinned() {
        assert_eq!(
            EXEMPT_FILES,
            &[
                ".lh_wallet",
                ".lh_owner",
                ".lh_linked_owner",
                ".lh_device_key",
                ".lh_local_model.safetensors",
                ".lh_local_tokenizer.json",
                ".lh_notif_pending.json",
                ".lh_notif_inbox.json",
            ],
            "exemption list changed — verify the boot path + seed safety before re-pinning"
        );
        assert!(
            EncryptedFilesystem::is_exempt("some/dir/.lh_wallet"),
            "exemption must match on the file name regardless of directory"
        );
        assert!(!EncryptedFilesystem::is_exempt(".lh_history.json"));
    }

    /// Rename moves the ciphertext verbatim and it stays decryptable at
    /// the new path (ciphertext is not path-bound).
    #[tokio::test]
    async fn rename_preserves_decryptability() {
        let (dir, enc, raw) = setup();
        let from = p(&dir, "draft.txt");
        let to = p(&dir, "final.txt");
        enc.write_atomic(&from, b"movable secret").await.unwrap();

        enc.rename(&from, &to).await.unwrap();

        assert!(EncryptedFilesystem::looks_sealed(&raw.read(&to).await.unwrap()));
        assert_eq!(enc.read(&to).await.unwrap(), b"movable secret");
    }

    /// Two writes of the same plaintext produce different ciphertexts
    /// (fresh random nonce per seal) — no deterministic-encryption leak.
    #[tokio::test]
    async fn fresh_nonce_per_write() {
        let (dir, enc, raw) = setup();
        let a = p(&dir, "a.txt");
        let b = p(&dir, "b.txt");
        enc.write_atomic(&a, b"same plaintext").await.unwrap();
        enc.write_atomic(&b, b"same plaintext").await.unwrap();
        assert_ne!(raw.read(&a).await.unwrap(), raw.read(&b).await.unwrap());
    }

    /// Debug never prints key material.
    #[test]
    fn debug_redacts_key() {
        let raw = Arc::new(NativeFilesystem::new());
        let enc = EncryptedFilesystem::new(raw, &KEY);
        let dbg = format!("{enc:?}");
        assert!(dbg.contains("<redacted>"));
        assert!(!dbg.contains("7, 7, 7"));
    }
}