feat: author Slack/dashboard/schedule commits + PRs as the app by default (#57) (#60)
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: 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.
This commit is contained in:
Adam Moussa 2026-06-29 14:22:33 -04:00 • committed by GitHub
parent 134963647b
commit 8a9974c3c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 245 additions and 46 deletions

1
.gitignore vendored
View file

@ -72,3 +72,4 @@ __pycache__/
TODO.md
# infra/cdk-outputs.json
.idea/

View file

@ -1,5 +1,15 @@
{
"suppressions": [
{
"id": "AUTHZ-SLACK-BOT-DEFAULT-001",
"title": "Slack entrypoint lacks a per-user repo-access check; default-bot PR authoring removes the implicit per-user repo boundary",
"file": "agent/webapp.py",
"severity": "medium",
"status": "confirmed",
"suppression_justification": "ACCEPTED (Adam, 2026-06-29) while Open SWE has a SINGLE user. The Slack run entrypoint does not call require_repo_access_for_user (dashboard/schedule do), so with the default App installation token a mapped Slack user could act on any repo in the App's installation regardless of their own access. Bounded by the compensating controls: the seahaven-openswe App is installed on open-swe-pilot ONLY and ALLOWED_GITHUB_REPOS is locked, so the bot token cannot reach repos outside the pilot, and the only triggering user is the owner. Tracked as open issue #59 with three remediation options. REVISIT TRIGGER: before expanding the user base OR broadening the App's installation beyond open-swe-pilot — at that point this becomes HIGH and a gate (option (c): per-user check when a token exists) must be added. Verified medium (not high) by /sh-security-review proof-or-kill verifier.",
"owner": "adam@seahavenind.com",
"added": "2026-06-29"
},
{
"id": "OSWE-IAC-SECRETS-LIST-01",
"title": "EC2 instance role grants BatchGetSecretValue on \"*\" (operation-level; secret-NAME existence enumeration account-wide)",

View file

@ -92,6 +92,23 @@ def profile_create_prs(profile: dict[str, Any] | None) -> bool:
return False
def profile_author_prs_as_user(profile: dict[str, Any] | None) -> bool:
"""Return whether Slack/dashboard/schedule PRs should be authored as the
triggering user (their per-user OAuth token) instead of the App bot.
Defaults to False: by default these PRs (and the run's git/gh operations) are
authored as the GitHub App `seahaven-openswe[bot]`, matching GitHub-issue
runs and making the self-review 422 impossible. Set the profile flag
``author_prs_as_user: true`` to opt back into per-user attribution.
"""
if not isinstance(profile, dict):
return False
value = profile.get("author_prs_as_user")
if isinstance(value, bool):
return value
return False
def _normalize_profile_model_pair(
profile: dict[str, Any],
*,

View file

@ -391,7 +391,7 @@ When you have completed your implementation, follow these steps in order:
2. **Review your changes**: Review the diff to ensure correctness. Verify no regressions or unintended modifications.
3. **Submit**: Commit locally, push with `git push origin <branch>`, then open or update the PR when a PR is requested, necessary, or required by the Always Create PRs dashboard setting.
- **Open a new PR** with the `open_pull_request` tool (pass `owner`, `repo`, `head` = your branch, `base`, `title`, `body`). This attributes the PR to the triggering user. Push the branch BEFORE calling it.
- **Open a new PR** with the `open_pull_request` tool (pass `owner`, `repo`, `head` = your branch, `base`, `title`, `body`). By default the PR is authored by the app (`seahaven-openswe[bot]`), like GitHub-issue-triggered runs (a user can opt back into per-user attribution via the `author_prs_as_user` profile setting). Push the branch BEFORE calling it.
- **Update an existing PR** (edit the body, mark ready for review, etc.) with `GH_TOKEN=dummy gh pr edit`. If a PR already exists for the branch (including one the user pasted in), do NOT open a duplicate — `open_pull_request` returns the existing PR's URL, so switch to `gh pr edit`. For follow-up changes, add a new commit on top of the existing branch history.
**PR Title** (under 70 characters): the title rule is **repo-aware** — first detect whether the target repo enforces a conventional-commit PR title, then pick the matching style. The repo is already cloned, so this check is cheap.

View file

@ -16,7 +16,7 @@ ReviewThreadMatch = tuple[ReviewThread, int | None]
def _is_open_swe_bot_comment(comment: ReviewThread) -> bool:
return comment.get("author") in {"open-swe", "open-swe[bot]"}
return comment.get("author") in {"open-swe", "open-swe[bot]", "seahaven-openswe[bot]"}
def _int_list(value: Any) -> list[int]:
@ -52,7 +52,7 @@ def _human_replies_after_bot_comment(
if not seen_bot_comment:
continue
author = comment.get("author")
if author in {"open-swe", "open-swe[bot]"}:
if author in {"open-swe", "open-swe[bot]", "seahaven-openswe[bot]"}:
continue
replies.append(comment)
return replies

View file

@ -36,6 +36,7 @@ from .dashboard.agent_overrides import (
load_profile,
normalize_profile_overrides,
normalize_profile_subagent_overrides,
profile_author_prs_as_user,
profile_create_prs,
resolve_github_login,
)
@ -681,9 +682,31 @@ async def get_agent(config: RunnableConfig) -> Pregel:
profile_login = resolve_github_login(config)
configurable = (config or {}).get("configurable") or {}
prompt_default_repo = await _resolve_prompt_default_repo(configurable)
triggering_user_identity_task = asyncio.create_task(
asyncio.to_thread(resolve_triggering_user_identity, config, github_token)
)
# Commit identity must follow the SAME default-bot decision as the token
# (SH-IDSPLIT-01): by default slack/dashboard/schedule runs author commits as the
# app bot, so resolve the triggering USER's git identity ONLY when authoring as the
# user (the author_prs_as_user opt-in, or a non-default source). Otherwise leave it
# None so construct_system_prompt sets the bot identity (OPEN_SWE_BOT_NAME/EMAIL) and
# commits don't get mis-attributed to a human who didn't write them.
if configurable.get("source") in ("slack", "dashboard", "schedule"):
_author_as_user = bool(
isinstance(profile_login, str)
and profile_login.strip()
and profile_author_prs_as_user(await load_profile(profile_login.strip()))
)
else:
_author_as_user = True
async def _no_triggering_identity() -> Any:
return None
if _author_as_user:
triggering_user_identity_task = asyncio.create_task(
asyncio.to_thread(resolve_triggering_user_identity, config, github_token)
)
else:
triggering_user_identity_task = asyncio.create_task(_no_triggering_identity())
sandbox_task = asyncio.create_task(
ensure_sandbox_for_thread(thread_id, repo=prompt_default_repo)
)

View file

@ -25,11 +25,12 @@ _REFERENCES_HEADING = "## References"
async def _resolve_pr_author_token() -> tuple[str | None, str]:
"""Return ``(token, kind)`` for opening the PR.
Prefers the triggering user's OAuth token (so the PR is created *as them*)
for Slack/dashboard runs with a mapped GitHub login, resolving it by login
from the dashboard OAuth store. Falls back to the GitHub App installation
token (creator = open-swe[bot]) for GitHub-triggered runs, unmapped users,
or bot-token-only deployments — preserving today's behavior.
DEFAULT: open the PR as the GitHub App bot ``seahaven-openswe[bot]`` (the
installation token) for every source, so Slack/dashboard PRs are attributed
to the app — matching GitHub-issue runs and making the self-review 422
impossible by construction. OPT-IN: when the triggering user's profile has
``author_prs_as_user: true``, open Slack/dashboard PRs as that user (their
per-user OAuth token, resolved by login from the dashboard OAuth store).
The token is resolved by login rather than read from the shared thread
metadata: Slack thread ids are shared across a conversation, so a cached
@ -40,12 +41,19 @@ async def _resolve_pr_author_token() -> tuple[str | None, str]:
github_login = configurable.get("github_login")
if source in _USER_TOKEN_SOURCES and isinstance(github_login, str) and github_login.strip():
from ..dashboard.profiles import get_valid_access_token
login = github_login.strip()
from ..dashboard.agent_overrides import load_profile, profile_author_prs_as_user
user_token = await get_valid_access_token(github_login.strip())
if user_token:
return user_token, "user"
logger.info("No valid user token for %s; opening PR as open-swe[bot]", github_login.strip())
if profile_author_prs_as_user(await load_profile(login)):
from ..dashboard.profiles import get_valid_access_token
user_token = await get_valid_access_token(login)
if user_token:
return user_token, "user"
logger.info(
"author_prs_as_user set but no valid user token for %s; opening PR as the app bot",
login,
)
return await get_github_app_installation_token(), "bot"

View file

@ -92,7 +92,7 @@ async def _reply_to_finding_thread_async(
"kind": "bot_reply",
"github_comment_id": reply_id if isinstance(reply_id, int) else None,
"github_parent_comment_id": comment_id,
"author": "open-swe[bot]",
"author": "seahaven-openswe[bot]",
"body": body.strip(),
"created_at": "",
"needs_reassessment": False,

View file

@ -440,26 +440,31 @@ async def resolve_github_token(config: RunnableConfig, thread_id: str) -> tuple[
github_login = configurable.get("github_login")
# Per-user OAuth from the dashboard store wins even in bot-token-only mode,
# for sources that carry a mapped GitHub login (Slack, dashboard). This is
# what lets the agent open PRs as the triggering user.
# DEFAULT: Slack/dashboard/schedule runs use the GitHub App installation token,
# so all git/gh operations + the PR come in as the app `seahaven-openswe[bot]`
# (deterministic; matches GitHub-issue runs; eliminates the self-review 422).
# OPT-IN: a profile with `author_prs_as_user: true` restores the per-user OAuth
# token so the run is attributed to the triggering user.
if (
source in ("slack", "dashboard", "schedule")
and isinstance(github_login, str)
and github_login.strip()
):
try:
user_token = await _resolve_dashboard_user_token(thread_id, github_login)
except ValueError as exc:
logger.error("GitHub auth failed for thread %s: %s", thread_id, str(exc))
raise RuntimeError(str(exc)) from exc
if user_token is not None:
return user_token
# No valid user token. In bot-token-only mode fall back to the bot so the
# deployment stays functional; otherwise block and require auth.
if is_bot_token_only_mode():
return await _resolve_bot_installation_token(thread_id)
raise GitHubUserAuthRequired(source, github_login)
from ..dashboard.agent_overrides import load_profile, profile_author_prs_as_user
if profile_author_prs_as_user(await load_profile(github_login.strip())):
try:
user_token = await _resolve_dashboard_user_token(thread_id, github_login)
except ValueError as exc:
logger.error("GitHub auth failed for thread %s: %s", thread_id, str(exc))
raise RuntimeError(str(exc)) from exc
if user_token is not None:
return user_token
# Opt-in set but no valid user token: in bot-token-only mode fall back
# to the bot; otherwise block and require auth.
if not is_bot_token_only_mode():
raise GitHubUserAuthRequired(source, github_login)
return await _resolve_bot_installation_token(thread_id)
if is_bot_token_only_mode():
return await _resolve_bot_installation_token(thread_id)

View file

@ -10,11 +10,18 @@ import httpx
logger = logging.getLogger(__name__)
OPEN_SWE_BOT_NAME = "open-swe[bot]"
# Use the open-swe user noreply address: the bot's numeric noreply
# (215916821+open-swe[bot]@...) doesn't resolve to a GitHub account Vercel
# accepts, which broke preview deploys on commits carrying this co-author.
OPEN_SWE_BOT_EMAIL = "open-swe@users.noreply.github.com"
# 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)

View file

@ -10,7 +10,12 @@ 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]"})
# 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:

View file

@ -3179,7 +3179,7 @@ async def process_github_review_finding_reply(payload: dict[str, Any]) -> None:
sender = payload.get("sender", {})
sender_login = sender.get("login") if isinstance(sender, dict) else None
if sender_login == "open-swe[bot]":
if sender_login in INTERNAL_BOT_LOGINS:
return
repo = payload.get("repository", {})

View file

@ -0,0 +1,34 @@
from __future__ import annotations
from agent.dashboard.agent_overrides import profile_author_prs_as_user
from agent.utils.authorship import OPEN_SWE_BOT_EMAIL, OPEN_SWE_BOT_NAME
from agent.utils.github_org_membership import INTERNAL_BOT_LOGINS
def test_author_prs_as_user_defaults_off() -> None:
# Default is the app bot; the per-user opt-in must be explicit.
assert profile_author_prs_as_user(None) is False
assert profile_author_prs_as_user({}) is False
assert profile_author_prs_as_user({"author_prs_as_user": "true"}) is False
assert profile_author_prs_as_user({"author_prs_as_user": True}) is True
def test_commit_identity_is_the_app_bot() -> None:
assert OPEN_SWE_BOT_NAME == "seahaven-openswe[bot]"
assert OPEN_SWE_BOT_EMAIL == "296972425+seahaven-openswe[bot]@users.noreply.github.com"
def test_self_trigger_guard_recognizes_our_app_bot() -> None:
# Bot-authored PRs/actions must be recognized as our own to avoid self-trigger loops.
assert "seahaven-openswe[bot]" in INTERNAL_BOT_LOGINS
def test_default_commit_identity_in_prompt_is_the_app_bot() -> None:
# SH-IDSPLIT-01: with no triggering-user identity (the default-bot path), the
# constructed prompt must set the git commit identity to the app bot — so
# commits, push, and PR all come in as the app (not the triggering user).
from agent.prompt import construct_system_prompt
prompt = construct_system_prompt(working_dir="/workspace", triggering_user_identity=None)
assert OPEN_SWE_BOT_NAME in prompt
assert OPEN_SWE_BOT_EMAIL in prompt

View file

@ -78,10 +78,42 @@ def _stub_dashboard_store(
monkeypatch.setattr(profiles, "_get_value", fake_get_value)
def test_resolve_github_token_slack_uses_dashboard_store(
def _set_profile(monkeypatch: pytest.MonkeyPatch, *, author_prs_as_user: bool) -> None:
"""Mock the per-user profile lookup that gates per-user attribution.
``author_prs_as_user=False`` → no opt-in (default: author as the app bot).
"""
from agent.dashboard import agent_overrides
profile = {"author_prs_as_user": True} if author_prs_as_user else None
async def fake_load_profile(login: str):
return profile
monkeypatch.setattr(agent_overrides, "load_profile", fake_load_profile)
def test_resolve_github_token_slack_defaults_to_bot(monkeypatch: pytest.MonkeyPatch) -> None:
# DEFAULT (no author_prs_as_user opt-in): slack runs author as the app bot,
# even when a valid per-user token exists — so PRs come in as the app.
_stub_dashboard_store(monkeypatch, token="user-tok")
_set_profile(monkeypatch, author_prs_as_user=False)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
async def fake_bot(thread_id: str):
return ("bot-tok", None)
monkeypatch.setattr(auth, "_resolve_bot_installation_token", fake_bot)
token, _ = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
assert token == "bot-tok"
def test_resolve_github_token_slack_optin_uses_dashboard_store(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_dashboard_store(monkeypatch, token="user-tok")
_set_profile(monkeypatch, author_prs_as_user=True)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
token, expires_at = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
@ -90,7 +122,7 @@ def test_resolve_github_token_slack_uses_dashboard_store(
assert expires_at == "2099-01-01T00:00:00Z"
def test_resolve_github_token_slack_ignores_stale_thread_cache(
def test_resolve_github_token_slack_optin_ignores_stale_thread_cache(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Slack thread ids are shared, so a prior user's cached token must NOT be
@ -100,6 +132,7 @@ def test_resolve_github_token_slack_ignores_stale_thread_cache(
token="bob-token",
cached=("alice-token", "2099-01-01T00:00:00Z"),
)
_set_profile(monkeypatch, author_prs_as_user=True)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
token, _ = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
@ -107,24 +140,26 @@ def test_resolve_github_token_slack_ignores_stale_thread_cache(
assert token == "bob-token"
def test_resolve_github_token_slack_no_token_raises(
def test_resolve_github_token_slack_optin_no_token_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_dashboard_store(monkeypatch, token=None)
_set_profile(monkeypatch, author_prs_as_user=True)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
with pytest.raises(auth.GitHubUserAuthRequired):
asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
def test_resolve_github_token_per_user_wins_over_bot_only_mode(
def test_resolve_github_token_optin_per_user_wins_over_bot_only_mode(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_dashboard_store(monkeypatch, token="user-tok")
_set_profile(monkeypatch, author_prs_as_user=True)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: True)
async def fail_bot(thread_id: str):
raise AssertionError("bot token must not be used when a user token exists")
raise AssertionError("bot token must not be used when the opt-in user token exists")
monkeypatch.setattr(auth, "_resolve_bot_installation_token", fail_bot)
@ -132,10 +167,11 @@ def test_resolve_github_token_per_user_wins_over_bot_only_mode(
assert token == "user-tok"
def test_resolve_github_token_slack_no_token_falls_back_to_bot_in_bot_only_mode(
def test_resolve_github_token_slack_optin_no_token_falls_back_to_bot_in_bot_only_mode(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_dashboard_store(monkeypatch, token=None)
_set_profile(monkeypatch, author_prs_as_user=True)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: True)
async def fake_bot(thread_id: str):

View file

@ -11,6 +11,24 @@ import agent.tools.open_pull_request # noqa: F401
opr = sys.modules["agent.tools.open_pull_request"]
def _set_profile(monkeypatch: pytest.MonkeyPatch, *, author_prs_as_user: bool) -> None:
"""Mock the per-user profile lookup that gates per-user PR attribution."""
from agent.dashboard import agent_overrides
profile = {"author_prs_as_user": True} if author_prs_as_user else None
async def fake_load_profile(login: str):
return profile
monkeypatch.setattr(agent_overrides, "load_profile", fake_load_profile)
@pytest.fixture(autouse=True)
def _default_no_optin_profile(monkeypatch: pytest.MonkeyPatch) -> None:
# Default: the author_prs_as_user opt-in is OFF, so PRs author as the app bot.
_set_profile(monkeypatch, author_prs_as_user=False)
class _FakeResponse:
def __init__(self, status_code: int, payload: Any = None, text: str = "") -> None:
self.status_code = status_code
@ -101,11 +119,46 @@ def _open() -> dict[str, Any]:
)
def test_uses_user_token_for_slack_with_login(monkeypatch: pytest.MonkeyPatch) -> None:
def test_defaults_to_bot_for_slack_without_optin(monkeypatch: pytest.MonkeyPatch) -> None:
# DEFAULT (no author_prs_as_user opt-in): slack PRs are opened as the app bot,
# even when a valid per-user token exists — so the PR is attributed to the app.
_set_config(monkeypatch, {"source": "slack", "github_login": "johannes117"})
from agent.dashboard import profiles
async def fail_user_token(login: str, **_kw: Any) -> str | None:
raise AssertionError("user token must not be used without the author_prs_as_user opt-in")
monkeypatch.setattr(profiles, "get_valid_access_token", fail_user_token)
async def fake_bot() -> str | None:
return "bot-tok"
monkeypatch.setattr(opr, "get_github_app_installation_token", fake_bot)
client = _FakeClient(
post=_FakeResponse(
201,
{
"html_url": "https://x/pull/1",
"number": 1,
"user": {"login": "seahaven-openswe[bot]"},
},
)
)
_install_client(monkeypatch, client)
result = _open()
assert result["token_kind"] == "bot"
assert client.post_calls[0]["headers"]["Authorization"] == "Bearer bot-tok"
def test_uses_user_token_for_slack_with_optin(monkeypatch: pytest.MonkeyPatch) -> None:
_set_config(monkeypatch, {"source": "slack", "github_login": "johannes117"})
_set_profile(monkeypatch, author_prs_as_user=True)
from agent.dashboard import profiles
async def fake_user_token(login: str, **_kw: Any) -> str | None:
assert login == "johannes117"
return "user-tok"