{"slug": "stop-hooks-as-hard-constraints-enforcing-claude-code-behavior-outside-the-model", "title": "Stop Hooks as Hard Constraints: Enforcing Claude Code Behavior Outside the Model", "summary": "A developer building a fleet of Claude Code agents replaced soft prompt constraints with stop hooks—shell scripts that run after every model response and can force a revision. The hooks act as hard constraints, blocking outputs that defer decisions to the user when the model already has sufficient data. The approach uses two independent conditions—a behavioral anti-pattern regex and evidence of prior tool calls—to avoid false positives.", "body_md": "*Originally published on hexisteme notes.*\n\nI 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.\n\nStop 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.\n\nA stop hook is a shell script registered in\n\n`settings.json`\n\nunder a`Stop`\n\nevent. It runs after every response, receives the full session as JSON on stdin, and either exits`0`\n\n(allow it through) or exits`2`\n\nwith a message (block it and force a revision Claude must address). Because it runs outside the model, it's enforced, not suggested.\n\nThe registration itself is small:\n\n```\n// ~/.claude/settings.json\n{\n  \"hooks\": {\n    \"Stop\": [\n      {\n        \"matcher\": \"\",\n        \"hooks\": [{\n          \"type\": \"command\",\n          \"command\": \"~/.claude/hooks/stop_combined.sh\"\n        }]\n      }\n    ]\n  }\n}\n```\n\nThe script receives the full session context as JSON on stdin and can:\n\n`0`\n\n— allow the response through`2`\n\n— block it and inject a correction message Claude must addressThe 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.\n\nA 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.\n\nThe fix is to require two independent conditions before firing:\n\n```\nCONDITION_1: Response text matches behavioral anti-pattern regex\n     AND\nCONDITION_2: Session transcript shows prior data-collection tool calls\n\n# Only fire if BOTH are true\nif [[ $RESPONSE_MATCHES == \"1\" && $HAD_TOOL_CALLS == \"1\" ]]; then\n    block_with_message\nfi\n```\n\nThe 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.\n\nThe 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.\n\nThe hook, `stop_decision_ownership_check.sh`\n\n, works like this (condensed from the production script):\n\n``` bash\n#!/bin/bash\n# Read session JSON from stdin\nSESSION=$(cat)\n\n# Extract last response text\nRESPONSE=$(echo \"$SESSION\" | python3 -c \"\nimport json,sys\nd=json.load(sys.stdin)\nmsgs = d.get('messages', [])\nfor m in reversed(msgs):\n    if m.get('role') == 'assistant':\n        content = m.get('content', '')\n        if isinstance(content, list):\n            print(' '.join(b.get('text','') for b in content if b.get('type')=='text'))\n        else:\n            print(content)\n        break\n\")\n\n# Check for deference patterns\nDEFER_REGEX='(어느.*(좋아|나을|선택)|당신이.*(정하|결정)|뭐가.*좋을|what.*prefer|which.*would you|up to you|your call|depends on your)'\nif ! echo \"$RESPONSE\" | grep -qiE \"$DEFER_REGEX\"; then\n    exit 0  # No match, allow\nfi\n\n# Check for prior tool evidence (AND gate)\nTOOL_CALLS=$(echo \"$SESSION\" | python3 -c \"\nimport json,sys\nd=json.load(sys.stdin)\ncount = sum(1 for m in d.get('messages',[])\n            for b in (m.get('content',[]) if isinstance(m.get('content'), list) else [])\n            if isinstance(b,dict) and b.get('type') in ('tool_use','tool_result'))\nprint(count)\n\" 2>/dev/null || echo \"0\")\n\nif [[ \"$TOOL_CALLS\" -lt 2 ]]; then\n    exit 0  # No tool evidence, likely a legitimate question\nfi\n\n# Nag-once: compute hash, check if already nag'd\nHASH=$(echo \"$RESPONSE\" | shasum -a 256 | cut -c1-16)\nNAG_FILE=\"/tmp/.hook_nag_ownership_$HASH\"\nif [[ -f \"$NAG_FILE\" ]]; then\n    exit 0  # Already nag'd this exact response\nfi\ntouch \"$NAG_FILE\"\n\necho \"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\nexit 2\n```\n\nThe regex mixes Korean and English deference markers on purpose — I work across both languages, and the pattern has to catch either.\n\nThe 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.\n\n`stop_challenge_reverify.sh`\n\ncatches the pattern with a three-part gate: challenge, capitulation, and the absence of any actual verification.\n\n```\n# Gate: (challenge in user message) AND (capitulation in response) AND (no verification evidence)\n# USER_MSG and RESPONSE are pulled from the session transcript the same way hook 1 extracts RESPONSE\n\nCHALLENGE_REGEX='(아닌데|그건 아니|틀렸|잘못|다시 생각|actually|that'\\''s wrong|not quite|disagree|incorrect)'\nCAPITULATE_REGEX='(맞네요|맞습니다|수정|맞는 말씀|사실|좋은 지적|you'\\''re right|good point|I was wrong|actually yes|I misread)'\nVERIFY_REGEX='(검증|계산|확인|출처|논문|측정|verified|calculated|source|measured|according to)'\n\nif echo \"$USER_MSG\" | grep -qiE \"$CHALLENGE_REGEX\"; then\n    if echo \"$RESPONSE\" | grep -qiE \"$CAPITULATE_REGEX\"; then\n        if ! echo \"$RESPONSE\" | grep -qiE \"$VERIFY_REGEX\"; then\n            echo \"CHALLENGE_REVERIFY: Position changed without new evidence. Choose:\nHOLD — restate original position with grounds\nCHANGE — cite specific new evidence or calculation that overrides original\" >&2\n            exit 2\n        fi\n    fi\nfi\n```\n\nThis 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.\n\nBlocking indefinitely just creates a new failure: Claude keeps generating blocked responses and I get no output at all. Nag-once breaks that loop:\n\n`/tmp/.hook_nag_{hash}`\n\n, block with a message`0`\n\nand let it throughThis 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.\n\nHooks 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.\n\nI run several gates through one `stop_combined.sh`\n\nthat 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.\n\n``` bash\n#!/bin/bash\n# stop_combined.sh — ordered gate sequence\nSESSION=$(cat)\n\n# Gate 1: Decision ownership (highest priority)\nRESULT=$(echo \"$SESSION\" | ~/.claude/hooks/stop_decision_ownership_check.sh 2>&1)\nif [[ $? -eq 2 ]]; then echo \"$RESULT\" >&2; exit 2; fi\n\n# Gate 2: Sycophancy / challenge reverify\nRESULT=$(echo \"$SESSION\" | ~/.claude/hooks/stop_challenge_reverify.sh 2>&1)\nif [[ $? -eq 2 ]]; then echo \"$RESULT\" >&2; exit 2; fi\n\nexit 0\n```\n\nNone 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.\n\n*More notes at hexisteme.github.io/notes.*", "url": "https://wpnews.pro/news/stop-hooks-as-hard-constraints-enforcing-claude-code-behavior-outside-the-model", "canonical_source": "https://dev.to/hexisteme/stop-hooks-as-hard-constraints-enforcing-claude-code-behavior-outside-the-model-1g6b", "published_at": "2026-07-13 09:00:06+00:00", "updated_at": "2026-07-13 09:17:06.305634+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "large-language-models", "ai-safety"], "entities": ["Claude Code", "Anthropic"], "alternates": {"html": "https://wpnews.pro/news/stop-hooks-as-hard-constraints-enforcing-claude-code-behavior-outside-the-model", "markdown": "https://wpnews.pro/news/stop-hooks-as-hard-constraints-enforcing-claude-code-behavior-outside-the-model.md", "text": "https://wpnews.pro/news/stop-hooks-as-hard-constraints-enforcing-claude-code-behavior-outside-the-model.txt", "jsonld": "https://wpnews.pro/news/stop-hooks-as-hard-constraints-enforcing-claude-code-behavior-outside-the-model.jsonld"}}