Claude Code hooks for production safety
Reflex installs as a Claude Code plugin or through its setup command and adds a PreToolUse hook that checks every Bash command before it runs. Its deterministic rules block destructive production operations and force pushes to main, and it knows the AWS profile, kube context, Terraform workspace and git branch a command runs in.
Install: npx @ursuciprian/reflex setup starts with local rules in shadow mode, no account or key.
Plugins for Claude Code, Codex CLI and opencode: setup guide.
How do I stop Claude Code from running dangerous commands?
Install Reflex, which adds a Claude Code PreToolUse hook that checks every Bash command before it
runs: npx @ursuciprian/reflex setup. Its deterministic rules deny rm -rf ~, destructive
operations on production and force pushes to main, and ask before reads of SSH private keys,
~/.aws/credentials, .netrc, .pgpass, .env files or Kubernetes secrets; the rules block in
shadow mode too. Commands the rules do not cover are asked about (local engine) or judged by Jev
in context (AWS profile, kube context, Terraform workspace, git branch, the script the command
runs). After a shadow period, reflex setup --mode enforce puts those judgments in front of the
agent.
See: docs/SETUP.md, real-world scenarios with outputs.
How do I install Reflex as a Claude Code plugin?
Add the marketplace in this repository and install the plugin, inside Claude Code:
/plugin marketplace add ursuciprian/reflex, then /plugin install reflex@reflex (or, from a
shell, claude plugin marketplace add ursuciprian/reflex and claude plugin install reflex@reflex).
The plugin wires the same Claude Code hooks as reflex setup: the PreToolUse command gate on
Bash and subagent spawns, the post-tool records, conditional instructions and the prompt injection
guard. It adds read-only commands (/reflex:status, /reflex:check <command>, /reflex:report,
/reflex:replay, /reflex:queue, /reflex:suggest). It needs Node.js 18+ on the PATH and no
build step, npm install or API key; with no saved settings it runs the local engine in shadow mode.
If reflex setup hooks are also in ~/.claude/settings.json, the plugin's hooks stand down so
nothing is judged twice, and reflex doctor shows which one is active. Use reflex setup for
other agents, the autonomous profile, or the permission rules that guard Reflex's own files.
See: README: Claude Code plugin, docs/SETUP.md: Claude Code plugin.
Claude Code plugin
This repository is a Claude Code plugin marketplace (.claude-plugin/marketplace.json) that lists
one plugin, reflex, in plugin/. Claude Code clones the repository, copies plugin/ into its
plugin cache and runs the hooks from there with node: no node_modules, no npx at hook time.
/plugin marketplace add ursuciprian/reflex # inside Claude Code
/plugin install reflex@reflex
claude plugin marketplace add ursuciprian/reflex # or from your shell
claude plugin install reflex@reflex
claude plugin update reflex@reflex # later, for a new release
Then restart the session or run /reload-plugins. To try it for one session without installing,
clone the repository and run claude --plugin-dir /path/to/reflex/plugin.
The plugin bundle (plugin/)
plugin/ is generated from the repository by node scripts/build-plugin.mjs and committed, since
the marketplace installs from git. It holds only what plugin mode runs: the runtime modules, the
setup files the gate and the guard read (no golden sets, fixtures or plan fixtures), hooks/,
commands/, the scripts the commands run (scripts/*.sh), skills/, .mcp.json,
.claude-plugin/plugin.json (with userConfig, no icon), README.md and LICENSE. Code that only
reflex setup and the selfchecks run is marked in the source with // @reflex:setup-only begin and
// @reflex:setup-only end (in Markdown, <!-- @reflex:setup-only begin -->) and left out: the
allow answer and everything that produces it, the Hermes adapter, the rewritten tool result, the
Keychain and key variable reads, Laya setup, the doctor probes, the shell shim, the System 2 cli
backend and every other start of an agent CLI, the selfchecks, test doubles, evals and benchmarks. A region may only hold code plugin mode cannot reach, never a check
that makes a decision stricter.
The build fails when a region is unbalanced, when a module does not parse or does not link, when a
file is over 256 KiB, when a file other than the icon is binary, or when a forbidden pattern (the
list is FORBIDDEN in the script: an allow answer, a rewritten input or output, a Keychain call, a
provider key variable, pip, a Hugging Face download, curl, npx, a global npm install, a flag or
setting that turns an agent's prompts or hooks off (dangerously-skip-permissions, bypassPermissions,
approval_policy, ask-for-approval, full-auto, --yolo, disableAllHooks, permission-mode), the
shell shim, and a child process other than node, git, sed or the infra binaries infra.mjs finds)
is left in it.
After changing anything the plugin ships, rebuild and commit plugin/:
node scripts/build-plugin.mjs # writes plugin/ and lists every file with its size
CLAUDE_PLUGIN_ROOT=plugin node test.mjs --plugin-only # the plugin-mode checks against the bundle
claude plugin validate --strict plugin
CI builds it again and fails when plugin/ differs from what is committed. The directory
submission to Anthropic uses the plugin path plugin in this repository (the marketplace entry's
"source": "./plugin"); resubmit after a release that changes it.
What the plugin adds:
| Part | What it is |
|---|---|
hooks/hooks.json | the same Claude Code events, matchers and timeouts as install.mjs --agent claude (see step 3): PreToolUse on Bash|Task|Agent (10 s), PostToolUse, PostToolUseFailure and PermissionDenied records (5 s), PermissionRequest (5 s), UserPromptSubmit instructions (10 s) and prompt guard (5 s), and the injection guard on PostToolUse for ^(WebFetch|WebSearch|Read|Bash)$|^mcp__ (15 s). Each runs node "${CLAUDE_PLUGIN_ROOT}/<script>.mjs" <flag> --plugin; a test keeps the file in step with install.mjs |
commands/ | /reflex:status, /reflex:check <command>, /reflex:report, /reflex:replay, /reflex:queue, /reflex:suggest. All read-only: Claude runs the plugin's own script with the Bash tool (node "${CLAUDE_PLUGIN_ROOT}/<script>.mjs" --plugin ..., never a reflex from PATH), through the gate like any other command, and never with --write, approve or --push |
skills/reflex | tells Claude when to check a command and replay past sessions, and not to work around a deny |
.mcp.json | the Reflex MCP server (node "${CLAUDE_PLUGIN_ROOT}/mcp.mjs", the plugin options in its env): read-only, advisory tools reflex_check, reflex_scan, reflex_status, reflex_audit and reflex_explain (MCP server). A local server: it runs in Claude Code and Cowork, not in claude.ai chat |
The plugin puts nothing on the Bash PATH (the CLI lives in scripts/, not a top-level bin/,
which claude.ai and Cowork refuse to install), and nothing it runs downloads or installs anything:
the Laya setup, install.sh and npx are for reflex setup only.
Plugin options
Claude Code asks for these when you enable the plugin; change them later with /plugin, then
reflex, then Configure, or under pluginConfigs in settings.json. Each one may stay empty.
| Option | Values | Empty means |
|---|---|---|
engine | local (rules only, no key, nothing leaves the machine) or jev | engine in config.json, else jev when the Jev API key is set, else local |
provider | typesafe, openrouter, cloudflare, vercel or compatible | provider in config.json, else typesafe |
jev_api_key | the key for that provider (sensitive: kept in the system's secure storage) | no Jev: the engine stays local |
mode | off, shadow or enforce | mode in config.json, else shadow |
judge_api_key | the key for a System 2 judge with backend anthropic or openai-compatible (sensitive) | no key for System 2 |
Claude Code hands the options to the hooks as CLAUDE_PLUGIN_OPTION_<KEY> and to the MCP server
through its env in .mcp.json. In the plugin those options and Reflex's own config files
(~/.config/reflex/config.json, fastlane.json, the policy under ~/.config/reflex/tool-gate/) are
the only settings the hooks and the MCP server read. They never ask the macOS Keychain, never read
TYPESAFE_API_KEY or another provider's key variable, ANTHROPIC_API_KEY or the judge.key_env
variable (every *_API_KEY and *_API_TOKEN variable is removed from their environment, and from
every process they start), and ignore every REFLEX_* variable but REFLEX_DATA_DIR (where the logs go), every
JEV_* variable and CLOUDFLARE_ACCOUNT_ID (set cloudflare_account_id or provider_url in
config.json instead). The Codex CLI plugin below keeps reading the environment and the Keychain as
reflex setup does.
The /reflex:* commands run through the Bash tool, which Claude Code gives no plugin options, so
they read config.json alone: /reflex:check judges with its engine (local when none is set), and
/reflex:status shows its mode and engine and says that the hooks apply the options on top.
Three more differences from reflex setup, all from the plugin directory's policy:
- The allow gate is off: a plugin hook never answers
allow(what setup would allow is a silent pass, so Claude Code's own permission rules decide) and never rewrites a tool's input. A System 2 approval is that silent pass too. - The plugin never launches another agent session and never pre-answers a permission prompt.
System 2 in the plugin is API-only:
judge.backendanthropicoropenai-compatibleinconfig.json, with the key from thejudge_api_keyoption. Theclibackend (claude -porcodex exec, whichreflex setuppicks when the claude CLI is installed) is turned intononethere, aclitier is skipped, and/reflex:statuswarns about both; uncertain decisions then go to a human. The plugin starts no agent CLI for anything else either, not even--version. - The injection guard does not replace a tool result. A result it would block reaches Claude with
the guard's warning next to it (
additionalContext), and the session is checked more strictly from then on, as after any block.reflex setupremoves the injected text instead.
With no options and no saved settings the gate runs the local engine in shadow mode, which is also
where a fresh reflex setup starts. The plugin writes no settings. Logs go to
~/.local/state/reflex as usual.
Hooks run with the PATH Claude Code was started with. If node is not on it (for example Claude
Code started from a desktop launcher with a minimal PATH), the hook command fails, Claude Code
treats that as a non-blocking hook error and the command runs ungated. reflex doctor from the same
environment, or a denied /reflex:check git push --force origin main, confirms the gate is live.
Plugin and reflex setup together. Both install the same hooks. When reflex setup (or
install.mjs --agent claude) has written Reflex hooks into ~/.claude/settings.json, every plugin
hook sees them and exits at once without reading its input or writing a log line, so each call is
judged and counted once, by the settings hooks. The plugin checks the user settings file Claude
Code reads: $CLAUDE_CONFIG_DIR/settings.json when that variable is set. install.mjs always
writes ~/.claude/settings.json, so with CLAUDE_CONFIG_DIR set the setup hooks do not run in
Claude Code and the plugin stays active. A settings hook whose script no longer exists (a deleted
checkout) does not count: it gates nothing, so the plugin keeps running, and reflex status reports
it as an error. reflex status and reflex doctor print which path is active
(Claude Code hooks: ...); doctor also probes the plugin's own gate when the plugin is the active
one. To switch to the plugin only, remove the Claude Code hooks with the copy that installed them:
node ~/.local/share/reflex/lib/node_modules/@ursuciprian/reflex/install.mjs --agent claude --uninstall
after reflex setup, or node <checkout>/install.mjs --agent claude --uninstall for a clone
(reflex uninstall removes the hooks of every agent and the package). To switch to setup only,
claude plugin uninstall reflex@reflex.
What only reflex setup does, because a plugin cannot change settings:
- the
permissions.askrules that make Claude Code ask before its edit tools change the Reflex checkout, its logs,~/.config/reflexor~/.claude/settings*.json. With the plugin alone, add them yourself if you want them (shell commands that write there, andclaude plugin disable,uninstallormarketplace remove, are still asked by the gate'stamperrule), for example in~/.claude/settings.json:"permissions": {"ask": ["Edit(~/.config/reflex/**)", "Edit(~/.local/state/reflex/**)", "Edit(~/.claude/plugins/**)", "Edit(~/.claude/settings*.json)"]} - a
PreToolUsetimeout sized to System 2. The plugin's is 10 s, the same asreflex setupwithout System 2, so in the plugin System 2 gets what is left of those 10 s since the hook started, less 2 s (at mostjudge.timeout_ms), and is not asked with under 1 s left. A judge that does not answer in time goes to a human like any other System 2 failure: the hook asks (with the approval queue on, it parks the command), and it always answers before Claude Code's timeout. For a slower judge usereflex setup --profile autonomous, which sizes the hook to it.reflex statussays so.
To remove the plugin: claude plugin uninstall reflex@reflex, and
claude plugin marketplace remove reflex for the marketplace. Your settings and logs stay, as with
reflex uninstall.
Supported agents: Claude Code hooks, Codex hooks and more
| Agent | Hook | How ask is shown |
|---|---|---|
| Claude Code | PreToolUse | Claude Code permission prompt |
| Codex CLI | PreToolUse | Blocked; human uses reflex run in their own terminal |
| pi, oh-my-pi | extension tool_call | Native confirm dialog |
| opencode | plugin tool.execute.before | Blocked; human uses reflex run in their own terminal |
| Hermes | pre_tool_call | Hermes approval prompt |
| Other | scripts/reflex-sh as the shell | y/N on the terminal |
The injection guard reads tool results and prompts through each agent's own hooks, so what it can do differs:
| Agent | Tool results | Prompts with a pasted credential |
|---|---|---|
| Claude Code | PostToolUse on web, MCP, Read and Bash: warn adds a note, block rewrites the result | UserPromptSubmit: blocked, reason shown |
| Codex CLI | PostToolUse on Bash and MCP tools: warn adds a note, block replaces the result with the reason and the cleaned text; web search is not hookable | UserPromptSubmit: blocked, reason shown |
| pi, oh-my-pi | extension tool_result: block rewrites the result, warn appends a note | extension input: dropped with a notification |
| opencode | plugin tool.execute.after: block rewrites the result, warn appends a note | chat.message: stopped with an error |
| Hermes | post_tool_call is observe-only: logged, and the note reaches the model on the next turn | cannot block; the model is told not to repeat it |
| Other | not covered | not covered |
Coverage is agent shell tools, subagent spawns (for dedup) and the optional router's own calls.
File-edit tools and MCP tool calls go through each agent's own permissions. Hosts without hooks (Claude Desktop, Cursor) get
the MCP server, which advises and does not enforce. Doctor cannot prove host
trust or verify a native approval dialog; run a harmless command in a fresh agent session and check
reflex status. Plain chat confirmation does not unblock a Codex or opencode hook. See
docs/SETUP.md for manual steps and limits.