fix(hooks): close the five escapes the skills#52 review found
CI / Check / Test (push) Successful in 4s
CI / Tag release (push) Skipped
CI / Check / Test (pull_request) Successful in 5s
CI / Tag release (pull_request) Skipped

- Fail-closed path no longer needs jq: the ask JSON is a printf literal,
  and a host without jq asks instead of staying silent.
- A tool_name that is not a non-empty string asks (was: no opinion).
- Gate workflow_run_trigger, tag_create, release_create, and
  file_write_branch when it lands on main/master, has no branch, or
  empties a file (same effect as pr_merge / file_delete).
- Header claim narrowed: gitea-mcp spellings on any server name, not
  other servers' spellings of the same operation.
- Tests run the hook as hooks.json does (bash <path>), assert exit 0 on
  every case, and cover null/missing/wrong-type tool_name, last-segment
  verb parsing and a PATH without jq. 13 new cases were red first.

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:45:19 +02:00
co-authored by Claude Opus 5.5
parent d068cbc279
commit 4a82206393
2 changed files with 108 additions and 58 deletions
+43 -17
View File
@@ -6,41 +6,67 @@
# so an agent holds the full scope of the caller's PAT. repo_delete's own guard # 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, # (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 # 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. # deny lists covered this; on 2026-10-01 none existed in any settings file.
# #
# Policy: match the VERB, not the server. The same tool is reachable as # Policy: match gitea-mcp's verb spellings on ANY server name. The same tool is
# mcp__plugin_skills_gitea__*, mcp__claude_ai_gitea__* (connector) or under # reachable as mcp__plugin_skills_gitea__*, mcp__claude_ai_gitea__* or under
# any name a host registers it with, so keying on the server name would let a # whatever name a host registers it with, so keying on the server name would
# rename bypass the gate. # let a rename bypass the gate. It does NOT recognise other servers' spellings
# of the same operation (e.g. a third-party `delete_repo`).
# #
# Emits "ask" or nothing. Never "allow": a hook allow would override the # file_write_branch is gated when it would land on main/master (on infra that
# user's own deny/ask rules. Malformed input fails closed (ask). # 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.
# #
# Scope limit, recorded in the ADR: this gates MCP calls only. A session with # Emits "ask" or nothing; never "allow" (a hook allow would override the
# the PAT in its environment can still reach the Gitea REST API via Bash. # 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 -uo pipefail set -u
DESTRUCTIVE='repo_delete|file_delete|branch_delete|pr_merge|tbd_ship|repo_update|repo_mirror_push' DESTRUCTIVE='repo_delete|file_delete|branch_delete|pr_merge|tbd_ship|repo_update|repo_mirror_push|workflow_run_trigger|tag_create|release_create'
ask() { ask() {
jq -n --arg reason "$1" \ # $1 must contain only [A-Za-z0-9 _.:()'/-] — it is spliced into JSON verbatim.
'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "ask", permissionDecisionReason: $reason}}' printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"%s"}}\n' "$1"
exit 0 exit 0
} }
INPUT="$(cat)" 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." 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 fi
verb="${TOOL_NAME##*__}"
case "$TOOL_NAME" in case "$TOOL_NAME" in
mcp__*__*) ;; mcp__*__*) ;;
*) exit 0 ;; *) exit 0 ;;
esac esac
verb="${TOOL_NAME##*__}"
safe_verb="$(printf '%s' "$verb" | tr -cd 'A-Za-z0-9_-')"
if printf '%s' "$verb" | grep -Eqix "($DESTRUCTIVE)"; then 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." 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
verdict="$(printf '%s' "$INPUT" | jq -r '
.tool_input as $i
| if ($i | type) != "object" then "ask"
elif (($i.branch // "") | ascii_downcase) as $b | ($b == "" or $b == "main" or $b == "master") then "ask"
elif (($i.content // "") == "") then "ask"
else "pass" end' 2>/dev/null || echo ask)"
[ "$verdict" = "pass" ] \
|| ask "file_write_branch to main/master, with no branch, or emptying a file (infra ADR-0027): needs your approval."
fi fi
exit 0 exit 0
+65 -41
View File
@@ -4,9 +4,11 @@
# #
# Contract: reads PreToolUse hook JSON on stdin, ALWAYS exits 0, and answers # Contract: reads PreToolUse hook JSON on stdin, ALWAYS exits 0, and answers
# in hookSpecificOutput.permissionDecision. "ask" = a human must approve; # in hookSpecificOutput.permissionDecision. "ask" = a human must approve;
# no decision emitted ("NONE") = the guard stays out of the way and normal # empty stdout ("NONE") = no opinion, normal permission rules apply. The guard
# permission rules apply. The guard never emits "allow": an allow from a hook # never emits "allow": an allow from a hook would bypass the user's own rules.
# would bypass the user's own deny/ask rules. #
# Cases marked (gatekeeper) are escapes found by the skills#52 review: each
# passed the first version of this suite.
set -u set -u
@@ -16,60 +18,82 @@ GUARD="$ROOT/hooks/mcp-destructive-guard.sh"
pass=0 pass=0
fail=0 fail=0
# An empty stdout is a valid hook answer ("no opinion"), reported as NONE. # run_guard <json> [PATH] — invoked exactly as hooks.json does: `bash <path>`.
decode() { # Prints "<decision> rc=<exit>", decision NONE for an empty answer.
_out=$(cat) run_guard() {
[ -z "$_out" ] && { echo NONE; return; } if [ $# -ge 2 ]; then
printf '%s' "$_out" | jq -r '.hookSpecificOutput.permissionDecision // "NONE"' 2>/dev/null || echo BROKEN _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)
decision_for() { check() {
jq -n --arg t "$1" '{tool_name: $t, tool_input: {owner: "mathias", repo: "x"}}' \ _want=$1 _json=$2 _label=$3
| "$GUARD" | decode _got=$(run_guard "$_json")
} if [ "$_got" = "$_want rc=0" ]; then
expect() {
_want=$1 _tool=$2 _label=$3
_got=$(decision_for "$_tool")
if [ "$_got" = "$_want" ]; then
pass=$((pass + 1)); printf ' ok %s\n' "$_label" pass=$((pass + 1)); printf ' ok %s\n' "$_label"
else else
fail=$((fail + 1)); printf ' FAIL %s\n want=%s got=%s tool=%s\n' "$_label" "$_want" "$_got" "$_tool" fail=$((fail + 1)); printf ' FAIL %s\n want=%s rc=0 got=%s\n in=%s\n' "$_label" "$_want" "$_got" "$_json"
fi fi
} }
tool() { printf '{"tool_name":"%s","tool_input":{"owner":"mathias","repo":"x"}}' "$1"; }
echo "mcp-destructive-guard tests" 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 \
for v in repo_delete file_delete branch_delete pr_merge tbd_ship repo_update repo_mirror_push; do workflow_run_trigger tag_create release_create; do
expect ask "mcp__plugin_skills_gitea__$v" "plugin gitea $v asks" check ask "$(tool "mcp__plugin_skills_gitea__$v")" "plugin gitea $v asks"
done done
# The same verb reached through a different server name must not slip past: # The verb is matched on any server name: connector, bare, or renamed.
# the claude.ai connector, a bare local registration, or any future rename. check ask "$(tool mcp__claude_ai_gitea__repo_delete)" "connector namespace asks"
expect ask "mcp__claude_ai_gitea__repo_delete" "connector namespace asks" check ask "$(tool mcp__gitea__repo_delete)" "bare server name asks"
expect ask "mcp__gitea__repo_delete" "bare server name asks" check ask "$(tool mcp__git__repo_delete)" "renamed server still asks"
expect ask "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"
# Case variants must not bypass (tool names are matched exactly by the host, # file_write_branch: content landing on main, or emptying a file, is the same
# but a guard that lowercases nothing should not depend on that). # effect as the gated pr_merge / file_delete (gatekeeper).
expect ask "mcp__plugin_skills_gitea__REPO_DELETE" "uppercase verb asks" 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 untouched. # Reversible / read verbs pass through.
for v in issue_close issue_create file_read repo_list pr_create file_write_branch; do for v in issue_close issue_create file_read repo_list pr_create; do
expect NONE "mcp__plugin_skills_gitea__$v" "$v passes through" check NONE "$(tool "mcp__plugin_skills_gitea__$v")" "$v passes through"
done 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"
# Near-miss names are not the guarded verb. # Malformed input fails CLOSED (gatekeeper: non-string tool_name got NONE).
expect NONE "mcp__plugin_skills_gitea__repo_delete_dry_run_list" "suffix near-miss passes" check ask 'not json' "non-JSON asks"
expect NONE "Bash" "non-MCP tool passes" 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"
# Malformed input fails CLOSED (ask), never open. # Without jq on PATH the guard must still answer ask, not stay silent
_got=$(printf 'not json' | "$GUARD" | decode) # (gatekeeper: the fail-closed path itself needed jq).
if [ "$_got" = "ask" ]; then pass=$((pass+1)); echo " ok malformed input asks"; else fail=$((fail+1)); echo " FAIL malformed input: got=$_got"; fi 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
# Executed as a program, like Claude Code does (shebang decides interpreter). _got=$(run_guard "$(tool mcp__plugin_skills_gitea__repo_delete)" "$nojq")
[ -x "$GUARD" ] && { pass=$((pass+1)); echo " ok guard is executable"; } || { fail=$((fail+1)); echo " FAIL guard not executable"; } 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" printf '\n%d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ] [ "$fail" -eq 0 ]