From 5cc351e5da6fe557fa1b0668e38581e405c71d0f Mon Sep 17 00:00:00 2001 From: Sentinel-Bluebuilder Date: Mon, 10 Aug 2026 19:40:25 +0530 Subject: [PATCH] chore: add pre-merge identity guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge commit for PR #19 (`8f5b0f2`) landed on master authored as a personal email, even though every commit inside the PR was correctly authored as the bot. This was NOT a wrong-account mistake. GitHub's record for PR #19 shows `merged_by: Sentinel-Bluebuilder` — the correct bot account performed the merge. The actual cause: 1. `gh pr merge` builds the merge commit SERVER-SIDE. Local git config is ignored, and no client-side hook can intercept it because no local commit ever happens. 2. GitHub stamps that commit with the merging account's PRIMARY email. 3. The bot account did not have "Keep my email addresses private" enabled, so its primary email was a personal address. The real fix is that GitHub setting (browser-only — there is no API for it; `PATCH user/email/visibility` only controls PUBLIC PROFILE display and does NOT change what server-side commits are stamped with). This script is the enforcement check for it: node scripts/check-merge-identity.mjs && gh pr merge --merge Note it reads `gh api user/emails` (the real primary), NOT `gh api user`.email — the latter is the PUBLIC profile email and is null whenever the profile email is unset, which reads as "safe" while the primary is still personal. A first version of this script used that field and gave a false PASS on the exact account that caused the leak; it now fails closed when it cannot read the deciding value, rather than guessing. Verified: blocks on the bot account (primary email personal), and blocks on a token lacking the `user` scope rather than reporting safe. Claude-Session: https://claude.ai/code/session_01LQj6ekufSijMXPcXUD3596 --- scripts/check-merge-identity.mjs | 125 +++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 scripts/check-merge-identity.mjs diff --git a/scripts/check-merge-identity.mjs b/scripts/check-merge-identity.mjs new file mode 100644 index 0000000..5c69163 --- /dev/null +++ b/scripts/check-merge-identity.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +// ─── Pre-Merge Identity Guard ──────────────────────────────────────────────── +// Run this BEFORE every `gh pr merge`. Exits non-zero if merging right now +// would stamp a personal email onto the merge commit. +// +// WHY THIS EXISTS +// --------------- +// On 2026-08-10 the merge commit for PR #19 (`8f5b0f2`) landed on master +// authored as `Build your own dVPN ` — a personal +// email — even though every commit inside the PR was correctly authored as +// `Sentinel-Bluebuilder <...@users.noreply.github.com>`. +// +// THE CAUSE IS NOT A WRONG ACCOUNT. GitHub's own record for PR #19 shows +// `merged_by: Sentinel-Bluebuilder` — the correct bot account did the merge. +// The problem is that: +// +// 1. `gh pr merge` builds the merge commit SERVER-SIDE. Local git config +// (user.name / user.email) is ignored entirely, and no client-side git +// hook can intercept it because no local commit ever happens. +// 2. GitHub stamps that commit with the merging account's PRIMARY email. +// 3. The bot account did NOT have "Keep my email addresses private" +// enabled, so its primary email was a personal address. +// +// THE REAL FIX is the GitHub setting, not the account choice: +// Settings → Emails → [x] Keep my email addresses private +// https://github.com/settings/emails (while signed in AS THE BOT) +// With that enabled, server-side commits use +@users.noreply.github.com. +// +// This script is the belt-and-braces check for that setting. +// +// USAGE +// node scripts/check-merge-identity.mjs && gh pr merge --merge +// +// The `&&` matters — it is what makes the guard load-bearing rather than +// advisory. If the check fails, the merge never runs. + +import { execFileSync } from 'node:child_process'; + +const FORBIDDEN_EMAIL_RE = /@(gmail|googlemail|outlook|hotmail|yahoo|proton(mail)?|icloud|me|aol)\./i; +const NOREPLY = '@users.noreply.github.com'; + +const sh = (cmd, args) => execFileSync(cmd, args, { encoding: 'utf8' }).trim(); + +let failed = false; +const fail = (msg) => { console.error(` ✗ ${msg}`); failed = true; }; +const ok = (msg) => console.log(` ✓ ${msg}`); + +console.log('\nPre-merge identity check\n' + '─'.repeat(64)); + +// ─── 1. Which account will author the server-side merge commit? ───────── +let login = '(unknown)'; +try { + login = JSON.parse(sh('gh', ['api', 'user'])).login || '(unknown)'; + console.log(` active gh account: ${login}`); +} catch (e) { + fail(`could not determine the active gh account: ${e.message}`); +} + +// ─── 2. Is that account's PRIMARY email private? ──────────────────────── +// This is the value GitHub actually stamps on the merge commit. It is NOT +// `gh api user`.email — that field is the PUBLIC profile email and is null +// whenever the profile email is unset, which reads as "safe" even when the +// primary email is a personal address. That false-negative is exactly how +// 8f5b0f2 slipped through, so read the real list instead. +try { + const emails = JSON.parse(sh('gh', ['api', 'user/emails'])); + const primary = emails.find((e) => e.primary) || emails[0]; + if (!primary) { + fail('no primary email visible on the active account — cannot verify'); + } else if (primary.email.endsWith(NOREPLY)) { + ok(`primary email is the noreply address (${primary.email})`); + } else if (FORBIDDEN_EMAIL_RE.test(primary.email)) { + fail(`primary email is PERSONAL: ${primary.email}`); + fail('a server-side merge commit WOULD be stamped with it'); + } else { + fail(`primary email is not a noreply address: ${primary.email} — verify before merging`); + } +} catch (e) { + // The token may lack the `user` scope (the bot token has it; some others do + // not). Refuse rather than guess: a guard that cannot read the deciding + // value must never report "safe". That false pass is the whole bug. + fail('cannot read the account\'s primary email (token likely missing the "user" scope)'); + fail('run: gh auth refresh -h github.com -s user — then re-run this check'); + console.error(` (${e.message.split('\n')[0]})`); +} + +// ─── 3. Do the commits being merged carry a clean identity? ───────────── +try { + const base = process.argv[2] || 'origin/master'; + const authors = sh('git', ['log', '--format=%an <%ae>', `${base}..HEAD`]) + .split('\n').filter(Boolean); + const bad = [...new Set(authors.filter((a) => FORBIDDEN_EMAIL_RE.test(a)))]; + if (bad.length) bad.forEach((a) => fail(`commit authored with a personal identity: ${a}`)); + else if (authors.length) ok(`${authors.length} commit(s) carry a clean identity`); +} catch (e) { + console.log(` · skipped commit-author scan (${e.message.split('\n')[0]})`); +} + +console.log('─'.repeat(64)); +if (failed) { + console.error(` + MERGE BLOCKED. + + Most likely fix — enable email privacy ON THE MERGING ACCOUNT: + 1. Sign in to github.com as ${login} + 2. Settings → Emails → [x] Keep my email addresses private + https://github.com/settings/emails + 3. Re-run this check + + This is the setting that governs SERVER-SIDE merge commits. Local + git config does not affect them. + + If the active account is wrong instead: + gh auth switch --user Sentinel-Autonomybuilder + (the bot; gh reports the pre-rename name — same token as + Sentinel-Bluebuilder, which returns "no accounts matched") + + \`gh auth switch\` may revert between commands, so always re-verify + IMMEDIATELY before merging, never once at the start of a session. +`); + process.exit(1); +} + +console.log('\n Safe to merge.\n'); +process.exit(0);