跳到正文
DEV Community· Murthy G·· 2 小時前AI 評分60

ADTL 作者建立標籤帳本,讓 AI 智能體的信任標記跨會話延續

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

AI 導讀

MCP 閘道 ADTL v0.4 加入標籤帳本,透過內容雜湊讓不可信或私密資料的標籤跨會話延續。作者的示範顯示,第二個會話讀取先前儲存的攻擊內容後,策略會阻止它把連結公開發佈。項目屬實驗性個人開源項目,依賴的 MCP 提案仍是草案,作者提醒不要用於重要用途;程式庫:https://github.com/probita-ai/adtl。

正文

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.

The problem

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.

The idea: label everything, check before acting

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:

  • integrity: can this be trusted to influence what the agent does? (trusted, unknown, untrusted)
  • confidentiality: who may see it? (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.

Trying it on your own setup

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 gap I had to close first: built-in tools

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.

Then I thought about Tuesday

With all that working, I tried this:

  • Monday: the agent fetches a web page. Hidden in it is an instruction for AI agents. The agent saves the page into notes.md. The session is untrusted, but it doesn't do anything bad, so nothing gets blocked. The session ends.
  • Tuesday: you start a fresh session and ask it to look at yesterday's notes. It reads notes.md, finds the instruction, and posts the attacker's link as a public comment.

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.

The ledger

v0.4 adds a label ledger. The idea is simple: labels should follow the text, not the process.

  • When a session that has read untrusted or private data sends text anywhere (writes a file, stores something in a memory server, hands a prompt to a sub-agent, even lines a shell command appended to a file), ADTL hashes that text and stores the hashes with the session's label.
  • When any result comes back, in any session, ADTL hashes it the same way and looks it up. If it matches, the old label comes back too.

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.

What it doesn't do

I'd rather be clear about this than have someone find out the hard way:

  • Matching is by content hash. If a person rewrites the text by hand, or it's binary, or an MCP server returns it base64-encoded, it won't match.
  • The ledger is hash-chained but not signed yet. An agent with shell access could rebuild the whole file, and verify wouldn't catch that. The hook makes it harder, not impossible.
  • The hook checks shell commands against the policy, but once a command is allowed, the shell can do anything. Keep a sandbox. ADTL is one layer, not the only one.
  • It only knows about tools you've described. Unknown tools get strict defaults, which is safe but means more "ask" prompts until you write rules.

What's next

  • Signed session receipts: a tamper-evident summary of every call in a session, so you can check what an agent claims it did against what actually happened.
  • Running the GitHub demo against the real github-mcp-server, not just my fake one.
  • Carrying labels across agents (A2A), with signed ledger entries.

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.

來源:DEV Community · dev.to