Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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`

---
Expand Down Expand Up @@ -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
Expand Down
23 changes: 18 additions & 5 deletions src/dippy/dippy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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", "")
Expand Down
46 changes: 46 additions & 0 deletions tests/test_entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
36 changes: 35 additions & 1 deletion tests/test_modes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
Loading