#!/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; # empty stdout ("NONE") = no opinion, normal permission rules apply. The guard # never emits "allow": an allow from a hook would bypass the user's own rules. # # Cases marked (gatekeeper) are escapes found by the skills#52 review: each # passed the first version of this suite. set -u ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) GUARD="$ROOT/hooks/mcp-destructive-guard.sh" pass=0 fail=0 # run_guard [PATH] — invoked exactly as hooks.json does: `bash `. # Prints " rc=", decision NONE for an empty answer. run_guard() { if [ $# -ge 2 ]; then _out=$(printf '%s' "$1" | PATH="$2" "$BASH_BIN" "$GUARD"); _rc=$? else _out=$(printf '%s' "$1" | bash "$GUARD"); _rc=$? fi if [ -z "$_out" ]; then _d=NONE else _d=$(printf '%s' "$_out" | jq -r '.hookSpecificOutput.permissionDecision // "NONE"' 2>/dev/null || echo BROKEN) fi printf '%s rc=%s' "$_d" "$_rc" } BASH_BIN=$(command -v bash) check() { _want=$1 _json=$2 _label=$3 _got=$(run_guard "$_json") if [ "$_got" = "$_want rc=0" ]; then pass=$((pass + 1)); printf ' ok %s\n' "$_label" else fail=$((fail + 1)); printf ' FAIL %s\n want=%s rc=0 got=%s\n in=%s\n' "$_label" "$_want" "$_got" "$_json" fi } tool() { printf '{"tool_name":"%s","tool_input":{"owner":"mathias","repo":"x"}}' "$1"; } echo "mcp-destructive-guard tests" for v in repo_delete file_delete branch_delete pr_merge tbd_ship repo_update repo_mirror_push \ workflow_run_trigger tag_create release_create; do check ask "$(tool "mcp__plugin_skills_gitea__$v")" "plugin gitea $v asks" done # The verb is matched on any server name: connector, bare, or renamed. check ask "$(tool mcp__claude_ai_gitea__repo_delete)" "connector namespace asks" check ask "$(tool mcp__gitea__repo_delete)" "bare server name asks" check ask "$(tool mcp__git__repo_delete)" "renamed server still asks" check ask "$(tool mcp__plugin_skills_gitea__REPO_DELETE)" "uppercase verb asks" # The verb is the LAST segment (gatekeeper: parsing the second segment stayed green). check ask "$(tool mcp__a__b__repo_delete)" "verb taken from last segment" check NONE "$(tool mcp__repo_delete__issue_get)" "server named like a verb does not trigger" # file_write_branch: content landing on main, or emptying a file, is the same # effect as the gated pr_merge / file_delete (gatekeeper). fwb() { printf '{"tool_name":"mcp__plugin_skills_gitea__file_write_branch","tool_input":{"branch":"%s","content":"%s"}}' "$1" "$2"; } check ask "$(fwb main x)" "file_write_branch to main asks" check ask "$(fwb master x)" "file_write_branch to master asks" check ask "$(fwb feat/y '')" "file_write_branch emptying a file asks" check ask '{"tool_name":"mcp__plugin_skills_gitea__file_write_branch","tool_input":{"content":"x"}}' "file_write_branch without branch asks" check NONE "$(fwb feat/y x)" "file_write_branch to a feature branch passes" # Reversible / read verbs pass through. for v in issue_close issue_create file_read repo_list pr_create; do check NONE "$(tool "mcp__plugin_skills_gitea__$v")" "$v passes through" done check NONE "$(tool mcp__plugin_skills_gitea__repo_delete_dry_run_list)" "suffix near-miss passes" check NONE "$(tool Bash)" "non-MCP tool passes" check NONE "$(tool repo_delete)" "non-MCP tool named like a verb passes" # Malformed input fails CLOSED (gatekeeper: non-string tool_name got NONE). check ask 'not json' "non-JSON asks" check ask '{}' "missing tool_name asks" check ask '{"tool_name":null}' "null tool_name asks" check ask '{"tool_name":""}' "empty tool_name asks" check ask '{"tool_name":5}' "number tool_name asks" check ask '{"tool_name":["mcp__x__repo_delete"]}' "array tool_name asks" check ask '{"tool_name":{"a":1}}' "object tool_name asks" # Without jq on PATH the guard must still answer ask, not stay silent # (gatekeeper: the fail-closed path itself needed jq). nojq=$(mktemp -d); trap 'rm -rf "$nojq"' EXIT for b in bash cat grep tr sed; do ln -s "$(command -v $b)" "$nojq/$b"; done _got=$(run_guard "$(tool mcp__plugin_skills_gitea__repo_delete)" "$nojq") if [ "$_got" = "ask rc=0" ]; then pass=$((pass+1)); echo " ok no jq on PATH still asks"; else fail=$((fail+1)); echo " FAIL no jq on PATH: got=$_got"; fi _got=$(run_guard "$(tool mcp__plugin_skills_gitea__issue_get)" "$nojq") if [ "$_got" = "ask rc=0" ]; then pass=$((pass+1)); echo " ok no jq on PATH asks even for a read verb (cannot parse = cannot clear)"; else fail=$((fail+1)); echo " FAIL no jq read verb: got=$_got"; fi printf '\n%d passed, %d failed\n' "$pass" "$fail" [ "$fail" -eq 0 ]