Skip to content

Commit 0ba467d

Browse files
ci: Gatekeeper App auto-approver bridge + auto-sweep (MCP-1249) (#687)
* ci: gatekeeper App auto-approver bridge scaffold (MCP-1249) Bridge that turns a Paperclip Codex review verdict of ACCEPT into a real GitHub approving review posted by the "MCPProxy Gatekeeper" GitHub App, so the required-1-approving-review branch protection is satisfied without an admin override and without the author approving their own PR (author!=approver). Pairs with scripts/arm-auto-merge.sh for full Model B. - Reads verdict of record from the Paperclip review thread (bots don't post to GitHub); only ACCEPT approves, request_changes/unknown are no-ops. - Mints a GitHub App installation token (RS256 JWT via openssl, no extra deps). - Inert until configured (GATEKEEPER_APP_ID / _INSTALLATION_ID / _PRIVATE_KEY, e.g. ~/.mcpproxy-gatekeeper/env) — exits 2 with setup guidance. - --dry-run + --verdict overrides for testing. Validated against live data: reads accept for PR #622, fails safe unconfigured, no-ops on request_changes. App registration + cred wiring is the remaining (owner) step. Related MCP-1249 * ci: gatekeeper auto-trigger — sweep + launchd timer, hardened approve (MCP-1249) Hands-off half of Model B: a launchd timer runs gatekeeper-sweep.sh every 5m, which invokes gatekeeper-approve.sh for each open PR. Codex ACCEPT -> App approval -> GitHub auto-merge, no human, no admin. - gatekeeper-approve.sh hardened to be safe under repeated/unattended runs: - no-op if PR is closed/merged - stale-verdict guard: only approve the exact SHA Codex reviewed (head moved past it -> skip, re-review needed) [exit 6] - idempotency: skip if the Gatekeeper already approved the current head - resolver now also extracts the reviewer-pinned SHA - gatekeeper-sweep.sh: bash-3.2 safe (macOS /bin/bash, no mapfile); skips quietly when unconfigured or Paperclip unreachable; timestamped logging. - app.mcpproxy.gatekeeper.sweep.plist: launchd template (StartInterval 300, RunAtLoad, background/low-IO) + install/enable/disable instructions. Validated: dry sweep over 14 open PRs no-ops correctly; idempotent no-op on the already-merged #622; timer loaded and first run logged. Related MCP-1249 * fix(gatekeeper): fail-closed SHA guard — refuse approval without a matching reviewed SHA (MCP-1249) Codex REQUEST_CHANGES (PR #687): the stale-SHA guard in gatekeeper-approve.sh ran only `if [[ -n "$REVIEWED_SHA" && ... ]]`, so a verdict that pinned NO SHA (REVIEWED_SHA empty) — or the manual --verdict override path, which always yielded an empty SHA — bypassed the check and auto-approved the PR's CURRENT head. An old ACCEPT could thus approve unreviewed code after a post-review force-push. Fix (fail-closed): - Make the SHA checks UNCONDITIONAL. No reviewed SHA resolvable -> REFUSE (exit 7, explicit message); reviewed SHA != head -> REFUSE (exit 6, stale); only reviewed SHA == head approves. - Add --reviewed-sha to pair with --verdict so the manual override is held to the same fail-closed requirement (no blind manual approvals either). - Document the invariant + new exit code 7 in the header. Adds scripts/gatekeeper-approve.test.sh: hermetic (stubs gh, --dry-run, no network/GitHub) regression covering all four Codex acceptance cases. Red on the old script (case 1 approved blind), green on the fix. Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent 5a2ef31 commit 0ba467d

4 files changed

Lines changed: 384 additions & 0 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!--
3+
launchd template for the Gatekeeper auto-trigger (MCP-1249).
4+
Runs gatekeeper-sweep.sh on an interval so Codex-ACCEPT PRs are approved
5+
(and auto-merged) hands-off. Local-machine deployment only.
6+
7+
Install:
8+
sed "s#__SWEEP__#$PWD/scripts/gatekeeper-sweep.sh#; s#__LOG__#$HOME/.mcpproxy-gatekeeper/sweep.log#" \
9+
scripts/app.mcpproxy.gatekeeper.sweep.plist > ~/Library/LaunchAgents/app.mcpproxy.gatekeeper.sweep.plist
10+
launchctl load ~/Library/LaunchAgents/app.mcpproxy.gatekeeper.sweep.plist # enable
11+
launchctl unload ~/Library/LaunchAgents/app.mcpproxy.gatekeeper.sweep.plist # disable
12+
-->
13+
<plist version="1.0">
14+
<dict>
15+
<key>Label</key> <string>app.mcpproxy.gatekeeper.sweep</string>
16+
<key>ProgramArguments</key>
17+
<array>
18+
<string>/bin/bash</string>
19+
<string>__SWEEP__</string>
20+
</array>
21+
<key>StartInterval</key> <integer>300</integer>
22+
<key>RunAtLoad</key> <true/>
23+
<key>StandardOutPath</key> <string>__LOG__</string>
24+
<key>StandardErrorPath</key> <string>__LOG__</string>
25+
<key>EnvironmentVariables</key>
26+
<dict>
27+
<key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
28+
</dict>
29+
<key>ProcessType</key> <string>Background</string>
30+
<key>LowPriorityIO</key> <true/>
31+
</dict>
32+
</plist>

scripts/gatekeeper-approve.sh

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
#!/usr/bin/env bash
2+
# gatekeeper-approve.sh — "Gatekeeper" GitHub App auto-approver bridge (MCP-1249).
3+
#
4+
# Purpose: turn a Paperclip Codex review verdict of ACCEPT into a real GitHub
5+
# *approving review* posted by a branded GitHub App identity, so the repo's
6+
# "1 approving review by a reviewer with write access" branch-protection rule is
7+
# satisfied WITHOUT an admin override and WITHOUT the PR author approving their
8+
# own PR (author != approver). Once the App approval lands, GitHub auto-merge
9+
# (squash) completes the merge with zero admin — full "Model B".
10+
#
11+
# This is the missing piece of MCP-1249. It pairs with arm-auto-merge.sh.
12+
#
13+
# Verdict source of record = the Paperclip review comment (the review bots do NOT
14+
# post to GitHub). This script reads that verdict and, only on ACCEPT, posts the
15+
# GitHub approval as the App.
16+
#
17+
# ─────────────────────────────────────────────────────────────────────────────
18+
# Configuration (env, or sourced from a gitignored file — NEVER commit secrets):
19+
# GATEKEEPER_APP_ID GitHub App ID (integer)
20+
# GATEKEEPER_INSTALLATION_ID Installation ID for smart-mcp-proxy/mcpproxy-go
21+
# GATEKEEPER_PRIVATE_KEY Path to the App private key .pem
22+
# GATEKEEPER_REPO (optional) owner/repo, default smart-mcp-proxy/mcpproxy-go
23+
# PAPERCLIP_API_URL (optional) default http://localhost:3100
24+
# PAPERCLIP_COMPANY_ID (optional) default 16edd8ed-8691-4a89-aa30-74ab6b931663
25+
# CODEX_REVIEWER_AGENT_ID (optional) default 5b94562c-524f-4c29-bc24-3524c1acd8e9
26+
# A convenient place: ~/.mcpproxy-gatekeeper/env (chmod 600, gitignored).
27+
#
28+
# Usage:
29+
# gatekeeper-approve.sh --pr <N> [--verdict accept|request_changes]
30+
# [--reviewed-sha <sha>] [--dry-run]
31+
#
32+
# --pr <N> (required) PR number to act on.
33+
# --verdict <v> override the Paperclip verdict lookup (testing/manual).
34+
# --reviewed-sha <s> override the reviewed SHA (pairs with --verdict; required
35+
# to approve via the manual override path — fail-closed).
36+
# --dry-run do everything except POST the review (and print the plan).
37+
#
38+
# Safe to run repeatedly (idempotent): no-ops if the PR is closed/merged, if the
39+
# verdict isn't ACCEPT, or if the Gatekeeper already approved the current head.
40+
#
41+
# FAIL-CLOSED SHA GUARD (MCP-1249 Codex REQUEST_CHANGES): the script approves the
42+
# PR's CURRENT head ONLY when it can resolve a reviewed SHA that EQUALS that head.
43+
# If no reviewed SHA can be resolved, it REFUSES (never approves blind) so a
44+
# post-review force-push of unreviewed code cannot inherit an old ACCEPT. The
45+
# manual --verdict accept override is held to the same requirement.
46+
#
47+
# Exit codes: 0 ok/approved/dry-run/no-op; 2 not configured; 3 verdict not accept;
48+
# 5 GitHub/API error; 6 stale verdict (reviewed SHA != head);
49+
# 7 no reviewed SHA resolvable (fail-closed refusal).
50+
# ─────────────────────────────────────────────────────────────────────────────
51+
set -euo pipefail
52+
53+
REPO="${GATEKEEPER_REPO:-smart-mcp-proxy/mcpproxy-go}"
54+
PAPERCLIP_API_URL="${PAPERCLIP_API_URL:-http://localhost:3100}"
55+
PAPERCLIP_COMPANY_ID="${PAPERCLIP_COMPANY_ID:-16edd8ed-8691-4a89-aa30-74ab6b931663}"
56+
CODEX_REVIEWER_AGENT_ID="${CODEX_REVIEWER_AGENT_ID:-5b94562c-524f-4c29-bc24-3524c1acd8e9}"
57+
58+
# Optional config file
59+
[[ -f "${HOME}/.mcpproxy-gatekeeper/env" ]] && source "${HOME}/.mcpproxy-gatekeeper/env"
60+
61+
PR=""; VERDICT_OVERRIDE=""; REVIEWED_SHA_OVERRIDE=""; DRY_RUN=0
62+
while [[ $# -gt 0 ]]; do
63+
case "$1" in
64+
--pr) PR="$2"; shift 2;;
65+
--verdict) VERDICT_OVERRIDE="$2"; shift 2;;
66+
--reviewed-sha) REVIEWED_SHA_OVERRIDE="$2"; shift 2;;
67+
--dry-run) DRY_RUN=1; shift;;
68+
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0;;
69+
*) echo "unknown arg: $1" >&2; exit 1;;
70+
esac
71+
done
72+
[[ -z "$PR" ]] && { echo "ERROR: --pr <N> required" >&2; exit 1; }
73+
74+
log() { echo "[gatekeeper] $*" >&2; }
75+
76+
# ── 1. Resolve the Codex review verdict (+reviewed SHA) from Paperclip ───────
77+
# Emits "<verdict> <reviewed_sha_or_empty>".
78+
resolve_verdict() {
79+
if [[ -n "$VERDICT_OVERRIDE" ]]; then echo "$VERDICT_OVERRIDE ${REVIEWED_SHA_OVERRIDE:-}"; return; fi
80+
# Reads are fine unauthenticated against the local instance.
81+
curl -fsS -m 15 "${PAPERCLIP_API_URL}/api/companies/${PAPERCLIP_COMPANY_ID}/issues?q=Review%20PR%20%23${PR}" 2>/dev/null \
82+
| PR="$PR" CODEX="$CODEX_REVIEWER_AGENT_ID" BASE="$PAPERCLIP_API_URL" python3 -c '
83+
import sys, json, os, re, urllib.request
84+
pr, codex, base = os.environ["PR"], os.environ["CODEX"], os.environ["BASE"]
85+
iss = json.load(sys.stdin)
86+
iss = iss if isinstance(iss, list) else iss.get("issues", iss.get("data", []))
87+
# Codex review tasks for this PR (title references "PR #<n>"), assigned to the
88+
# Codex reviewer, newest first (round-2 supersedes round-1).
89+
needle = "PR #%s" % pr
90+
revs = [i for i in iss
91+
if needle in (i.get("title") or "") and i.get("assigneeAgentId") == codex]
92+
revs.sort(key=lambda x: x.get("createdAt", ""), reverse=True)
93+
verdict, sha = "unknown", ""
94+
for i in revs:
95+
url = "%s/api/issues/%s/comments" % (base, i.get("id"))
96+
try:
97+
c = json.load(urllib.request.urlopen(url, timeout=15))
98+
except Exception:
99+
continue
100+
c = c if isinstance(c, list) else c.get("comments", c.get("data", []))
101+
for cm in reversed(c):
102+
body = cm.get("body") or ""
103+
b = body.lower()
104+
if "verdict:" not in b:
105+
continue
106+
tail = b.split("verdict:", 1)[1][:40]
107+
shas = re.findall(r"\b[0-9a-f]{40}\b", body) # SHA the reviewer pinned
108+
sha = shas[-1] if shas else ""
109+
if "accept" in tail:
110+
verdict = "accept"; break
111+
if "request_changes" in tail or "request changes" in tail:
112+
verdict = "request_changes"; break
113+
if verdict != "unknown":
114+
break
115+
print(verdict, sha)
116+
'
117+
}
118+
119+
# ── 2. Mint a GitHub App installation access token (RS256 JWT via openssl) ────
120+
mint_installation_token() {
121+
local app_id="$1" install_id="$2" pem="$3"
122+
local now exp header payload b64 signing sig jwt
123+
now=$(date +%s); exp=$((now + 540)) # 9-min window (max 10)
124+
b64() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
125+
header=$(printf '{"alg":"RS256","typ":"JWT"}' | b64)
126+
payload=$(printf '{"iat":%d,"exp":%d,"iss":"%s"}' "$((now-60))" "$exp" "$app_id" | b64)
127+
signing="${header}.${payload}"
128+
sig=$(printf '%s' "$signing" | openssl dgst -sha256 -sign "$pem" -binary | b64)
129+
jwt="${signing}.${sig}"
130+
curl -fsS -m 20 -X POST \
131+
-H "Authorization: Bearer ${jwt}" \
132+
-H "Accept: application/vnd.github+json" \
133+
"https://api.github.com/app/installations/${install_id}/access_tokens" \
134+
| python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])'
135+
}
136+
137+
# ── main ────────────────────────────────────────────────────────────────────
138+
read -r VERDICT REVIEWED_SHA <<<"$(resolve_verdict || echo 'unknown ')"
139+
log "PR #${PR} Codex verdict = ${VERDICT}${REVIEWED_SHA:+ (reviewed ${REVIEWED_SHA:0:9})}"
140+
141+
if [[ "$VERDICT" != "accept" ]]; then
142+
log "verdict is not 'accept' — NOT approving (no-op). request_changes/unknown must not auto-approve."
143+
exit 3
144+
fi
145+
146+
# Current PR head + author (one API read).
147+
read -r HEAD AUTHOR PR_STATE <<<"$(gh pr view "$PR" --repo "$REPO" --json headRefOid,author,state \
148+
-q '"\(.headRefOid) \(.author.login) \(.state)"' 2>/dev/null || echo '? ? ?')"
149+
log "PR #${PR} state=${PR_STATE} head=${HEAD:0:9} author=${AUTHOR} (App approves as a distinct identity)"
150+
151+
# Don't act on already-closed/merged PRs.
152+
if [[ "$PR_STATE" != "OPEN" ]]; then
153+
log "PR is ${PR_STATE} — nothing to do."
154+
exit 0
155+
fi
156+
157+
# Fail-closed stale-verdict guard (MCP-1249 Codex REQUEST_CHANGES): we approve
158+
# ONLY the exact SHA the reviewer reviewed. The checks below are UNCONDITIONAL —
159+
# a missing or non-matching reviewed SHA must REFUSE, never approve blind. This
160+
# closes the hole where an old ACCEPT (or a verdict that pinned no SHA) would
161+
# auto-approve the current head after a post-review force-push of unreviewed code.
162+
if [[ -z "$HEAD" ]]; then
163+
log "REFUSING: could not resolve the PR head SHA — fail-closed (will not approve)."
164+
exit 5
165+
fi
166+
if [[ -z "$REVIEWED_SHA" ]]; then
167+
log "REFUSING: no reviewed SHA could be resolved from the ACCEPT verdict — fail-closed (will not approve blind)."
168+
log " The reviewer must pin the reviewed commit SHA in the verdict comment; for a manual override pass --reviewed-sha <sha>."
169+
exit 7
170+
fi
171+
if [[ "$REVIEWED_SHA" != "$HEAD" ]]; then
172+
log "STALE: reviewer reviewed ${REVIEWED_SHA:0:9} but head is ${HEAD:0:9} — NOT approving (re-review needed)."
173+
exit 6
174+
fi
175+
176+
# Idempotency: skip if the Gatekeeper already has an APPROVED review at this head.
177+
ALREADY="$(gh api "repos/${REPO}/pulls/${PR}/reviews" 2>/dev/null \
178+
| HEAD="$HEAD" python3 -c '
179+
import sys, json, os
180+
head = os.environ.get("HEAD","")
181+
try: revs = json.load(sys.stdin)
182+
except Exception: revs = []
183+
for r in revs:
184+
u = (r.get("user") or {})
185+
if u.get("type") == "Bot" and "gatekeeper" in (u.get("login","").lower()) \
186+
and r.get("state") == "APPROVED" and (not head or r.get("commit_id") == head):
187+
print("yes"); break
188+
' 2>/dev/null)"
189+
if [[ "$ALREADY" == "yes" ]]; then
190+
log "already approved by Gatekeeper at head ${HEAD:0:9} — no-op (idempotent)."
191+
exit 0
192+
fi
193+
194+
if [[ -z "${GATEKEEPER_APP_ID:-}" || -z "${GATEKEEPER_INSTALLATION_ID:-}" || -z "${GATEKEEPER_PRIVATE_KEY:-}" ]]; then
195+
log "NOT CONFIGURED: set GATEKEEPER_APP_ID, GATEKEEPER_INSTALLATION_ID, GATEKEEPER_PRIVATE_KEY"
196+
log "(register the 'MCPProxy Gatekeeper' App, install on ${REPO}, drop creds in ~/.mcpproxy-gatekeeper/env)"
197+
exit 2
198+
fi
199+
[[ -f "$GATEKEEPER_PRIVATE_KEY" ]] || { log "private key not found: $GATEKEEPER_PRIVATE_KEY"; exit 2; }
200+
201+
BODY="✅ **Gatekeeper approval** — Codex review verdict: ACCEPT.
202+
203+
This approval is posted automatically by the MCPProxy Gatekeeper App on behalf of the Codex reviewer (verdict of record lives in the Paperclip review thread). Author≠approver satisfied; QA + CI gates enforced separately.
204+
205+
_Auto-approved per Model B (MCP-1249)._"
206+
207+
if [[ "$DRY_RUN" == "1" ]]; then
208+
log "DRY-RUN: would mint installation token (app=${GATEKEEPER_APP_ID} install=${GATEKEEPER_INSTALLATION_ID}) and POST APPROVE review on ${REPO}#${PR}."
209+
exit 0
210+
fi
211+
212+
log "minting installation token…"
213+
TOKEN="$(mint_installation_token "$GATEKEEPER_APP_ID" "$GATEKEEPER_INSTALLATION_ID" "$GATEKEEPER_PRIVATE_KEY")" \
214+
|| { log "failed to mint installation token"; exit 5; }
215+
216+
log "posting APPROVE review on ${REPO}#${PR}"
217+
HTTP=$(curl -s -o /tmp/gatekeeper-resp.json -w '%{http_code}' -m 20 -X POST \
218+
-H "Authorization: token ${TOKEN}" \
219+
-H "Accept: application/vnd.github+json" \
220+
"https://api.github.com/repos/${REPO}/pulls/${PR}/reviews" \
221+
--data "$(python3 -c 'import json,sys; print(json.dumps({"event":"APPROVE","body":sys.argv[1]}))' "$BODY")")
222+
223+
if [[ "$HTTP" == "200" ]]; then
224+
log "✅ approved ${REPO}#${PR} as Gatekeeper."
225+
exit 0
226+
else
227+
log "❌ GitHub review POST failed (HTTP ${HTTP}):"; cat /tmp/gatekeeper-resp.json >&2; echo >&2
228+
exit 5
229+
fi

scripts/gatekeeper-approve.test.sh

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#!/usr/bin/env bash
2+
# gatekeeper-approve.test.sh — fail-closed SHA-guard regression tests (MCP-1249).
3+
#
4+
# Proves the security invariant Codex flagged: gatekeeper-approve.sh must REFUSE
5+
# to post an approving review unless it can resolve a reviewed SHA that equals
6+
# the PR's current head. A missing reviewed SHA must fail CLOSED (never approve
7+
# blind), so a post-review force-push of unreviewed code cannot inherit an old
8+
# ACCEPT. The manual --verdict accept override is held to the same requirement.
9+
#
10+
# Hermetic: stubs `gh` on PATH and uses --verdict/--dry-run so no network, no
11+
# Paperclip, and no real GitHub approval is ever posted.
12+
set -uo pipefail
13+
14+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15+
APPROVE="$SCRIPT_DIR/gatekeeper-approve.sh"
16+
TMP="$(mktemp -d)"
17+
trap 'rm -rf "$TMP"' EXIT
18+
19+
HEAD_SHA="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
20+
OTHER_SHA="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
21+
22+
# Stub gh: PR head is OPEN at HEAD_SHA, no existing reviews.
23+
cat > "$TMP/gh" <<EOF
24+
#!/usr/bin/env bash
25+
if [[ "\$1" == "pr" && "\$2" == "view" ]]; then echo "$HEAD_SHA author-x OPEN"; exit 0; fi
26+
if [[ "\$1" == "api" ]]; then echo "[]"; exit 0; fi
27+
exit 0
28+
EOF
29+
chmod +x "$TMP/gh"
30+
31+
# Config gate must pass so a *matching* SHA can reach the (dry-run) approve step.
32+
touch "$TMP/key.pem"
33+
export GATEKEEPER_APP_ID=123 GATEKEEPER_INSTALLATION_ID=456 GATEKEEPER_PRIVATE_KEY="$TMP/key.pem"
34+
export PATH="$TMP:$PATH"
35+
36+
pass=0; fail=0
37+
check() { # desc expected actual
38+
if [[ "$2" == "$3" ]]; then echo "ok - $1 (exit $3)"; pass=$((pass+1));
39+
else echo "FAIL - $1 (expected exit $2, got $3)"; fail=$((fail+1)); fi
40+
}
41+
refused() { echo "$1" | grep -qiE "refus|stale" ; }
42+
43+
# 1. accept, NO reviewed SHA -> fail-closed refuse, must NOT approve.
44+
out="$("$APPROVE" --pr 999 --verdict accept --dry-run 2>&1)"; rc=$?
45+
check "no reviewed SHA -> refuse (fail-closed)" "7" "$rc"
46+
if refused "$out"; then echo " msg: refusal text present"; else echo " FAIL: no refusal text"; fail=$((fail+1)); fi
47+
if echo "$out" | grep -qi "DRY-RUN: would"; then echo " FAIL: reached approve with no SHA!"; fail=$((fail+1)); fi
48+
49+
# 2. accept, reviewed SHA != head -> stale refuse.
50+
out="$("$APPROVE" --pr 999 --verdict accept --reviewed-sha "$OTHER_SHA" --dry-run 2>&1)"; rc=$?
51+
check "reviewed SHA != head -> stale refuse" "6" "$rc"
52+
if echo "$out" | grep -qi "DRY-RUN: would"; then echo " FAIL: reached approve while stale!"; fail=$((fail+1)); fi
53+
54+
# 3. accept, reviewed SHA == head -> reaches approve (dry-run, exit 0).
55+
out="$("$APPROVE" --pr 999 --verdict accept --reviewed-sha "$HEAD_SHA" --dry-run 2>&1)"; rc=$?
56+
check "reviewed SHA == head -> approves (dry-run)" "0" "$rc"
57+
if echo "$out" | grep -qi "DRY-RUN: would"; then echo " reached approve step as expected"; else echo " FAIL: did not reach approve"; fail=$((fail+1)); fi
58+
59+
echo "----"
60+
echo "pass=$pass fail=$fail"
61+
[[ "$fail" == "0" ]]

scripts/gatekeeper-sweep.sh

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#!/usr/bin/env bash
2+
# gatekeeper-sweep.sh — auto-trigger for the Gatekeeper App (MCP-1249).
3+
#
4+
# Sweeps every OPEN PR in the repo and invokes gatekeeper-approve.sh for each.
5+
# That call is idempotent and self-guarding: it approves a PR only when the
6+
# Codex review verdict is ACCEPT for the *current* head and the Gatekeeper hasn't
7+
# already approved that head; everything else is a no-op. So this script is safe
8+
# to run unattended on a timer (launchd/cron) — it's the hands-off half of
9+
# "full Model B": Codex ACCEPT → App approval → GitHub auto-merge, no admin.
10+
#
11+
# Reads creds from ~/.mcpproxy-gatekeeper/env (via gatekeeper-approve.sh).
12+
# Designed for launchd: sets a sane PATH and logs each sweep with a timestamp.
13+
#
14+
# Usage: gatekeeper-sweep.sh [--dry-run]
15+
# Env: GATEKEEPER_REPO (default smart-mcp-proxy/mcpproxy-go)
16+
set -euo pipefail
17+
18+
# launchd starts with a minimal PATH — make sure our tools resolve.
19+
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${PATH:-}"
20+
21+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22+
REPO="${GATEKEEPER_REPO:-smart-mcp-proxy/mcpproxy-go}"
23+
DRY=""; [[ "${1:-}" == "--dry-run" ]] && DRY="--dry-run"
24+
25+
ts() { date '+%Y-%m-%dT%H:%M:%S%z'; }
26+
log() { echo "$(ts) [sweep] $*"; }
27+
28+
# Gatekeeper not configured → exit quietly (don't spam logs every interval).
29+
[[ -f "${HOME}/.mcpproxy-gatekeeper/env" ]] || { log "not configured (~/.mcpproxy-gatekeeper/env missing) — skipping."; exit 0; }
30+
31+
# Paperclip (verdict source) must be reachable; otherwise every approve call
32+
# would no-op as 'unknown' anyway — skip the sweep to keep logs clean.
33+
PAPERCLIP_API_URL="${PAPERCLIP_API_URL:-http://localhost:3100}"
34+
if ! curl -fsS -m 5 "${PAPERCLIP_API_URL}/api/health" >/dev/null 2>&1; then
35+
log "Paperclip not reachable at ${PAPERCLIP_API_URL} — skipping this sweep."
36+
exit 0
37+
fi
38+
39+
# bash 3.2 (macOS /bin/bash) safe — no mapfile.
40+
PRS=()
41+
while IFS= read -r n; do [[ -n "$n" ]] && PRS+=("$n"); done \
42+
< <(gh pr list --repo "$REPO" --state open --json number -q '.[].number' 2>/dev/null || true)
43+
log "open PRs: ${#PRS[@]}${PRS:+ (${PRS[*]})}"
44+
45+
approved=0
46+
[[ ${#PRS[@]} -eq 0 ]] && { log "no open PRs — done."; exit 0; }
47+
for pr in "${PRS[@]}"; do
48+
[[ -z "$pr" ]] && continue
49+
set +e
50+
out="$("$HERE/gatekeeper-approve.sh" --pr "$pr" $DRY 2>&1)"; rc=$?
51+
set -e
52+
case $rc in
53+
0) if echo "$out" | grep -q 'approved.*as Gatekeeper'; then
54+
log "PR #$pr → APPROVED"; approved=$((approved+1))
55+
fi ;; # other rc=0 = idempotent/closed no-op, stay quiet
56+
3) : ;; # not accept — quiet
57+
6) log "PR #$pr → stale verdict (re-review needed)" ;;
58+
2) log "PR #$pr → gatekeeper not configured"; break ;;
59+
*) log "PR #$pr → error (rc=$rc): $(echo "$out" | tail -1)" ;;
60+
esac
61+
done
62+
log "sweep done — ${approved} newly approved."

0 commit comments

Comments
 (0)