Файл дня
Хук для Claude Code, который пропускает команды чтения без вопроса
Слава Сажин 25.09.2026 2 мин чтения 2 просмотра
PreToolUse-хук, который даёт Claude Code запускать grep, cat, ls и git log без вопроса о разрешении, а остальные команды оставляет вам. Один скрипт.
Сегодня в «Файле дня» хук для Claude Code. Перед каждым grep, ls или git log Claude Code спрашивает разрешения, и к двадцатому разу «да» нажимается не глядя. Хук сам отвечает за команды, которые только читают, и на экране остаются вопросы о тех, что что-то меняют.
Как он решает
Хук делит команду на части по ;, &&, ||, | и & так же, как оболочка, поэтому разделитель в кавычках остаётся частью слова. Команда проходит, только если каждая её часть есть в закрытом списке: около сорока программ вроде grep, cat, ls и head и подкоманды git, которые только читают, например log, diff и status.
Всё, в чём он не уверен, уходит к вам, как раньше: запись в файл, $(...), heredoc, присваивание переменной, программа по пути, а ещё sed, find, awk и xargs, которые умеют запускать другие программы. Отдельно он ловит опции, которые превращают чтение в запись, вроде sort -o или git log --output.
Что поменять под себя
Списки лежат в начале файла. Свои программы для чтения допишите в CHTENIE, подкоманды git - в GIT_CHTENIE. Прежде чем добавить программу, проверьте, нет ли у неё опции, которая пишет файл или запускает команду, и если есть, внесите эту опцию в OPASNYE_OPCII.
Как поставить
Скрипт кладётся в ~/.claude/hooks/, блок настроек - в ~/.claude/settings.json. Нужны python3 и jq. Команды, блок и проверка в две строки есть в инструкции ниже.
Файлы
#!/usr/bin/env python3
"""Команда, которая ТОЛЬКО ЧИТАЕТ: разбор для сторожа команд оболочки.
Делит строку на команды по разделителям (`;`, `&&`, `||`, `|`, `&`) и отвечает 0, только
когда КАЖДАЯ команда - чтение из закрытого списка имён. Всё прочее - 1. Разбор идёт по
лексемам оболочки (shlex): разделитель ВНУТРИ кавычек (`grep "a|b"`) командой не считается.
Строгость: любое сомнение - ответ 1. Не освобождаются: несколько строк, подстановка `$(...)`,
обратные кавычки, ввод из файла, heredoc, скобки, запись в файл, присваивание переменной,
программа по пути и команда, чьё имя в список чтения не входит.
Запуск: команда на stdin, код возврата 0 - только чтение, 1 - нет.
"""
import shlex
import sys
# Имена, которые только читают и печатают. Список закрытый: нет в нём - значит не чтение.
# Нет намеренно: `env`, `less`, `more`, `bat`, `awk`, `find`, `xargs`, `sed`, `sudo`,
# оболочки и питон - они умеют запускать чужое.
CHTENIE = {
"grep", "egrep", "fgrep", "rg", "cat", "head", "tail", "ls", "wc",
"sort", "uniq", "cut", "tr", "column", "echo", "printf", "comm", "nl", "tac", "which",
"type", "file", "stat", "basename", "dirname", "realpath", "readlink", "pwd", "date",
"diff", "md5sum", "sha256sum", "cksum", "true", "false", "test",
"whoami", "id", "uptime", "df", "du", "jq", "git",
}
# Опции, которыми читающая программа пишет файл или запускает чужое. Длинная ловится и по
# сокращению (`--compress-prog`), короткая - и в склейке (`-no`, `-Oless`).
OPASNYE_OPCII = {
"sort": ("--compress-program", "--output", "-o"),
"rg": ("--pre", "--hostname-bin", "--search-zip", "-z"),
"file": ("--compile", "-C"),
"date": ("--set", "-s"),
"git": ("-c", "--exec-path", "--upload-pack", "--receive-pack", "--ext-diff", "--output",
"--open-files-in-pager", "-O", "--textconv", "--filters", "--config-env",
"--attr-source", "--shallow-file"),
}
# Подкоманды git, которые только читают. Остальные (push, merge, commit, checkout) - действия.
GIT_CHTENIE = {"log", "diff", "show", "status", "blame", "ls-files", "rev-parse", "cat-file",
"describe", "shortlog", "grep"}
RAZDELITELI = {";", "&&", "||", "|", "&"}
ZNAKI = set("();<>|&")
def tolko_chtenie(stroka):
stroka = stroka.strip()
if not stroka or any(z in stroka for z in ("$(", "`", "<", "\n", "\r")):
return False
try:
lex = shlex.shlex(stroka, posix=True, punctuation_chars=True)
lex.whitespace_split = True
lex.commenters = ""
leksemy = list(lex)
except ValueError:
return False
komanda = []
i = 0
while i <= len(leksemy):
lek = leksemy[i] if i < len(leksemy) else None
if lek is None or lek in RAZDELITELI:
if komanda and not komanda_chtenie(komanda):
return False
komanda = []
i += 1
continue
if lek in (">", ">>", "&>", ">&"):
# Пускаем только /dev/null и склейку потоков `2>&1`, запись в файл - нет.
cel = leksemy[i + 1] if i + 1 < len(leksemy) else None
if cel != "/dev/null" and not (lek == ">&" and cel is not None and cel.isdigit()):
return False
if komanda and komanda[-1].isdigit():
komanda.pop()
i += 2
continue
if set(lek) <= ZNAKI: # скобки и склейки знаков (`;(`, `>|`, `>(`)
return False
komanda.append(lek)
i += 1
return True
def komanda_chtenie(slova):
imya = slova[0]
# Присваивание меняет поведение программы, имя по пути - чужая программа.
if "=" in imya or "/" in imya or imya not in CHTENIE:
return False
# Фигурные скобки, маска и переменная раскрываются оболочкой после разбора: `{-o,f}`, `*`.
dop = "*?[$" if imya in OPASNYE_OPCII or imya == "uniq" else ""
if any(z in s for s in slova for z in "{}" + dop):
return False
for s in slova[1:]:
op = s.split("=", 1)[0]
for o in OPASNYE_OPCII.get(imya, ()):
if o.startswith("--"):
if op.startswith("--") and len(op) > 2 and o.startswith(op):
return False
elif s.startswith("-") and not s.startswith("--") and o[1] in s[1:]:
return False
# `-` - это стандартный ввод, а после `--` имя - файл: `uniq - out` пишет `out`.
operandy = [s for s in slova[1:] if s == "-" or not s.startswith("-")]
if imya == "uniq" and ("--" in slova or len(operandy) > 1):
return False
if imya == "git":
pod, j = None, 1
while j < len(slova):
s = slova[j]
if s.startswith("-"): # опции самой git, -C и подобные несут своё слово
j += 2 if s in ("-C", "--git-dir", "--work-tree", "--namespace") else 1
continue
pod = s
break
if pod not in GIT_CHTENIE:
return False
return True
if __name__ == "__main__":
sys.exit(0 if tolko_chtenie(sys.stdin.read()) else 1)
# Как поставить проверку «команда только читает»
Проверка разбирает команду оболочки по словам и отвечает кодом 0 лишь тогда, когда каждая её часть только читает: `grep`, `cat`, `ls`, `git log` и подобные из закрытого списка. Всё остальное, как и любой сомнительный случай, получает код 1. Ниже показано, как поставить её хуком: команды чтения Claude Code будет запускать без вопроса о разрешении, остальные пойдут обычным порядком.
Куда положить:
```
mkdir -p ~/.claude/hooks
cp tolko_chtenie.py ~/.claude/hooks/
```
Что вписать у себя: в `~/.claude/settings.json` добавьте хук ниже; если раздел `hooks` в файле уже есть, допишите в него только `PreToolUse`. Нужна программа `jq`.
```
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r .tool_input.command | python3 ~/.claude/hooks/tolko_chtenie.py && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\"}}' || true"
}
]
}
]
}
}
```
Свои программы для чтения допишите в список `CHTENIE` в файле, а подкоманды git - в `GIT_CHTENIE`.
Как проверить, что всё работает:
```
printf 'git log --oneline -3 | head -2' | python3 ~/.claude/hooks/tolko_chtenie.py; echo $?
printf 'cat a.txt > b.txt' | python3 ~/.claude/hooks/tolko_chtenie.py; echo $?
```
Первая команда печатает 0, вторая - 1. Потом откройте новую сессию Claude Code и попросите показать `git status`: команда пройдёт без вопроса о разрешении, а на `git commit` вопрос появится.
Статья стоила вашего времени?
Все мастер-классы по одной подписке
Записи мастер-классов и файлы к ним, новые выходят каждую неделю. €10 в месяц.