feat: support allowed repos in addition to allowed orgs for webhook filtering (#1092)

* feat: add ALLOWED_GITHUB_REPOS env var for owner/repo-level webhook filtering

Previously only org-level filtering was supported via ALLOWED_GITHUB_ORGS.
This adds ALLOWED_GITHUB_REPOS for finer-grained control, allowing specific
owner/repo pairs to be allowlisted independently of org membership.

* fix: update test patches for _is_repo_org_allowed rename

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Johannes du Plessis <johannes@langchain.dev>
This commit is contained in:
Brace Sproul 2026-05-08 13:26:30 -07:00 • committed by GitHub
parent 37c194dc6d
commit 1f8a83d4f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 46 additions and 22 deletions

View file

@ -213,13 +213,18 @@ GITHUB_USER_EMAIL_MAP = {
}
```
You should also add the GitHub organization which should be allowed to be triggered from in GitHub:
You should also configure which GitHub organizations and/or repositories the agent is allowed to operate on. You can specify allowed orgs, specific `owner/repo` pairs, or both:
`agent/webapp.py`
```python
ALLOWED_GITHUB_ORGS = "langchain-ai,anthropics"
```bash
# Allow all repos in these orgs
ALLOWED_GITHUB_ORGS="langchain-ai,anthropics"
# Allow specific repos (owner/repo format)
ALLOWED_GITHUB_REPOS="some-user/their-repo,another-org/specific-repo"
```
A webhook is accepted if the repo's org is in `ALLOWED_GITHUB_ORGS` **or** the `owner/repo` is in `ALLOWED_GITHUB_REPOS`. If both are empty, all repos are allowed.
### Linear (optional)
Open SWE listens for Linear comments that mention `@openswe`.
@ -376,10 +381,14 @@ GITHUB_WEBHOOK_SECRET="" # The secret you generated in step 3b
# With these, each user authenticates with their own GitHub account.
GITHUB_OAUTH_PROVIDER_ID="" # The provider ID from steps 3a / 4b
# === Org Allowlist (optional) ===
# === Repo Allowlist (optional) ===
# Comma-separated list of GitHub orgs the agent is allowed to operate on.
# Leave empty to allow all orgs.
ALLOWED_GITHUB_ORGS="" # e.g. "my-org,my-other-org"
# Comma-separated list of specific owner/repo pairs the agent is allowed to operate on.
# A repo is allowed if its org is in ALLOWED_GITHUB_ORGS OR its owner/repo is in ALLOWED_GITHUB_REPOS.
# Leave both empty to allow all repos.
ALLOWED_GITHUB_REPOS="" # e.g. "some-user/their-repo,another-org/specific-repo"
# === Default Repository ===
# Used across all triggers when no repo is specified.

View file

@ -120,6 +120,12 @@ ALLOWED_REVIEWER_GITHUB_REPOS: frozenset[str] = frozenset(
# the public-repo gate is disabled (back-compat).
PUBLIC_REPO_ORG_GATE: str = os.environ.get("PUBLIC_REPO_ORG_GATE", "").strip()
ALLOWED_GITHUB_REPOS: frozenset[str] = frozenset(
repo.strip().lower()
for repo in os.environ.get("ALLOWED_GITHUB_REPOS", "").split(",")
if repo.strip()
)
LINEAR_API_KEY = os.environ.get("LINEAR_API_KEY", "")
_GITHUB_BOT_MESSAGE_PREFIXES = (
@ -341,16 +347,22 @@ def _run_id_for_logging(run: Any) -> str:
return run_id if isinstance(run_id, str) and run_id else "<unknown>"
def _is_repo_org_allowed(repo_config: dict[str, str]) -> bool:
"""Check if the repo owner/org is in the allowlist.
def _is_repo_allowed(repo_config: dict[str, str]) -> bool:
"""Check if the repo is in the allowlist.
Returns True if no allowlist is configured (empty ALLOWED_GITHUB_ORGS),
or if the repo owner is in the allowlist.
Returns True if no allowlist is configured (both ALLOWED_GITHUB_ORGS and
ALLOWED_GITHUB_REPOS are empty), or if the repo owner is in
ALLOWED_GITHUB_ORGS, or if owner/name is in ALLOWED_GITHUB_REPOS.
"""
if not ALLOWED_GITHUB_ORGS:
if not ALLOWED_GITHUB_ORGS and not ALLOWED_GITHUB_REPOS:
return True
owner = repo_config.get("owner", "").lower()
return owner in ALLOWED_GITHUB_ORGS
name = repo_config.get("name", "").lower()
if ALLOWED_GITHUB_ORGS and owner in ALLOWED_GITHUB_ORGS:
return True
if ALLOWED_GITHUB_REPOS and f"{owner}/{name}" in ALLOWED_GITHUB_REPOS:
return True
return False
def _is_repo_allowed_for_reviewer(repo_config: dict[str, str]) -> bool:
@ -1117,12 +1129,13 @@ async def linear_webhook( # noqa: PLR0911, PLR0912, PLR0915
},
)
if not _is_repo_org_allowed(repo_config):
if not _is_repo_allowed(repo_config):
logger.warning(
"Rejecting Linear webhook: org '%s' not in ALLOWED_GITHUB_ORGS",
"Rejecting Linear webhook: repo '%s/%s' not in allowlist",
repo_config.get("owner"),
repo_config.get("name"),
)
return {"status": "ignored", "reason": "Repository org not in allowlist"}
return {"status": "ignored", "reason": "Repository not in allowlist"}
repo_owner = repo_config["owner"]
repo_name = repo_config["name"]
@ -1252,12 +1265,13 @@ async def slack_webhook(request: Request, background_tasks: BackgroundTasks) ->
}
repo_config = await get_slack_repo_config(text, channel_id, thread_ts)
if not _is_repo_org_allowed(repo_config):
if not _is_repo_allowed(repo_config):
logger.warning(
"Rejecting Slack webhook: org '%s' not in ALLOWED_GITHUB_ORGS",
"Rejecting Slack webhook: repo '%s/%s' not in allowlist",
repo_config.get("owner"),
repo_config.get("name"),
)
return {"status": "ignored", "reason": "Repository org not in allowlist"}
return {"status": "ignored", "reason": "Repository not in allowlist"}
background_tasks.add_task(process_slack_mention, event_data, repo_config)
@ -2271,12 +2285,13 @@ async def github_webhook(request: Request, background_tasks: BackgroundTasks) ->
background_tasks.add_task(process_github_push_event, payload)
return {"status": "accepted", "message": "Processing GitHub push for reviewer watch"}
if not _is_repo_org_allowed(webhook_repo_config):
if not _is_repo_allowed(webhook_repo_config):
logger.warning(
"Rejecting GitHub webhook: org '%s' not in ALLOWED_GITHUB_ORGS",
"Rejecting GitHub webhook: repo '%s/%s' not in allowlist",
webhook_repo_config.get("owner"),
webhook_repo_config.get("name"),
)
return {"status": "ignored", "reason": "Repository org not in allowlist"}
return {"status": "ignored", "reason": "Repository not in allowlist"}
if is_issue_event:
action = payload.get("action", "")

View file

@ -92,7 +92,7 @@ class TestLinearWebhookRepoOverride:
"comments": {"nodes": []},
},
),
patch("agent.webapp._is_repo_org_allowed", return_value=True),
patch("agent.webapp._is_repo_allowed", return_value=True),
patch("agent.webapp.BackgroundTasks"),
):
mock_request = AsyncMock()
@ -142,7 +142,7 @@ class TestLinearWebhookRepoOverride:
"comments": {"nodes": []},
},
),
patch("agent.webapp._is_repo_org_allowed", return_value=True),
patch("agent.webapp._is_repo_allowed", return_value=True),
):
mock_request = AsyncMock()
mock_request.body.return_value = json.dumps(payload).encode()