cd /news/ai-agents/claude-pretooluse-hook-that-blocks-f… · home topics ai-agents article
[ARTICLE · art-133571] src=gist.github.com ↗ pub= topic=ai-agents verified=true sentiment=· neutral

Claude PreToolUse hook that blocks fable from editing files, and forces subagents to do work

A developer published a Claude Code PreToolUse hook that inspects the session transcript to detect whether the active model is a "fable" model and, if so, blocks the parent session from editing files via Bash commands such as redirects, heredocs, cp/mv/rm, sed -i, and tee, while still allowing subagents to make edits. The hook builds its detection regex from named token fragments and reads the transcript file to identify the model before deciding whether to deny the tool call.

by read6 min views12 publishedSep 11, 2026

| | #!/usr/bin/env node | | | import { readFileSync } from "node:fs"; | | | // PreToolUse: blocks file edits from the parent (claude-fable-*) session. Subagents may edit. | | | |

|  | // --- regex token vocabulary (jfred's regex_expressions.ts style: named source-string fragments, composed with | 
|  | // `+`, wrapped in `new RegExp(...)` only at the end) ------------------------------------------------------- | 

| | const startAnchor = "^"; // matches the start position | | | const wordBoundary = "\b"; // a transition between a word char and a non-word char | | | const wordChar = "\w"; // one word char: letter, digit, or underscore | | | const whitespace = "\s"; // one whitespace char | | | const nonWhitespace = "\S"; // one NON-whitespace char | | | const digit = "\d"; // one digit, 0-9 | | | const oneOrMore = "+"; // one or more of the token immediately before it | | | const zeroOrMore = "*"; // zero or more of the token immediately before it | | | | | | const whitespaceStar = whitespace + zeroOrMore; // \s* — optional spaces | | | const whitespacePlus = whitespace + oneOrMore; // \s+ — a run of spaces | | | |

|  | const noneOf = (chars: string): string => "[^" + chars + "]"; // a negated character class `[^…]` | 
|  | const negativeLookahead = (inner: string): string => "(?!" + inner + ")"; // inner must NOT follow | 
|  | const negativeLookbehind = (inner: string): string => "(?<!" + inner + ")"; // inner must NOT precede | 

| | // One of several alternative fragments (?:a\|b\|c), built with a for loop rather than .join("\|"). |

|  | function anyOf(...alternatives: string[]): string { | 
|  | let combined = ""; | 
|  | for (let i = 0; i < alternatives.length; i++) { | 
|  | if (i > 0) | 
|  | combined += "\|"; | 
|  | combined += alternatives[i]; | 

| | } | | | return "(?:" + combined + ")"; | | | } | | | | | | // A >/>> redirect to a real file, excluding >/dev/null and >& (fd dups like 2>&1). | | | const redirectToFile = | | | noneOf("<>&" + digit) + negativeLookahead(">{1,2}" + whitespaceStar + "/dev/null") + ">{1,2}" + negativeLookahead("&"); | | | | | | // A heredoc start: << then a word char, so it still matches once quotes around the delimiter are stripped. | | | const heredoc = "<<" + whitespaceStar + wordChar + oneOrMore; | | | | | | // cp/ mv/ rm/ mkdir/ touch/ chmod as a command: at the start, or right after &&/\|\|/;/\|. | | | const commandBoundaryOperators = anyOf(startAnchor, "&&", "\|\|", ";", "\|"); | | | const fileMutatingCommandNames = anyOf("cp", "mv", "rm", "mkdir", "touch", "chmod"); | | | const fileCommand = commandBoundaryOperators + whitespaceStar + fileMutatingCommandNames + wordBoundary; | | | | | | // A CLI -o <path> output flag that writes a file, excused right after rg/ grep (their -o means something else). | | | const toolsWhoseOutputFlagIsNotAFile = anyOf("rg", "grep"); | | | const outputFlag = negativeLookbehind(wordBoundary + toolsWhoseOutputFlagIsNotAFile) + whitespace + "-o" + whitespacePlus + nonWhitespace + oneOrMore; | | | | | | // sd (find-and-replace) or sed -i (in-place edit). e.g. "sd foo bar file.ts" or "sed -i s/a/b/ f". | | | const sdOrSedInPlace = anyOf(wordBoundary + "sd" + wordBoundary, wordBoundary + "sed" + whitespacePlus + "-i"); | | | | | | // tee writes its stdin to a file while piping it through. e.g. "echo hi | tee f.txt". | | | const teeCommand = wordBoundary + "tee" + wordBoundary; | | | | | | const BASH_BLOCK_PATTERN = new RegExp(anyOf(redirectToFile, heredoc, fileCommand, outputFlag, sdOrSedInPlace, teeCommand)); | | | |

|  | export function isFableModel(transcriptPath: string): boolean { | 
|  | const lines = readFileSync(transcriptPath, "utf8").split("\n"); | 
|  | let model = ""; | 
|  | for (let i = lines.length - 1; i >= 0; i--) { | 
|  | if (!lines[i]) | 

| | continue; |

|  | const entry = JSON.parse(lines[i]); | 
|  | const isAssistantWithModel = entry.type === "assistant" && entry.message?.model; | 
|  | if (isAssistantWithModel) { | 
|  | model = entry.message.model; | 

| | break; | | | } | | | } | | | return model.startsWith("claude-fable"); | | | } | | | |

|  | export function isBashCommandBlocked(command: string): boolean { | 
|  | const strippedCommand = command.replace(/'[^']*'\|"[^"]*"/g, ""); | 
|  | return BASH_BLOCK_PATTERN.test(strippedCommand); | 

| | } | | | |

|  | if (import.meta.main) { | 
|  | const input = JSON.parse(readFileSync(0, "utf8")); | 
|  | const isSubagent = typeof input.agent_id === "string" \|\| typeof input.agent_type === "string"; | 

| | const subagentModel = process.env.CLAUDE_CODE_SUBAGENT_MODEL ?? ""; | | | const isFableSubagent = isSubagent && subagentModel.startsWith("claude-fable"); | | | const isFableParent = !isSubagent && isFableModel(input.transcript_path); | | | const parentInPlanMode = input.permission_mode === "plan"; | | | const isBlockedParent = isFableParent && !parentInPlanMode; | | | | | | const denyRules: { blocked: boolean; reason: string }[] = [ | | | { blocked: isFableSubagent, reason: "Fable subagents must not edit files. Fable should never edit code. Change the CLAUDE_CODE_SUBAGENT_MODEL in the global .claude/settings.json environment variable to something other than a Fable model." }, | | | { blocked: isBlockedParent, reason: "Parent session must not edit files. Delegate the edit to a subagent. If a subagent already edited these files this session, resume it with SendMessage instead of spawning a new one. When spawning a new subagent with the Agent tool, start the prompt with: "First action: call the Skill tool with skill ponytail:ponytail and args ultra."" }, | | | ]; | | | |

|  | let denyReason = ""; | 
|  | for (let i = 0; i < denyRules.length; i++) { | 
|  | if (denyRules[i].blocked) { | 
|  | denyReason = denyRules[i].reason; | 

| | break; | | | } | | | } | | | |

|  | if (!denyReason) | 
|  | process.exit(0); | 

| | | | | const toolName = input.tool_name; |

|  | let blocked = toolName === "Edit" \|\| toolName === "Write" \|\| toolName === "NotebookEdit"; | 
|  | if (toolName === "Bash") | 
|  | blocked = isBashCommandBlocked(input.tool_input?.command ?? ""); | 

| | | | | if (blocked) { | | | console.log( |

|  | JSON.stringify({ | 
|  | hookSpecificOutput: { | 

| | hookEventName: "PreToolUse", | | | permissionDecision: "deny", | | | permissionDecisionReason: denyReason, | | | }, |

|  | }), | 
|  | ); | 

| | } | | | } |

── more in #ai-agents 4 stories · sorted by recency
── more on @claude 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/claude-pretooluse-ho…] indexed:0 read:6min 2026-09-11 ·