diff --git a/README.md b/README.md index cee91a3..b7339d3 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ brew install dippy ```bash git clone https://github.com/ldayton/Dippy.git +cd Dippy +uv pip install -e . ``` ### Configure @@ -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 diff --git a/examples/config b/examples/config new file mode 100644 index 0000000..2d9c73d --- /dev/null +++ b/examples/config @@ -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 "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" diff --git a/examples/test-config.sh b/examples/test-config.sh new file mode 100644 index 0000000..29a65dd --- /dev/null +++ b/examples/test-config.sh @@ -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