diff --git a/README.md b/README.md index cee91a3..f83319f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ > **Stop the permission fatigue.** Claude Code asks for approval on every `ls`, `git status`, and `cat` - destroying your flow state. You check Slack, come back, and your assistant's just sitting there waiting. -Dippy is a shell command hook that auto-approves safe commands while still prompting for anything destructive. When it blocks, your custom deny messages can steer Claude back on track—no wasted turns. Get up to **40% faster development** without disabling permissions entirely. +Dippy is a shell command hook that auto-approves safe commands while still prompting for anything destructive. When it blocks, your custom deny messages can steer the AI back on track—no wasted turns. Get up to **40% faster development** without disabling permissions entirely. + +Works with **Claude Code**, **Cursor**, and **Gemini CLI**. Built on [Parable](https://github.com/ldayton/Parable), our own hand-written bash parser—no external dependencies, just pure Python. 14,000+ tests between the two. @@ -61,6 +63,8 @@ git clone https://github.com/ldayton/Dippy.git ### Configure +#### Claude Code + Add to `~/.claude/settings.json` (or use `/hooks` interactively): ```json @@ -76,6 +80,37 @@ Add to `~/.claude/settings.json` (or use `/hooks` interactively): } ``` +#### Cursor + +WARNING: While Dippy does support the input and ouput formats expected by Cursor the latest version of Cursor's use of hooks is broken and integrates pooly with their sandbox and network sandbox system. You have two choices: +1) Use Dippy as an extra layer: With `beforeShellExecution` it honors the Deny response but then will still prompt on Allow or Ask. +2) Forgo protections for non-shell commands: This is NOT recommended but with the `preToolUse` hook in cursor Allow/Deny work but Ask is treated as "no opinion" so it falls through. So you can set Cursor to "run everything" but this approves all tool use, Read, Write, WebSearch etc automatically as well. + +Add to `~/.cursor/hooks.json` (global) or `.cursor/hooks.json` in your project root: + +```json +{ + "version": 1, + "hooks": { + "preToolUse": [ + { "matcher": "Shell", "command": "dippy" } + ] + } +} +``` + +The `matcher` ensures Dippy only runs for shell commands, not every tool invocation. + +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`. + +Logs go to `~/.cursor/hook-approvals.log`. + +> **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. + +> **Note:** Cursor's `beforeShellExecution` hook has a known bug where `allow` responses are ignored — only `deny` works correctly. Use `preToolUse` instead, however this does NOT respect `ask` commands. + +> **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 `allow` and `deny` responses. + If you installed manually, use the full path instead: `/path/to/Dippy/bin/dippy-hook` --- @@ -104,7 +139,7 @@ Dippy can do more than filter shell commands. See the [wiki](https://github.com/ ## Uninstall -Remove the hook entry from `~/.claude/settings.json`, then: +Remove the hook entry from `~/.claude/settings.json` or `~/.cursor/hooks.json`, then: ```bash brew uninstall dippy # if installed via Homebrew diff --git a/src/dippy/dippy.py b/src/dippy/dippy.py index eb069da..2283bf4 100644 --- a/src/dippy/dippy.py +++ b/src/dippy/dippy.py @@ -53,10 +53,14 @@ def _detect_mode_from_flags() -> str | None: def _detect_mode_from_input(input_data: dict) -> str: """Auto-detect mode from input JSON structure.""" - # Cursor: {"command": "...", "cwd": "..."} + # Cursor beforeShellExecution: {"command": "...", "cwd": "..."} if "command" in input_data and "tool_name" not in input_data: return "cursor" + # Cursor preToolUse: has cursor_version field + if "cursor_version" in input_data: + return "cursor" + # Claude/Gemini: {"tool_name": "...", "tool_input": {...}} tool_name = input_data.get("tool_name", "") @@ -259,6 +263,7 @@ def handle_mcp_post_tool_use(tool_name: str, config: Config) -> None: SHELL_TOOL_NAMES = frozenset( { "Bash", # Claude Code + "Shell", # Cursor preToolUse "shell", # Gemini CLI "run_shell", # Gemini CLI alternate "run_shell_command", # Gemini CLI official name @@ -340,12 +345,20 @@ def main(): hook_event = input_data.get("hook_event_name", "PreToolUse") # Extract command based on mode - # Cursor: {"command": "...", "cwd": "..."} + # Cursor beforeShellExecution: {"command": "...", "cwd": "..."} + # Cursor preToolUse: {"tool_name": "Shell", "tool_input": {"command": "..."}} # Claude/Gemini: {"tool_name": "...", "tool_input": {"command": "..."}} if MODE == "cursor": - # Cursor sends command directly (beforeShellExecution hook) - command = input_data.get("command", "") - tool_name = None + # beforeShellExecution hook: command is top-level + # preToolUse hook (delegated via claude-approve): command is in tool_input + tool_name = input_data.get("tool_name") + if tool_name is not None: + if tool_name not in SHELL_TOOL_NAMES: + print(json.dumps({})) + return + command = input_data.get("tool_input", {}).get("command", "") + else: + command = input_data.get("command", "") else: # Claude Code and Gemini CLI use tool_name/tool_input format tool_name = input_data.get("tool_name", "") diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py index 5b48202..3e5d65c 100644 --- a/tests/test_entrypoint.py +++ b/tests/test_entrypoint.py @@ -129,6 +129,52 @@ def test_non_bash_tool_passthrough(self): assert output == {} +class TestCursorPreToolUse: + """Tests for Cursor preToolUse hook format.""" + + def test_cursor_pretooluse_shell_allowed(self): + """Cursor preToolUse with Shell tool and safe command returns allow.""" + input_data = { + "tool_name": "Shell", + "tool_input": {"command": "git status", "cwd": ""}, + "hook_event_name": "preToolUse", + "cursor_version": "2.6.18", + "workspace_roots": ["/tmp"], + } + result = run_hook(input_data) + assert result.returncode == 0 + output = json.loads(result.stdout) + assert output.get("permission") == "allow" + + def test_cursor_pretooluse_shell_dangerous(self): + """Cursor preToolUse with Shell tool and dangerous command returns ask.""" + input_data = { + "tool_name": "Shell", + "tool_input": {"command": "rm -rf /", "cwd": ""}, + "hook_event_name": "preToolUse", + "cursor_version": "2.6.18", + "workspace_roots": ["/tmp"], + } + result = run_hook(input_data) + assert result.returncode == 0 + output = json.loads(result.stdout) + assert output.get("permission") == "ask" + + def test_cursor_pretooluse_non_shell_passthrough(self): + """Cursor preToolUse with non-Shell tool returns empty (passthrough).""" + input_data = { + "tool_name": "Read", + "tool_input": {"path": "/etc/passwd"}, + "hook_event_name": "preToolUse", + "cursor_version": "2.6.18", + "workspace_roots": ["/tmp"], + } + result = run_hook(input_data) + assert result.returncode == 0 + output = json.loads(result.stdout) + assert output == {} + + class TestErrorHandling: """Test graceful handling of bad input.""" diff --git a/tests/test_modes.py b/tests/test_modes.py index 877dd84..b103537 100644 --- a/tests/test_modes.py +++ b/tests/test_modes.py @@ -245,11 +245,45 @@ def test_auto_detect_cursor_from_input(): """Test auto-detection of Cursor mode from input structure.""" from dippy.dippy import _detect_mode_from_input - # Cursor sends command directly without tool_name + # Cursor beforeShellExecution: command directly without tool_name input_data = {"command": "ls", "cwd": "/home/user"} assert _detect_mode_from_input(input_data) == "cursor" +def test_auto_detect_cursor_pretooluse_from_input(): + """Test auto-detection of Cursor mode from preToolUse input structure.""" + from dippy.dippy import _detect_mode_from_input + + # Cursor preToolUse: has cursor_version field + input_data = { + "tool_name": "Shell", + "tool_input": {"command": "ls", "cwd": ""}, + "hook_event_name": "preToolUse", + "cursor_version": "2.6.18", + } + assert _detect_mode_from_input(input_data) == "cursor" + + +def test_auto_detect_cursor_pretooluse_non_shell_tool(): + """Test auto-detection of Cursor mode for non-shell preToolUse tools.""" + from dippy.dippy import _detect_mode_from_input + + input_data = { + "tool_name": "Read", + "tool_input": {"path": "/some/file"}, + "hook_event_name": "preToolUse", + "cursor_version": "2.6.18", + } + assert _detect_mode_from_input(input_data) == "cursor" + + +def test_shell_tool_names_includes_cursor(): + """Test that SHELL_TOOL_NAMES includes Cursor's Shell tool name.""" + from dippy.dippy import SHELL_TOOL_NAMES + + assert "Shell" in SHELL_TOOL_NAMES + + def test_no_flag_defaults_to_auto_detect(monkeypatch): """Test that no flag means auto-detection will be used.""" monkeypatch.setattr("sys.argv", ["dippy"])