Skip to content

Commit d1b4af0

Browse files
author
Claude
committed
fix(hookify): a missing hook script reports an error instead of blocking
Each hook ran 'python3 <script>'. When the script cannot be opened, python3 exits 2, and a hook that exits 2 blocks: every prompt (UserPromptSubmit) and every tool call (PreToolUse) is refused, with no way out from inside the session. This happens when the plugin directory moves under a running session, as with an org-synced plugin re-synced to a new path. The commands now check that the script exists; if not, they print the path to stderr and exit 1, a non-blocking error Claude Code shows instead of a block. The hookify scripts themselves only ever exit 0, so no intended block is lost. Mitigates upstream issue 98839 for hookify. The root cause there, stale hook paths in a live Cowork session and exit 2 from a missing file being treated as a block, is in Claude Code itself. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01LoLAv3aLaRJocsRMcjFbpv
1 parent 071d458 commit d1b4af0

2 files changed

Lines changed: 29 additions & 5 deletions

File tree

‎plugins/hookify/hooks/hooks.json‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"hooks": [
77
{
88
"type": "command",
9-
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.py\"",
9+
"command": "f=\"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.py\"; if [ ! -f \"$f\" ]; then echo \"hookify: hook script not found, skipping: $f\" >&2; exit 1; fi; exec python3 \"$f\"",
1010
"timeout": 10
1111
}
1212
]
@@ -17,7 +17,7 @@
1717
"hooks": [
1818
{
1919
"type": "command",
20-
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/posttooluse.py\"",
20+
"command": "f=\"${CLAUDE_PLUGIN_ROOT}/hooks/posttooluse.py\"; if [ ! -f \"$f\" ]; then echo \"hookify: hook script not found, skipping: $f\" >&2; exit 1; fi; exec python3 \"$f\"",
2121
"timeout": 10
2222
}
2323
]
@@ -28,7 +28,7 @@
2828
"hooks": [
2929
{
3030
"type": "command",
31-
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/stop.py\"",
31+
"command": "f=\"${CLAUDE_PLUGIN_ROOT}/hooks/stop.py\"; if [ ! -f \"$f\" ]; then echo \"hookify: hook script not found, skipping: $f\" >&2; exit 1; fi; exec python3 \"$f\"",
3232
"timeout": 10
3333
}
3434
]
@@ -39,7 +39,7 @@
3939
"hooks": [
4040
{
4141
"type": "command",
42-
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/userpromptsubmit.py\"",
42+
"command": "f=\"${CLAUDE_PLUGIN_ROOT}/hooks/userpromptsubmit.py\"; if [ ! -f \"$f\" ]; then echo \"hookify: hook script not found, skipping: $f\" >&2; exit 1; fi; exec python3 \"$f\"",
4343
"timeout": 10
4444
}
4545
]

‎plugins/hookify/tests/test_hookify.py‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,11 +225,35 @@ def test_hooks_json_commands_survive_a_path_with_a_space(self):
225225
self.assertIn('"${CLAUDE_PLUGIN_ROOT}', command, "path must be quoted")
226226

227227
# Run the PreToolUse command through a shell, as the harness does.
228-
pre = next(c for c in commands if c.endswith('pretooluse.py"'))
228+
pre = next(c for c in commands if "/hooks/pretooluse.py" in c)
229229
self.assert_rule_fired(
230230
self.run_hook(pre.replace("python3", shlex.quote(sys.executable), 1), root)
231231
)
232232

233+
def test_missing_hook_script_reports_an_error_instead_of_blocking(self):
234+
# Exit code 2 from a hook means "block". python3 exits 2 when it cannot
235+
# open the script, so a plugin directory that moved under a running
236+
# session (an org plugin re-synced to a new path) would block every
237+
# prompt and tool call. The commands must exit non-zero but not 2.
238+
root = self.install("synced", "org", "hookify~g2")
239+
hooks = json.loads((root / "hooks" / "hooks.json").read_text())["hooks"]
240+
commands = {event: entries[0]["hooks"][0]["command"] for event, entries in hooks.items()}
241+
gone = self.tmp / "synced" / "org" / "hookify~g1" # the path the session still holds
242+
for event, command in commands.items():
243+
with self.subTest(event=event):
244+
result = subprocess.run(
245+
command,
246+
shell=True,
247+
cwd=self.project,
248+
env=dict(os.environ, CLAUDE_PLUGIN_ROOT=str(gone)),
249+
input=json.dumps({"hook_event_name": event}),
250+
capture_output=True,
251+
text=True,
252+
)
253+
self.assertNotEqual(result.returncode, 2, "exit 2 would block")
254+
self.assertNotEqual(result.returncode, 0, "the failure must be visible")
255+
self.assertIn("hook script not found", result.stderr)
256+
233257

234258
if __name__ == "__main__":
235259
unittest.main()

0 commit comments

Comments
 (0)