Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .github/workflows/docs-agent-pubkey.asc
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----

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
b7ai0g==
=DLpX
-----END PGP PUBLIC KEY BLOCK-----
201 changes: 201 additions & 0 deletions .github/workflows/no-originator-self-approval.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
name: Block originator self-approval on docs-agent PRs

# When the docs-agent (JackReacher0807) opens a PR on behalf of someone who
# requested the change via Slack, it includes a "Requested-by: @<github_username>"
# trailer in the commit message. This workflow watches for approvals on those
# PRs and dismisses any approval submitted by that user, so a docs-agent PR
# always requires a non-originator review before merging.
#
# Branch protection on main only needs "Require 1 approval"; this workflow
# ensures that 1 approval can't come from the person who originated the request.
#
# Why commit message and not PR body: the PR body is editable by anyone with
# write access (including the originator), who could remove their own @mention
# before approving. The commit message is GPG-signed by docs-agent's key
# (AB0009C56564B53A); force-pushing to amend the trailer would either lose
# the signature (no agent key on the originator's machine) or — if the repo
# has "Dismiss stale pull request approvals when new commits are pushed"
# enabled — drop existing approvals.
#
# Scope: this workflow does NOT affect human-authored PRs — it only fires when
# the PR author is JackReacher0807 (the docs-agent's GitHub identity).

on:
pull_request_review:
types: [submitted]

jobs:
block-originator-self-approval:
if: github.event.review.state == 'approved' && github.event.pull_request.user.login == 'JackReacher0807'
runs-on: ubuntu-latest
permissions:
pull-requests: write
Comment thread
SahilAujla marked this conversation as resolved.
steps:
- name: Checkout to access pinned agent public key
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |
.github/workflows/docs-agent-pubkey.asc
sparse-checkout-cone-mode: false
- name: Compare approver to originator and dismiss if same
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPROVER: ${{ github.event.review.user.login }}
REVIEW_ID: ${{ github.event.review.id }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail

# PINNED KEY VERIFICATION (defense against account compromise):
#
# GitHub's `verification.verified == true` only confirms the
# signature is valid against SOME key on JackReacher0807's account.
# If that account is compromised and an attacker adds their own
# GPG key, commits signed with that key would also pass GitHub's
# check. To close this, we re-verify each commit's signature
# against ONLY the pinned agent fingerprint, in an isolated
# gpg keyring containing only the agent's checked-in public key.
# The fingerprint is hardcoded below; account compromise can't
# change it.
EXPECTED_FPR="48AB9EC753D58474E845E808AB0009C56564B53A"
export GNUPGHOME="$(mktemp -d)"
gpg --batch --quiet --import .github/workflows/docs-agent-pubkey.asc

# Collect ALL Requested-by trailers from commits in this PR that
# are BOTH:
# 1. Authored on GitHub by JackReacher0807 (the docs-agent), AND
# 2. Cryptographically signed by the pinned agent GPG key
# (verified locally in the workflow, not just trusting
# GitHub's flag)
#
# We then dismiss the approval if the approver matches ANY of the
# trailers (case-insensitive — GitHub logins are case-insensitive).
#
# Why "any of" instead of "the first":
#
# An attacker with PR-branch-write access could push an additional
# signed commit with a fake "Requested-by: @<some-other-victim>"
# trailer, hoping to redirect the rule away from themselves. Taking
# only the first/last trailer is vulnerable to this. By checking
# the approver against the full set, the original (real) trailer
# is still in the set, so the real originator's approval is still
# dismissed even if other trailers were added.
#
# Residual force-push risk: if the attacker REPLACES (not adds) the
# entire commit history with a single commit attributing to someone
# else, the real originator's trailer is gone and only the fake
# one remains. This requires (a) push access to the branch, and
# (b) the attacker still can't sign with our pinned key, so any
# replacement commits would fail the local-verification filter
# below and be excluded from the trailer set entirely (treated
# as untrusted, missing-attribution path fires).
# Branch-protection settings further reduce attack surface:
# - "Require signed commits"
# - "Dismiss stale pull request approvals when new commits are pushed"
# - "Restrict who can push to matching branches"
# See PR #1262 for the full threat-model discussion.
#
# `|| true` on each command substitution is critical: under pipefail,
# grep-no-match propagates a non-zero exit and the step aborts before
# the empty-attribution fallback runs.
#
# Fetch ALL commits (paginated). GitHub's pull-request commits
# endpoint returns 30/page by default; PRs with many fixup commits
# could span multiple pages and a missing-page would silently drop
# the originator's trailer from ALL_REQUESTED_BY. Use --paginate
# with per_page=100 (the max for this endpoint) and merge pages.
#
# Fail-closed on API errors: if commits-fetch fails after retries,
# we cannot validate the originator, so the only safe action is
# to dismiss the approval (rather than treat as "no attribution"
# and silently allow). The approver can re-approve once the API
# recovers.
fetch_commits() {
for attempt in 1 2 3; do
if out=$(gh api --paginate -F per_page=100 "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/commits" 2>/tmp/gh-err); then
Comment thread
SahilAujla marked this conversation as resolved.
Outdated
# gh api --paginate emits one JSON array per page; merge them.
printf '%s' "$out" | jq -s 'add // []'
return 0
fi
echo "Commit-fetch attempt $attempt failed:" >&2
cat /tmp/gh-err >&2 || true
sleep $((attempt * 5))
done
return 1
}

if ! COMMITS_JSON="$(fetch_commits)"; then
echo "ERROR: could not fetch PR commits after 3 retries — failing closed by dismissing approval"
gh api -X PUT "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews/$REVIEW_ID/dismissals" \
-f message=":warning: Originator self-approval check could not run (GitHub API unavailable after 3 retries). Approval dismissed by default per fail-closed policy; please re-approve once the workflow check passes." || true
exit 1
fi

NUM_COMMITS="$(printf '%s' "$COMMITS_JSON" | jq 'length' 2>/dev/null || echo 0)"

TRAILERS_FILE="$(mktemp)"
for i in $(seq 0 $((NUM_COMMITS - 1))); do
payload="$(printf '%s' "$COMMITS_JSON" | jq -r ".[$i].commit.verification.payload // empty")"
signature="$(printf '%s' "$COMMITS_JSON" | jq -r ".[$i].commit.verification.signature // empty")"
message="$(printf '%s' "$COMMITS_JSON" | jq -r ".[$i].commit.message // empty")"
author_login="$(printf '%s' "$COMMITS_JSON" | jq -r ".[$i].author.login // empty")"
sha="$(printf '%s' "$COMMITS_JSON" | jq -r ".[$i].sha // empty")"

# Skip if not authored by the agent on GitHub
if [ "$author_login" != "JackReacher0807" ]; then
echo "skip $sha: author=$author_login (not JackReacher0807)"
continue
fi

# Skip if no signature attached
if [ -z "$payload" ] || [ -z "$signature" ]; then
echo "skip $sha: missing payload or signature"
continue
fi

# Cryptographically verify against our pinned key only
sig_file="$(mktemp)"
payload_file="$(mktemp)"
printf '%s' "$signature" > "$sig_file"
printf '%s' "$payload" > "$payload_file"
if gpg --status-fd=1 --verify "$sig_file" "$payload_file" 2>/dev/null | grep -qE "^\[GNUPG:\] VALIDSIG $EXPECTED_FPR"; then
Comment thread
SahilAujla marked this conversation as resolved.
Outdated
echo "trust $sha: verified against pinned key"
printf '%s' "$message" \
| grep -oE '[Rr]equested[-_ ][Bb]y[[:space:]]*:[[:space:]]*@[A-Za-z0-9_-]+' \
| grep -oE '@[A-Za-z0-9_-]+' \
| tr -d '@' \
| tr '[:upper:]' '[:lower:]' \
>> "$TRAILERS_FILE" || true
else
echo "skip $sha: signature does not match pinned key $EXPECTED_FPR"
fi
rm -f "$sig_file" "$payload_file"
done

ALL_REQUESTED_BY="$(sort -u "$TRAILERS_FILE" | grep -v '^$' || true)"
APPROVER_LOWER="$(printf '%s' "$APPROVER" | tr '[:upper:]' '[:lower:]')"

if [ -z "$ALL_REQUESTED_BY" ]; then
echo "No 'Requested-by:' trailer found in any verified docs-agent commit on this PR."
# Post a one-time heads-up so reviewers know the rule isn't enforced
# for this PR. Skip if a comment already exists.
EXISTING="$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
--jq '.[] | select(.body | startswith(":warning: docs-agent PR has no Requested-by attribution")) | .id' \
| head -1 || true)"
if [ -z "$EXISTING" ]; then
gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
-f body=":warning: docs-agent PR has no Requested-by attribution in any verified docs-agent commit message. The originator-self-approval rule cannot be enforced for this PR; please apply review judgment."
fi
exit 0
fi

echo "Approver=$APPROVER_LOWER, AllRequestedBy=$(printf '%s' "$ALL_REQUESTED_BY" | tr '\n' ',' | sed 's/,$//')"

if printf '%s\n' "$ALL_REQUESTED_BY" | grep -qFx "$APPROVER_LOWER"; then
echo "Approver matches a Requested-by trailer in this PR. Dismissing approval $REVIEW_ID."
gh api -X PUT "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews/$REVIEW_ID/dismissals" \
-f message="@$APPROVER you are listed as the originator of this docs request (via the Requested-by trailer on a docs-agent commit). Per the docs-agent self-review policy, the originator can't approve their own request — please ask another team member to review."
else
echo "Approver does not match any Requested-by trailer. No action."
fi
Loading