Safety gates: the hooks that refuse a command

The three PreToolUse hooks that stop an action outright — destructive mix commands, the dependency supply-chain gate, and edit-scope freezes — plus the fail-open contract behind them.

View source
On this page
  1. block-dangerous-ops.sh
  2. What it does not block
  3. Leading-whitespace evasion
  4. Fail-open contract
  5. deps-audit-gate.sh
  6. Policy modes
  7. Escape hatches
  8. freeze-gate.sh
  9. Related

About this hook group

Hooks PreToolUse

The three PreToolUse hooks that stop an action outright — destructive mix commands, the dependency supply-chain gate, and edit-scope freezes — plus the fail-open contract behind them.

Safety Gates (PreToolUse)

Three hooks that run before a tool executes and can refuse it. These are the only hooks in the plugin that stop an action outright — everything else advises after the fact.

All three emit a deny as JSON permissionDecision: "deny" plus additionalContext, never as a non-zero exit code. Claude Code 2.1.110+ keeps additionalContext on a blocked tool call, so the reason and the safer alternative survive into Claude’s next turn instead of being lost — which is what stops it from immediately retrying the same command.

ScriptFires onCan block?
block-dangerous-ops.shevery Bash callYes — destructive commands
deps-audit-gate.shBash(*mix deps.*)Yes — unvetted dependency changes
freeze-gate.shEdit / Write / NotebookEditYes — when a freeze is active

block-dangerous-ops.sh

Blocks three command shapes before they run.

PatternGated on mix.exs?Why
mix ecto.reset / mix ecto.dropYesDestroys all data
Force push (--force / -f)No — globalRewrites remote history
MIX_ENV=prod mix …YesRuns in production mode

What you see instead of the command running:

BLOCKED: Destructive database operation detected.
mix ecto.reset/drop will destroy all data. If intentional, run manually
outside Claude Code. Safer alternatives:
- mix ecto.rollback --step 1 (undo last migration)
- mix ecto.migrate (apply pending migrations)

The force-push block is deliberately not gated on mix.exs — history rewriting is dangerous in any repo, so this one fires in your Rust and Python projects too. Everything Elixir-specific stays gated.

What it does not block

--force-with-lease is explicitly allowed. It refuses to clobber commits you have not seen, which makes it the safe form. An earlier pattern (git push.*(--force|-f)\b) accidentally caught it: in ERE, \b matches between e and -, so the trailing hyphen satisfied the word boundary and the whole flag matched. Fixed in issue #61.

Quoted mentions are also safe. A force-push string inside an echo does not trigger, because the pattern only anchors at start-of-line or after a real shell separator (;, &, |) — never after arbitrary mid-line whitespace.

Leading-whitespace evasion

The start-of-line anchor is ^[[:space:](]*, not a bare ^. With a bare ^, a command preceded by a tab, by two spaces, or wrapped in a subshell paren all executed while evading every deny. Claude Code shipped a fix for the same padding-hides-the-command class of hole in its own permission prompts in 2.1.223; this hook was fixed 2026-08-10.

( is deliberately not added to the separator class [;&|]. Doing so would make a parenthesised mention inside a quoted string match again, reintroducing the false positive issue #61 removed. Only the start-of-line branch is widened.

Invisible Unicode padding (U+00A0 and friends) is intentionally out of scope: the shell would treat the padded word as a command name that does not exist, so it is not an execution path.

Fail-open contract

Every intentional deny goes through emit_block — JSON output plus exit 0. A non-zero exit from this script is always a bug, so hooks.json appends || exit 0:

"command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/block-dangerous-ops.sh || exit 0"

This matters. The script once got corrupted by merge-conflict markers, and because bash exited non-zero, every Bash call in the session was blocked. Failing open turns that class of failure into “the guard is off” rather than “the session is bricked”. If jq is missing, the hook prints a notice to stderr and exits 0 for the same reason.

Never add a deny path that relies on a non-zero exit code.


deps-audit-gate.sh

Guards mix deps.get, mix deps.update, and mix deps.compile against supply-chain risk. It is the enforcement arm of the /phx:deps-audit skill — the skill does the deep scan, the hook keeps you from silently pulling in something the scan flagged.

It is built as a tiered fast path, because a PreToolUse hook sits directly in the latency of a command you run often:

TierBudgetWhat runs
Tier 0<200 msCache hit — mix.lock SHA matches .claude/deps-audit/last-run.json, previous audit passed, policy unchanged → silent exit
Tier 1<2 sRule 1 (bidi/RLO control chars in mix.lock) + Rule 5 (new :git / :path deps vs origin/main). Zero false positives, no network, no LLM
Tier 2opt-inThe full audit pipeline. Not chained from the hook — the budget is already spent by Tier 1. :full mode runs it from the skill body instead

Policy modes

Read from hex_vet.exspolicy.block_on_unvetted:

ValueBehavior
absent / falseWarn only, never blocks
:new_onlyBlocks when the change adds an unvetted version (default once a ledger exists)
:strictBlocks on any Tier 1 finding
:full:strict rules, and Tier 2 runs via the skill
trueDeprecated — treated as :strict, with a notice

The parser strips Elixir line comments before matching and takes the last uncommented match, mirroring Elixir’s last-assignment-wins map-literal semantics. Before the v3.0.1 hotfix it took head -1 of the raw file, so a commented-out example line could silently downgrade enforcement — failing open in the worst possible direction.

Escape hatches

PHX_SKIP_DEPS_AUDIT=1 mix deps.get   # skip the gate entirely
PHX_DEPS_AUDIT_BASE=origin/develop   # change the git ref for the new-dep diff

Note this gate reports via exit 2 + stderr, not a JSON permission decision — it predates the emit_block convention and surfaces findings as a message.


freeze-gate.sh

An edit lock you switch on for a focused task. This is the on-demand, skill-scoped hook pattern from Anthropic’s “how we use skills”: rather than a permanent guard, it is one the /phx:freeze skill turns on and off.

Claude Code has no native skill-scoped hooks, so the state lives in a sentinel file:

$CLAUDE_PROJECT_DIR/.claude/.freeze
Sentinel stateEffect
MissingNo-op. The hook is dormant, which is why it ships enabled
Present, emptyAll Edit / Write / NotebookEdit denied — read-only investigation mode
Present, path linesOnly edits at or under a listed prefix are allowed

Blank lines and # comments in the sentinel are ignored. Relative paths resolve against the project root, and the edit target is normalized to an absolute path before the prefix comparison, so a relative and an absolute reference to the same file behave identically.

The deny text tells Claude not to retry, and that only the user can lift it:

Edit freeze is active (edits limited to: lib/accounts test/accounts).
Do not retry this edit. The user must run '/phx:freeze off' to lift the lock,
or '/phx:freeze <dir>' to allow a directory.

The /phx:freeze skill writes the sentinel through Bash, never Edit or Write — otherwise the gate would block the skill from turning itself off.

  • Code Quality — the advisory PostToolUse checks
  • Workflow State — plan and compaction state hooks
  • /phx:deps-audit, /phx:deps-vet, /phx:freeze