Skip to content

Commit 28ca662

Browse files
author
Nick Davies
committed
Support Cursor preToolUse hook
The `beforeShellExecution` hook is a more natural place for Dippy but it's currently broken because Cursor won't honor "allow" responses it always runs it's own approval prompt after the hook unless the hook denies the request. I updated the docs too to call out the issue but this appears to now work the same as claude
1 parent 9288e06 commit 28ca662

File tree

4 files changed

+108
-9
lines changed

4 files changed

+108
-9
lines changed

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,18 +88,24 @@ Add to `~/.cursor/hooks.json` (global) or `.cursor/hooks.json` in your project r
8888
{
8989
"version": 1,
9090
"hooks": {
91-
"beforeShellExecution": [
92-
{ "command": "dippy" }
91+
"preToolUse": [
92+
{ "matcher": "Shell", "command": "dippy" }
9393
]
9494
}
9595
}
9696
```
9797

98+
The `matcher` ensures Dippy only runs for shell commands, not every tool invocation.
99+
98100
Dippy auto-detects Cursor's input format, so no extra flags are needed. If you prefer to be explicit, use `dippy --cursor` or set `DIPPY_CURSOR=1`.
99101

100102
Logs go to `~/.cursor/hook-approvals.log`.
101103

102-
> **Note:** Cursor has a known bug where only the first hook in an array runs. If you have other `beforeShellExecution` hooks, Dippy must be listed first.
104+
> **Note:** Cursor has a known bug where only the first hook in an array runs. If you have other hooks of the same type, Dippy must be listed first.
105+
106+
> **Note:** Cursor's `beforeShellExecution` hook has a known bug where `allow` responses are ignored — only `deny` works correctly. Use `preToolUse` instead, which handles all tool types and respects allow/deny/ask.
107+
108+
> **Note:** Cursor's sandbox mode bypasses hook permission decisions. Commands that run in the sandbox are auto-approved regardless of what the hook returns. Only unsandboxed commands (those requiring `required_permissions`) respect `ask` and `deny` responses.
103109
104110
If you installed manually, use the full path instead: `/path/to/Dippy/bin/dippy-hook`
105111

src/dippy/dippy.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,14 @@ def _detect_mode_from_flags() -> str | None:
5353

5454
def _detect_mode_from_input(input_data: dict) -> str:
5555
"""Auto-detect mode from input JSON structure."""
56-
# Cursor: {"command": "...", "cwd": "..."}
56+
# Cursor beforeShellExecution: {"command": "...", "cwd": "..."}
5757
if "command" in input_data and "tool_name" not in input_data:
5858
return "cursor"
5959

60+
# Cursor preToolUse: has cursor_version field
61+
if "cursor_version" in input_data:
62+
return "cursor"
63+
6064
# Claude/Gemini: {"tool_name": "...", "tool_input": {...}}
6165
tool_name = input_data.get("tool_name", "")
6266

@@ -259,6 +263,7 @@ def handle_mcp_post_tool_use(tool_name: str, config: Config) -> None:
259263
SHELL_TOOL_NAMES = frozenset(
260264
{
261265
"Bash", # Claude Code
266+
"Shell", # Cursor preToolUse
262267
"shell", # Gemini CLI
263268
"run_shell", # Gemini CLI alternate
264269
"run_shell_command", # Gemini CLI official name
@@ -340,12 +345,20 @@ def main():
340345
hook_event = input_data.get("hook_event_name", "PreToolUse")
341346

342347
# Extract command based on mode
343-
# Cursor: {"command": "...", "cwd": "..."}
348+
# Cursor beforeShellExecution: {"command": "...", "cwd": "..."}
349+
# Cursor preToolUse: {"tool_name": "Shell", "tool_input": {"command": "..."}}
344350
# Claude/Gemini: {"tool_name": "...", "tool_input": {"command": "..."}}
345351
if MODE == "cursor":
346-
# Cursor sends command directly (beforeShellExecution hook)
347-
command = input_data.get("command", "")
348-
tool_name = None
352+
# beforeShellExecution hook: command is top-level
353+
# preToolUse hook (delegated via claude-approve): command is in tool_input
354+
tool_name = input_data.get("tool_name")
355+
if tool_name is not None:
356+
if tool_name not in SHELL_TOOL_NAMES:
357+
print(json.dumps({}))
358+
return
359+
command = input_data.get("tool_input", {}).get("command", "")
360+
else:
361+
command = input_data.get("command", "")
349362
else:
350363
# Claude Code and Gemini CLI use tool_name/tool_input format
351364
tool_name = input_data.get("tool_name", "")

tests/test_entrypoint.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,52 @@ def test_non_bash_tool_passthrough(self):
129129
assert output == {}
130130

131131

132+
class TestCursorPreToolUse:
133+
"""Tests for Cursor preToolUse hook format."""
134+
135+
def test_cursor_pretooluse_shell_allowed(self):
136+
"""Cursor preToolUse with Shell tool and safe command returns allow."""
137+
input_data = {
138+
"tool_name": "Shell",
139+
"tool_input": {"command": "git status", "cwd": ""},
140+
"hook_event_name": "preToolUse",
141+
"cursor_version": "2.6.18",
142+
"workspace_roots": ["/tmp"],
143+
}
144+
result = run_hook(input_data)
145+
assert result.returncode == 0
146+
output = json.loads(result.stdout)
147+
assert output.get("permission") == "allow"
148+
149+
def test_cursor_pretooluse_shell_dangerous(self):
150+
"""Cursor preToolUse with Shell tool and dangerous command returns ask."""
151+
input_data = {
152+
"tool_name": "Shell",
153+
"tool_input": {"command": "rm -rf /", "cwd": ""},
154+
"hook_event_name": "preToolUse",
155+
"cursor_version": "2.6.18",
156+
"workspace_roots": ["/tmp"],
157+
}
158+
result = run_hook(input_data)
159+
assert result.returncode == 0
160+
output = json.loads(result.stdout)
161+
assert output.get("permission") == "ask"
162+
163+
def test_cursor_pretooluse_non_shell_passthrough(self):
164+
"""Cursor preToolUse with non-Shell tool returns empty (passthrough)."""
165+
input_data = {
166+
"tool_name": "Read",
167+
"tool_input": {"path": "/etc/passwd"},
168+
"hook_event_name": "preToolUse",
169+
"cursor_version": "2.6.18",
170+
"workspace_roots": ["/tmp"],
171+
}
172+
result = run_hook(input_data)
173+
assert result.returncode == 0
174+
output = json.loads(result.stdout)
175+
assert output == {}
176+
177+
132178
class TestErrorHandling:
133179
"""Test graceful handling of bad input."""
134180

tests/test_modes.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,11 +245,45 @@ def test_auto_detect_cursor_from_input():
245245
"""Test auto-detection of Cursor mode from input structure."""
246246
from dippy.dippy import _detect_mode_from_input
247247

248-
# Cursor sends command directly without tool_name
248+
# Cursor beforeShellExecution: command directly without tool_name
249249
input_data = {"command": "ls", "cwd": "/home/user"}
250250
assert _detect_mode_from_input(input_data) == "cursor"
251251

252252

253+
def test_auto_detect_cursor_pretooluse_from_input():
254+
"""Test auto-detection of Cursor mode from preToolUse input structure."""
255+
from dippy.dippy import _detect_mode_from_input
256+
257+
# Cursor preToolUse: has cursor_version field
258+
input_data = {
259+
"tool_name": "Shell",
260+
"tool_input": {"command": "ls", "cwd": ""},
261+
"hook_event_name": "preToolUse",
262+
"cursor_version": "2.6.18",
263+
}
264+
assert _detect_mode_from_input(input_data) == "cursor"
265+
266+
267+
def test_auto_detect_cursor_pretooluse_non_shell_tool():
268+
"""Test auto-detection of Cursor mode for non-shell preToolUse tools."""
269+
from dippy.dippy import _detect_mode_from_input
270+
271+
input_data = {
272+
"tool_name": "Read",
273+
"tool_input": {"path": "/some/file"},
274+
"hook_event_name": "preToolUse",
275+
"cursor_version": "2.6.18",
276+
}
277+
assert _detect_mode_from_input(input_data) == "cursor"
278+
279+
280+
def test_shell_tool_names_includes_cursor():
281+
"""Test that SHELL_TOOL_NAMES includes Cursor's Shell tool name."""
282+
from dippy.dippy import SHELL_TOOL_NAMES
283+
284+
assert "Shell" in SHELL_TOOL_NAMES
285+
286+
253287
def test_no_flag_defaults_to_auto_detect(monkeypatch):
254288
"""Test that no flag means auto-detection will be used."""
255289
monkeypatch.setattr("sys.argv", ["dippy"])

0 commit comments

Comments
 (0)