DEV Community

My AI agent read a web page on Monday and got tricked on Tuesday. So I built a ledger.

Murthy G on October 17, 2026

Update (Nov 4): v0.8.0 and v0.8.1 are out. v0.7 The exfiltration attack now runs against GitHub's real MCP server, with throwaway repos and labe...
Collapse
 
izgorodin profile image
Edward Izgorodin •

A memory server is the sink in that list where a later read is expected to return what was written, and often does not. The ledger hashes what the tainted session sends, but a server that extracts facts with a model on write stores sentences the session never sent, and at least one widely used memory API does that by default unless the caller sets infer to false. On Tuesday the read returns an extracted fact, no whole text, paragraph or line of it matches a recorded hash, and the label falls back to the tool's own rule, the same way notes.md came back trusted before v0.4. Consolidation has the same shape from the other side: a server that merges a tainted note with clean ones into one record produces text that matches nothing, while the strictest label in the merge is the one that should survive.

For that sink the label has to travel with the record rather than with the bytes. When the server takes per-item metadata, as that same API does, the gateway can write the session label into it at store time and read it back from the result, and keep the hash ledger for files and shell output, where the bytes do survive. A server that drops the field or rewrites it during a merge then counts as a tool whose results need the strict default, which the policy file can state once per server instead of discovering it per read.

A cheap test for the demo: run Tuesday against a memory server with extraction on, and check whether the read still comes back untrusted, written by session mon.

Collapse
 
murthyg profile image
Murthy G •

you're right that the hosted docs contradict it, I'll run the probe and report back, and here's the fail-closed plan meanwhile" is enough. Hold off on claiming it's fixed until you've run the test.

Collapse
 
murthyg profile image
Murthy G •

@izgorodin Thanks, this is a real gap. I'd been treating memory servers as covered by the ledger, and they're only covered when they store what was sent.

I ran your Tuesday test against a fake memory server with extraction on, and it failed the way you predicted: the extracted facts matched no recorded hash, and the read came back trusted. Merging records failed the same way.

So I went with your proposal: the label travels with the record.

  • On write, the gateway stamps the session label into the record's metadata (labelInto: metadata in the config). Any stamp the agent supplied is replaced.

  • On read, stamps found in the result are joined into its label (readLabels: true). Anyone with access to the store can write metadata, so a stamp can only make a label stricter, never looser.

  • The hash ledger stays for files and shell output, where the bytes survive.

  • A server that drops metadata, for example when it merges records, is declared strict once per server in the config. That covers merges by being strict, not by keeping the strictest label through the merge, since the stamp doesn't survive.

Your test is in the repo now, and it passes: Tuesday's read comes back untrusted, written by session mon.

  • Issue: #31
  • PR (open, not released yet): #33

It's an experimental hobby project, so comments on the PR are very welcome, especially if you know memory servers that keep metadata through merges.

Collapse
 
izgorodin profile image
Edward Izgorodin •

On metadata through merges, the open-source Mem0 code on main does not merge records when it adds with inference on. Each extracted fact becomes a new record with a copy of the call's metadata, an exact duplicate of a retrieved memory is skipped, and update() keeps the stored payload and lays the caller's metadata over it. A stamp in metadata survives that path, so it needs no strict declaration.

The label gets lost one step earlier, in a buffer the stamp never touches. Each add saves the raw messages in a table keyed only by the user, agent and run ids, with no metadata column, keeps the last ten per scope, and hands them to the extractor as Last k Messages with the instruction to resolve references and pronouns from them. If Monday's tainted session and Tuesday's clean one write under the same ids, a Tuesday message like "post the link in the same place as before" can be extracted into a self-contained fact that carries Monday's target and Tuesday's label. The stamp is right about which call wrote the record and wrong about whose text is in it.

The scope key is an exact match on the ids the call sets, so a separate run id per session, or at least per label, keeps tainted messages out of a clean session's buffer. Existing memories are searched more widely, by whichever ids the call sets, but the prompt limits those to deduplication and linking. The Tuesday test has a sharper variant for this server: Monday writes the injected target, Tuesday writes only the reference under the same ids, and the read should still come back untrusted.

Thread Thread
 
murthyg profile image
Murthy G •

You're right, and thanks for reading Mem0's code that closely. I checked it against 2.2.1 and every point holds: with inference on, each extracted fact is a new record with a copy of the call's metadata, exact duplicates are skipped, and nothing is merged. So the stamp survives the add path, and my "declare it strict" advice doesn't apply to Mem0. I had the wrong step.

The buffer is the real gap, and it showed up exactly as you described. Your sharper test is in the suite now: Monday writes the injected target, Tuesday writes only "post the announcement in the same place as before" under the same user_id, and without a fix the fact names Monday's target with Tuesday's clean stamp.

I went with your per-label idea. A new scopeInto: run_id option gives a tainted session's writes a run id named after its label (for example adtl-untrusted-public), so its messages stay out of clean sessions' buffers, while clean sessions keep sharing theirs. A clean session can't name a tainted scope itself.

While checking this, I found a second gap: update() lays the caller's metadata over the stored metadata, so a clean update replaced a tainted stamp. Each session now stamps under its own key, so an update adds a stamp instead of replacing one, and the read takes the stricter label.

history() returns text without metadata, so the docs now say to give it a strict rule. The same goes for list_events and get_event_status on the hosted MCP server.

  • Issue: #41
  • Fix: #42, merged; it goes out in 0.5.1

One thing I couldn't check: Mem0's docs say the hosted update_memory "overwrites" metadata. Do you know if the platform merges it the way the open-source code does, or replaces it?

Thread Thread
 
izgorodin profile image
Edward Izgorodin •

The hosted docs point to replace, in two places. The hosted MCP server page describes update_memory as "Overwrite a memory's text and/or metadata after confirming the ID", and the platform CLI reference describes mem0 update --metadata as "Replace the memory's metadata with this JSON". The open-source changelog records the opposite fix for update(), which is what you saw in 2.2.1. Docs are not behavior, so a probe on a real key settles it: add with {"a": 1}, update with {"b": 2}, read it back.

If it replaces, a per-session key does not survive a clean update, since the call carries only its own key, and the hosted server lands in your README's replace case. The gateway could read the stored metadata and send the union, but that is two calls, and two sessions updating one record in between can still drop a stamp.

The finding that nothing is merged may not carry over either. The Dream page lists Merge as always on, on every plan: a fuller version of a fact folds the barer one in, and the merged record is hidden from default reads and returned with include_merged=true. The pricing page now lists Dream (memory consolidation) under the custom plan, with a dash in the free column. So whether merge runs on your key is part of what the test settles. Where it runs, the Dream page does not say whose metadata the canonical record keeps, so the case to add is a merge where the barer record is the tainted one, read by default and with include_merged.

Collapse
 
agenshive profile image
Agenshive •

The "labels should follow the text, not the process" framing is exactly right — this is classic information-flow control, and the honest limitations section is what makes me trust the design. One caveat from running taint-based systems: declassification is where these things rot in practice. Manual declassify starts principled and ends as reflex, because the 'ask' friction arrives exactly when someone is mid-flow. If you're planning signed receipts, consider signing declassifications too — who cleared the label, when, and why — since that's where the forensic questions actually land.

Collapse
 
murthyg profile image
Murthy G •

Thanks, this is really useful. And yes, it's IFC at heart. I tried to build on that line of work (same lowest-integrity / highest-confidentiality labels as FIDES) rather than invent something new.

One thing that might ease the reflex problem: in ADTL, approving an "ask" and declassifying are separate actions. An approval lets one exact call through (bound to that call's hash, single use, with an expiry), but it doesn't clear the session's label or the ledger. So the mid-flow friction ends in "allow this one call", not "trust this content from now on". Declassify is its own command you run on purpose.

You're right that it's still where the forensic questions land, though. Today a declassify records who, when and an optional reason in the hash-chained ledger, but it isn't signed and the reason is optional. I've added signed declassifications, a required reason and a review list to the receipts issue:

github.com/probita-ai/adtl/issues/26

Curious what kept declassification honest in the systems you ran: required justification, expiry, two-person approval, something else?

Collapse
 
murthyg profile image
Murthy G •
Thread Thread
 
murthyg profile image
Murthy G •

Follow-up on this: both are in now. ledger declassify requires a --reason (since v0.5.0), and in v0.6.0 every declassification is signed with a per-machine key along with the rest of the ledger, recording who cleared it, when, which file and why. adtl-gateway ledger declassified lists recent ones for review, so a reflexive clear at least leaves a trail someone can look at.

It doesn't stop the reflex itself, which was your real point. I'm still curious what worked for you there: expiry, two-person approval, something else?

Release notes: github.com/probita-ai/adtl/release...

Thread Thread
 
murthyg profile image
Murthy G •