fix(hooks): file_write_branch clears only one exact-case key and a plain branch name
CI / Check / Test (push) Successful in 5s
CI / Tag release (push) Skipped
CI / Check / Test (pull_request) Successful in 5s
CI / Tag release (pull_request) Skipped

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>
This commit is contained in:
2026-10-01 22:19:00 +02:00
co-authored by Claude Opus 5.5
parent 4a82206393
commit a41f5abfba
2 changed files with 38 additions and 5 deletions
+19 -5
View File
@@ -60,13 +60,27 @@ if printf '%s' "$verb" | grep -Eqix "($DESTRUCTIVE)"; then
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"
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)"
| 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, with no branch, or emptying a file (infra ADR-0027): needs your approval."
|| ask "file_write_branch to main/master, an unusual branch name, or emptying a file (infra ADR-0027): needs your approval."
fi
exit 0