Cursor hooks.json generator
Pick what Cursor's agent should and shouldn't do, and copy a hooks.json plus one bash script per hook. Our tests run every generated script under bash with sample hook input, so a blocked command really is blocked and a normal one still runs.
By Jake Marsh. Event names, input fields and output fields checked against Cursor's hooks docs on 2026-11-01. Runs in your browser and sends nothing anywhere. Every event is listed in the Cursor hooks reference.
1. Install
Run in a terminal. The scripts need jq.
mkdir -p ~/.cursor/hooks
# save each script below into that folder, then:
chmod +x ~/.cursor/hooks/guard-shell.sh ~/.cursor/hooks/protect-env.sh ~/.cursor/hooks/notify-stop.sh
command -v jq >/dev/null || echo 'Install jq first: brew install jq (macOS) or apt install jq'2. hooks.json
Save as ~/.cursor/hooks.json. Restart Cursor if a hook doesn't run.
{
"version": 1,
"hooks": {
"beforeShellExecution": [
{
"command": "./hooks/guard-shell.sh",
"timeout": 5
}
],
"beforeReadFile": [
{
"command": "./hooks/protect-env.sh",
"timeout": 5
}
],
"stop": [
{
"command": "./hooks/notify-stop.sh",
"timeout": 10
}
]
}
}3. guard-shell.sh
Save as ~/.cursor/hooks/guard-shell.sh
#!/bin/bash
# guard-shell.sh: Cursor beforeShellExecution hook. Made with taskhold.com/tools/cursor-hooks.
# Reads the hook's JSON from stdin; needs jq.
# Blocks: git push --force / -f, git reset --hard, rm -rf / or ~.
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.command // empty')
if printf '%s' "$cmd" | grep -qE '(git push.*(--force|-f( |$)))|(git reset --hard)|(rm -[a-z]*r[a-z]*f?[a-z]* +(/|~)( |$))'; then
jq -cn --arg c "$cmd" '{permission: "deny", user_message: ("Blocked by a hook: " + $c), agent_message: "This command is blocked by a hook. Use a safer alternative or ask me to run it."}'
exit 0
fi
echo '{"permission":"allow"}'
4. protect-env.sh
Save as ~/.cursor/hooks/protect-env.sh
#!/bin/bash
# protect-env.sh: Cursor beforeReadFile hook. Made with taskhold.com/tools/cursor-hooks.
# Reads the hook's JSON from stdin; needs jq.
input=$(cat)
path=$(printf '%s' "$input" | jq -r '.file_path // empty')
name=$(basename "$path")
case "$name" in
.env|.env.*|*.pem|*.key|id_rsa|id_ed25519)
case "$name" in .env.example|.env.sample|.env.template) echo '{"permission":"allow"}'; exit 0 ;; esac
jq -cn --arg p "$path" '{permission: "deny", user_message: ("Kept out of the agent: " + $p)}'
exit 0 ;;
esac
echo '{"permission":"allow"}'
5. notify-stop.sh
Save as ~/.cursor/hooks/notify-stop.sh
#!/bin/bash
# notify-stop.sh: Cursor stop hook. Made with taskhold.com/tools/cursor-hooks.
# Reads the hook's JSON from stdin; needs jq.
input=$(cat)
status=$(printf '%s' "$input" | jq -r '.status // "completed"')
case "$status" in
completed) msg="Agent finished" ;;
aborted) msg="Agent stopped (aborted)" ;;
*) msg="Agent stopped with an error" ;;
esac
{
osascript -e "display notification \"$msg\" with title \"Cursor\"" 2>/dev/null
{ afplay /System/Library/Sounds/Glass.aiff || paplay /usr/share/sounds/freedesktop/stereo/complete.oga; } >/dev/null 2>&1 &
} || true
echo '{}'
What each recipe uses
beforeShellExecutiongets the fullcommandand answerspermissionallow or deny, with auser_messagefor you and anagent_messagetelling the agent why, so it can pick a safer command.beforeReadFilegets thefile_pathbefore the agent reads it. Denying .env files keeps the secrets out of the model's context; .env.example and other templates stay readable.afterFileEditruns after each edit; its output is ignored, so the script just runs the formatter on files with a matching extension and exits 0.stopgets the run'sstatus. A non-emptyfollowup_messageis sent as your next message, which is how the tests recipe sends the agent back with the last lines of the failure.
Exit code 2 from any hook also blocks the action, the same convention Claude Code uses. Any other non-zero exit fails open, which is why the scripts print a decision instead of relying on exit codes.
When a hook doesn't run
- Check the script is executable (the install block runs chmod +x) and that jq is installed:
jq --version. - Run a script by hand with sample input, for example
echo '{"command":"git push -f"}' | ~/.cursor/hooks/guard-shell.sh. - Project hooks need a trusted workspace, and cloud agents skip the user-level ~/.cursor/hooks.json.
- On Windows, the desktop app runs hook commands through PowerShell; these scripts are for macOS and Linux.
Where Taskhold goes further
Hooks act on one agent at a time. Taskhold gives Cursor, Claude Code, Codex and other MCP clients one task list: each agent reports its steps and progress there, and asks its questions on a board you can answer from your phone. Try the live board demo without an account, or connect Cursor from the Cursor setup page.
Questions
- Where does Cursor's hooks.json go?
- ~/.cursor/hooks.json applies to every project for you, and relative commands in it run from ~/.cursor/. .cursor/hooks.json in a repo applies to that project, runs from the project root, and only in a trusted workspace. Hooks from every level run together.
- Why does the generated script always print JSON?
- Permission hooks such as beforeShellExecution and beforeReadFile block the action when they print invalid JSON, even without failClosed. Printing {"permission":"allow"} on the normal path keeps every other command working.
- Can the stop hook loop forever?
- No. Cursor caps follow-ups from a stop hook with loop_limit, 5 by default; the generator sets it from the Retries field. The script also sends the agent back only after a completed run, never after you abort it.
- Does the page send my settings anywhere?
- No. The page builds the text in your browser and makes no requests with what you choose or type.
Sources
Checked 2026-11-01.
See every agent's progress on one board.
Taskhold gives Cursor, Claude Code, Codex and other MCP clients one task list with steps, progress bars and questions you answer from your phone. $12 a month or $120 a year per person, 7-day trial with a card.