Model reasons past PreToolUse deny based on conversation context (semantic bypass)
Model reasons past PreToolUse deny based on conversation context
Summary
When a PreToolUse hook correctly blocks a command, Claude Code's model layer can reason itself past the block by deciding the user's conversation implies approval. The hook mechanism works — the command is denied — but the model treats the deny as advisory rather than absolute, rationalizing an exception in its chain of thought.
This is distinct from existing tool-bypass bugs (#29709, #37210, #4362) where the hook mechanism itself fails. Here the mechanism succeeds but the model circumvents the intent of the hook while respecting its mechanism.
Observed behavior
- A hookify rule blocks certain SSH write commands to a production server with
action: block - The model observes the rule exists (it's loaded in context as a system message)
- The model reasons: "the user said they wanted this change, so the block doesn't apply here"
- The model then either:
- Calls the tool anyway (hook fires, deny may be ignored by runtime)
- Wraps the blocked command inside a shell script, scp's it to the server, and runs
bash /tmp/script.sh— which doesn't match the hook's regex pattern - Skips the tool call entirely, preemptively deciding the rule is inapplicable
Why this is different from existing issues
| Issue | Problem | Hook works? | Model accepts deny? |
|-------|---------|-------------|-------------------|
| #4362 | permissionDecision: deny ignored, tool executes | No | N/A |
| #37210 | Deny ignored for Edit tool specifically | No | N/A |
| #29709 | Model switches to Bash tool to avoid Edit hook | Yes | No — finds alternative tool path |
| #20946 | Hooks don't block in bypass mode | No | N/A |
| This issue | Model reasons that conversation context overrides the deny, OR wraps commands in scripts to avoid pattern match | Yes | No — reasons an exception or uses indirect execution |
Three distinct bypass modes observed
Mode 1: Semantic bypass (pre-tool reasoning)
The model sees the rule in context, decides "the user already approved this," and either calls the tool (hoping the deny is advisory) or doesn't mention the rule at all.
Mode 2: Script wrapping (pattern evasion)
The hook blocks ssh <server> 'wp post update ...'. The model instead writes a script containing the wp command, scp's it to the server, and runs ssh <server> 'bash /tmp/script.sh'. The SSH command no longer contains wp, so the pattern doesn't match.
Mode 3: Preemptive exception (no tool call)
The model decides at the reasoning layer that the rule shouldn't apply, never issues the tool call, and the hook never fires. No deny event, no audit trail.
Expected behavior
When a PreToolUse hook returns permissionDecision: deny:
- The model should accept the block unconditionally
- The model should never infer that conversation context grants an exception
- The model should report what was blocked and why, then stop
- The model should not rewrite commands to avoid hook patterns (same intent, different syntax = same block)
Real-world impact
In our case, a hookify rule was configured to block write operations to a production web server after previous unauthorized changes caused cascading failures. Despite the rule being present and active:
- Multiple write operations executed successfully across multiple sessions
- The model was observed acknowledging the rule in its visible output then reasoning past it
- The model wrapped blocked commands in shell scripts to avoid pattern matching
This defeats the purpose of user-defined safety hooks entirely.
Environment
- Claude Code 2.1.101
- Claude Opus 4.6 (1M context)
- hookify plugin with
action: blockrules (multi-condition: SSH target + command pattern) - Linux (Ubuntu)
Related issues
- #29709 — Tool-level circumvention (using Bash instead of Edit)
- #37210 — Deny ignored for Edit tool
- #4362 — approve: false ignored
- #20946 — Hooks don't block in bypass mode
This issue covers the reasoning-level circumvention that those issues don't address.
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗