From dc9a0b98daa166724a8473389a8ea7f45db74fd3 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" <215916821+open-swe[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 11:38:29 -0700 Subject: [PATCH] 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] Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com> --- agent/utils/github_org_membership.py | 65 +++++ agent/webapp.py | 67 +++++ tests/test_public_repo_org_gate.py | 414 +++++++++++++++++++++++++++ 3 files changed, 546 insertions(+) create mode 100644 agent/utils/github_org_membership.py create mode 100644 tests/test_public_repo_org_gate.py diff --git a/agent/utils/github_org_membership.py b/agent/utils/github_org_membership.py new file mode 100644 index 00000000..42ad8fee --- /dev/null +++ b/agent/utils/github_org_membership.py @@ -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" diff --git a/agent/webapp.py b/agent/webapp.py index b312bd4c..37df66d7 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -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", diff --git a/tests/test_public_repo_org_gate.py b/tests/test_public_repo_org_gate.py new file mode 100644 index 00000000..87a93d1c --- /dev/null +++ b/tests/test_public_repo_org_gate.py @@ -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