open-swe/agent/utils/authorship.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

148 lines
5.5 KiB
Python

"""Helpers for resolving the triggering user's git identity."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
import httpx
logger = logging.getLogger(__name__)
# Sea Haven fork identity: commits AND PRs come in as our GitHub App bot
# `seahaven-openswe[bot]` (user id 296972425) so Slack/dashboard/schedule runs are
# attributed to the app, not the triggering user (deterministic; eliminates the
# self-review 422). The numeric noreply is the canonical GitHub form.
# NOTE (Adam, 2026-06-29 — ACCEPTED RISK): the `<id>+<login>@users.noreply` form
# may NOT resolve to a GitHub account Vercel preview deploys accept (the upstream
# `open-swe[bot]` hit exactly this and worked around it with a non-numeric
# address). We deliberately accept that risk here in exchange for a consistent
# bot identity across commits + PRs. If Vercel preview deploys on a target repo
# start rejecting our commits, this is the cause — revert to a resolvable address.
OPEN_SWE_BOT_NAME = "seahaven-openswe[bot]"
OPEN_SWE_BOT_EMAIL = "296972425+seahaven-openswe[bot]@users.noreply.github.com"
@dataclass(frozen=True)
class CollaboratorIdentity:
"""Identity used for git trailers and PR attribution."""
display_name: str
commit_name: str
commit_email: str
github_login: str = ""
@property
def pr_attribution_name(self) -> str:
"""Display name with GitHub login when available."""
if self.github_login and self.github_login != self.display_name:
return f"{self.display_name} (@{self.github_login})"
return self.display_name
def _normalize_text(value: Any) -> str:
return value.strip() if isinstance(value, str) else ""
def _github_noreply_email(login: str, user_id: Any = None) -> str:
normalized_login = _normalize_text(login)
if not normalized_login:
return ""
normalized_user_id = str(user_id).strip() if user_id is not None else ""
if normalized_user_id:
return f"{normalized_user_id}+{normalized_login}@users.noreply.github.com"
return f"{normalized_login}@users.noreply.github.com"
def _identity_from_github_token(github_token: str | None) -> CollaboratorIdentity | None:
if not github_token:
return None
try:
response = httpx.get(
"https://api.github.com/user",
headers={
"Authorization": f"Bearer {github_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
timeout=5.0,
)
if response.status_code != 200: # noqa: PLR2004
logger.debug("GitHub user lookup returned %s", response.status_code)
return None
payload = response.json()
login = _normalize_text(payload.get("login"))
display_name = _normalize_text(payload.get("name")) or login
commit_email = _github_noreply_email(login, payload.get("id")) or _normalize_text(
payload.get("email")
)
if not display_name or not commit_email:
return None
if commit_email == OPEN_SWE_BOT_EMAIL and display_name == OPEN_SWE_BOT_NAME:
return None
return CollaboratorIdentity(
display_name=display_name,
commit_name=display_name,
commit_email=commit_email,
github_login=login,
)
except httpx.HTTPError:
logger.debug("Failed to resolve GitHub user identity from token", exc_info=True)
return None
def _identity_from_config(config: dict[str, Any]) -> CollaboratorIdentity | None:
configurable = config.get("configurable", {})
slack_thread = configurable.get("slack_thread", {})
linear_issue = configurable.get("linear_issue", {})
display_name = (
_normalize_text(slack_thread.get("triggering_user_name"))
or _normalize_text(linear_issue.get("triggering_user_name"))
or _normalize_text(configurable.get("user_email")).split("@", 1)[0]
)
github_login = _normalize_text(configurable.get("github_login"))
if github_login:
github_user_id = configurable.get("github_user_id")
from ..dashboard.user_mappings import cached_email_for_login
commit_email = _github_noreply_email(github_login, github_user_id) or _normalize_text(
cached_email_for_login(github_login)
)
if commit_email:
commit_name = display_name or github_login
return CollaboratorIdentity(
display_name=commit_name,
commit_name=commit_name,
commit_email=commit_email,
github_login=github_login,
)
commit_email = _normalize_text(configurable.get("user_email")) or _normalize_text(
slack_thread.get("triggering_user_email")
)
if display_name and commit_email:
return CollaboratorIdentity(
display_name=display_name,
commit_name=display_name,
commit_email=commit_email,
)
return None
def resolve_triggering_user_identity(
config: dict[str, Any],
github_token: str | None = None,
) -> CollaboratorIdentity | None:
"""Resolve the triggering user's git identity.
Prefer the GitHub account identity derived from the token when available.
Fall back to config metadata when the run originated from GitHub or when
Slack/Linear supplied an explicit user name and email.
"""
return _identity_from_github_token(github_token) or _identity_from_config(config)