I think the CLI does run the hooks just fine (bugs included)
[cnd@RoG:~/.cursor]$ dir
total 400
lrwxrwxrwx 1 cnd cnd 30 2025-10-01 10:21 User -> ../AppData/Roaming/Cursor/User/
lrwxrwxrwx 1 cnd cnd 37 2025-10-10 07:58 versions -> ../.local/share/cursor-agent/versions/
-rw-rw-r-- 1 cnd cnd 3222 2025-10-18 09:28 all_cursor_files.txt
-rwxrwxrwx 1 cnd cnd 1403 2025-12-24 11:38 hooks.json
-rwxrwxrwx 1 cnd cnd 10580 2025-12-24 11:39 log_cursor_hook.py
-rw------- 1 cnd cnd 441 2026-01-01 22:19 mcp.json
-rw-rw-rw- 1 cnd cnd 1263 2026-01-04 17:40 ide_state.json
-rw-rw-r-- 1 cnd cnd 523 2026-01-04 20:53 cli-config.json.bad
drwxrwxr-x 1 cnd cnd 512 2026-01-06 15:28 projects/
-rw-rw-r-- 1 cnd cnd 53 2026-01-09 20:39 agent-cli-state.json
drwxrwxr-x 1 cnd cnd 512 2026-01-09 20:39 chats/
-rw-rw-r-- 1 cnd cnd 31538 2026-01-10 00:44 python_debug.log
-rw-rw-r-- 1 cnd cnd 342475 2026-01-10 00:44 hook_calls.txt
-rw-rw-r-- 1 cnd cnd 549 2026-01-12 12:50 cli-config.json
[cnd@RoG:~/.cursor]$ cat hooks.json
{
"version": 1,
"hooks": {
"beforeSubmitPrompt": [
{
"command": "python /home/cnd/.cursor/log_cursor_hook.py"
}
],
"beforeShellExecution": [
{
"command": "python /home/cnd/.cursor/log_cursor_hook.py"
}
],
"beforeMCPExecution": [
{
"command": "python /home/cnd/.cursor/log_cursor_hook.py"
}
],
"beforeReadFile": [
{
"command": "python /home/cnd/.cursor/log_cursor_hook.py"
}
],
"beforeTabFileRead": [
{
"command": "python /home/cnd/.cursor/log_cursor_hook.py"
}
],
"afterAgentResponse": [
{
"command": "python /home/cnd/.cursor/log_cursor_hook.py"
}
],
"afterAgentThought": [
{
"command": "python /home/cnd/.cursor/log_cursor_hook.py"
}
],
"afterFileEdit": [
{
"command": "python /home/cnd/.cursor/log_cursor_hook.py"
}
],
"afterTabFileEdit": [
{
"command": "python /home/cnd/.cursor/log_cursor_hook.py"
}
],
"afterShellExecution": [
{
"command": "python /home/cnd/.cursor/log_cursor_hook.py"
}
],
"afterMCPExecution": [
{
"command": "python /home/cnd/.cursor/log_cursor_hook.py"
}
],
"stop": [
{
"command": "python /home/cnd/.cursor/log_cursor_hook.py"
}
]
}
}
[cnd@RoG:~/.cursor]$ cat log_cursor_hook.py
#!/usr/bin/env python3
import sys
import os
import json
import time
import traceback
from pathlib import Path
# Platform specific locking
if os.name == "nt":
import msvcrt
def lock_file( f ):
# Lock 1 byte at the beginning of the file
msvcrt.locking( f.fileno( ), msvcrt.LK_LOCK, 1 )
def unlock_file( f ):
msvcrt.locking( f.fileno( ), msvcrt.LK_UNLCK, 1 )
else:
import fcntl
def lock_file( f ):
fcntl.flock( f.fileno( ), fcntl.LOCK_EX )
def unlock_file( f ):
fcntl.flock( f.fileno( ), fcntl.LOCK_UN )
def sanitize_for_utf8( obj ):
"""
Recursively sanitize an object to remove lone surrogates while preserving valid Unicode.
Lone surrogates are replaced with � (U+FFFD REPLACEMENT CHARACTER).
"""
if isinstance( obj, str ):
# Encode to UTF-8 with 'replace' to fix surrogates, then decode back
return obj.encode( "utf-8", errors = "replace" ).decode( "utf-8" )
elif isinstance( obj, dict ):
return { k: sanitize_for_utf8( v ) for k, v in obj.items( ) }
elif isinstance( obj, list ):
return [ sanitize_for_utf8( item ) for item in obj ]
else:
return obj
def log_hook_call( event_name, payload, response, script_path, error = None ):
"""
Log both the incoming hook event and outgoing response on a single line.
Always succeeds - never raises exceptions.
"""
try:
# Use hardcoded path for consistency
log_path = Path( r"/home/cnd/.cursor/hook_calls.txt" )
timestamp = time.strftime( "%Y-%m-%d %H:%M:%S", time.localtime( ) )
log_record = {
"timestamp": timestamp,
"event": event_name,
"payload": sanitize_for_utf8( payload ),
"response": sanitize_for_utf8( response ) if response is not None else None,
"error": str( error ) if error is not None else None
}
# Single line JSON with ensure_ascii=False to preserve emoji
log_line = json.dumps( log_record, ensure_ascii = False )
# Append with locking to prevent interleaving
with open( log_path, "a", encoding = "utf-8" ) as f:
try:
lock_file( f )
except Exception:
pass
f.write( log_line + "\n" )
f.flush( )
try:
unlock_file( f )
except Exception:
pass
except Exception as log_error:
# If logging fails, try to write to a fallback error log
try:
error_log_path = Path( r"/home/cnd/.cursor/hook_log_errors.txt" )
timestamp = time.strftime( "%Y-%m-%d %H:%M:%S", time.localtime( ) )
with open( error_log_path, "a", encoding = "utf-8", errors = "replace" ) as f:
f.write( f"[{timestamp}] Failed to log {event_name}: {log_error}\n" )
except:
# Complete failure - nothing we can do
pass
def create_response_for_event( event_name, payload, script_path ):
"""
Create spec-compliant response for each hook event type.
See: https://cursor.com/docs/agent/hooks
"""
# beforeShellExecution and beforeMCPExecution - return permission decision
if event_name in ( "beforeShellExecution", "beforeMCPExecution" ):
return {
"permission": "allow",
"user_message": f"{event_name} permitted",
"agent_message": f"Hook intercepted {event_name}. Operation allowed."
}
# beforeReadFile - return permission decision (Agent only)
elif event_name == "beforeReadFile":
return {
"permission": "allow",
"user_message": f"{event_name} allowed",
"agent_message": f"Hook intercepted {event_name}. Operation allowed."
}
# beforeTabFileRead - return permission decision (Tab only)
elif event_name == "beforeTabFileRead":
return {
"permission": "allow",
"user_message": f"{event_name} allowed",
"agent_message": f"Hook intercepted {event_name}. Tab file read allowed."
}
# beforeSubmitPrompt - can prevent submission
elif event_name == "beforeSubmitPrompt":
return {
"continue": True,
"user_message": f"{event_name} continued",
"agent_message": f"Hook intercepted {event_name}. Operation allowed."
}
# stop - can optionally auto-submit follow-up message
elif event_name == "stop":
# Use a sentinel file to prevent infinite loops
# If file exists: no response (already sent followup once)
# If file doesn't exist: create it and send followup_message
sentinel_file = script_path.parent / "stop_hook_tested.sentinel"
if sentinel_file.exists( ):
# Already tested, don't send followup_message
return None
else:
# First time - create sentinel file and send followup_message
try:
sentinel_file.touch( )
except Exception:
pass
return {
"followup_message": "Hook test: The stop hook just auto-submitted this message. Can you see it AI?"
}
# afterShellExecution, afterMCPExecution, afterFileEdit, afterAgentResponse, afterAgentThought
# These hooks are observe-only per spec, but we'll return messages to test if they reach the AI
elif event_name in ( "afterShellExecution", "afterMCPExecution", "afterFileEdit", "afterAgentResponse", "afterAgentThought" ):
return {
"user_message": f"[Hook] {event_name} completed",
"agent_message": f"Hook observed {event_name}. Can you see this message?"
}
# afterTabFileEdit - observe-only (Tab only, no output fields supported per spec)
elif event_name == "afterTabFileEdit":
# Per spec: No output fields currently supported, but we'll return messages to test
return {
"user_message": f"[Hook] {event_name} completed",
"agent_message": f"Hook observed {event_name}. Tab file edit completed."
}
# Unknown event - allow by default
else:
return {
"continue": True,
"permission": "allow",
"user_message": f"{event_name} weird",
"agent_message": f"Hook intercepted {event_name}. Operation allowed."
}
def main( ):
raw = None
payload = None
event_name = "unknown"
response = None
script_path = None
# Debug: Log that Python was called
try:
with open( r"/home/cnd/.cursor/python_debug.log", "a", encoding = "utf-8" ) as f:
f.write( f"\n[PYTHON] Called at {time.strftime('%Y-%m-%d %H:%M:%S')}\n" )
f.write( f"[PYTHON] sys.stdin.isatty(): {sys.stdin.isatty()}\n" )
except:
pass
try:
raw = sys.stdin.read( )
# Debug: Log what we received
try:
with open( r"/home/cnd/.cursor/python_debug.log", "a", encoding = "utf-8" ) as f:
f.write( f"[PYTHON] Received {len(raw)} bytes\n" )
f.write( f"[PYTHON] First 200 chars: {raw[:200]}\n" )
except:
pass
if not raw.strip( ):
return
try:
payload = json.loads( raw )
except json.JSONDecodeError:
payload = { "raw": raw }
# Extract event name if present
event_name = payload.get( "hook_event_name", "unknown_event" )
# Get script path
script_path = Path( __file__ ).resolve( )
# Create spec-compliant response
response = create_response_for_event( event_name, payload, script_path )
# Log both the incoming event and outgoing response
log_hook_call( event_name, payload, response, script_path )
# Only output response if one is required
if response is not None:
print( json.dumps( response, ensure_ascii = True ) )
sys.stdout.flush( )
except Exception as e:
# Log the error along with whatever we captured
try:
if script_path is None:
script_path = Path( __file__ ).resolve( )
# Log this failed call with error info
log_hook_call( event_name, payload, response, script_path, error = e )
# Also write detailed error to separate error log
error_log_path = Path( r"/home/cnd/.cursor/hook_errors.txt" )
timestamp = time.strftime( "%Y-%m-%d %H:%M:%S", time.localtime( ) )
# Get full stack trace
tb = traceback.format_exc( )
# Write with errors='replace' to handle any encoding issues
with open( error_log_path, "a", encoding = "utf-8", errors = "replace" ) as f:
f.write( f"\n{'='*70}\n" )
f.write( f"[{timestamp}] Error in event '{event_name}': {str(e)}\n" )
f.write( f"Stack trace:\n{tb}\n" )
# Log the problematic input data if we captured it
if raw is not None:
try:
safe_input = repr( raw[:1000] ) # First 1000 chars
f.write( f"Raw input (first 1000 chars):\n{safe_input}\n\n" )
except Exception as repr_error:
f.write( f"Raw input: <could not repr: {repr_error}>\n\n" )
if payload is not None:
try:
keys = list( payload.keys( ) ) if isinstance( payload, dict ) else "<not a dict>"
f.write( f"Payload keys: {keys}\n" )
except:
f.write( f"Payload: <could not inspect>\n" )
f.write( f"{'='*70}\n\n" )
except:
# Even error logging failed - just continue to output valid JSON
pass
# Always return safe default response so Cursor doesn't hang
try:
error_response = {
"continue": True,
"permission": "allow"
}
print( json.dumps( error_response, ensure_ascii = True ) )
sys.stdout.flush( )
except:
# Absolute last resort
try:
print( '{"continue":true,"permission":"allow"}' )
sys.stdout.flush( )
except:
pass
if __name__ == "__main__":
main( )
As you can see - it’s writing to my hook_calls.txt file at least.
Update - looks like it’s never calling any stop hook though?
cat hook_calls.txt | nowrap
{"timestamp": "2025-12-24 11:40:02", "event": "unknown_event", "payload": {"raw": "asd\n"},
{"timestamp": "2025-12-24 12:25:02", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2025-12-24 12:25:04", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2025-12-24 12:25:04", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 17:44:05", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 17:44:05", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 17:46:16", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-04 17:46:17", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 17:46:17", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-04 17:46:18", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 17:46:40", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-04 17:46:41", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-04 17:46:41", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 17:46:41", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 17:54:39", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-04 17:54:39", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-04 17:54:40", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 17:54:40", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 17:58:52", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-04 17:58:57", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-04 17:58:58", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-04 19:02:37", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 19:02:40", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-04 19:02:41", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 19:02:45", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-09 23:00:59", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-09 23:01:06", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-09 23:01:53", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-09 23:02:00", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-09 23:02:01", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-09 23:02:01", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-09 23:02:02", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-09 23:02:18", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-09 23:02:19", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-09 23:02:19", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-09 23:02:20", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-09 23:02:30", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-09 23:02:31", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-09 23:02:43", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-09 23:02:44", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-09 23:03:00", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-09 23:03:00", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-09 23:03:09", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-09 23:03:10", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-09 23:03:10", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-09 23:03:11", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-09 23:03:25", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-09 23:04:00", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-09 23:04:24", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-09 23:04:24", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-09 23:04:37", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-09 23:04:37", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-09 23:04:57", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-09 23:05:17", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-09 23:05:44", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-09 23:07:12", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:31:49", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-10 00:31:51", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-10 00:32:26", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:32:36", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-10 00:32:37", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-10 00:33:03", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:33:06", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:33:40", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-10 00:35:06", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:35:18", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-10 00:35:19", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-10 00:35:53", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:36:01", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-10 00:36:02", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-10 00:36:05", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-10 00:36:06", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-10 00:36:22", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-10 00:36:23", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-10 00:36:36", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-10 00:36:37", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-10 00:36:49", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:36:50", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:37:20", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:37:32", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-10 00:37:35", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-10 00:38:08", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:38:40", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:39:00", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-10 00:40:17", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:41:12", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:42:06", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:42:29", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:43:01", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:43:10", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-10 00:43:12", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-10 00:43:21", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-10 00:43:22", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-10 00:44:27", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:44:36", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-10 00:44:37", "event": "afterShellExecution", "payload": {"conversati