Read the script
#!/usr/bin/env bash
# PreToolUse gate for the PostHog MCP `exec` tool.
#
# The PostHog MCP exposes a single `exec` tool that dispatches subcommands like
# `tools | search | info | schema | call <tool_name> [json]`. Once the user
# allow-lists `mcp__posthog__exec`, every subsequent `call` (including writes
# like `experiment-update`, `notebooks-destroy`, `cdp-functions-delete`) runs
# without a prompt. This hook re-introduces a prompt for write `call`s by
# returning `permissionDecision: "ask"`.
#
# Read-only PostHog tools and non-`call` exec verbs are left alone — the hook
# exits 0 so normal permission flow applies. A tool is read-only only when
# `hooks/read-tools.txt` lists it (every registry tool with readOnlyHint=true,
# regenerated by scripts/generate-read-tools.sh); every other `call` counts as
# a write, so tools added after the list was generated still reach the gate.
# Third-party tools proxied through the MCP gateway (`<server>__<tool>`) are
# left alone too.
#
# By default the prompt fires only for a curated "sensitive" subset of write
# tools — feature-flag writes and any delete/destroy — rather than every write.
# Three env vars tune this (all matched against the PostHog tool name):
#
# POSTHOG_MCP_EXEC_GATE_DISABLE — set to a non-empty value (e.g. `1`) to turn
# the gate off entirely. Useful for remote devboxes and Claude Cloud runs
# where the prompt can't be answered. Example:
#
# export POSTHOG_MCP_EXEC_GATE_DISABLE=1
#
# POSTHOG_MCP_EXEC_GATE_DENY — comma-separated bash globs selecting which
# write tools prompt. Overrides the built-in default set. Use `*` to
# prompt on every write (the previous behaviour). Example:
#
# export POSTHOG_MCP_EXEC_GATE_DENY="*feature-flag*,*-delete"
#
# POSTHOG_MCP_EXEC_GATE_ALLOW — comma-separated bash globs that opt specific
# write tools out of the prompt. Applied on top of the deny set (allow
# wins). Example:
#
# export POSTHOG_MCP_EXEC_GATE_ALLOW="llma-skill-*,annotation-create"
#
# Unless the gate is disabled, `posthog-connection-call` and
# `posthog-connection-forward` prompt regardless of DENY and ALLOW, as does any
# input the hook can't read reliably.
#
# Pure bash; no jq or other third-party tools required. Relies on the fact
# that PostHog tool names are kebab-case alphanumerics, so a narrow regex on
# the raw JSON payload is safe.
# `--fail-closed` is for hooks/codex-gate-exec-write.sh, which approves every
# call this script passes silently. In that mode the script passes only known
# reads (exec's read-only verbs and `call`s of tools on the read list), asks for
# everything else whatever the POSTHOG_MCP_EXEC_GATE_* variables say, and lets
# runtime failures exit non-zero, so the caller can tell a pass from a crash.
fail_closed=0
[[ "${1:-}" == --fail-closed ]] && fail_closed=1
# Otherwise fail open — this gate must never break a Claude Code tool call.
#
# The "ask" decision is delivered entirely through the stdout JSON below; the
# exit code is never used to signal it. So we force every exit path to 0 via an
# EXIT trap. Any unexpected runtime failure — an unbound variable under `set -u`,
# a failed builtin, an unusually old bash — then falls through to normal
# permission flow instead of surfacing as a hook error. Crucially, it can never
# exit 2, which Claude Code interprets as a hard *block* of the tool call.
#
# The one failure a trap can't catch is a parse-time syntax error (the trap
# isn't installed yet); `tests/test_gate_exec_write.sh` guards that with `bash -n`.
(( fail_closed )) || trap 'exit 0' EXIT
set -u
# Prompts with reason $1 and exits. The reason is interpolated into JSON
# unescaped, so callers pass fixed text or a [a-zA-Z0-9_-]+ tool name.
ask() {
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"%s"}}' "$1"
exit 0
}
# Passes in normal mode; asks in fail-closed mode.
not_a_known_read() {
(( fail_closed )) || exit 0
ask "The PostHog exec command is not a known read — approve to run."
}
# Codex compatibility: Codex's PreToolUse protocol does not support
# `permissionDecision: "ask"` (it is parsed then rejected as unsupported), so
# Codex runs this script through hooks/codex-gate-exec-write.sh instead. Skip the
# gate if a Codex build loads it from hooks/hooks.json directly, detected via its
# native PLUGIN_ROOT env var — Claude Code only ever sets CLAUDE_PLUGIN_ROOT,
# never PLUGIN_ROOT. See https://developers.openai.com/codex/hooks
if [[ -n "${PLUGIN_ROOT:-}" ]] && (( ! fail_closed )); then
exit 0
fi
# Full opt-out — turn the gate off entirely. Set to any non-empty value other
# than `0`. Lets remote devboxes and Claude Cloud runs, where no one can answer
# the prompt, run write `call`s without interruption. In fail-closed mode it
# would approve every write, so it is ignored there.
if [[ -n "${POSTHOG_MCP_EXEC_GATE_DISABLE:-}" && "${POSTHOG_MCP_EXEC_GATE_DISABLE}" != "0" ]] && (( ! fail_closed )); then
exit 0
fi
# Default set of write tools that prompt. Comma-separated bash globs, grounded in
# the live PostHog MCP tool registry. Covers the genuinely sensitive surface:
#
# *feature-flag* — feature-flag rollout writes: create-feature-flag,
# update-feature-flag, delete-feature-flag,
# feature-flags-bulk-{delete,update-tags}-create,
# feature-flags-copy-flags-create
# *delete* — any deletion: insight-delete, dashboard-delete,
# cdp-functions-delete, persons-bulk-delete,
# external-data-schemas-delete-data, session-recording-delete, …
# *destroy* — any destroy: notebooks-destroy, accounts-destroy,
# experiment-saved-metrics-destroy, agent-applications-destroy, …
# experiment-launch / experiment-ship-variant / experiment-reset
# — start exposing users / roll a variant to everyo