Skip to content

Commit 8f03a7b

Browse files
committed
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
1 parent fc7da77 commit 8f03a7b

3 files changed

Lines changed: 149 additions & 16 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: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,12 @@
3232
# --verdict <v> override the Paperclip verdict lookup (testing).
3333
# --dry-run do everything except POST the review (and print the plan).
3434
#
35-
# Exit codes: 0 ok/approved/dry-run; 2 not configured; 3 verdict not accept
36-
# (no-op); 4 author==approver guard; 5 GitHub/API error.
35+
# Safe to run repeatedly (idempotent): no-ops if the PR is closed/merged, if the
36+
# verdict isn't ACCEPT, if the Gatekeeper already approved the current head, or
37+
# if Codex's reviewed SHA != the current head (stale → needs re-review).
38+
#
39+
# Exit codes: 0 ok/approved/dry-run/no-op; 2 not configured; 3 verdict not accept;
40+
# 5 GitHub/API error; 6 stale verdict (head moved past reviewed SHA).
3741
# ─────────────────────────────────────────────────────────────────────────────
3842
set -euo pipefail
3943

@@ -59,23 +63,24 @@ done
5963

6064
log() { echo "[gatekeeper] $*" >&2; }
6165

62-
# ── 1. Resolve the Codex review verdict for this PR from Paperclip ───────────
66+
# ── 1. Resolve the Codex review verdict (+reviewed SHA) from Paperclip ───────
67+
# Emits "<verdict> <reviewed_sha_or_empty>".
6368
resolve_verdict() {
64-
if [[ -n "$VERDICT_OVERRIDE" ]]; then echo "$VERDICT_OVERRIDE"; return; fi
69+
if [[ -n "$VERDICT_OVERRIDE" ]]; then echo "$VERDICT_OVERRIDE "; return; fi
6570
# Reads are fine unauthenticated against the local instance.
6671
curl -fsS -m 15 "${PAPERCLIP_API_URL}/api/companies/${PAPERCLIP_COMPANY_ID}/issues?q=Review%20PR%20%23${PR}" 2>/dev/null \
6772
| PR="$PR" CODEX="$CODEX_REVIEWER_AGENT_ID" BASE="$PAPERCLIP_API_URL" python3 -c '
68-
import sys, json, os, urllib.request
73+
import sys, json, os, re, urllib.request
6974
pr, codex, base = os.environ["PR"], os.environ["CODEX"], os.environ["BASE"]
7075
iss = json.load(sys.stdin)
7176
iss = iss if isinstance(iss, list) else iss.get("issues", iss.get("data", []))
72-
# Codex review tasks for this PR (any title that references "PR #<n>"),
73-
# assigned to the Codex reviewer, newest first (round-2 supersedes round-1).
77+
# Codex review tasks for this PR (title references "PR #<n>"), assigned to the
78+
# Codex reviewer, newest first (round-2 supersedes round-1).
7479
needle = "PR #%s" % pr
7580
revs = [i for i in iss
7681
if needle in (i.get("title") or "") and i.get("assigneeAgentId") == codex]
7782
revs.sort(key=lambda x: x.get("createdAt", ""), reverse=True)
78-
verdict = "unknown"
83+
verdict, sha = "unknown", ""
7984
for i in revs:
8085
url = "%s/api/issues/%s/comments" % (base, i.get("id"))
8186
try:
@@ -84,17 +89,20 @@ for i in revs:
8489
continue
8590
c = c if isinstance(c, list) else c.get("comments", c.get("data", []))
8691
for cm in reversed(c):
87-
b = (cm.get("body") or "").lower()
92+
body = cm.get("body") or ""
93+
b = body.lower()
8894
if "verdict:" not in b:
8995
continue
9096
tail = b.split("verdict:", 1)[1][:40]
97+
shas = re.findall(r"\b[0-9a-f]{40}\b", body) # SHA the reviewer pinned
98+
sha = shas[-1] if shas else ""
9199
if "accept" in tail:
92100
verdict = "accept"; break
93101
if "request_changes" in tail or "request changes" in tail:
94102
verdict = "request_changes"; break
95103
if verdict != "unknown":
96104
break
97-
print(verdict)
105+
print(verdict, sha)
98106
'
99107
}
100108

@@ -117,18 +125,49 @@ mint_installation_token() {
117125
}
118126

119127
# ── main ────────────────────────────────────────────────────────────────────
120-
VERDICT="$(resolve_verdict || echo unknown)"
121-
log "PR #${PR} Codex verdict = ${VERDICT}"
128+
read -r VERDICT REVIEWED_SHA <<<"$(resolve_verdict || echo 'unknown ')"
129+
log "PR #${PR} Codex verdict = ${VERDICT}${REVIEWED_SHA:+ (reviewed ${REVIEWED_SHA:0:9})}"
122130

123131
if [[ "$VERDICT" != "accept" ]]; then
124132
log "verdict is not 'accept' — NOT approving (no-op). request_changes/unknown must not auto-approve."
125133
exit 3
126134
fi
127135

128-
# Author != approver guard. The App identity is inherently != the PR author,
129-
# but verify the PR author is not somehow the bot (defense in depth).
130-
AUTHOR="$(gh pr view "$PR" --repo "$REPO" --json author -q .author.login 2>/dev/null || echo '?')"
131-
log "PR #${PR} author = ${AUTHOR} (App approves as a distinct identity)"
136+
# Current PR head + author (one API read).
137+
read -r HEAD AUTHOR PR_STATE <<<"$(gh pr view "$PR" --repo "$REPO" --json headRefOid,author,state \
138+
-q '"\(.headRefOid) \(.author.login) \(.state)"' 2>/dev/null || echo '? ? ?')"
139+
log "PR #${PR} state=${PR_STATE} head=${HEAD:0:9} author=${AUTHOR} (App approves as a distinct identity)"
140+
141+
# Don't act on already-closed/merged PRs.
142+
if [[ "$PR_STATE" != "OPEN" ]]; then
143+
log "PR is ${PR_STATE} — nothing to do."
144+
exit 0
145+
fi
146+
147+
# Stale-verdict guard: only approve the exact SHA Codex reviewed. If the head
148+
# moved past the reviewed commit, the verdict is stale → needs re-review.
149+
if [[ -n "$REVIEWED_SHA" && -n "$HEAD" && "$REVIEWED_SHA" != "$HEAD" ]]; then
150+
log "STALE: Codex reviewed ${REVIEWED_SHA:0:9} but head is ${HEAD:0:9} — NOT approving (re-review needed)."
151+
exit 6
152+
fi
153+
154+
# Idempotency: skip if the Gatekeeper already has an APPROVED review at this head.
155+
ALREADY="$(gh api "repos/${REPO}/pulls/${PR}/reviews" 2>/dev/null \
156+
| HEAD="$HEAD" python3 -c '
157+
import sys, json, os
158+
head = os.environ.get("HEAD","")
159+
try: revs = json.load(sys.stdin)
160+
except Exception: revs = []
161+
for r in revs:
162+
u = (r.get("user") or {})
163+
if u.get("type") == "Bot" and "gatekeeper" in (u.get("login","").lower()) \
164+
and r.get("state") == "APPROVED" and (not head or r.get("commit_id") == head):
165+
print("yes"); break
166+
' 2>/dev/null)"
167+
if [[ "$ALREADY" == "yes" ]]; then
168+
log "already approved by Gatekeeper at head ${HEAD:0:9} — no-op (idempotent)."
169+
exit 0
170+
fi
132171

133172
if [[ -z "${GATEKEEPER_APP_ID:-}" || -z "${GATEKEEPER_INSTALLATION_ID:-}" || -z "${GATEKEEPER_PRIVATE_KEY:-}" ]]; then
134173
log "NOT CONFIGURED: set GATEKEEPER_APP_ID, GATEKEEPER_INSTALLATION_ID, GATEKEEPER_PRIVATE_KEY"

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)