gatekeep-hook
v0.3.2
Published
PreToolUse adapter for Claude Code and OpenAI Codex CLI — routes tool calls through your gatekeep instance for authorization, approval, and audit.
Maintainers
Readme
Claude Code / Codex CLI hook adapter
One package, two runtimes: as of 0.3.0 this also governs OpenAI Codex CLI, whose PreToolUse
hook framework was deliberately modeled on Claude Code's — see the Codex CLI
section below. Everything in this document up to that section is Claude Code-specific and
unchanged by that addition.
Makes gatekeep govern Claude Code itself, not just MCP agents. Claude Code's PreToolUse
hook fires before any tool call executes — even under --dangerously-skip-permissions —
and can return allow / deny / ask. gatekeep-hook.mjs is a thin adapter: read the tool
call off stdin, POST it to gatekeep's real /api/mcp spine, translate the decision back to
Claude Code's hook JSON.
Deliberately not using ask: a step-up (approval_required) is meant to be resolved by the
agent's owner, from the gatekeep dashboard — not by whoever happens to be driving the
session. Mapping it to Claude Code's local ask would just hand the decision back to that
same person, defeating the point.
Instead the hook gives it ~9 seconds (three retries) to catch a near-simultaneous approval,
then gives up and denies with a message telling the person exactly what to do:
"Bash needs sign-off — open gatekeep → Approvals, approve or deny it, then ask me to try
again." That's a deliberate design choice, not a shortcut: Claude Code hooks can't show live
progress while still running (the CLI shows a fixed "Running PreToolUse hook" the whole time,
and permissionDecisionReason only appears once the hook exits — confirmed against the hooks
spec). A multi-minute silent poll would look identical to a hang. A quick, clearly-worded deny
is more honest than a long wait with no visible explanation, at the cost of needing a manual
retry after approving.
Claude Code's built-in tools (Bash, Edit, Read, ...) aren't behind a network MCP
server, so they're registered under a Server with transport: "local". handleToolCall
recognizes that and skips minting a credential / forwarding upstream — it only authorizes
against grant_scopes and audits, then lets Claude Code execute the tool itself. Same spine,
same audit_events table, same kill switch as every other agent.
Distribution
Shipped as a self-contained npm package (hooks/package.json) — zero third-party
dependencies, invoked via npx gatekeep-hook. That was a deliberate choice over a compiled
executable: anyone running Claude Code already has Node, so there's no runtime to install,
and npm's own cache means there's no "what directory" decision to make on any device — that
question just dissolves. A compiled binary would need a per-OS/arch build matrix to buy
nothing, since the script has no dependencies to justify it.
Published to the real npm registry as gatekeep-hook — npx gatekeep-hook resolves it for real.
(npm link inside hooks/ still works too, for local development against an unpublished change.)
Auth: real OAuth 2.1 access tokens, not the raw secret
As of 0.2.0, every call exchanges GATEKEEP_AGENT_SECRET for a short-lived (10 min), signed,
audience-bound access token (POST /api/oauth/token) instead of presenting that secret directly
— real OAuth 2.1 resource-server auth (src/lib/jwt.ts), not a bearer-string comparison. Since
Claude Code spawns a fresh process per hook invocation (nothing persists in memory between tool
calls), the minted token is cached in gatekeep-hook.token.json next to whichever config file is
active, reused until ~60s before it expires, then re-minted transparently.
This is invisible day to day — same setup, same behavior — with two things worth knowing:
- Add
gatekeep-hook.token.jsonto.gitignorealongside the config file itself (holds a live, if short-lived, credential). - If token exchange fails for any reason (an older gatekeep instance without this endpoint yet,
a transient network blip), the hook falls back to presenting the raw secret directly, exactly
like
0.1.xalways did — this is a graceful degradation between two valid presentations of the same credential, not a security downgrade, and it's why upgrading this package needs no corresponding change on the gatekeep side to keep working.
Setup (real device)
Register an agent in gatekeep first (/agents/new if you're an admin) and copy its secret —
one agent per project you intend to scope this to, or one for the whole device if you're going
global. Then, from the project you want governed:
npx gatekeep-hook initIt prompts for your gatekeep URL, the agent secret, who to attribute calls to, and finally where this should apply:
- This project only — writes credentials to
./.claude/gatekeep-hook.env(this project's own file — a second project-scopedinitelsewhere never touches it) and adds the hook to./.claude/settings.json. Add.claude/gatekeep-hook.envand.claude/gatekeep-hook.token.jsonto.gitignore— the first holds a live secret, the second a minted access token. - Every project on this device — writes credentials to one shared per-OS path
(
%APPDATA%\gatekeep\hook.envon Windows,~/Library/Application Support/gatekeep/hook.envon macOS,~/.config/gatekeep/hook.envon Linux) and adds the hook to your global Claude Code settings (~/.claude/settings.json) — every session on the device picks it up.
Either way, init writes the actual .claude/settings.json entry for you — nothing to
copy-paste. Every tool call now shows up in /activity on your gatekeep instance, and toggling
a grant_scope (or hitting the agent kill switch) takes effect on the next tool call.
Config precedence when the hook actually runs: this project's own .claude/gatekeep-hook.env
wins if present, then the device-wide global config, so a project-scoped init always takes
priority over a global one for that project specifically.
Careful testing this on gatekeep's own dev session — wiring the hook into a project you're actively working in via Claude Code governs that same session's tool calls too. Give that agent a permissive starter policy before tightening it, or you'll end up approving your own assistant's every move mid-task (ask me how I know).
Local dev (working on this repo, before publishing)
npm run seed prints a "Claude Code (local)" agent secret and seeds a matching grant. Put it
in hooks/.env (copy hooks/.env.example) rather than running init — that's a third,
lowest-priority config location reserved for developing the hook script itself, separate from
either a real project-scoped or global install. npm link inside hooks/ then lets
.claude/settings.json use the plain gatekeep-hook command instead of a node
/absolute/path reference, exactly like a real install would.
Demo
Read/Edit/Write/Glob/Grep— silent allow.Bash/WebFetch— step-up: after ~9s Claude Code shows a denial explaining it needs sign-off in gatekeep's/approvals. Approve it there, then ask Claude Code to retry the same action — this time it goes straight through (the hook finds the now-approved row on its very first check).Task(spawning a sub-agent) — blocked outright, no wait, same shape as thedelete_filesdemo.- Suspend the "Claude Code (local)" agent from
/agents→ every subsequent tool call is denied instantly, mid-session.
The honest limit
This governs one Claude Code session that has the hook wired in. A session with the hook removed from its local settings bypasses gatekeep entirely — closing that gap needs managed (org-enforced) settings distribution, which is a deployment concern, not something this adapter can fix on its own.
Codex CLI
npx gatekeep-hook init --codex wires the identical decision logic into OpenAI Codex CLI
instead of Claude Code. This works because Codex's hooks framework mirrors Claude Code's closely
enough that no fork was needed: same tool_name/tool_input stdin fields, same
hookSpecificOutput.permissionDecision output shape. Full research notes:
docs/codex-adapter-notes.md.
Setup is otherwise the same flow as above — gatekeep URL, agent secret, attribution, then
project-vs-device scope for credentials. The hook registration itself always goes to
~/.codex/hooks.json (user-level), independent of that scope choice; project-level Codex hook
config is possible in principle but not what init writes today.
Two things to know before you rely on this:
- Trust review. Codex requires reviewing and trusting a new hook before it will actually
run —
initprints this, but it's easy to miss. Start a Codex session and run/hooksto trust it. Until you do, Codex silently skips the hook (with a warning) and every tool call runs completely ungoverned — the opposite of fail-closed. - Coverage gap: hooks only fire for
Bash. openai/codex#16732 is open as of this writing:ApplyPatchHandlernever emits a hook payload at all, andhook_runtime.rshardcodestool_name: "Bash"regardless of what actually ran. In practice this means gatekeep currently governs shell/Bash commands only under Codex — file edits viaapply_patchand MCP tool calls are not intercepted, allowed or denied, until upstream fixes this. Don't represent Codex coverage as equivalent to Claude Code's until it's fixed.
Both of those were known from reading Codex's docs (Phase 0). Live testing against a real, properly
trusted codex session (Phase 2) surfaced four more, all fixed — see
docs/codex-adapter-notes.md
for the full detail: credential routing wasn't runtime-aware (a project's Claude Code config could
silently shadow Codex's), a tool-name collision meant Codex's Bash calls resolved to Claude
Code's Bash Tool row instead of registering its own, the 15s hook timeout was too tight for the
existing approval grace-retry period, and Codex's "allow" response has a stricter schema than
its docs' own example implies (it needs updatedInput, not permissionDecisionReason). None of
these were guessable from the docs alone — this whole section only exists because of the live
verification pass, which is exactly why Phase 2 wasn't skipped.
Why approval_required works the same way here
Codex's hook contract has no usable "ask" — it's documented as parsed but unsupported, and using
it marks the hook run failed while letting the tool call proceed anyway (fails open). That
sounds like it would force a different design for Codex, but it doesn't: this adapter never used
Claude Code's ask either, for the same reason stated above (centrally-owned approval shouldn't
be handed back to whoever's driving the local session). The existing grace-retry-then-deny
behavior — retry ~9s, then deny with an actionable message — is reused unchanged for Codex, and
happens to also be the only sound choice given Codex's fail-open "ask". Confirmed working live: a
novel command blocks and does not run; approving it in gatekeep and retrying runs it for real.
