feat(hooks): mcp-destructive-guard asks before irreversible MCP verbs
CI / Check / Test (push) Successful in 4s
CI / Tag release (push) Skipped

gitea-mcp authenticates by PAT pass-through, so an agent holds the
caller's full PAT scope, and repo_delete's confirm field is filled in by
the model. The infra-mcp README said client-side deny lists covered
destructive verbs; on 2026-10-01 none existed in any settings file.

PreToolUse hook on mcp__.*: repo_delete, file_delete, branch_delete,
pr_merge, tbd_ship, repo_update and repo_mirror_push get "ask". Matched
on the verb, not the server name, so the connector namespace or a
renamed registration cannot bypass it. Never emits allow; malformed
input fails closed.

Scope limit: MCP calls only. The Gitea REST API via Bash with the PAT
in env is not covered (recorded in infra ADR-0027).

Refs mathias/infra#508

Bump-Type: minor

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 20:37:46 +02:00
co-authored by Claude Opus 5.5
parent 55b2e811d1
commit 9ba7cb2dcd
3 changed files with 131 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
#!/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 ]