feat(hooks): mcp-destructive-guard asks before irreversible MCP verbs
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:
@@ -33,6 +33,16 @@
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "mcp__.*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/mcp-destructive-guard.sh\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse hook (mcp__.* matcher) — makes a human approve irreversible MCP
|
||||
# verbs instead of letting the model run them unattended.
|
||||
#
|
||||
# Why (infra#508, infra ADR-0027): gitea-mcp authenticates by PAT pass-through,
|
||||
# so an agent holds the full scope of the caller's PAT. repo_delete's own guard
|
||||
# (confirm == repo) is filled in by the model on both sides: it stops typos,
|
||||
# not an injected or mistaken agent. The infra-mcp README claimed client-side
|
||||
# deny lists covered this; on 2026-10-01 none existed on any settings file.
|
||||
#
|
||||
# Policy: match the VERB, not the server. The same tool is reachable as
|
||||
# mcp__plugin_skills_gitea__*, mcp__claude_ai_gitea__* (connector) or under
|
||||
# any name a host registers it with, so keying on the server name would let a
|
||||
# rename bypass the gate.
|
||||
#
|
||||
# Emits "ask" or nothing. Never "allow": a hook allow would override the
|
||||
# user's own deny/ask rules. Malformed input fails closed (ask).
|
||||
#
|
||||
# Scope limit, recorded in the ADR: this gates MCP calls only. A session with
|
||||
# the PAT in its environment can still reach the Gitea REST API via Bash.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
DESTRUCTIVE='repo_delete|file_delete|branch_delete|pr_merge|tbd_ship|repo_update|repo_mirror_push'
|
||||
|
||||
ask() {
|
||||
jq -n --arg reason "$1" \
|
||||
'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "ask", permissionDecisionReason: $reason}}'
|
||||
exit 0
|
||||
}
|
||||
|
||||
INPUT="$(cat)"
|
||||
if ! TOOL_NAME="$(printf '%s' "$INPUT" | jq -er '.tool_name' 2>/dev/null)"; then
|
||||
ask "mcp-destructive-guard: could not parse the tool call, so it cannot be shown safe. Approve only if you expected it."
|
||||
fi
|
||||
|
||||
verb="${TOOL_NAME##*__}"
|
||||
case "$TOOL_NAME" in
|
||||
mcp__*__*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
if printf '%s' "$verb" | grep -Eqix "($DESTRUCTIVE)"; then
|
||||
ask "Irreversible MCP verb '$verb' (infra ADR-0027): needs your approval. The model filled in every argument, including any confirm field."
|
||||
fi
|
||||
exit 0
|
||||
Reference in New Issue
Block a user