fix(hooks): detect grep/read shell commands in Codex exec_command (fixes #1394)

When the Codex client sends shell work via `exec_command`, the tool name is
always `exec_command` and the actual command is under `tool_input.cmd`.  The
previous `is_grep_tool` / `is_read_file_tool` checks inspected only the tool
name and therefore never fired for Codex grep/read workflows.

Add `_GREP_SHELL_COMMANDS` and `_READ_SHELL_COMMANDS` frozensets and a helper
`_get_exec_command_name()` that extracts the basename of the first token in
`cmd`.  Both detection methods now use these to classify `exec_command` calls
when the client is `HookClient.CODEX`, leaving all other client paths unchanged.

Co-Authored-By: Octopus <liyuan851277048@icloud.com>
This commit is contained in:
octo-patchandOctopus authored and Michael Panchenko committed 2026-04-30 11:54:25 +02:00
1 parent 0a96f71bd9
commit 84e117c5c2
2 files changed
+34 -1

No files matched your search

+3
View File
@@ -2,6 +2,9 @@
Status of the `main` branch. Changes prior to the next official version change will appear here.
* Hooks:
- Extension: heuristics for parsing commands and firing a hook on too many greps or reads. Important for clients that, unlike claude code, don't have dedicated grep/read tools.
# v1.2.0 (2026-04-27)
* General:
+31 -1
View File
@@ -241,20 +241,50 @@ class PreToolUseRemindAboutSerenaHook(PreToolUseHook):
#: ingest. Conservative on purpose: only verbs that strongly imply *reading*
#: a file, never *modifying* one — so ``view_file``/``open_file``/``show_file``
#: are caught alongside ``read_file``, while ``write_file``/``edit_file`` are not.
_READ_FILE_VERB_SUBSTRINGS: tuple[str, ...] = ("read", "view", "open", "show")
_READ_FILE_VERB_SUBSTRINGS: frozenset[str] = frozenset(("read", "view", "open", "show"))
#: Shell commands that perform grep-like search; used to classify Codex
#: ``exec_command`` calls whose ``cmd`` field starts with one of these.
_GREP_SHELL_COMMANDS: frozenset[str] = frozenset(("grep", "rg", "ag", "ack", "fgrep", "egrep"))
#: Shell commands that perform file-read operations; used to classify Codex
#: ``exec_command`` calls whose ``cmd`` field starts with one of these.
_READ_SHELL_COMMANDS: frozenset[str] = frozenset(("cat", "head", "tail", "sed", "less", "more", "bat"))
def __init__(self, client: HookClient):
super().__init__(client)
self._tool_call_counter = self.ToolUseCounter.load(self)
def _get_exec_command_name(self) -> str:
"""Extract the command name from an ``exec_command`` tool input.
Returns the basename of the first whitespace-separated token of the
``cmd`` field, lowercased, so that both bare names (``rg``) and
path-prefixed invocations (``/usr/bin/grep``) are normalised the same
way. Returns an empty string when the input is absent or empty.
"""
if self._tool_input is None:
return ""
cmd = str(self._tool_input.get("cmd", "") or "").strip()
if not cmd:
return ""
first_token = cmd.split()[0]
return os.path.basename(first_token).lower()
def is_grep_tool(self) -> bool:
if self._client == HookClient.CLAUDE_CODE:
return self._tool_name == "grep"
if self._client == HookClient.CODEX and self._tool_name == "exec_command":
return self._get_exec_command_name() in self._GREP_SHELL_COMMANDS
# heuristic for other clients
return "grep" in self._tool_name
def is_read_file_tool(self) -> bool:
if self._client == HookClient.CLAUDE_CODE:
return self._tool_name == "read"
if self._client == HookClient.CODEX and self._tool_name == "exec_command":
return self._get_exec_command_name() in self._READ_SHELL_COMMANDS
# heuristic for other clients
name = self._tool_name
if "file" not in name:
return False