Round-2 review of skills#52 committed to main through gitea-mcp with
{"branch":"feat/y8","Branch":"main"}: Go's encoding/json matches keys
case-insensitively and the last one wins, while the hook read only the
lowercase key. Same trick on content emptied a file.
The guard now asks unless exactly one key case-folds to "branch" and
one to "content", both spelled exactly, both strings. The branch is
cleared by shape ([A-Za-z0-9][A-Za-z0-9._/-]*, no "..", no refs/ prefix,
no trailing slash) instead of a blocklist, since refs/heads/main created
a second ref that wedged every API write to main. Whitespace-only
content asks.
11 new cases; 6 were red before the change, 5 pinned untested-but-correct
behaviour the reviewer's flips showed nothing covered.
Refs mathias/infra#508
Bump-Type: minor
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
87 lines
4.3 KiB
Bash
Executable File
87 lines
4.3 KiB
Bash
Executable File
#!/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 in any settings file.
|
|
#
|
|
# Policy: match gitea-mcp's verb spellings on ANY server name. The same tool is
|
|
# reachable as mcp__plugin_skills_gitea__*, mcp__claude_ai_gitea__* or under
|
|
# whatever name a host registers it with, so keying on the server name would
|
|
# let a rename bypass the gate. It does NOT recognise other servers' spellings
|
|
# of the same operation (e.g. a third-party `delete_repo`).
|
|
#
|
|
# file_write_branch is gated when it would land on main/master (on infra that
|
|
# is a Flux deploy within ~60s — the same effect as pr_merge) or empty a file
|
|
# (the same effect as file_delete). Writes to other branches pass.
|
|
#
|
|
# Emits "ask" or nothing; never "allow" (a hook allow would override the
|
|
# user's own deny/ask rules). Always exits 0: Claude Code ignores a hook's
|
|
# JSON on a non-zero exit. Anything it cannot parse — including a host
|
|
# without jq — fails closed to "ask". The ask JSON is a literal, so the
|
|
# fail-closed path needs no tool that might be missing.
|
|
#
|
|
# Scope limit, recorded in infra ADR-0027: this gates MCP calls only. A
|
|
# session with the PAT in its environment can still reach the Gitea REST API
|
|
# through a shell.
|
|
|
|
set -u
|
|
|
|
DESTRUCTIVE='repo_delete|file_delete|branch_delete|pr_merge|tbd_ship|repo_update|repo_mirror_push|workflow_run_trigger|tag_create|release_create'
|
|
|
|
ask() {
|
|
# $1 must contain only [A-Za-z0-9 _.:()'/-] — it is spliced into JSON verbatim.
|
|
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"%s"}}\n' "$1"
|
|
exit 0
|
|
}
|
|
|
|
INPUT="$(cat)"
|
|
|
|
command -v jq >/dev/null 2>&1 \
|
|
|| ask "mcp-destructive-guard: jq is not installed, so this MCP call cannot be checked. Approve only if you expected it."
|
|
|
|
if ! TOOL_NAME="$(printf '%s' "$INPUT" | jq -er '.tool_name | strings | select(length > 0)' 2>/dev/null)"; then
|
|
ask "mcp-destructive-guard: could not read a tool name from this call, so it cannot be shown safe. Approve only if you expected it."
|
|
fi
|
|
|
|
case "$TOOL_NAME" in
|
|
mcp__*__*) ;;
|
|
*) exit 0 ;;
|
|
esac
|
|
|
|
verb="${TOOL_NAME##*__}"
|
|
safe_verb="$(printf '%s' "$verb" | tr -cd 'A-Za-z0-9_-')"
|
|
|
|
if printf '%s' "$verb" | grep -Eqix "($DESTRUCTIVE)"; then
|
|
ask "Irreversible MCP verb '$safe_verb' (infra ADR-0027): needs your approval. The model filled in every argument, including any confirm field."
|
|
fi
|
|
|
|
if printf '%s' "$verb" | grep -Eqix 'file_write_branch'; then
|
|
# Cleared only when there is exactly ONE key that case-folds to "branch" and
|
|
# one to "content": gitea-mcp decodes with Go encoding/json, which matches
|
|
# keys case-insensitively and lets the last one win, so {"branch":"feat/x",
|
|
# "Branch":"main"} landed on main while a lowercase-only read saw feat/x.
|
|
# The branch is cleared by SHAPE (allowlist), not by a blocklist of names:
|
|
# "refs/heads/main" created a second ref that wedged API writes to main.
|
|
verdict="$(printf '%s' "$INPUT" | jq -r '
|
|
.tool_input as $i
|
|
| if ($i | type) != "object" then "ask" else
|
|
([$i | to_entries[] | select(.key | ascii_downcase == "branch")]) as $b
|
|
| ([$i | to_entries[] | select(.key | ascii_downcase == "content")]) as $c
|
|
| if ($b | length) != 1 or ($c | length) != 1 then "ask"
|
|
elif ($b[0].key != "branch") or ($c[0].key != "content") then "ask"
|
|
elif ($b[0].value | type) != "string" or ($c[0].value | type) != "string" then "ask"
|
|
elif ($b[0].value | test("^[A-Za-z0-9][A-Za-z0-9._/-]*$") | not) then "ask"
|
|
elif ($b[0].value | test("\\.\\.|^refs/|/$")) then "ask"
|
|
elif ($b[0].value | ascii_downcase | . == "main" or . == "master") then "ask"
|
|
elif ($c[0].value | test("^\\s*$")) then "ask"
|
|
else "pass" end
|
|
end' 2>/dev/null || echo ask)"
|
|
[ "$verdict" = "pass" ] \
|
|
|| ask "file_write_branch to main/master, an unusual branch name, or emptying a file (infra ADR-0027): needs your approval."
|
|
fi
|
|
exit 0
|