#!/bin/sh # Tests for hooks/mcp-destructive-guard.sh — the PreToolUse gate that makes a # human approve irreversible MCP verbs (infra#508, infra ADR-0027). # # Contract: reads PreToolUse hook JSON on stdin, ALWAYS exits 0, and answers # in hookSpecificOutput.permissionDecision. "ask" = a human must approve; # no decision emitted ("NONE") = the guard stays out of the way and normal # permission rules apply. The guard never emits "allow": an allow from a hook # would bypass the user's own deny/ask rules. set -u ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) GUARD="$ROOT/hooks/mcp-destructive-guard.sh" pass=0 fail=0 # An empty stdout is a valid hook answer ("no opinion"), reported as NONE. decode() { _out=$(cat) [ -z "$_out" ] && { echo NONE; return; } printf '%s' "$_out" | jq -r '.hookSpecificOutput.permissionDecision // "NONE"' 2>/dev/null || echo BROKEN } decision_for() { jq -n --arg t "$1" '{tool_name: $t, tool_input: {owner: "mathias", repo: "x"}}' \ | "$GUARD" | decode } expect() { _want=$1 _tool=$2 _label=$3 _got=$(decision_for "$_tool") if [ "$_got" = "$_want" ]; then pass=$((pass + 1)); printf ' ok %s\n' "$_label" else fail=$((fail + 1)); printf ' FAIL %s\n want=%s got=%s tool=%s\n' "$_label" "$_want" "$_got" "$_tool" fi } echo "mcp-destructive-guard tests" # Irreversible verbs, on the plugin namespace. for v in repo_delete file_delete branch_delete pr_merge tbd_ship repo_update repo_mirror_push; do expect ask "mcp__plugin_skills_gitea__$v" "plugin gitea $v asks" done # The same verb reached through a different server name must not slip past: # the claude.ai connector, a bare local registration, or any future rename. expect ask "mcp__claude_ai_gitea__repo_delete" "connector namespace asks" expect ask "mcp__gitea__repo_delete" "bare server name asks" expect ask "mcp__git__repo_delete" "renamed server still asks" # Case variants must not bypass (tool names are matched exactly by the host, # but a guard that lowercases nothing should not depend on that). expect ask "mcp__plugin_skills_gitea__REPO_DELETE" "uppercase verb asks" # Reversible / read verbs pass through untouched. for v in issue_close issue_create file_read repo_list pr_create file_write_branch; do expect NONE "mcp__plugin_skills_gitea__$v" "$v passes through" done # Near-miss names are not the guarded verb. expect NONE "mcp__plugin_skills_gitea__repo_delete_dry_run_list" "suffix near-miss passes" expect NONE "Bash" "non-MCP tool passes" # Malformed input fails CLOSED (ask), never open. _got=$(printf 'not json' | "$GUARD" | decode) if [ "$_got" = "ask" ]; then pass=$((pass+1)); echo " ok malformed input asks"; else fail=$((fail+1)); echo " FAIL malformed input: got=$_got"; fi # Executed as a program, like Claude Code does (shebang decides interpreter). [ -x "$GUARD" ] && { pass=$((pass+1)); echo " ok guard is executable"; } || { fail=$((fail+1)); echo " FAIL guard not executable"; } printf '\n%d passed, %d failed\n' "$pass" "$fail" [ "$fail" -eq 0 ]