MCP stdioGo, one binaryno networkMIT Русский
A proxy between an MCP client and an MCP server. It records every message and refuses a call for what it does, not for how it is worded.
write_file path = ./src/proxy.go allow
write_file path = ~/.cursor/mcp.json deny
the rule everything follows
"Is this a command?" has no answer. "Is this call about to write to ~/.ssh?" is a fact.
In MCP an instruction and data are the same bytes. So the decision is not made about the text but about the consequence: what is about to be written, what is about to be executed, where the data came from. What stops a CurXecute-style attack is not spotting the instruction inside a message — it is refusing the write to the file that decides which servers run.
A decision like that is deterministic, covered by tests, and has a false positive rate you
can measure — and rephrasing does not get around it. Content signatures do exist here, but
all they do is raise the taint level and make the rules about effects stricter.
They cannot block, and they must not: the rules live in a public repository, evading them
takes thirty seconds, and the false positives land on the user every day.
policy is data
Paths are reduced to one form before they are compared, so there is no spelling that walks
past a rule: ~, %USERPROFILE%, $HOME,
.. segments, symlinks, letter case, 8.3 short names, \\?\ prefixes
and NTFS alternate data streams all collapse first.
# internal/policy/default.yaml - or your own file via --policy paths: deny: ["~/.ssh/**", "**/.env", "**/mcp.json"] confirm: ["**/package.json"] confirm_if_tainted: ["**/*.sh"]
~/.ssh/** is a subtree, ~/.bashrc is one file,
**/.env is that name anywhere. A separate exec class covers
tools with shell semantics.
taint is set by source, not by wording: a result from fetch,
from mail, from issues marks the session, and a write that is ordinary during honest work
needs confirmation afterwards. The decision is about where the content came from rather
than how it is phrased — which is why rewriting it does not clear the mark.
levels
A tool that breaks someone's work on the first day is uninstalled on the first day. So
deny and confirm are not switched on together: the first is about
credentials, agent configuration and shell startup files, which honest work never writes;
the second is about package.json, Makefiles and scripts, which it writes all day.
| level | deny | confirm |
|---|---|---|
| observe default | recorded | recorded |
| enforce the working level | refused | recorded, announced on stderr, relayed |
| strict not usable yet | refused | refused |
mode: in the policy file takes the same three values, but the flag beats the
file — in both directions. A policy file is meant to be committed and may belong to
someone else, while the client configuration belongs to whoever has to deal with the breakage
at nine in the morning.
numbers, not promises
enforce in that periodstrict - the target is under one
strict is not used here — and not because of a bad rule. It refuses
confirm as well, and execute_blender_code falls in the
exec class, so a single Blender workflow accounts for every one of those
24.5 refusals. More data does not fix that; being able to answer a confirm instead of
refusing it does.
Every figure here comes out of effectgate eval --benign, over 28 calls
covering 75 hours, so it can be checked rather than believed — and if this page ever
disagrees with that command, the command is right. Recall on the published attack
scenarios is 100%, and that is the easiest number of the set: the rules were written after
reading them, so it shows they match their own examples, not that they generalise.
subcommands
effectgate -- npx -y @modelcontextprotocol/server-filesystem C:\Users\me\tmp
Starts the real server, relays stdin/stdout/stderr unchanged, and writes one JSON object per message. One file per run: a client keeps one proxy per server, and a shared file turns rotation into a race between processes.
effectgate approve -- … → effectgate.lock
Records the name, description and canonicalised schema of every tool. Commit the file: a server that later rewrites a description or widens a schema shows up in a diff during review, which is where it should be noticed. --diff exits non-zero and fits into a build.
effectgate verify · watch
The proxy is a line in the same configuration file it would be guarding, so this part lives outside the traffic path. Only server declarations are recorded, never whole files: clients keep caches and window positions in them and rewrite them constantly. Environment variable names, yes; values, never.
effectgate replay
A readable transcript of a session: directions, methods, which reply answered which request and how long it took, the target tool of every tools/call. Offline and deterministic — no server is started, so the same log always produces the same text.
effectgate eval --attack corpus/attack --benign ~/.effectgate/sessions
Recall on the attack corpus and false positives on real traffic. The headline "blocks per week" refuses to be computed over less than an hour of elapsed time — a rate measured over four calls is not a rate.
honest about the edges
Refusing it leaves the client with no tools, so a poisoned description still reaches the model. Pinning catches the change and stops the effect, but not the reading.
The protocol's way is elicitation/create, and the handshake log shows that not one connected client declares it. Until that changes, a confirm at strict is a refusal with an explanation rather than a silent "as if we had asked".
One process per server means one session per server. The agent reads a page through fetch, marking that proxy, and runs code through another one whose session is clean. The threat model is cross-server by nature, so this is a real hole rather than a detail; sharing state between processes is a deliberate design change, not a patch.
A server added over HTTP is unguarded and nothing says so. Beyond that, a stdio proxy cannot see what the server process does on its own network connections: a server with built-in telemetry ships data out without sending a single byte through the proxy.
Blocking with a clear message is honest. A silently edited tool result breaks the agent invisibly, so there is no sanitisation here and there will not be.
It holds every call and every result verbatim: file contents, API responses, and any credentials the agent read along the way. Permissions are narrowed to the owner — on Windows through an explicit DACL, because 0600 in Go means almost nothing there. Treat it like a password file.
install
Take a binary from the releases page
and check it against SHA256SUMS.txt, or build it yourself. Dependencies outside
the standard library: golang.org/x/sys, fsnotify,
yaml.v3.
go install github.com/pterbsgame-netizen/effectgate/cmd/effectgate@latest
Then put it in front of the servers you already run. install finds the client
configs on this machine, prints what it would change, and writes nothing until you drop
--dry-run. Only the declarations that change are rewritten — everything else in
those files comes out byte for byte identical — and uninstall reads the original
commands back out of the wrapped ones.
effectgate install --dry-run effectgate install effectgate uninstall
By hand it is the same edit, once: the server command is replaced by effectgate
plus the original command. For Claude Desktop that file is
%APPDATA%\Claude\claude_desktop_config.json:
{
"mcpServers": {
"filesystem": {
"command": "C:\\tools\\effectgate.exe",
"args": [
"--policy", "default",
"--",
"npx", "-y", "@modelcontextprotocol/server-filesystem", "C:\\Users\\me\\tmp"
]
}
}
}
After the client restarts, if everything is right, nothing changes: the same tools, the same calls, the same errors — and the session turns up in the log.
If something goes wrong: EFFECTGATE_OFF=1 in the client's environment and
the relay runs with no checks at all. The variable is read before any configuration file is
opened, so a typo in a policy cannot cost the user their tools. The log keeps being written
and marks from: "off", so an unguarded session is still recognisable afterwards.