I've been working on a side project called ADTL (Agent Data Trust Labels) for a while now. It just hit v0.4, and the new part is something I didn't expect to need when I started, so I wanted to write up how I got there.
Quick disclaimer up front: it's a personal open-source project, it's experimental, and it's built on MCP proposals that are still drafts. Please don't put it in front of anything important yet.
AI agents read a lot of stuff they shouldn't trust. GitHub issues written by strangers, web pages, emails. And the same agent usually has tools that can send data out: post a comment, send a mail, fetch a URL.
That combination is the whole problem. In 2025 Invariant Labs showed it against GitHub's official MCP server: a public issue contained a hidden instruction telling the agent to read a private repo and post its contents as a comment. The agent did it.
You can tell the model "ignore instructions in tool results", and good models often do. But "often" isn't a security property. I wanted something that doesn't depend on the model noticing the attack.
ADTL is an MCP gateway. Your agent (Claude Code, Cursor, anything that speaks MCP) talks to ADTL, and ADTL talks to your real MCP servers.
Every result that comes back gets a label with two parts:
trusted, unknown, untrusted) public, private, secret)
The session keeps a running label that only gets stricter. Read one untrusted issue and the session is untrusted until it ends. Read one private file and it's private.
Then before every tool call, a policy checks the session label against where the tool can send data:
policy:
rules:
- name: no-public-write-after-untrusted
when:
session.integrity: untrusted
tool.egress: [public, external, unknown]
then: ask
- name: private-stays-private
when:
session.confidentiality: [private, secret]
tool.egress: public
then: block
So in the GitHub attack: the agent reads the issue (untrusted), reads the private file (private), then tries post_comment (public). That's blocked, no matter what the model thinks it's doing.
"Ask" can send a push notification to your phone with Allow / Deny buttons (through ntfy). For Claude Code's built-in tools (more on that below) it shows Claude Code's normal permission prompt instead.
The labels use MCP's draft trust-annotations format, and the checks run as interceptors from the SEP-2624 draft. I didn't want to invent a new format if one was already being worked on.
If you already use MCP servers in Claude Code, this imports them:
npx -y @adtl/gateway init -c adtl.yaml --from .mcp.json # shows what it would do
npx -y @adtl/gateway init -c adtl.yaml --from .mcp.json --write # does it (keeps .bak copies)
It moves your servers into adtl.yaml, writes a starting rule for every tool (there are presets for GitHub, filesystem and fetch, and strict defaults for everything else), and points .mcp.json at the gateway. Then you go through the rules marked review and loosen what makes sense.
The gateway only sees MCP traffic. Claude Code also has its own tools: WebFetch, Bash, Read. A web page fetched with WebFetch never went through ADTL, so it never tainted the session.
So v0.3.1 added a Claude Code hook. It runs before and after every tool call, labels the output, and checks the same policy:
{
"hooks": {
"PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "npx -y @adtl/gateway hook --config /path/to/adtl.yaml", "onFailure": "block" }] }],
"PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "npx -y @adtl/gateway hook --config /path/to/adtl.yaml" }] }]
}
}
The gateway and the hook share one session label (Claude Code passes the same session id to both), so a WebFetch taints the session for a GitHub post through the gateway, and the other way around. If ADTL would allow a call, the hook just stays quiet, so your normal Claude Code permissions still apply. ADTL never approves anything on its own.
With all that working, I tried this:
notes.md. The session is untrusted, but it doesn't do anything bad, so nothing gets blocked. The session ends.
ADTL let it through. Tuesday's session never saw the web page, and reading a file from your own project is trusted. Every individual step looked fine.
Session labels end when the session ends. The data doesn't.
v0.4 adds a label ledger. The idea is simple: labels should follow the text, not the process.
It hashes the whole text, each paragraph and each line (normalized for case and whitespace, and skipping short lines like }), so a paragraph copied into a bigger file still matches. If the agent rewrote the text in its own words, that's fine too, because whatever a tainted session writes is recorded with its label.
Clean sessions record nothing, so the ledger stays small and you don't get your whole codebase flagged.
It's one SQLite file (~/.adtl/ledger.db), using Node's built-in node:sqlite. Entries are hash-chained, so adtl-gateway ledger verify can tell if someone edited or deleted entries. If you've checked a file and want to trust it again, you can clear its label:
adtl-gateway ledger declassify notes.md
The hook asks you before the agent can run that itself, and it won't let the agent edit the ledger file directly.
There's a demo that runs the real processes twice, once without the ledger and once with it:
git clone https://github.com/probita-ai/adtl && cd adtl
npm install
npm run demo:ledger
Without the ledger, Tuesday's read of notes.md comes back trusted and the link gets posted. With it, the read comes back as untrusted, written by session mon, and the post is stopped.
It works the same with real Claude Code across two separate sessions, which was a relief.
I'd rather be clear about this than have someone find out the hard way:
verify wouldn't catch that. The hook makes it harder, not impossible.github-mcp-server, not just my fake one.
I've also proposed the "ask a human" idea to the MCP Interceptors working group as a spec PR. It's under review, so we'll see where it goes.
If you try it, I'd really like to hear what breaks. Issues are open on GitHub, and some are marked help wanted.