{"slug": "claude-pretooluse-hook-that-blocks-fable-from-editing-files-and-forces-subagents", "title": "Claude PreToolUse hook that blocks fable from editing files, and forces subagents to do work", "summary": "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.", "body_md": "|  | #!/usr/bin/env node | \n|  | import { readFileSync } from \"node:fs\"; | \n|  | // PreToolUse: blocks file edits from the parent (claude-fable-*) session. Subagents may edit. | \n|  |  | \n|  | // --- regex token vocabulary (jfred's regex_expressions.ts style: named source-string fragments, composed with | \n|  | // `+`, wrapped in `new RegExp(...)` only at the end) ------------------------------------------------------- | \n|  | const startAnchor = \"^\"; // matches the start position | \n|  | const wordBoundary = \"\\\\b\"; // a transition between a word char and a non-word char | \n|  | const wordChar = \"\\\\w\"; // one word char: letter, digit, or underscore | \n|  | const whitespace = \"\\\\s\"; // one whitespace char | \n|  | const nonWhitespace = \"\\\\S\"; // one NON-whitespace char | \n|  | const digit = \"\\\\d\"; // one digit, 0-9 | \n|  | const oneOrMore = \"+\"; // one or more of the token immediately before it | \n|  | const zeroOrMore = \"*\"; // zero or more of the token immediately before it | \n|  |  | \n|  | const whitespaceStar = whitespace + zeroOrMore; // `\\s*` — optional spaces | \n|  | const whitespacePlus = whitespace + oneOrMore; // `\\s+` — a run of spaces | \n|  |  | \n|  | const noneOf = (chars: string): string => \"[^\" + chars + \"]\"; // a negated character class `[^…]` | \n|  | const negativeLookahead = (inner: string): string => \"(?!\" + inner + \")\"; // inner must NOT follow | \n|  | const negativeLookbehind = (inner: string): string => \"(?<!\" + inner + \")\"; // inner must NOT precede | \n|  | // One of several alternative fragments `(?:a\\|b\\|c)`, built with a for loop rather than `.join(\"\\|\")`. | \n|  | function anyOf(...alternatives: string[]): string { | \n|  | let combined = \"\"; | \n|  | for (let i = 0; i < alternatives.length; i++) { | \n|  | if (i > 0) | \n|  | combined += \"\\|\"; | \n|  | combined += alternatives[i]; | \n|  | } | \n|  | return \"(?:\" + combined + \")\"; | \n|  | } | \n|  |  | \n|  | // A `>`/`>>` redirect to a real file, excluding `>/dev/null` and `>&` (fd dups like `2>&1`). | \n|  | const redirectToFile = | \n|  | noneOf(\"<>&\" + digit) + negativeLookahead(\">{1,2}\" + whitespaceStar + \"/dev/null\") + \">{1,2}\" + negativeLookahead(\"&\"); | \n|  |  | \n|  | // A heredoc start: `<<` then a word char, so it still matches once quotes around the delimiter are stripped. | \n|  | const heredoc = \"<<\" + whitespaceStar + wordChar + oneOrMore; | \n|  |  | \n|  | // `cp`/` mv`/` rm`/` mkdir`/` touch`/` chmod` as a command: at the start, or right after `&&`/`\\|\\|`/`;`/`\\|`. | \n|  | const commandBoundaryOperators = anyOf(startAnchor, \"&&\", \"\\\\\\|\\\\\\|\", \";\", \"\\\\\\|\"); | \n|  | const fileMutatingCommandNames = anyOf(\"cp\", \"mv\", \"rm\", \"mkdir\", \"touch\", \"chmod\"); | \n|  | const fileCommand = commandBoundaryOperators + whitespaceStar + fileMutatingCommandNames + wordBoundary; | \n|  |  | \n|  | // A CLI `-o <path>` output flag that writes a file, excused right after `rg`/` grep` (their `-o` means something else). | \n|  | const toolsWhoseOutputFlagIsNotAFile = anyOf(\"rg\", \"grep\"); | \n|  | const outputFlag = negativeLookbehind(wordBoundary + toolsWhoseOutputFlagIsNotAFile) + whitespace + \"-o\" + whitespacePlus + nonWhitespace + oneOrMore; | \n|  |  | \n|  | // `sd` (find-and-replace) or `sed -i` (in-place edit). e.g. \"sd foo bar file.ts\" or \"sed -i s/a/b/ f\". | \n|  | const sdOrSedInPlace = anyOf(wordBoundary + \"sd\" + wordBoundary, wordBoundary + \"sed\" + whitespacePlus + \"-i\"); | \n|  |  | \n|  | // `tee` writes its stdin to a file while piping it through. e.g. \"echo hi \\| tee f.txt\". | \n|  | const teeCommand = wordBoundary + \"tee\" + wordBoundary; | \n|  |  | \n|  | const BASH_BLOCK_PATTERN = new RegExp(anyOf(redirectToFile, heredoc, fileCommand, outputFlag, sdOrSedInPlace, teeCommand)); | \n|  |  | \n|  | export function isFableModel(transcriptPath: string): boolean { | \n|  | const lines = readFileSync(transcriptPath, \"utf8\").split(\"\\n\"); | \n|  | let model = \"\"; | \n|  | for (let i = lines.length - 1; i >= 0; i--) { | \n|  | if (!lines[i]) | \n|  | continue; | \n|  | const entry = JSON.parse(lines[i]); | \n|  | const isAssistantWithModel = entry.type === \"assistant\" && entry.message?.model; | \n|  | if (isAssistantWithModel) { | \n|  | model = entry.message.model; | \n|  | break; | \n|  | } | \n|  | } | \n|  | return model.startsWith(\"claude-fable\"); | \n|  | } | \n|  |  | \n|  | export function isBashCommandBlocked(command: string): boolean { | \n|  | const strippedCommand = command.replace(/'[^']*'\\|\"[^\"]*\"/g, \"\"); | \n|  | return BASH_BLOCK_PATTERN.test(strippedCommand); | \n|  | } | \n|  |  | \n|  | if (import.meta.main) { | \n|  | const input = JSON.parse(readFileSync(0, \"utf8\")); | \n|  | const isSubagent = typeof input.agent_id === \"string\" \\|\\| typeof input.agent_type === \"string\"; | \n|  | const subagentModel = process.env.CLAUDE_CODE_SUBAGENT_MODEL ?? \"\"; | \n|  | const isFableSubagent = isSubagent && subagentModel.startsWith(\"claude-fable\"); | \n|  | const isFableParent = !isSubagent && isFableModel(input.transcript_path); | \n|  | const parentInPlanMode = input.permission_mode === \"plan\"; | \n|  | const isBlockedParent = isFableParent && !parentInPlanMode; | \n|  |  | \n|  | const denyRules: { blocked: boolean; reason: string }[] = [ | \n|  | { 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.\" }, | \n|  | { 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`.\\\"\" }, | \n|  | ]; | \n|  |  | \n|  | let denyReason = \"\"; | \n|  | for (let i = 0; i < denyRules.length; i++) { | \n|  | if (denyRules[i].blocked) { | \n|  | denyReason = denyRules[i].reason; | \n|  | break; | \n|  | } | \n|  | } | \n|  |  | \n|  | if (!denyReason) | \n|  | process.exit(0); | \n|  |  | \n|  | const toolName = input.tool_name; | \n|  | let blocked = toolName === \"Edit\" \\|\\| toolName === \"Write\" \\|\\| toolName === \"NotebookEdit\"; | \n|  | if (toolName === \"Bash\") | \n|  | blocked = isBashCommandBlocked(input.tool_input?.command ?? \"\"); | \n|  |  | \n|  | if (blocked) { | \n|  | console.log( | \n|  | JSON.stringify({ | \n|  | hookSpecificOutput: { | \n|  | hookEventName: \"PreToolUse\", | \n|  | permissionDecision: \"deny\", | \n|  | permissionDecisionReason: denyReason, | \n|  | }, | \n|  | }), | \n|  | ); | \n|  | } | \n|  | } |", "url": "https://wpnews.pro/news/claude-pretooluse-hook-that-blocks-fable-from-editing-files-and-forces-subagents", "canonical_source": "https://gist.github.com/matkatmusic/d1fa777f23894c0eb199404aa2377cc7", "published_at": "2026-09-11 01:49:29+00:00", "updated_at": "2026-09-18 10:54:00.902649+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-safety"], "entities": ["Claude", "Claude Code", "Anthropic"], "alternates": {"html": "https://wpnews.pro/news/claude-pretooluse-hook-that-blocks-fable-from-editing-files-and-forces-subagents", "markdown": "https://wpnews.pro/news/claude-pretooluse-hook-that-blocks-fable-from-editing-files-and-forces-subagents.md", "text": "https://wpnews.pro/news/claude-pretooluse-hook-that-blocks-fable-from-editing-files-and-forces-subagents.txt", "jsonld": "https://wpnews.pro/news/claude-pretooluse-hook-that-blocks-fable-from-editing-files-and-forces-subagents.jsonld"}}