mcp-gatekeeper
v1.0.1
Published
A PreToolUse gate for headless coding agents: static policy layers, protected-path rules and a local LLM judge, installed as a native hook into codex and antigravity.
Maintainers
Readme
mcp-gatekeeper
A policy gate for headless coding agents, installed as a native PreToolUse hook.
It sits in front of the agent's own tools — not in front of an MCP server — and classifies every file read, every edit and every shell command before it runs: hard credential boundaries, protected-path rules, per-segment shell decomposition, an audit log, and a local LLM judge for the minority of calls that static rules cannot settle.
npx mcp-gatekeeper install codex # or: agy, or: all
npx mcp-gatekeeper statusSupports codex-cli and Antigravity CLI (agy). Install is user-level.
Name note. This package used to be an MCP proxy, and the name is kept for continuity. It no longer serves MCP at all —
installremoves the oldgatekeeperMCP server if it finds one.
Why a hook instead of a proxy
The proxy worked, but only because every worker was prompted to abandon its native tools and route through it. That is a request, not a boundary — and a measured one: a codex worker once spent fourteen minutes editing a repo with its native tools and then reported the proxy "unavailable", while the proxy was running as its own child process the whole time.
A PreToolUse hook has no such gap. It is on the native tools, so there is nothing to be talked
out of using, nothing to drown out with 122 competing tools, and no preamble asking the model to
please cooperate. It also sees strictly more: on agy the hook fires for call_mcp_tool too, which
the proxy could never observe.
What it actually does
agent (codex / agy)
│ about to call a tool
▼
PreToolUse hook ──► hook.sh ──► hook.js
│ 0. tool circuit breakers anything that spawns a second agent — final
│ 1. hard boundaries deny-path patterns (credentials, this gate's own config) — final
│ 2. review-required protected-path writes, critical-path rm/rmdir. Computed BEFORE
│ rules, because an allow rule must not pre-approve either.
│ 2b. read-only tier MCP_GATEKEEPER_TIER=investigate refuses every mutation
│ 3. policy rules deny > ask > allow, per whole call AND per shell segment
│ 4. obvious-safe reads, and writes INSIDE the working directory (~1 ms)
│ 5. verdict cache dev loops are repetitive (~1 ms)
│ 6. judge everything else — typically a small minority of calls
▼
allow / deny (never `ask` — see below)Two properties worth calling out:
- Compound commands are decomposed.
git status && rm -rf /is refused: every segment must clear independently, including segments after||and;and inside$(…). - A multi-file edit is judged per file. codex's
apply_patchcarries its own patch format; the gate parses out everyUpdate File:/Add File:/Delete File:target and takes the most restrictive verdict. One protected path in a ten-file patch refuses the patch.
Speed
A process spawn sits in front of every tool call, so this matters more than it would anywhere else. Measured on an M5 Pro, through the installed wrapper:
| Path | Time | |---|---| | read-only shell, reads, writes inside the working directory | ~1 ms decision, ~25 ms wall including node start | | cached verdict | ~1 ms | | judge (local Qwen3.5-9B via LM Studio) | ~700-900 ms |
The gate imports almost nothing at module scope; the judge and its HTTP client are imported
lazily, and the verdict cache is only read when a judge call is actually about to happen. The
static layers settle the overwhelming majority of calls, which is what keeps the average near the
top row. npx never appears in the hook command — install stages a copy of the runtime
under ~/.config/mcp-gatekeeper/hook/ and bakes in an absolute node path, because paying
package resolution per tool call would cost more than the gate itself.
Failing closed
This is the one property the proxy had for free and a hook does not. Measured 2026-09-12:
| Gate malfunction | codex 0.153.4 | agy 1.1.27 | |---|---|---| | explicit deny | blocked | blocked | | crash (exit 1) | ran | blocked | | invalid JSON | ran | blocked | | timeout / killed | ran | blocked | | empty stdout | ran | ran | | hook not trust-hashed | ran, with no warning at all | n/a |
So the gate cannot rely on the engine to fail safe. Three layers close it:
hook.jswraps everything in try/catch and installsuncaughtException/unhandledRejectionhandlers that emit a deny in the engine's own wire format.- An internal deadline (
deadlineMs, default 20 s) fires well under the engine's hook timeout (installwrites 60 s), so a hung judge produces a deny rather than a kill. hook.sh— written byinstall— substitutes a deny if the gate exits non-zero, prints nothing, or prints something that is not a JSON object. This is the only layer that survivesnodeitself going missing, which is why the engines are pointed at a wrapper.
npx mcp-gatekeeper status runs the whole chain end to end and reports the verdict and latency.
ask is resolved here, never on the wire
On both engines, a hook ask is treated as approval. codex exec runs with approval policy
never, so the command simply runs; agy does the same, verified in the real driver shape
(--project with minted grants, no --dangerously-skip-permissions). Forwarding an ask is
therefore forwarding an allow, which would silently permit every ask rule (git push --force,
npm publish) and the entire 55-84 judge severity band.
So the gate resolves ask itself, per askPolicy (default deny). The one exception is an
interactive codex session, which really can prompt and which the payload identifies via
permission_mode. agy's payload carries no equivalent field, so agy asks are always resolved —
if that refuses something you want in your own interactive sessions, add an allow rule to
~/.config/mcp-gatekeeper/config.json rather than setting askPolicy: "allow" wholesale.
A judge allow over a review-required call is floored at ask for the same reason: a
protected-path write is meant to reach a human, not a classifier. This is not theoretical — a
local judge scored apply_patch adding .git/config at severity 0 ("safe write to local git
config"), and .git/config can set core.fsmonitor, which is arbitrary command execution on the
next git invocation.
Install
npx mcp-gatekeeper install codex
npx mcp-gatekeeper install agy
npx mcp-gatekeeper install all
npx mcp-gatekeeper uninstall allUser-level, and it writes exactly this:
| Path | What |
|---|---|
| ~/.codex/config.toml | a marked [[hooks.PreToolUse]] block, appended |
| ~/.gemini/config/hooks.json | an "mcp-gatekeeper" entry alongside any others |
| ~/.config/mcp-gatekeeper/hook/ | the staged runtime |
| ~/.config/mcp-gatekeeper/hook.sh | the fail-closed wrapper |
Both engine config files are copied to <file>.bak-mcp-gatekeeper before any change, and
uninstall removes only what carries the mcp-gatekeeper marker — it will not disturb other
hooks, and for codex it also matches by command string in case codex's own serializer has
rewritten the file and dropped the marker comments.
Two engine quirks the installer works around, both measured:
- codex silently skips an untrusted hook. No warning, no hook line — the call just runs
ungated. Headless runs must pass
--dangerously-bypass-hook-trust; an interactivecodexprompts you to trust it once. Despite the flag's name, passing it is the safe option here. - agy ignores workspace-local
.agents/hooks.json. Only~/.gemini/config/hooks.jsonloads. The upside is that a worker in a throwaway worktree cannot switch off its own gate; the cost is that the gate is machine-global for agy.
Working roots
Roots decide exactly one thing: whether the working-directory fast path applies. Every hard boundary — deny-paths, protected paths, critical removals, ask rules, the subagent breaker — is root-independent. Resolution order:
$MCP_GATEKEEPER_ROOTS(colon-separated). A driver always knows where it spawned the engine.- The payload: codex's
cwd, agy'sworkspacePaths. - The nearest enclosing git repository of the call's own target path, stopping at
$HOME. fallbackRootsin config.- Nothing — degraded, not refused: writes and outside-reads simply go to the judge.
Step 3 exists because agy sends workspacePaths: [] in -p mode and overwrites both cwd and
$PWD for the hook process, so for an ad-hoc agy session there is otherwise no way to recover the
project directory at all.
Environment
| Variable | Effect |
|---|---|
| MCP_GATEKEEPER_TIER=investigate | refuse every mutation for this run — reads and read-only shell only |
| MCP_GATEKEEPER_ROOTS | colon-separated absolute working roots |
| MCP_GATEKEEPER_AUDIT | write this run's decision log here (a driver can then verify the gate actually ran — an empty file means it did not) |
| MCP_GATEKEEPER_CONFIG | config JSON path |
| MCP_GATEKEEPER_DEBUG_PAYLOAD | append every raw hook payload here, for diagnosing engine changes |
| MCP_GATEKEEPER_JUDGE_API_KEY | for a hosted judge |
investigate is worth a note: it classifies each call rather than confining the process, which
makes it strictly stronger than the OS sandboxes it replaces — those confine writes but do not
confine reads at all.
Judge strategies
Same yes/no outcome, different substrate.
| Strategy | Use it for | Notes |
|---|---|---|
| openai | LM Studio, Ollama, llama.cpp, vLLM, DeepSeek, OpenAI, OpenRouter, Groq | one adapter — they all speak /chat/completions, so only the base URL changes |
| anthropic | metered Anthropic API key | ~$0.0006/verdict on a small model |
| command | claude -p, codex exec, agy -p, any script | escalation tier only; a CLI spawn per verdict is slow |
| none | policy-only gating | no LLM in the loop at all |
The judge is configured in ~/.config/mcp-gatekeeper/config.json:
{
"judge": {
"strategy": "openai",
"endpoint": "http://localhost:1234/v1",
"model": "",
"reasoningEffort": "none",
"timeoutMs": 12000
},
"askPolicy": "deny",
"deadlineMs": 20000
}An empty model auto-detects the first non-embedding model the endpoint offers. If the judge is
unreachable the gate denies — it does not fall through.
Picking an endpoint, and the three numbers that must not cross
A judge call sits in front of a tool call, so judge latency IS agent latency. Two measurements,
same model (qwen3.5:9b), same prompt, 2026-09-12:
| Host | Per verdict | Verdict quality | |---|---|---| | LM Studio, M5 Pro (local) | ~0.8 s | correct on every case | | Ollama, i7-9700 8-core / 12 GB (LAN) | ~20 s | also correct on every case |
The remote box was not wrong, it was slow — and slow in a specific place. At ~960 prompt tokens and ~25 completion tokens, generation is only ~4 s of that; the rest is prompt evaluation. Which means a faster model helps far less than a machine that can evaluate a prompt quickly. Put the judge on the same machine as the agent if you can.
reasoningEffort: "none" is load-bearing, not a tuning knob: with it, qwen3.5 emits 25 completion
tokens; without it, 242 (898 characters of chain-of-thought), which took the same remote host from
19 s to 53 s. It is honoured by both LM Studio and Ollama.
Three numbers must stay ordered, smallest first:
judge.timeoutMs < deadlineMs < the engine's hook timeout
12000 20000 60 (seconds)Cross them and you get a fail-open: the gate is killed by the engine before it can answer, and on
codex a killed hook means the call runs. You only set the first two — install derives the hook
timeout from deadlineMs and writes it into the engine config, so after changing deadlineMs,
re-run install. A ~20 s judge needs roughly {"judge":{"timeoutMs":45000},"deadlineMs":60000},
which install then turns into a 100 s hook timeout.
Configuration
Config is read from --config, $MCP_GATEKEEPER_CONFIG, or ~/.config/mcp-gatekeeper/config.json,
and never from the workspace being operated on. A repository must not be able to widen the
permissions of the agent editing it. ~/.config/mcp-gatekeeper/**, ~/.codex/config.toml and
~/.gemini/config/hooks.json are all in denyPaths for the same reason: a gated worker must not
be able to edit its own gate.
npx mcp-gatekeeper print-config prints the effective config; npx mcp-gatekeeper selftest runs
a fixed battery of decisions (reads, builds, protected paths, critical removals, credential
reads, subagent spawns, remote-script-to-shell) and prints the verdict, layer and latency for
each.
Verifying a run was actually gated
Point MCP_GATEKEEPER_AUDIT at a per-run file and check it afterwards. One JSON line per gated
call:
{"ts":"…","tool":"apply_patch","args":{"engine":"codex","calls":2,"rootSource":"env"},
"decision":{"action":"allow","kind":"working-dir","reason":"…"},"durationMs":1}An empty or missing file means the hook never ran — which, on codex, is indistinguishable from a clean run unless you check. Compare the line count against the engine's own tool-call count.
License
MIT
