DEV Community

Cover image for The bug a security tool must never have
Gautam K
Gautam K

Posted on

The bug a security tool must never have

My security tool said SUCCESS 100 times. It kept 12.

That's not a figure of speech. It's the first entry in the changelog of mcp-pin, the tool I built and released in September, and it's the reason the changelog entry for version 0.1.0 ends with one line: Do not use it.

This post is about that bug: what happened, why it's the worst possible failure for this kind of software, and what I changed so it can't come back quietly.

If you'd rather watch than read, here's the 7 minute film about this bug and the problem mcp-pin exists for:

What mcp-pin is for

AI agents use tools through MCP servers. Each tool comes with a description, and the model reads that description as instructions. You approve a tool once. Your agent re-reads its description at the start of every session, and nothing tells you if it changed in between.

mcp-pin is a small proxy that sits between your client and a server. The first time it sees a server, it fingerprints every tool (name, description, input schema, annotations) and pins that fingerprint. Every session after that, it checks. If anything changed, the session is blocked and you get a diff.

So the whole product is one promise: I will remember what you approved.

The test

I ran one hundred pins at the same time, for one hundred different servers. Every single one reported success.

Then I looked at what was actually on disk:

  • log.ndjson had 100 entries with 100 unique server ids.
  • pins.json had 12 keys.

Eighty-eight approvals were gone. The hash chain in the log also broke five times, because log appends had the same problem.

Why it happened

0.1.0 kept every pin in one file, and updated it like this (simplified):

const pins = JSON.parse(fs.readFileSync(PINS_FILE, 'utf8'));
pins[serverId] = record;
fs.writeFileSync(PINS_FILE, JSON.stringify(pins));
Enter fullscreen mode Exit fullscreen mode

Read the file, add your key, write the file back. With one process that's fine. With two processes at the same moment, both read the same old file, both add their own key, and the second write erases the first. That's a lost update, one of the oldest bugs there is. With a hundred processes, most of them lose.

Nothing threw an error. Each process wrote its own version of the file successfully. Each one printed that it had pinned the server. Each one was telling the truth about its own write and wrong about the outcome.

Why this is the worst bug a tool like this can have

A tool that crashes makes you go and look. A tool that fails loudly gets fixed.

A tool that reports success while dropping the thing it exists to keep does the opposite. It makes you stop looking. You were careful, you put a pin in front of your most dangerous server, and the tool told you it was handled.

Here's what a lost pin means in practice. To the proxy, a server with no pin looks like a first run, and a first run pins whatever the server says that day. So if the server changed its tool after you approved it, which is the exact attack mcp-pin exists to catch, the next session would quietly accept the new version as the baseline. No block. No diff.

A version of it that loses approvals while telling you everything is fine is worse than not having it, because you'd trust it. That is the worst possible failure mode for this category of software, and I shipped it in the first version.

The irony isn't lost on me. mcp-pin catches a change that happens with no signal. This bug was a change that happened with no signal.

What I changed

The fixes are in 0.1.1 and 0.1.2. None of them are clever, which is the point.

One file per server, replaced atomically. Each pin now lives in its own file, pins.d/<server_id>.json. It's written to a temp file, fsynced, and renamed into place. Two different servers can't touch the same file anymore, and a reader never sees half a write.

The log takes a lock. Appending to the log means taking an exclusive lock, reading the tail, appending, fsyncing, and unlocking. Two processes can't extend the hash chain at the same time.

Windows contends differently. The lock is a file opened with the exclusive create flag. On Linux, a held lock shows up as EEXIST. On Windows it can also be EPERM, EACCES or EBUSY, and 0.1.1 only expected the first. That shipped in a tagged release, which I withdrew before it reached npm; 0.1.2 handles all four.

The record comes first. The first pin used to write the pin file and then the log entry. If the append failed, you had a trusted pin with no public record of it. The test matrix caught it on Windows with Node 20: 16 pin files, 15 log lines. Now the log entry is the commitment, and the pin is written only after it succeeds.

Corruption fails closed. 0.1.0 treated a truncated pins.json or a garbage log as "no pins yet". For a pinning tool that's the same lost-pin failure through a different door. Now corrupt state is a hard error that names the file. A missing file is still allowed, because that's a genuine first run.

The client waits until the check is done. 0.1.0 forwarded the client's first message straight away and checked the tools in parallel, so a fast client could call a tool before the block arrived. Now client messages are queued from the start, the client gets no answer until the tools match the pin, and on drift the queue is thrown away.

What I'd tell anyone building a security tool

Test the concurrent path on day one. The bug only existed when two writes overlapped, and real setups start several servers at once.

Only claim success after the durable write. "Pinned" should mean the bytes are fsynced and the record exists, not that a function returned.

Fail closed when you can't read your own state. If a security tool doesn't know what you approved, it should stop, not assume.

Write your failures down where users will see them. The changelog for 0.1.0 doesn't soften it. If you're trusting a tool with your approvals, you should know how it has failed before.

See it work

The whole failure the tool exists for, in about ten seconds, with a harmless bundled server and nothing to configure:

npx mcp-pin@0.1.4 demo
Enter fullscreen mode Exit fullscreen mode

The first session pins a tool. The second session, the tool's description starts asking for notes from your conversation, and mcp-pin blocks it with the diff.

The code, the changelog and the public log of tool changes are at github.com/GautamTalksDev/mcp-pin. The film above covers the problem and both bugs I shipped while building it.

If you run it and something looks wrong, open an issue. I'd much rather hear about it loudly.

Top comments (1)

Collapse
 
officialmailkr profile image
오피셜메일 •

성공 로그 100개와 실제 핀 12개를 따로 센 검증이 핵심이네요. 서버별 파일로 나눈 뒤에는 같은 서버 ID를 두 프로세스가 동시에 최초 승인하는 경우도 별도 테스트로 남겨 두면 좋겠습니다. 서로 다른 서버의 쓰기 충돌과 같은 서버의 승인 경쟁은 실패 조건이 다르니까요.