feat: gate @open-swe mentions on public repos to org members (#1273)

Adds a webhook-level check so only members of $PUBLIC_REPO_ORG_GATE
(e.g. langchain-ai) can trigger Open SWE via mentions or review
requests on public repositories. Private repos remain governed by the
existing org/repo allowlists. Internal bots bypass the gate.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com>
This commit is contained in:
open-swe[bot] 2026-05-08 11:38:29 -07:00 • committed by GitHub
parent 5d1020e8d7
commit dc9a0b98da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 546 additions and 0 deletions

View file

@ -0,0 +1,65 @@
"""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__)
INTERNAL_BOT_LOGINS: frozenset[str] = frozenset({"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.
"""
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"

View file

@ -43,6 +43,7 @@ from .utils.github_comments import (
sanitize_github_comment_body,
verify_github_signature,
)
from .utils.github_org_membership import INTERNAL_BOT_LOGINS, is_user_active_org_member
from .utils.github_token import get_github_token_from_thread
from .utils.github_user_email_map import GITHUB_USER_EMAIL_MAP
from .utils.linear import post_linear_trace_comment
@ -115,6 +116,9 @@ ALLOWED_REVIEWER_GITHUB_REPOS: frozenset[str] = frozenset(
for repo in os.environ.get("ALLOWED_REVIEWER_GITHUB_REPOS", "").split(",")
if repo.strip()
)
# Org whose members are allowed to tag @open-swe on public repos. When empty,
# the public-repo gate is disabled (back-compat).
PUBLIC_REPO_ORG_GATE: str = os.environ.get("PUBLIC_REPO_ORG_GATE", "").strip()
LINEAR_API_KEY = os.environ.get("LINEAR_API_KEY", "")
@ -363,6 +367,57 @@ def _is_repo_allowed_for_reviewer(repo_config: dict[str, str]) -> bool:
return owner in ALLOWED_REVIEWER_GITHUB_ORGS
_PUBLIC_REPO_GATE_REJECTION = {
"status": "ignored",
"reason": "Sender is not a member of the allowed organization for public-repo triggers",
}
async def _is_sender_allowed_for_public_repo(payload: dict[str, Any]) -> bool:
"""Public-repo gate: only ``PUBLIC_REPO_ORG_GATE`` org members may trigger.
Returns True (allowed) when:
- The gate is disabled (``PUBLIC_REPO_ORG_GATE`` empty), OR
- The repo is private (gate only applies to public repos), OR
- The sender is a known internal bot, OR
- The sender is an active member of ``PUBLIC_REPO_ORG_GATE``.
"""
if not PUBLIC_REPO_ORG_GATE:
return True
repository = payload.get("repository") or {}
if repository.get("private", False):
return True
sender = payload.get("sender") or {}
sender_login = sender.get("login", "") or ""
if sender_login in INTERNAL_BOT_LOGINS:
return True
if not sender_login:
return False
return await is_user_active_org_member(sender_login, PUBLIC_REPO_ORG_GATE)
async def _enforce_public_repo_org_gate(
payload: dict[str, Any], event_type: str
) -> dict[str, str] | None:
"""Return a rejection response if the public-repo org gate blocks this event."""
if await _is_sender_allowed_for_public_repo(payload):
return None
sender_login = (payload.get("sender") or {}).get("login", "")
repo = payload.get("repository") or {}
logger.warning(
"Blocking GitHub %s from non-org-member sender '%s' on public repo '%s/%s'",
event_type,
sender_login,
(repo.get("owner") or {}).get("login", ""),
repo.get("name", ""),
)
return _PUBLIC_REPO_GATE_REJECTION
async def _upsert_slack_thread_repo_metadata(
thread_id: str, repo_config: dict[str, str], langgraph_client: LangGraphClient
) -> None:
@ -2201,6 +2256,10 @@ async def github_webhook(request: Request, background_tasks: BackgroundTasks) ->
reason = "Repository org not in allowlist"
return {"status": "ignored", "reason": reason}
gate_rejection = await _enforce_public_repo_org_gate(payload, "pull_request")
if gate_rejection is not None:
return gate_rejection
logger.info("Accepted GitHub PR review request webhook, scheduling reviewer task")
background_tasks.add_task(process_github_pr_review_request, payload)
return {"status": "accepted", "message": "Processing GitHub PR review request"}
@ -2235,6 +2294,10 @@ async def github_webhook(request: Request, background_tasks: BackgroundTasks) ->
logger.info("Ignoring issue that does not mention @openswe or @open-swe")
return {"status": "ignored", "reason": "Issue does not mention @openswe or @open-swe"}
gate_rejection = await _enforce_public_repo_org_gate(payload, event_type)
if gate_rejection is not None:
return gate_rejection
logger.info("Accepted GitHub issue webhook, scheduling background task")
background_tasks.add_task(process_github_issue, payload, event_type)
return {"status": "accepted", "message": "Processing GitHub issue event"}
@ -2258,6 +2321,10 @@ async def github_webhook(request: Request, background_tasks: BackgroundTasks) ->
)
return {"status": "ignored", "reason": "Comment does not mention @openswe or @open-swe"}
gate_rejection = await _enforce_public_repo_org_gate(payload, event_type)
if gate_rejection is not None:
return gate_rejection
logger.info("Accepted GitHub webhook: event=%s, scheduling background task", event_type)
if is_pull_request_comment or event_type in {
"pull_request_review_comment",

View file

@ -0,0 +1,414 @@
"""Tests for the public-repo org-membership gate on GitHub webhooks."""
from __future__ import annotations
import hashlib
import hmac
import json
import pytest
from fastapi.testclient import TestClient
from agent import webapp
_TEST_WEBHOOK_SECRET = "test-secret-for-webhook"
def _sign_body(body: bytes, secret: str = _TEST_WEBHOOK_SECRET) -> str:
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return f"sha256={sig}"
def _post_github_webhook(client: TestClient, event_type: str, payload: dict) -> object:
body = json.dumps(payload, separators=(",", ":")).encode()
return client.post(
"/webhooks/github",
content=body,
headers={
"X-GitHub-Event": event_type,
"X-Hub-Signature-256": _sign_body(body),
"Content-Type": "application/json",
},
)
def _install_membership_stub(monkeypatch, members: set[str]) -> dict[str, list[str]]:
"""Stub the org-membership lookup to return True only for ``members``."""
seen: dict[str, list[str]] = {"calls": []}
async def fake_is_user_active_org_member(username: str, org: str) -> bool:
seen["calls"].append(username)
return username in members
monkeypatch.setattr(webapp, "is_user_active_org_member", fake_is_user_active_org_member)
return seen
def _common_setup(monkeypatch, *, gate: str = "langchain-ai") -> None:
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
monkeypatch.setattr(webapp, "PUBLIC_REPO_ORG_GATE", gate)
monkeypatch.setattr(webapp, "ALLOWED_GITHUB_ORGS", frozenset())
monkeypatch.setattr(webapp, "ALLOWED_REVIEWER_GITHUB_ORGS", frozenset())
monkeypatch.setattr(webapp, "ALLOWED_REVIEWER_GITHUB_REPOS", frozenset())
def test_gate_blocks_non_member_on_public_pr_comment(monkeypatch) -> None:
_common_setup(monkeypatch)
seen = _install_membership_stub(monkeypatch, members={"insider"})
async def fake_process_github_pr_review_command(*_args, **_kwargs) -> None:
raise AssertionError("should not be called")
async def fake_process_github_pr_comment(*_args, **_kwargs) -> None:
raise AssertionError("should not be called")
monkeypatch.setattr(
webapp, "process_github_pr_review_command", fake_process_github_pr_review_command
)
monkeypatch.setattr(webapp, "process_github_pr_comment", fake_process_github_pr_comment)
client = TestClient(webapp.app)
response = _post_github_webhook(
client,
"issue_comment",
{
"action": "created",
"issue": {
"id": 1,
"number": 7,
"title": "PR title",
"pull_request": {
"url": "https://api.github.com/repos/langchain-ai/open-swe/pulls/7"
},
},
"comment": {"body": "@open-swe review"},
"repository": {
"owner": {"login": "langchain-ai"},
"name": "open-swe",
"private": False,
},
"sender": {"login": "stranger", "type": "User"},
},
)
assert response.status_code == 200
body = response.json()
assert body["status"] == "ignored"
assert "not a member" in body["reason"]
assert seen["calls"] == ["stranger"]
def test_gate_allows_org_member_on_public_pr_comment(monkeypatch) -> None:
_common_setup(monkeypatch)
_install_membership_stub(monkeypatch, members={"insider"})
called: dict[str, object] = {}
async def fake_process_github_pr_review_command(payload, event_type, pr_url_override) -> None:
called["event"] = event_type
monkeypatch.setattr(
webapp, "process_github_pr_review_command", fake_process_github_pr_review_command
)
client = TestClient(webapp.app)
response = _post_github_webhook(
client,
"issue_comment",
{
"action": "created",
"issue": {
"id": 1,
"number": 7,
"title": "PR title",
"pull_request": {
"url": "https://api.github.com/repos/langchain-ai/open-swe/pulls/7"
},
},
"comment": {"body": "@open-swe review"},
"repository": {
"owner": {"login": "langchain-ai"},
"name": "open-swe",
"private": False,
},
"sender": {"login": "insider", "type": "User"},
},
)
assert response.status_code == 200
assert response.json()["status"] == "accepted"
assert called["event"] == "issue_comment"
def test_gate_skipped_on_private_repo(monkeypatch) -> None:
_common_setup(monkeypatch)
seen = _install_membership_stub(monkeypatch, members=set())
called: dict[str, object] = {}
async def fake_process_github_pr_comment(payload, event_type) -> None:
called["event"] = event_type
monkeypatch.setattr(webapp, "process_github_pr_comment", fake_process_github_pr_comment)
client = TestClient(webapp.app)
response = _post_github_webhook(
client,
"issue_comment",
{
"action": "created",
"issue": {
"id": 1,
"number": 7,
"title": "PR title",
"pull_request": {
"url": "https://api.github.com/repos/langchain-ai/open-swe/pulls/7"
},
},
"comment": {"body": "@open-swe please look at this"},
"repository": {
"owner": {"login": "langchain-ai"},
"name": "open-swe",
"private": True,
},
"sender": {"login": "stranger", "type": "User"},
},
)
assert response.status_code == 200
assert response.json()["status"] == "accepted"
assert called["event"] == "issue_comment"
assert seen["calls"] == []
def test_gate_disabled_when_env_unset(monkeypatch) -> None:
_common_setup(monkeypatch, gate="")
seen = _install_membership_stub(monkeypatch, members=set())
called: dict[str, object] = {}
async def fake_process_github_pr_comment(payload, event_type) -> None:
called["event"] = event_type
monkeypatch.setattr(webapp, "process_github_pr_comment", fake_process_github_pr_comment)
client = TestClient(webapp.app)
response = _post_github_webhook(
client,
"issue_comment",
{
"action": "created",
"issue": {
"id": 1,
"number": 7,
"title": "PR title",
"pull_request": {
"url": "https://api.github.com/repos/langchain-ai/open-swe/pulls/7"
},
},
"comment": {"body": "@open-swe please look at this"},
"repository": {
"owner": {"login": "langchain-ai"},
"name": "open-swe",
"private": False,
},
"sender": {"login": "stranger", "type": "User"},
},
)
assert response.status_code == 200
assert response.json()["status"] == "accepted"
assert called["event"] == "issue_comment"
assert seen["calls"] == []
def test_gate_blocks_non_member_on_public_issue(monkeypatch) -> None:
_common_setup(monkeypatch)
_install_membership_stub(monkeypatch, members={"insider"})
async def fake_process_github_issue(*_args, **_kwargs) -> None:
raise AssertionError("should not be called")
monkeypatch.setattr(webapp, "process_github_issue", fake_process_github_issue)
client = TestClient(webapp.app)
response = _post_github_webhook(
client,
"issues",
{
"action": "opened",
"issue": {
"id": 1,
"number": 7,
"title": "@openswe please help",
"body": "x",
},
"repository": {
"owner": {"login": "langchain-ai"},
"name": "open-swe",
"private": False,
},
"sender": {"login": "stranger", "type": "User"},
},
)
assert response.status_code == 200
body = response.json()
assert body["status"] == "ignored"
assert "not a member" in body["reason"]
def test_gate_blocks_non_member_on_public_review_requested(monkeypatch) -> None:
_common_setup(monkeypatch)
_install_membership_stub(monkeypatch, members={"insider"})
async def fake_process_github_pr_review_request(*_args, **_kwargs) -> None:
raise AssertionError("should not be called")
monkeypatch.setattr(
webapp, "process_github_pr_review_request", fake_process_github_pr_review_request
)
client = TestClient(webapp.app)
response = _post_github_webhook(
client,
"pull_request",
{
"action": "review_requested",
"requested_reviewer": {"login": "open-swe[bot]"},
"pull_request": {
"number": 1244,
"html_url": "https://github.com/langchain-ai/open-swe/pull/1244",
"base": {"sha": "base-sha"},
"head": {"sha": "head-sha", "ref": "feature-branch"},
},
"repository": {
"owner": {"login": "langchain-ai"},
"name": "open-swe",
"private": False,
},
"sender": {"login": "stranger", "type": "User"},
},
)
assert response.status_code == 200
body = response.json()
assert body["status"] == "ignored"
assert "not a member" in body["reason"]
def test_gate_allows_internal_bot_sender(monkeypatch) -> None:
_common_setup(monkeypatch)
seen = _install_membership_stub(monkeypatch, members=set())
called: dict[str, object] = {}
async def fake_process_github_pr_comment(payload, event_type) -> None:
called["event"] = event_type
monkeypatch.setattr(webapp, "process_github_pr_comment", fake_process_github_pr_comment)
client = TestClient(webapp.app)
response = _post_github_webhook(
client,
"issue_comment",
{
"action": "created",
"issue": {
"id": 1,
"number": 7,
"title": "PR title",
"pull_request": {
"url": "https://api.github.com/repos/langchain-ai/open-swe/pulls/7"
},
},
"comment": {"body": "@open-swe please look at this"},
"repository": {
"owner": {"login": "langchain-ai"},
"name": "open-swe",
"private": False,
},
"sender": {"login": "open-swe[bot]", "type": "Bot"},
},
)
assert response.status_code == 200
assert response.json()["status"] == "accepted"
assert called["event"] == "issue_comment"
assert seen["calls"] == []
@pytest.mark.asyncio
async def test_is_user_active_org_member_returns_false_when_no_token(monkeypatch) -> None:
from agent.utils import github_org_membership
async def fake_token() -> None:
return None
monkeypatch.setattr(github_org_membership, "get_github_app_installation_token", fake_token)
assert await github_org_membership.is_user_active_org_member("alice", "langchain-ai") is False
class _FakeAsyncClient:
def __init__(self, response) -> None:
self._response = response
async def __aenter__(self) -> _FakeAsyncClient:
return self
async def __aexit__(self, *_args) -> None:
return None
async def get(self, *_args, **_kwargs):
return self._response
class _FakeResponse:
def __init__(self, status_code: int, payload: dict | None = None) -> None:
self.status_code = status_code
self._payload = payload or {}
def json(self) -> dict:
return self._payload
def _patch_membership_http(monkeypatch, response: _FakeResponse) -> None:
from agent.utils import github_org_membership
async def fake_token() -> str:
return "x"
monkeypatch.setattr(github_org_membership, "get_github_app_installation_token", fake_token)
def factory(*_args, **_kwargs) -> _FakeAsyncClient:
return _FakeAsyncClient(response)
monkeypatch.setattr(github_org_membership.httpx, "AsyncClient", factory)
@pytest.mark.asyncio
async def test_is_user_active_org_member_handles_404(monkeypatch) -> None:
from agent.utils import github_org_membership
_patch_membership_http(monkeypatch, _FakeResponse(404))
assert await github_org_membership.is_user_active_org_member("alice", "langchain-ai") is False
@pytest.mark.asyncio
async def test_is_user_active_org_member_active(monkeypatch) -> None:
from agent.utils import github_org_membership
_patch_membership_http(monkeypatch, _FakeResponse(200, {"state": "active"}))
assert await github_org_membership.is_user_active_org_member("alice", "langchain-ai") is True
@pytest.mark.asyncio
async def test_is_user_active_org_member_pending_returns_false(monkeypatch) -> None:
from agent.utils import github_org_membership
_patch_membership_http(monkeypatch, _FakeResponse(200, {"state": "pending"}))
assert await github_org_membership.is_user_active_org_member("alice", "langchain-ai") is False