A fast PreToolUse permission hook for coding agents — Claude Code, OpenAI Codex CLI, and xAI grok — that replaces glob-based permission arrays with PCRE2-compatible regex rules. One policy, enforced across every harness.
Naming (2026-07-10): the public GitHub repo is
jim80net/gatekeeper-claude(renamed fromclaude-gatekeeper; old URLs 301 to the new name). The binary, Claude Code plugin id, and Go module path still useclaude-gatekeeperso fleet dogfood and existing installs keep working. Full matrix: COMPAT.md. Product family (core + adapters): gatekeeper-flotilla charter.
Agent permission globs (Bash(git add:*)) can't match env-prefixed commands like FOO=bar git commit, pipe chains, HEAD:main refspecs, or complex argument patterns. Regex can.
agent-gatekeeper evaluates every tool call against a layered set of regex rules and returns allow, deny, or abstain (no opinion — the harness's native permission system decides). Deny always wins. When a tool call is denied, the agent sees the reason and can adjust.
One binary serves three harnesses, selected by --harness (or the GATEKEEPER_HARNESS env var; defaults to claude). Tool names are normalised to a canonical taxonomy inside each adapter, so the same gatekeeper.toml rules apply verbatim to all three.
| Harness | Wire | Status |
|---|---|---|
claude |
hookSpecificOutput.permissionDecision (exit 0) |
Stable. Byte-for-byte compatible with prior releases. |
codex |
Claude-compatible hookSpecificOutput envelope, narrower value set |
Live-verified (2026-07-03, codex-cli 0.142.5); ALLOW-value fix 2026-07-08. permissionDecision:"deny" blocks even under approval_policy=never (full auto); silent abstain falls through to the native policy; codex reads both ~/.codex/hooks.json (global) and project .codex/hooks.json. |
grok |
grok-native {"decision":"deny","reason":...} (exit 2 deny / exit 0 allow); abstain = fail-open (exit 1) |
Live-verified (2026-07-03, grok 0.2.82). A global ~/.grok/hooks/ PreToolUse hook deny ({"decision":"deny"} + exit 2) blocks the tool call even under --permission-mode bypassPermissions (full auto); an abstaining call (exit 1, no output) falls through and runs. |
codex is verified as a real gate: on a fully-autonomous codex agent (approval_policy=never), a hook deny — and therefore on_error = "deny" — actually blocks the command. The default abstain falls through to the native policy exactly as documented. Only deny is ever emitted explicitly. permissionDecision:"allow" and permissionDecision:"ask" are both rejected by codex's own PreToolUse handler ("PreToolUse hook returned unsupported permissionDecision:allow"/"...ask" — extracted verbatim from the codex-cli 0.143.0 binary, despite both being schema-legal members of the shared wire enum) — an ALLOW verdict is therefore encoded identically to abstain (nothing written), never as an explicit "allow".
grok is verified as a real gate too (2026-07-03 live probe, grok 0.2.82). In an isolated sandbox under --permission-mode bypassPermissions (grok's full-auto), a global ~/.grok/hooks/ PreToolUse hook that emits the gatekeeper's grok-native {"decision":"deny"} + exit 2 blocked a canary command (the file was never created), while an abstaining call (exit 1) let a control command run. So the gatekeeper's grok adapter is a real in-harness hard control on auto-approve grok agents — grok evaluates PreToolUse hooks before the permission system, "regardless of permission mode." Re-confirmed under the exact deploy config permission_mode = "always-approve" (which grok resolves to the same bypassPermissions mode it reports in the hook payload): deny blocked, abstain ran.
Grok 0.2.101's shipped hook guide and locally captured shipped tool schemas also pin these native input shapes: run_terminal_command.command, read_file.target_file, search_replace.file_path, write.file_path, list_dir.target_directory, grep.pattern, and web_fetch.url. Golden hook fixtures live in internal/adapter/grok/testdata. web_search is a verified native tool name, but its primary input field remains unverified without a live hook capture; the adapter deliberately does not guess one, so input-targeted WebSearch rules are not supported yet.
Grok hook wire — verified schema (corrects earlier inference). grok's PreToolUse hook stdin is camelCase:
toolName(the shell tool is"Shell"),toolInput({command,description}),hookEventName(value"pre_tool_use"),cwd/workspaceRoot,permissionMode. Register as a global~/.grok/hooks/*.jsonin the Claude-shaped{ "hooks": { "PreToolUse": [ … ] } }format. (Grok's separate settings-layer--denylist is a different mechanism and is not enforced under--always-approve— the hook is; don't rely on settings-deny for auto-approve agents.)
Register the hook per harness:
claude-gatekeeper setup --harness claude # writes ~/.claude/settings.json (default)
claude-gatekeeper setup --harness grok # writes ~/.grok/hooks/gatekeeper.json
claude-gatekeeper setup --harness codex # writes ~/.codex/hooks.json (global; preferred)
claude-gatekeeper setup --harness codex --project-dir . # writes ./.codex/hooks.json (project-scoped)Codex silently skips user-installed hooks until their exact normalized identity
has persisted trust. To prevent an apparently successful but inert install,
Codex setup writes the hook and then verifies its matching trusted_hash in
~/.codex/config.toml. A trusted hook exits successfully. A new hook or a hook
whose command/config changed exits nonzero with remediation: run /hooks in
Codex, review and approve the installed hook, then rerun setup. Vetted non-interactive
automation may instead launch Codex with --dangerously-bypass-hook-trust.
Use the read-only doctor command before changing an installed binary or hook:
claude-gatekeeper doctor
claude-gatekeeper doctor --json
claude-gatekeeper doctor --expected-binary ~/go/bin/claude-gatekeeper
claude-gatekeeper doctor --expected-version 1.3.1
claude-gatekeeper doctor --min-surfaces 3
claude-gatekeeper doctor --json --check-latest --min-surfaces 3It inventories live references in ~/.grok/hooks/gatekeeper.json,
~/.codex/hooks.json, Claude settings files, and installed Claude plugin hook
manifests. For each surface it reports the configured or wrapper-resolved binary,
the result of claude-gatekeeper --version, the selected harness, and drift from
the running command's expected binary/version and the surface's required harness.
For the global Codex surface, it also recomputes the current trust hash and
reports missing or stale persisted trust as drift, because Codex otherwise skips
that installed hook silently.
Plugin bin/run.sh entries resolve to the plugin's adjacent
bin/claude-gatekeeper; the doctor never executes the download/build wrapper.
Exit status is 0 when all discovered surfaces match, 1 when drift is found, and
2 when inventory or output fails. JSON output is intended for fleet automation
and contains ok, expected_binary, expected_version, min_surfaces,
warnings, files, and surfaces fields. With --check-latest, it also
contains a version_invariant built from executable-reported surface versions
and the latest published release. fail and unknown both exit nonzero; the
published source or an executable probe can never fail into OK. See
docs/doctor-detector.md for the independent
user-timer template and its deployment boundary.
The inventory subcommand is an exact alias for doctor.
The automated inventory is intentionally user-global. Project-scoped
.codex/hooks.json files written by setup --harness codex --project-dir ...
and project .claude/settings.json files are not searched because there is no
bounded, authoritative list of fleet project roots. Until project-scoped
discovery ships, those files remain a manual checklist item (for example, search
known workspaces for both paths). Set --min-surfaces to the fleet's expected global count so missing or
unparseable discovery cannot produce a successful migration gate.
Grok requires the project folder to be /hooks-trusted; Codex requires persisted hook trust (or --dangerously-bypass-hook-trust for vetted automation). Codex reads both the global ~/.codex/hooks.json and a project .codex/hooks.json.
/plugin marketplace add jim80net/claude-plugins
/plugin install claude-gatekeeper@jim80net-pluginsPre-built binaries for Linux, macOS, and Windows (amd64/arm64) are auto-downloaded from GitHub Releases on first run. Default rules are auto-copied to ~/.claude/gatekeeper.toml on first run.
If the plugin wrapper cannot find, download, or build the gatekeeper binary, it
blocks the tool call by default (exit 2) and prints remediation. An explicit
on_error = "abstain" in the normal layered config preserves fail-open behavior.
For first-install environments that intentionally prefer availability before
enforcement, GATEKEEPER_BOOTSTRAP_ABSTAIN=1 is an escape hatch; it emits a
warning that no gatekeeper policy is active.
Windows (PowerShell): If you're on native Windows without Git Bash, edit hooks/hooks.json and change the command to:
powershell -NoProfile -ExecutionPolicy Bypass -File ${CLAUDE_PLUGIN_ROOT}/bin/run.ps1
git clone https://github.com/jim80net/gatekeeper-claude.git
cd gatekeeper-claude
make build
claude --plugin-dir .(git clone …/claude-gatekeeper.git still works via GitHub redirect.)
Download a pre-built archive from Releases, extract it, and point Claude Code at the extracted directory:
claude --plugin-dir /path/to/gatekeeper-claudeRelease asset filenames remain claude-gatekeeper_${os}_${arch}.tar.gz (binary name lag).
- The harness invokes the gatekeeper before each tool call, sending JSON on stdin.
- The
--harnessadapter parses that harness-native JSON into a canonical tool call (normalising the tool name, e.g. grok'srun_terminal_command→Bash). - On first run, the shipped
gatekeeper.tomlis auto-copied to~/.claude/gatekeeper.tomlif no global config exists. - Rules are loaded and layered (see Config layering).
- Each rule has a
toolregex (matched against the canonical tool name) and aninputregex (matched against the command/file path/URL). - Deny always wins: if any deny rule matches, the call is blocked and the agent is told why.
- If any allow rule matches (and no deny), the call is auto-approved.
- If nothing matches, the gatekeeper abstains — it writes no verdict and the harness's native permission system decides. A missing or empty config also abstains unless the independent fail-closed posture below is active.
- On any gatekeeper error, the
on_errorposture decides (default: abstain).
For copy-pasteable policies with the real failure stories and regression probes behind them, see the rule cookbook.
The shipped gatekeeper.toml (auto-installed to ~/.claude/gatekeeper.toml on first run) denies:
| Category | Examples |
|---|---|
| Destructive git | git reset --hard, git clean -f, git push --force, git commit --amend, git branch -D |
| Push to main/master | A boundary regex covering origin main, -u origin main, HEAD:main, main:main, :main (delete), +main, refs/heads/main, git -C <path> push origin main, and implicit (on main branch, bare git push) — while allowing branches merely named main-feature/mainline |
| Recursive delete | rm -r, rm -rf |
| sed/awk | Forces the Edit tool instead |
| Destructive SQL | DROP, TRUNCATE, DELETE FROM |
| npm | Use pnpm instead (commented out by default — uncomment to enable) |
| Credential files | .env, .envrc, *key.json, id_rsa, .pem, credentials |
And allows:
| Category | Examples |
|---|---|
| Version control | git, gh |
| Containers | docker, docker-compose |
| Python | python, uv, pip, pytest |
| Go | go build, go test, golangci-lint |
| JavaScript/TypeScript | node, npx, pnpm, eslint, vitest |
| Build systems | make, cargo, gradle, mvn |
| Infrastructure | terraform, kubectl, helm, aws, gcloud |
| Shell utilities | ls, find, mkdir, curl, diff, wc, jq, openssl, timeout |
| Non-Bash tools | Read, Edit, Write, Glob, Grep, Agent, WebFetch |
[[rules]]
tool = 'Bash' # PCRE2 regex matching tool_name
input = 'git\s+reset\s+--hard' # PCRE2 regex matching the primary input
decision = "deny" # "allow" or "deny"
reason = "Destructive: git reset" # Shown to Claude on denyFor rules that need runtime context (e.g., checking the current git branch):
[[rules]]
tool = 'Bash'
input = '\bgit\s+push\b(?!.*\b(main|master)\b)'
precondition = 'git branch --show-current'
precondition_match = '^(main|master)$'
decision = "deny"
reason = "Implicit push to main/master"The precondition command runs only when tool and input both match. It has a 5-second timeout. The shell inherits the process environment plus GATEKEEPER_INPUT, set to the (heredoc-stripped) tool input under evaluation. That lets preconditions inspect the command itself — for example, parse gh pr merge --repo OWNER/REPO and compare it to the worktree's authority domain (git remote get-url origin, or an optional .gatekeeper/domain file). See scripts/merge-domain-check.sh for a fail-closed reference implementation used by multi-agent fleets (lead seat + domain-scoped merge).
Commands like FOO=bar git commit bypass anchored patterns. The defaults include commented-out variants:
# Default (anchored):
input = '(?:^|[|;&]\s*)git\s'
# Env-prefix aware (uncomment to enable):
# input = '(?:^|(\w+=\S+\s+)*)git\s'Paths are harness-neutral, with a back-compatible ~/.claude fallback. For each layer the first path that exists is used:
| Layer | Path (first that exists) | Scope |
|---|---|---|
| Global | ${XDG_CONFIG_HOME:-~/.config}/gatekeeper/gatekeeper.toml, then ~/.claude/gatekeeper.toml |
All projects. ~/.claude is also the first-run write target, so existing installs are undisturbed. |
| Project | <cwd>/.gatekeeper/gatekeeper.toml, then <cwd>/.claude/gatekeeper.toml |
Per-project (rules appended to global; scalar knobs like on_error overridden). |
Deny always wins across all layers. If no config files exist, the gatekeeper abstains on everything.
A top-level knob controls what the gatekeeper emits when it itself fails — unparseable stdin, a bad rule regex, an evaluate error, or a recovered panic. A readable global knob can also protect a malformed project overlay. A clean "no rule matched" is not an error and always abstains. Because this knob lives in the policy file, it cannot protect against its own file being missing or malformed; unreadable policy cannot communicate its own posture.
on_error = "abstain" # default — emit NO verdict; the harness's native permission system decides.
# on_error = "deny" # opt-in hard posture — on any error, emit an explicit deny.For a fail-closed posture that survives missing or malformed global/project policy, set the independent environment override on the hook command:
GATEKEEPER_ON_ERROR=deny claude-gatekeeper --harness claudeThe override is read before hook input or configuration. deny makes unparseable input, missing/malformed policy, engine errors, and zero loaded rules emit the harness-native deny. abstain explicitly selects advisory behavior. If the variable is absent, existing TOML/default behavior is unchanged. Any other non-empty value warns and fails closed; a typo cannot silently widen authority.
An empty XDG policy is selected ahead of the legacy ~/.claude/gatekeeper.toml for compatibility, but now always emits a stderr warning, including the shadowed legacy path when present. Under GATEKEEPER_ON_ERROR=deny, its zero-rule result denies rather than silently abstaining.
The default is abstain: the gatekeeper never decides for the permission system on its error path (it forces neither allow nor deny). Each harness encodes abstain natively — Claude/Codex write nothing and exit 0 (their native flow runs); grok has no first-class defer, so its adapter routes abstain through grok's documented fail-open path (no verdict, non-deny exit) so no allow is ever asserted.
On an auto-approve agent (grok
--always-approve, codexapproval_policy=never) the native decision is auto-run, so under the defaultabstaina gatekeeper error is a no-op there. If you need a hard floor that also covers policy loss/corruption, setGATEKEEPER_ON_ERROR=denyon the hook command and keep a server-side control (e.g. GitHub branch protection) — the hook alone cannot defend against not being invoked.
- Global config (
~/.claude/gatekeeper.toml) — trusted, controlled by you. - Project config (
.claude/gatekeeper.toml) — comes from the repository. A malicious repo could add allow rules or precondition commands that execute shell commands. Review project configs before trusting them. Precondition commands run with a 5-second timeout.
If you have existing permissions.allow / permissions.deny globs in your settings:
claude-gatekeeper migrateThis reads ~/.claude/settings.json and settings.local.json, converts permission globs to regex rules, and writes ~/.claude/gatekeeper.toml. A backup is created if the output file already exists.
Options:
claude-gatekeeper migrate --settings /path/to/settings.json --output /path/to/output.tomlReview the generated TOML — some globs may need manual refinement.
claude-gatekeeper test runs declarative cases through the same canonical rule
engine as live hooks. By default it loads the normal global plus project config
layers for the current directory. Use --config to test one candidate policy
file in isolation; the command exits 0 when all cases pass, 1 for expectation
failures, and 2 for invalid input or evaluation errors, making it suitable for
CI.
claude-gatekeeper test examples/force-push-policy-tests.toml
claude-gatekeeper test --config ./gatekeeper.toml examples/force-push-policy-tests.tomlCases may be TOML or JSON. command is a readable alias for input when the
tool is Bash; other tools use input. expected_reason is optional and, when
set, must exactly match the final rule reason.
[[cases]]
name = "force push is denied"
tool = "Bash"
command = "git push origin feature --force"
expected = "deny" # allow | deny | abstain
expected_reason = "Destructive: git force push"The shipped force-push matrix captures the wrapper, argument-position, newline, subshell, and false-positive probes used to review the default policy.
auth-domains shadow compiles a synthetic D1 block/exception generation and
simulates one request against an explicit coverage manifest:
claude-gatekeeper auth-domains shadow \
--policy examples/auth-domains/policy.json \
--request examples/auth-domains/request-protected.json \
--coverage examples/auth-domains/coverage.json
This is I1a inspection, not enforcement. It is not called by the Claude,
Codex, or Grok hook path; it does not emit a harness permission decision; it
does not read, locate, mount, or provision credentials; and every report states
enforcement: false. Ordinary objects remain open by default. The only initial
protected action is read on one logical exact-object identifier.
The table and --json output enumerate contract-only final-PEP, audit, replay,
policy-store, and lifecycle gaps. Exit 0 means valid inputs produced a shadow
report (including a simulated denial), 1 means the contract or coverage is
non-conformant, and 2 means invocation, decoding, or I/O failed. See
examples/auth-domains/ and the bounded
OpenSpec change.
The JSON report also exposes the deliberately lossy neutral-replay mapping.
permit_exception is not collapsed into ordinary allow: it is shown as
allow / exact_exception with representable_in_pinned_v1: false. The report
lists neutral-v1 omissions (request, generation, classifier, time,
canonicalization, and exception/constraint/lease evidence). The D1
credential:// logical object and the independent checker's inert fixture://
object are distinct non-path identities. Neutral coverage requires a traced
ordinary-work record even though that seam is non-critical.
The neutral/lifecycle alignment is pinned to landed Core main
cb557239a90a82c34686d6ca55046a7b2c9f3129, checker
8e376c79d64bc720b280ab839058cc71ca774990, and all 38 probe IDs in lifecycle
digest 4a5d12ff…. Unknown, missing, duplicate, or untraced probes fail.
Unknown/drift evidence must suppress the claim, fail or quarantine its receipt,
provide a reason, mark claims invalid, and list incomplete IDs. The shadow does
not reimplement that oracle or convert its evidence into enforcement.
Run with --debug to see rule evaluation on stderr:
# Test manually:
echo '{"tool_name":"Bash","tool_input":{"command":"git push --force"},"cwd":"/tmp"}' | claude-gatekeeper --debug
# Enable in the plugin by editing hooks/hooks.json:
"command": "${CLAUDE_PLUGIN_ROOT}/bin/claude-gatekeeper --debug"Debug output goes to stderr (visible in Claude Code verbose mode via Ctrl+R).
make build # Build from source to ./bin/claude-gatekeeper
make download # Download pre-built binary from GitHub Releases
make test # Run all tests with race detector
make lint # Run golangci-lint
make plugin-test # Show command to test as a plugin
make clean # Remove build artifactsMIT