From 272e4eb776e82ad5f1cb5042bc87c4b32edc8273 Mon Sep 17 00:00:00 2001 From: codex Date: Sun, 26 Jul 2026 17:51:43 +0000 Subject: [PATCH] fix(git-gate): reject AGit review refs --- bot_bottle/gateway/git_gate/render.py | 17 +++++++++++++++++ tests/unit/test_git_gate.py | 15 +++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/bot_bottle/gateway/git_gate/render.py b/bot_bottle/gateway/git_gate/render.py index 241ed9d8..145fcfee 100644 --- a/bot_bottle/gateway/git_gate/render.py +++ b/bot_bottle/gateway/git_gate/render.py @@ -252,6 +252,23 @@ cat > "$refs_file" zero=0000000000000000000000000000000000000000 +# AGit creates pull requests by pushing to refs/for/* (with refs/draft/* +# and refs/for-review/* aliases). Those server-owned review refs are not +# ordinary repository branches and leave the resulting PR without a +# branch that the bottle can update later. Require the normal +# push-branch-then-open-PR workflow instead. Deletion remains allowed so +# operators can clean up refs created before this guard existed. +while IFS=' ' read -r old new ref; do + [ -z "$ref" ] && continue + [ "$new" = "$zero" ] && continue + case "$ref" in + refs/for/*|refs/draft/*|refs/for-review/*) + echo "git-gate: AGit review refs are disabled; push to refs/heads/ and open the pull request from that branch" >&2 + exit 1 + ;; + esac +done < "$refs_file" + supervise_gitleaks_allow() { log_opts=$1 ref=$2 diff --git a/tests/unit/test_git_gate.py b/tests/unit/test_git_gate.py index bf7a891d..1b1fc751 100644 --- a/tests/unit/test_git_gate.py +++ b/tests/unit/test_git_gate.py @@ -181,6 +181,21 @@ class TestHookRender(unittest.TestCase): self.assertNotIn('log_opts="$new"', hook) self.assertNotIn('log_opts="$old..$new"', hook) + def test_agit_review_refs_are_rejected_before_scanning(self): + hook = git_gate_render_hook() + guard = "refs/for/*|refs/draft/*|refs/for-review/*" + self.assertIn(guard, hook) + self.assertIn( + "AGit review refs are disabled; push to refs/heads/", + hook, + ) + self.assertLess(hook.index(guard), hook.index("# Phase 1: gitleaks")) + # Ref deletion must remain possible for cleanup. + self.assertLess( + hook.index('[ "$new" = "$zero" ] && continue'), + hook.index(guard), + ) + def test_forward_ssh_is_non_interactive_and_bounded(self): # No prompt (BatchMode) and a connect timeout, so an unreachable # upstream fails fast instead of hanging the receive-pack.