open-swe/agent/utils/github_org_membership.py
Adam Moussa 8a9974c3c4
Some checks failed
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
Build & publish app artifacts / Publish + deploy (dev) (push) Has been cancelled
Build & publish app artifacts / Publish + deploy (prod) (push) Has been cancelled
Infra CD / Infra CI (pre-deploy) (push) Has been cancelled
Infra CD / Deploy open-swe-dev (push) Has been cancelled
Infra CD / Deploy open-swe-prod (push) Has been cancelled
feat: author Slack/dashboard/schedule commits + PRs as the app by default (#57) (#60)
* feat: default Slack/dashboard/schedule PRs + commits to the app identity (#57)

Slack/dashboard/schedule runs now author PRs and run git/gh operations as the
GitHub App seahaven-openswe[bot] by default (matching GitHub-issue runs), so the
self-review 422 is impossible by construction rather than guarded in the prompt.
A profile flag author_prs_as_user restores per-user attribution.

- open_pull_request._resolve_pr_author_token + auth.resolve_github_token: default
  to the installation token for these sources; per-user only when opted in.
- authorship: commit identity -> seahaven-openswe[bot] (numeric noreply;
  accepted Vercel-resolution risk, documented inline).
- self-trigger safety: INTERNAL_BOT_LOGINS + webapp/reviewer_reconcile/reply
  markers recognize seahaven-openswe[bot] (bot-authored events are now ours).

Supersedes the prompt-only guard in #58.

* fix: author commits as the app bot in the default path (SH-IDSPLIT-01)

Security review found the commit identity was NOT actually unified to the bot:
resolve_triggering_user_identity got a 403 from the installation token and fell
back to configurable['github_login'], so commits were still authored as the
triggering user (commit=user, push+PR=bot — a three-way split that missed the
stated goal). Now gate the triggering-user identity resolution on the same
default-bot decision as the token: slack/dashboard/schedule default to the app
bot identity unless author_prs_as_user is set.

* docs(security): record AUTHZ-SLACK-BOT-DEFAULT-001 as an accepted residual (#59)

Single-user deployment; bounded by App-on-pilot + ALLOWED_GITHUB_REPOS lock.
Revisit (add a per-user gate) before expanding users or the App installation.
2026-06-29 14:22:33 -04:00

74 lines
2.5 KiB
Python

"""GitHub organization membership checks for webhook gating."""
from __future__ import annotations
import logging
import httpx
from .github_app import get_github_app_installation_token
logger = logging.getLogger(__name__)
# Bot logins whose webhook events are our OWN actions — never re-process them
# (self-trigger guard). Sea Haven's App is `seahaven-openswe[bot]`; the upstream
# `open-swe[bot]` / `openswe-dev[bot]` are kept for historical/test events.
INTERNAL_BOT_LOGINS: frozenset[str] = frozenset(
{"seahaven-openswe[bot]", "open-swe[bot]", "openswe-dev[bot]"}
)
async def is_user_active_org_member(username: str, org: str) -> bool:
"""Return True if ``username`` is an *active* member of ``org``.
Uses the GitHub App installation token so that private organization
memberships are visible (the same approach as the reference
``tag-external-contributions.yml`` workflow). On any API error, returns
``False`` — fail-closed for security.
Requires the GitHub App to have the ``Organization -> Members: Read-only``
permission; the ``GET /orgs/{org}/memberships/{username}`` endpoint returns
403 (-> ``False``) without it. See INSTALLATION.md.
"""
if not username or not org:
return False
token = await get_github_app_installation_token()
if not token:
logger.warning(
"GitHub App token unavailable; cannot verify org membership for %s", username
)
return False
url = f"https://api.github.com/orgs/{org}/memberships/{username}"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
url,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
except Exception:
logger.exception("Error calling GitHub org membership API for %s/%s", org, username)
return False
if response.status_code == 404:
return False
if response.status_code != 200:
logger.warning(
"Unexpected status %s checking %s membership for %s",
response.status_code,
org,
username,
)
return False
try:
state = response.json().get("state")
except ValueError:
logger.warning("Failed to parse org membership response for %s/%s", org, username)
return False
return state == "active"