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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ brew install dippy

```bash
git clone https://github.com/ldayton/Dippy.git
cd Dippy
uv pip install -e .
```

### Configure
Expand Down Expand Up @@ -94,6 +96,20 @@ Dippy reads config from `~/.dippy/config` (global) and `.dippy` in your project

**Full documentation:** [Dippy Wiki](https://github.com/ldayton/Dippy/wiki)

### Getting Started

An annotated example config and test harness are included in [`examples/`](examples/):

- **[`examples/config`](examples/config)** — Starter config with rules for Git, Docker, npm, MCP tools, shell redirects, and home directory protection. Copy it and customize:
```bash
cp examples/config ~/.dippy/config
```

- **[`examples/test-config.sh`](examples/test-config.sh)** — Validates your config produces the expected allow/ask/deny decisions (45 tests across 6 categories). Auto-detects `dippy`/`dippy.exe`:
```bash
bash examples/test-config.sh
```

---

## Extensions
Expand Down
172 changes: 172 additions & 0 deletions examples/config
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# Dippy example config
# Copy to ~/.dippy/config and customize to your needs.
#
# Three decisions: allow (auto-approve) | ask (prompt user) | deny (block)
# Rule order matters: LAST MATCH WINS — put broad rules first, specific overrides after.

#---- Settings ----#
set log ~/.dippy/audit.log
set log-full # include full command text in audit log

#---- Python ----#
allow rm -rf /**/__pycache__ # cache cleanup is safe
deny python3 "Use `uv run python` to run in project environment"
deny python "Use `uv run python` to run in project environment"

#---- uv ----#
allow uv run # run commands in project venv
allow uv run pytest # test runner
allow uv run python -c # one-liners in venv
deny uv run ruff "Use `just fmt --fix` and `just lint --fix`"
allow uv sync # sync dependencies from lockfile
allow uv lock # update lockfile
allow uv tree # show dependency tree
ask uv add # adding deps changes lockfile
ask uv remove
ask uv pip install # escape hatch — prefer uv add
ask uv pip uninstall

#---- Git ----#
# Reads — safe, auto-approve
allow git status
allow git log
allow git diff
allow git diff --stat
allow git branch
allow git stash list
allow git show
allow git remote -v
allow git fetch

# Writes — prompt for confirmation
ask git push
ask git merge
ask git rebase
ask git cherry-pick
ask git tag

# Dangerous — block with message
deny git push --force "Dangerous — confirm manually in terminal"
deny git reset --hard "Dangerous — confirm manually in terminal"
deny git clean -f "Irreversible — confirm manually in terminal"

#---- Docker ----#
allow docker ps
allow docker images
allow docker logs
allow docker inspect
ask docker run
ask docker rm
ask docker rmi

#---- npm / node ----#
allow npm list
allow npm outdated
ask npm run
deny npm unpublish "Irreversible — don't unpublish packages"

#---- PowerShell ----#
ask pwsh
ask powershell

#---- Just (task runner) ----#
allow just check
allow just fmt
allow just lint
allow just test
deny just -C "Don't use -C, use the target directly"

#---- curl / wget ----#
ask curl
ask wget

# ==============================================================================
# MCP Tools
#
# IMPORTANT: Last match wins! Put the catch-all FIRST, then specific overrides.
# If you put the catch-all last, it will override all your specific rules.
# ==============================================================================

#---- MCP Tools ----#
# Catch-all default — ask for any unrecognized MCP tool
ask-mcp mcp__*

# Read-only actions — auto-approve
allow-mcp mcp__*__get_*
allow-mcp mcp__*__list_*
allow-mcp mcp__*__read_*
allow-mcp mcp__*__search_*
allow-mcp mcp__*__query_*

# Per-server overrides (allow all tools from trusted read-only servers)
# allow-mcp mcp__context7__*

# Mutating actions — prompt for confirmation
ask-mcp mcp__*__create_*
ask-mcp mcp__*__update_*
ask-mcp mcp__*__push_*

# Destructive actions — block (overrides the allows above)
deny-mcp mcp__*__delete_* "Destructive — confirm manually"
deny-mcp mcp__*__remove_* "Destructive — confirm manually"
deny-mcp mcp__*__drop_* "Destructive — confirm manually"

# ==============================================================================
# Redirects (shell output: >, >>, 2>, &>, tee, dd of=)
#
# These rules control where shell commands can WRITE via redirects.
# They do NOT affect Claude's Edit/Write tools (that requires a separate hook).
# ==============================================================================

#---- Redirect rules ----#
deny-redirect **/.env* "Don't overwrite .env files via shell"
deny-redirect **/secrets* "Don't overwrite secrets via shell"

# Block all shell writes under home directory
deny-redirect ~/** "Don't write to home directory via shell redirect"

# Allow temp and build output
allow-redirect /tmp/**
allow-redirect ./dist/**
allow-redirect ./build/**
allow-redirect ./target/**
allow-redirect ./coverage/**
allow-redirect ./.pytest_cache/**

# Logs
allow-redirect ./logs/**
allow-redirect ./*.log

# Protect source from shell redirects (prefer Edit/Write tools)
deny-redirect ./src/** "Use the AI's file editing tools instead"

# ==============================================================================
# Command argument protection
#
# These rules match commands with specific PATH ARGUMENTS.
# Pattern: deny <cmd> <path-glob> "message"
# ==============================================================================

#---- Home directory protection ----#
# Block mutations in ~, then carve out exceptions (last match wins)
deny rm ~/** "Don't delete files in home directory"
deny mv ~/** "Don't move files in home directory"
deny cp ~/** "Don't copy to home directory"
deny chmod ~/** "Don't change permissions in home directory"

# Exception: allow operations in project workspace
allow * ~/repos/**
# Exception: prompt for AI config changes
ask * ~/.claude/**

#---- System paths ----#
ask rm -rf
deny * /etc/** "Don't touch system config"
deny * /usr/** "Don't touch system files"
deny * ~/.ssh/** "Don't touch SSH keys"

#---- Sensitive redirect targets ----#
deny-redirect ~/.ssh/** "SSH keys are protected"
deny-redirect ~/.aws/** "AWS credentials are protected"
deny-redirect ~/.gnupg/** "GPG keys are protected"
deny-redirect /etc/** "System config is protected"
177 changes: 177 additions & 0 deletions examples/test-config.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
#!/usr/bin/env bash
# ==============================================================================
# Test harness for Dippy config rules
#
# Validates that your config produces the expected allow/ask/deny decisions
# by piping JSON hook payloads to Dippy and checking the response.
#
# Usage:
# 1. Copy examples/config to ~/.dippy/config (or set DIPPY_CONFIG)
# 2. Run: bash examples/test-config.sh
#
# The script auto-detects the Dippy binary (dippy or dippy.exe).
# ==============================================================================

set -euo pipefail

# Find Dippy binary
if command -v dippy.exe &>/dev/null; then
DIPPY="dippy.exe"
elif command -v dippy &>/dev/null; then
DIPPY="dippy"
else
echo "Error: dippy not found in PATH"
exit 1
fi

PASS=0
FAIL=0
CWD="$(pwd)"

green() { printf "\e[32m%s\e[0m\n" "$1"; }
red() { printf "\e[31m%s\e[0m\n" "$1"; }
header() { printf "\n\e[1;36m=== %s ===\e[0m\n" "$1"; }

# Send a Bash tool call to Dippy
check_bash() {
printf '{"tool_name":"Bash","tool_input":{"command":"%s","cwd":"%s"}}' "$1" "$CWD" \
| $DIPPY 2>/dev/null
}

# Send an MCP tool call to Dippy
check_mcp() {
printf '{"tool_name":"%s","tool_input":{}}' "$1" \
| $DIPPY 2>/dev/null
}

# Extract "permissionDecision" value from JSON response
get_decision() {
echo "$1" | sed 's/.*"permissionDecision" *: *"\([^"]*\)".*/\1/'
}

# Assert that response contains the expected decision
assert() {
local label="$1" expected="$2" response="$3"

if [ "$expected" = "defer" ] && [ "$response" = "{}" ]; then
green " PASS: $label -> defer"
PASS=$((PASS + 1))
return
fi

local got
got=$(get_decision "$response")

if [ "$got" = "$expected" ]; then
green " PASS: $label -> $expected"
PASS=$((PASS + 1))
else
red " FAIL: $label -> expected '$expected', got '$got'"
FAIL=$((FAIL + 1))
fi
}

# ==============================================================================
header "1. MCP Tools"
# ==============================================================================

# Read-only — allow
r=$(check_mcp "mcp__github__get_me"); assert "github get_me" "allow" "$r"
r=$(check_mcp "mcp__github__list_issues"); assert "github list_issues" "allow" "$r"
r=$(check_mcp "mcp__filesystem__read_file"); assert "filesystem read_file" "allow" "$r"
r=$(check_mcp "mcp__github__search_code"); assert "github search_code" "allow" "$r"
r=$(check_mcp "mcp__myserver__query_data"); assert "myserver query_data" "allow" "$r"

# Destructive — deny
r=$(check_mcp "mcp__github__delete_branch"); assert "github delete_branch" "deny" "$r"
r=$(check_mcp "mcp__db__remove_record"); assert "db remove_record" "deny" "$r"
r=$(check_mcp "mcp__db__drop_table"); assert "db drop_table" "deny" "$r"

# Mutating — ask
r=$(check_mcp "mcp__github__create_issue"); assert "github create_issue" "ask" "$r"
r=$(check_mcp "mcp__github__update_pull_request"); assert "github update_pr" "ask" "$r"
r=$(check_mcp "mcp__github__push_files"); assert "github push_files" "ask" "$r"

# Unknown tools — ask (catch-all)
r=$(check_mcp "mcp__unknown__do_something"); assert "unknown tool (catch-all)" "ask" "$r"
r=$(check_mcp "mcp__slack__send_message"); assert "slack send_message" "ask" "$r"

# ==============================================================================
header "2. Shell Redirects"
# ==============================================================================

r=$(check_bash "echo test > ~/Desktop/test.txt"); assert "echo > ~/Desktop" "deny" "$r"
r=$(check_bash "echo SECRET=x > .env"); assert "echo > .env" "deny" "$r"
r=$(check_bash "echo test > /tmp/test.txt"); assert "echo > /tmp" "allow" "$r"
r=$(check_bash "echo key > ~/.ssh/authorized_keys"); assert "echo > ~/.ssh" "deny" "$r"
r=$(check_bash "echo creds > ~/.aws/credentials"); assert "echo > ~/.aws" "deny" "$r"

# ==============================================================================
header "3. System Paths (deny * /path/**)"
# ==============================================================================

r=$(check_bash "cp file /etc/passwd"); assert "cp /etc/passwd" "deny" "$r"
r=$(check_bash "cp file /etc/apt/sources.list"); assert "cp /etc/apt/sources.list" "deny" "$r"
r=$(check_bash "cp file /usr/share/app/config"); assert "cp /usr/share/app" "deny" "$r"
r=$(check_bash "cp file ~/.ssh/config"); assert "cp ~/.ssh/config" "deny" "$r"

# ==============================================================================
header "4. Home Directory Protection"
# ==============================================================================

r=$(check_bash "rm ~/Documents/file.txt"); assert "rm ~/Documents" "deny" "$r"
r=$(check_bash "rm ~/repos/project/temp.txt"); assert "rm ~/repos (allow)" "allow" "$r"
r=$(check_bash "mv ~/file.txt ~/other.txt"); assert "mv ~/file.txt" "deny" "$r"
r=$(check_bash "cp file ~/.claude/settings.json"); assert "cp ~/.claude (ask)" "ask" "$r"

# ==============================================================================
header "5. Commands"
# ==============================================================================

r=$(check_bash "git status"); assert "git status" "allow" "$r"
r=$(check_bash "git log --oneline -10"); assert "git log" "allow" "$r"
r=$(check_bash "git push origin main"); assert "git push" "ask" "$r"
r=$(check_bash "git push --force origin main"); assert "git push --force" "deny" "$r"
r=$(check_bash "git reset --hard HEAD~1"); assert "git reset --hard" "deny" "$r"
r=$(check_bash "python3 script.py"); assert "python3 (deny)" "deny" "$r"
r=$(check_bash "uv run python script.py"); assert "uv run python" "allow" "$r"
r=$(check_bash "npm list"); assert "npm list" "allow" "$r"
r=$(check_bash "npm unpublish my-package"); assert "npm unpublish" "deny" "$r"
r=$(check_bash "docker ps -a"); assert "docker ps" "allow" "$r"
r=$(check_bash "docker run -it ubuntu bash"); assert "docker run" "ask" "$r"
r=$(check_bash "rm -rf ./node_modules"); assert "rm -rf" "ask" "$r"
r=$(check_bash "pwsh -Command Get-Process"); assert "pwsh" "ask" "$r"
r=$(check_bash "rm -rf /some/path/__pycache__"); assert "rm __pycache__" "allow" "$r"
r=$(check_bash "uv run pytest tests/"); assert "uv run pytest" "allow" "$r"
r=$(check_bash "uv run python -c 'print(1)'"); assert "uv run python -c" "allow" "$r"
r=$(check_bash "uv run ruff check ."); assert "uv run ruff (deny)" "deny" "$r"

# ==============================================================================
header "6. Just (task runner)"
# ==============================================================================

r=$(check_bash "just check"); assert "just check" "allow" "$r"
r=$(check_bash "just fmt"); assert "just fmt" "allow" "$r"
r=$(check_bash "just lint"); assert "just lint" "allow" "$r"
r=$(check_bash "just test"); assert "just test" "allow" "$r"
r=$(check_bash "just -C /other/dir test"); assert "just -C (deny)" "deny" "$r"

# ==============================================================================
header "7. Pipelines / Compound Commands"
# ==============================================================================

r=$(check_bash "git status && git log --oneline -5"); assert "status && log (safe)" "allow" "$r"
r=$(check_bash "git add . && git commit -m test && git push"); assert "add && commit && push" "ask" "$r"
r=$(check_bash "git status && git push --force origin main"); assert "status && push --force" "deny" "$r"

# ==============================================================================
echo ""
echo "=============================="
total=$((PASS + FAIL))
if [ $FAIL -eq 0 ]; then
green "RESULT: $PASS/$total tests PASSED"
else
red "RESULT: $PASS PASSED, $FAIL FAILED out of $total tests"
fi
echo "=============================="
exit $FAIL