| | #!/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, | | | }, |
| | }), |
| | ); |
| | } | | | } |