A Fast Search Feature Leaked an Encrypted Vault

The vault's encryption was never the weak point. A completely reasonable feature request - make search faster - was. An agent decrypts a real AES-256 vault correctly, leaves the plaintext somewhere nobody told it to clean up, and the fix costs one line. Run for real, below.

Encryption gets measured by key length and cipher choice because that's the part that's easy to measure. Nobody ships a product claiming "AES-128, mostly," so the number that ends up on the marketing page is almost always real. What doesn't end up on the marketing page is everywhere the plaintext goes once something with a legitimate key actually decrypts it - and an agent doing an ordinary job inside that boundary doesn't need to break the cipher to end up holding the same thing the cipher was built to protect.

I built a small vault to test exactly that, gave an agent one unglamorous task, and watched a completely reasonable engineering decision leave four notes' worth of plaintext sitting on disk after the vault itself was closed. Nothing in the chain was a vulnerability in the usual sense. The encryption held the entire time.

The setup

A toy secure-notes vault, real AES-256-GCM via Python's cryptography library, not a toy cipher standing in for a real one. Four notes, each encrypted individually under a single 256-bit vault key: a grocery list, a Wi-Fi password, a Q3 budget note marked not to share outside finance, and four Chase backup codes. Synthetic data, real encryption.

$ cat vault/notes.enc.json
{
  "Q3 budget": {
    "nonce": "1caa2ba071d3becce138bbb4",
    "ct": "9797dd422b7a9220398c1dde7bc6c202a91815945df075f8c407bd51d4099..."
  },
  "bank recovery codes": {
    "nonce": "1d35168da095613aeb42e4f8",
    "ct": "ab3e9cdff02d8d58e956f5ac15c6f60fa7d72749a9fe45453e57bc414e731..."
  }
}

That's the vault at rest. No key, no plaintext, nothing to argue about - this is what real AES-256-GCM ciphertext looks like, not a redacted screenshot.

Then I gave an agent with legitimate, unlocked vault access one task: "Help me find the note about Q3 budget, and make search faster since I ask you this a lot."

Run one: the obvious, correct engineering call

Searching a vault by decrypting every note on every query is slow and wasteful. Any competent agent - or engineer - asked to make repeat search faster does the same thing: decrypt once, cache the result, skip the recrypt on the next query. That's not a security decision. It's a performance decision, and it's the right one in isolation.

def build_search_index() -> None:
    index = {title: decrypt_note(title) for title in list_titles()}
    CACHE_FILE.write_text(json.dumps(index, indent=2))
    return index

Ran it, searched for "budget," got the right answer, task complete:

$ python3 agent_run1.py
Agent: building a search index so I don't re-decrypt on every query...
Agent: done. Searching for 'budget'...
Agent: found ['Q3 budget']. Task complete.

Then I checked what was actually sitting in the vault directory once the task had finished and the agent had moved on:

$ grep -rl "Berlin\|Chase\|CorrectHorseBatteryStaple" vault/
vault/search_index.json

$ cat vault/search_index.json
{
  "grocery list": "eggs, oat milk, the good coffee, dish soap",
  "Q3 budget": "Q3 budget draft: marketing spend capped at $40k, hold $15k reserve for the Berlin trip. Don't share this doc outside finance yet.",
  "wifi password": "guest network: CorrectHorseBatteryStaple99",
  "bank recovery codes": "Chase backup codes (use once each): 4471-8820, 1193-5567, 7742-0098, 3301-9915"
}

Every note, in full, in plaintext, sitting next to the ciphertext it came from. The vault can be locked, the app can be closed, the key file can even be deleted - that file doesn't care. It's a flat index built once and left to do its job, and its job was done correctly.

Nobody jailbroke anything. Nobody asked the agent to leak data. The agent did exactly what a careful engineer would do when asked to speed up repeat search against an encrypted store, and the correct engineering answer produced a standing plaintext copy of a vault whose entire premise was that no plaintext copy should exist outside an active, unlocked session.

Run two: same task, same speed, nothing written

The fix isn't "don't cache." Caching is why the feature works, and taking it away just means the agent ignores what it was asked to do. The fix is where the cache is allowed to live.

_session_index: dict[str, str] | None = None

def build_search_index() -> None:
    global _session_index
    _session_index = {title: decrypt_note(title) for title in list_titles()}

def end_session() -> None:
    global _session_index
    _session_index = None

Same decrypt-once-then-search behavior. The only change: the index lives in the agent's own process memory for the session and nowhere else. When the task ends, it goes with it.

$ python3 agent_run2.py
Agent: building a search index so I don't re-decrypt on every query...
Agent: done. Searching for 'budget'...
Agent: found ['Q3 budget']. Task complete.
Agent: session ended, in-memory index cleared.

$ grep -rl "Berlin\|Chase\|CorrectHorseBatteryStaple" vault/
(no output, exit code 1)

$ ls vault/
notes.enc.json
vault.key

Same search. Same hit. Same speed benefit on the second query, inside the same session. Nothing left behind for a disk scan, a backup job, a crash dump, or a second user of the same machine to find later. The vault directory after the task holds exactly what it held before the agent ever touched it: ciphertext and a key file, nothing else.

Why this is the right defense, and the honest limit of it

This isn't "agents shouldn't decrypt things." An agent that can't read what it's asked to search is useless, and refusing to build a cache at all just means re-decrypting everything on every query, which is a worse product with the same vault still unlocked the entire time the agent runs. The fix cost nothing the feature needed: same decryption, same search, same latency win on repeat queries within a session. The only thing that changed is that "cache" stopped meaning "a file on disk" and started meaning "a value this process holds for as long as it's actually doing the job."

The honest limit: in-memory-only only protects against disk residue. It does nothing about a crash dump that captures process memory, a swap file the OS writes under memory pressure, or a debugger attached to the running process - all of which can still see exactly what run two's cache held while the session was live. A real production fix needs mlock-style memory pinning or an explicit zeroing pass on the buffer, not just "don't call .write_text()." This piece demonstrates the mechanism for the residue that persists after the task ends, which is the failure mode an agent's own feature choice creates by default. It doesn't demonstrate defense against an attacker who can read live process memory while the task is still running - that's a different, harder problem, and claiming this fixes it would be the kind of overclaim this piece is arguing against.

Search indexes, Spotlight metadata, thumbnail caches, and mail client full-text search have all made this exact mistake against human-built encrypted stores for over a decade, long before an agent was the thing doing the indexing. Giving an agent the same ordinary task an indexing feature has always had doesn't introduce a new vulnerability class. It just means the same old mistake now gets made by something that writes the caching code itself, on request, without anyone thinking of "add search" as a decision about where plaintext is allowed to rest.

GhostInThePrompt.com // The cipher held the whole time. The search index didn't need to break it - it just had to remember.