DEV Community

John
John

Posted on • Originally published at hexisteme.github.io

Stop Hooks as Hard Constraints: Enforcing Claude Code Behavior Outside the Model

Originally published on hexisteme notes.

I run a fleet of Claude Code agents, and for a long time I tried to enforce behavioral rules the obvious way: write them into the system prompt and hope they hold. They mostly did — until pressure showed up. A plausible-sounding challenge from me, a long task, a shortcut that saves a turn, and the model would quietly drop a rule it had followed all session. Prompt instructions are soft constraints. They shape behavior probabilistically, and when they conflict with RLHF-trained patterns — deference to the user, hedged language, listing options instead of committing to one — the RLHF training usually wins, especially under pressure.

Stop hooks are what I actually rely on now. They're hard constraints: shell scripts that run after Claude Code generates a response and before I see it, and they can force a revision the model can't talk its way around.

A stop hook is a shell script registered in settings.json under a Stop event. It runs after every response, receives the full session as JSON on stdin, and either exits 0 (allow it through) or exits 2 with a message (block it and force a revision Claude must address). Because it runs outside the model, it's enforced, not suggested.

How stop hooks actually work

The registration itself is small:

// ~/.claude/settings.json
{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [{
          "type": "command",
          "command": "~/.claude/hooks/stop_combined.sh"
        }]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

The script receives the full session context as JSON on stdin and can:

  • Exit 0 — allow the response through
  • Exit 2 — block it and inject a correction message Claude must address

The correction message comes back as feedback, and Claude generates a new response. I only ever see the final, corrected output — the hook's intervention is invisible unless I go looking for it in the transcript.

The AND-gate pattern

A hook that fires on a regex match alone has an obvious failure mode: false positives. A rule like "block responses that ask the user to decide" will also block a legitimate clarifying question like "what's your timeline for this?" That's not a failure mode I can tolerate in something that blocks output on every turn.

The fix is to require two independent conditions before firing:

CONDITION_1: Response text matches behavioral anti-pattern regex
     AND
CONDITION_2: Session transcript shows prior data-collection tool calls

# Only fire if BOTH are true
if [[ $RESPONSE_MATCHES == "1" && $HAD_TOOL_CALLS == "1" ]]; then
    block_with_message
fi
Enter fullscreen mode Exit fullscreen mode

The tool-call evidence half is what makes this work. If Claude asks a clarifying question without having done any analysis first, that's a legitimate question — let it through. If it's deferring back to me after running searches and reading files, it already has what it needs and is offloading work it should be finishing itself.

Production hook 1: decision-ownership enforcement

The failure mode here is specific: RLHF trains models to hand judgment back to the user. "Which approach would you prefer?" after already analyzing both options. "It depends on your priorities" after having every piece of information needed to just recommend one. That isn't politeness — it's transferring cognitive labor that was the whole point of asking in the first place.

The hook, stop_decision_ownership_check.sh, works like this (condensed from the production script):

#!/bin/bash
# Read session JSON from stdin
SESSION=$(cat)

# Extract last response text
RESPONSE=$(echo "$SESSION" | python3 -c "
import json,sys
d=json.load(sys.stdin)
msgs = d.get('messages', [])
for m in reversed(msgs):
    if m.get('role') == 'assistant':
        content = m.get('content', '')
        if isinstance(content, list):
            print(' '.join(b.get('text','') for b in content if b.get('type')=='text'))
        else:
            print(content)
        break
")

# Check for deference patterns
DEFER_REGEX='(어느.*(좋아|나을|선택)|당신이.*(정하|결정)|뭐가.*좋을|what.*prefer|which.*would you|up to you|your call|depends on your)'
if ! echo "$RESPONSE" | grep -qiE "$DEFER_REGEX"; then
    exit 0  # No match, allow
fi

# Check for prior tool evidence (AND gate)
TOOL_CALLS=$(echo "$SESSION" | python3 -c "
import json,sys
d=json.load(sys.stdin)
count = sum(1 for m in d.get('messages',[])
            for b in (m.get('content',[]) if isinstance(m.get('content'), list) else [])
            if isinstance(b,dict) and b.get('type') in ('tool_use','tool_result'))
print(count)
" 2>/dev/null || echo "0")

if [[ "$TOOL_CALLS" -lt 2 ]]; then
    exit 0  # No tool evidence, likely a legitimate question
fi

# Nag-once: compute hash, check if already nag'd
HASH=$(echo "$RESPONSE" | shasum -a 256 | cut -c1-16)
NAG_FILE="/tmp/.hook_nag_ownership_$HASH"
if [[ -f "$NAG_FILE" ]]; then
    exit 0  # Already nag'd this exact response
fi
touch "$NAG_FILE"

echo "DECISION_OWNERSHIP: You collected data then deferred the decision back. Commit to a single recommendation with one-line rationale and one falsifiable condition under which you'd be wrong." >&2
exit 2
Enter fullscreen mode Exit fullscreen mode

The regex mixes Korean and English deference markers on purpose — I work across both languages, and the pattern has to catch either.

Production hook 2: sycophancy prevention

The second failure mode is worse, because it's invisible in the output. When challenged, Claude reverses its position at a 98% rate — that's from Anthropic's own published sycophancy research (arXiv 2310.13548). Not because the challenge supplied new evidence, but because RLHF rewarded agreement. Claude doesn't announce the capitulation ("you're right, I was wrong") — it just quietly shifts.

stop_challenge_reverify.sh catches the pattern with a three-part gate: challenge, capitulation, and the absence of any actual verification.

# Gate: (challenge in user message) AND (capitulation in response) AND (no verification evidence)
# USER_MSG and RESPONSE are pulled from the session transcript the same way hook 1 extracts RESPONSE

CHALLENGE_REGEX='(아닌데|그건 아니|틀렸|잘못|다시 생각|actually|that'\''s wrong|not quite|disagree|incorrect)'
CAPITULATE_REGEX='(맞네요|맞습니다|수정|맞는 말씀|사실|좋은 지적|you'\''re right|good point|I was wrong|actually yes|I misread)'
VERIFY_REGEX='(검증|계산|확인|출처|논문|측정|verified|calculated|source|measured|according to)'

if echo "$USER_MSG" | grep -qiE "$CHALLENGE_REGEX"; then
    if echo "$RESPONSE" | grep -qiE "$CAPITULATE_REGEX"; then
        if ! echo "$RESPONSE" | grep -qiE "$VERIFY_REGEX"; then
            echo "CHALLENGE_REVERIFY: Position changed without new evidence. Choose:
HOLD — restate original position with grounds
CHANGE — cite specific new evidence or calculation that overrides original" >&2
            exit 2
        fi
    fi
fi
Enter fullscreen mode Exit fullscreen mode

This hook has a deliberate blind spot: it can't catch silent capitulation, where the tone shifts without any explicit reversal language. I've accepted that trade-off — the priority is keeping false positives low on legitimate corrections, not catching every possible way a model can cave.

The nag-once pattern

Blocking indefinitely just creates a new failure: Claude keeps generating blocked responses and I get no output at all. Nag-once breaks that loop:

  1. First violation — compute a sha256 of the response, write it to /tmp/.hook_nag_{hash}, block with a message
  2. Second attempt with the identical response — the hash file already exists, so exit 0 and let it through
  3. A genuinely different response — new hash, new nag file, block again if it's still violating

This surfaces the issue exactly once and lets the conversation keep moving. I can still push back on the hook's correction if I disagree with it; that's fine — the hook's job is to force the question onto the table, not to win it.

What hooks can't do

Hooks operate on the final response text. They can't see the model's reasoning before it outputs anything, can't inject into the middle of a generation, and can't stop the model from starting down a bad path — only from completing it. Deeper behavioral constraints still have to live at the system-prompt and instruction level; hooks are the last line of enforcement, not the only one.

Combining hooks

I run several gates through one stop_combined.sh that calls each in sequence and returns on the first block — I don't want two hooks piling correction messages on top of each other. Ordering matters: the highest-priority gates run first.

#!/bin/bash
# stop_combined.sh — ordered gate sequence
SESSION=$(cat)

# Gate 1: Decision ownership (highest priority)
RESULT=$(echo "$SESSION" | ~/.claude/hooks/stop_decision_ownership_check.sh 2>&1)
if [[ $? -eq 2 ]]; then echo "$RESULT" >&2; exit 2; fi

# Gate 2: Sycophancy / challenge reverify
RESULT=$(echo "$SESSION" | ~/.claude/hooks/stop_challenge_reverify.sh 2>&1)
if [[ $? -eq 2 ]]; then echo "$RESULT" >&2; exit 2; fi

exit 0
Enter fullscreen mode Exit fullscreen mode

None of these hooks make the model smarter. What they do is close the gap between the behavior I actually want and the behavior RLHF defaults to under pressure, by moving enforcement outside the place where that pressure operates.

More notes at hexisteme.github.io/notes.

Top comments (1)

Collapse
 
hannune profile image
Tae Kim

The distinction between soft constraints and hard constraints is the right frame — prompt instructions are probabilistic and degrade under pressure in ways that are hard to characterize without running ablation tests across session lengths. The stop hook as the enforcement layer is architecturally clean because it sits outside the context window and cannot be talked out of; the model cannot reason about what it has not seen. The pattern mirrors what Unix systems do with capabilities-dropping: trust the model for everything within its sandbox, but use a non-bypassable gate for the invariants that actually matter. Are you storing the correction injection messages anywhere, or running blind on how often the hook fires in practice?