PreToolUse hook on mcp__.*: asks for repo_delete, file_delete, branch_delete, pr_merge, tbd_ship, repo_update, repo_mirror_push, workflow_run_trigger, tag_create, release_create on any server name, and for file_write_branch unless it targets a plain non-main/master branch with non-empty content under exactly one exact-case branch/content key. Fails closed; never emits allow. Refs mathias/infra#508. Reviewed in three rounds by pr-gatekeeper with execution against gitea-mcp and Gitea 1.27.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Mathias <mthbqv@gmail.com>
119 lines
6.4 KiB
Bash
Executable File
119 lines
6.4 KiB
Bash
Executable File
#!/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 <json> [PATH] — invoked exactly as hooks.json does: `bash <path>`.
|
|
# Prints "<decision> rc=<exit>", 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"
|
|
|
|
# Go's encoding/json matches keys case-insensitively, last one wins: gitea-mcp
|
|
# wrote to main from {"branch":"feat/y8","Branch":"main"} while the hook read
|
|
# only `branch` (gatekeeper round 2, verified against a real Gitea). So the
|
|
# guard clears a write only when exactly one key case-folds to branch/content.
|
|
FWB=mcp__plugin_skills_gitea__file_write_branch
|
|
check ask "{\"tool_name\":\"$FWB\",\"tool_input\":{\"branch\":\"feat/y8\",\"Branch\":\"main\",\"content\":\"x\"}}" "case-variant Branch key asks"
|
|
check ask "{\"tool_name\":\"$FWB\",\"tool_input\":{\"branch\":\"feat/y\",\"content\":\"x\",\"Content\":\"\"}}" "case-variant Content key asks"
|
|
check ask "{\"tool_name\":\"$FWB\",\"tool_input\":{\"BRANCH\":\"feat/y\",\"content\":\"x\"}}" "only an uppercase BRANCH key asks"
|
|
check ask "$(fwb Main x)" "file_write_branch to Main asks"
|
|
check ask "{\"tool_name\":\"$FWB\",\"tool_input\":{\"branch\":\"feat/y\"}}" "missing content key asks"
|
|
check ask "$(fwb feat/y ' ')" "whitespace-only content asks"
|
|
check ask "{\"tool_name\":\"$FWB\",\"tool_input\":\"branch=feat/y\"}" "non-object tool_input asks"
|
|
# Branch names are cleared by shape, not by blocklist: refs/heads/main created a
|
|
# second ref that wedged every API write to main (gatekeeper side finding).
|
|
check ask "$(fwb refs/heads/main x)" "refs/ prefixed branch asks"
|
|
check ask "$(fwb ' main' x)" "branch with whitespace asks"
|
|
check ask "$(fwb '../main' x)" "branch with .. asks"
|
|
check NONE "$(fwb release/v1.2_x x)" "ordinary branch name 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 ]
|