mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 10:23:14 +00:00
* fix: fall back to core GitHub App scope when optional grants missing (#1701) * fix: fall back to core GitHub App scope when optional grants missing Proxy-token minting requested workflows:write and actions:read in the permission set used for every sandbox. GitHub 422s a token request that asks for a permission the installation hasn't granted, so any install without workflows:write failed to mint a token and every run died in before-agent setup with "GitHub App installation token is unavailable". _resolve_proxy_token now walks a permission ladder (full -> +workflows -> core) and returns the first scope that mints, recording the granted scope so hourly proxy refreshes stay consistent. A missing optional grant now degrades to the install-time core scope instead of failing the run; workflow-file HITL pushes still require workflows:write and fail at push time when it is absent. * refactor: flatten proxy-token ladder loop with continue --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit f53caff1aa24a7b29d851b267aa3bdfe62c1e935) Sea Haven fork deviation: upstream #1701 folds workflows:write into the standing BASE/RUNTIME scope. This fork deliberately keeps workflows:write OUT of the standing permission ladder (RUNTIME = core + actions:read; LADDER = (RUNTIME, CORE)) so the sandbox proxy token cannot push .github/workflows/* during normal operation. workflows:write is minted only transiently by WorkflowPushGuardMiddleware for an approved HITL push and dropped on restore, preserving token scope as a backstop for the workflow- push approval control. Security-reviewed (agentic fan-out + GPT-4.1 cross review); the standing-scope-carries-workflows:write bypass was blocked. * fix(open-swe): harden proxy-token restore and mint error handling Two low-severity follow-ups from the security review of the #1701 port. Restore the recorded baseline scope after a workflow-push elevation instead of a hardcoded RUNTIME. An install granted workflows:write but not actions:read resolves its standing token to core; hardcoding RUNTIME on restore requested the ungranted actions:read, 422'd, and fired a false "SECURITY: failed to downscope" error on every approved workflow push before the core fallback recovered. The guard now captures the run's recorded scope before elevating (via the new get_recorded_proxy_permissions) and restores exactly that, falling back to the guaranteed core scope only when the baseline restore fails. Classify installation-token mint failures. get_github_app_installation_token_ with_expiry now treats HTTP 422 (a permission the installation hasn't granted) as the ladder's expected descend signal and keeps it at debug, while a non-422 failure (network/5xx/timeout) is surfaced at WARNING even when errors are otherwise suppressed — so a transient blip no longer silently downscopes a whole run under a debug-only trace. The reduced-scope warning no longer asserts a missing grant as the sole cause. * chore(triage): mark upstream #1701 landed on this branch Ported via PR #181 as Option A (workflows:write kept out of the standing proxy-token scope). Regenerated triage.md from triage.jsonl. --------- Co-authored-by: Ramon Nogueira <ramon.nogueira@langchain.dev>
220 lines
8.4 KiB
Python
220 lines
8.4 KiB
Python
"""GitHub App installation token generation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import time
|
|
from collections.abc import Mapping, Sequence
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import jwt
|
|
|
|
from .http import DEFAULT_HTTP_TIMEOUT
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
GITHUB_APP_ID = os.environ.get("GITHUB_APP_ID", "")
|
|
GITHUB_APP_PRIVATE_KEY = os.environ.get("GITHUB_APP_PRIVATE_KEY", "")
|
|
GITHUB_APP_INSTALLATION_ID = os.environ.get("GITHUB_APP_INSTALLATION_ID", "")
|
|
|
|
# Installation tokens are valid for 1 hour. Reuse a minted token until it is
|
|
# within this window of expiring so chat/review requests don't pay a fresh
|
|
# JWT-sign + GitHub round-trip every message. The margin stays above the proxy's
|
|
# 5-minute refresh window (``github_proxy.PROXY_TOKEN_REFRESH_WINDOW``) so a
|
|
# near-expiry proxy refresh still mints a genuinely fresh token.
|
|
_TOKEN_CACHE_MARGIN = timedelta(minutes=10)
|
|
# Granted on every installation at install time, so a token scoped to these
|
|
# always mints — the terminal rung of the fallback ladder.
|
|
CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS: dict[str, str] = {
|
|
"contents": "write",
|
|
"pull_requests": "write",
|
|
"issues": "write",
|
|
"checks": "write",
|
|
}
|
|
# `actions:read` (CI-log reads) is a later addition an installation may not have
|
|
# accepted. GitHub 422s a mint that requests an ungranted permission, so
|
|
# ``_resolve_proxy_token`` walks ``PROXY_TOKEN_PERMISSION_LADDER`` high→low and
|
|
# degrades to the guaranteed core scope instead of failing the whole run.
|
|
RUNTIME_PROXY_TOKEN_PERMISSIONS: dict[str, str] = {
|
|
**CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
|
"actions": "read",
|
|
}
|
|
# `workflows:write` is deliberately kept OUT of the standing runtime scope: the
|
|
# sandbox proxy token cannot push `.github/workflows/*` during normal operation.
|
|
# ``WorkflowPushGuardMiddleware`` mints this elevated scope only for an approved
|
|
# HITL workflow-file push and restores the standing scope afterwards, so token
|
|
# scope stays a backstop for the approval control rather than a standing grant.
|
|
WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS: dict[str, str] = {
|
|
**CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
|
"workflows": "write",
|
|
}
|
|
PROXY_TOKEN_PERMISSION_LADDER: tuple[dict[str, str], ...] = (
|
|
RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
|
CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
|
)
|
|
|
|
PermissionMap = Mapping[str, str]
|
|
PermissionKey = tuple[tuple[str, str], ...]
|
|
ScopeKey = tuple[tuple[int, ...], tuple[str, ...], PermissionKey]
|
|
|
|
# scope key -> (token, expires_at, good_until). In-process only; never persisted.
|
|
_TOKEN_CACHE: dict[ScopeKey, tuple[str, str | None, datetime]] = {}
|
|
|
|
|
|
def normalize_permissions(permissions: PermissionMap | None) -> PermissionKey:
|
|
"""Return a stable, hashable permission scope key."""
|
|
if not permissions:
|
|
return ()
|
|
return tuple(sorted((str(k), str(v)) for k, v in permissions.items() if str(k) and str(v)))
|
|
|
|
|
|
def _scope_key(
|
|
repository_ids: Sequence[int] | None,
|
|
repositories: Sequence[str] | None,
|
|
permissions: PermissionMap | None = None,
|
|
) -> ScopeKey:
|
|
"""Cache key segregating repo and permission-scoped tokens."""
|
|
ids = tuple(sorted(int(i) for i in repository_ids)) if repository_ids else ()
|
|
names = tuple(sorted(str(r) for r in repositories)) if repositories else ()
|
|
return ids, names, normalize_permissions(permissions)
|
|
|
|
|
|
def _parse_expiry(expires_at: Any) -> datetime | None:
|
|
"""Best-effort parse of a GitHub ``expires_at`` ISO timestamp to a UTC datetime."""
|
|
if not isinstance(expires_at, str):
|
|
return None
|
|
raw = expires_at.strip()
|
|
if not raw:
|
|
return None
|
|
if raw.endswith("Z"):
|
|
raw = raw[:-1] + "+00:00"
|
|
try:
|
|
parsed = datetime.fromisoformat(raw)
|
|
except ValueError:
|
|
return None
|
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
|
|
|
|
|
def _cached_token(key: ScopeKey, *, now: datetime) -> tuple[str, str | None] | None:
|
|
cached = _TOKEN_CACHE.get(key)
|
|
if cached is None:
|
|
return None
|
|
token, expires_at, good_until = cached
|
|
if now < good_until:
|
|
return token, expires_at
|
|
_TOKEN_CACHE.pop(key, None)
|
|
return None
|
|
|
|
|
|
def clear_app_token_cache() -> None:
|
|
"""Drop all cached installation tokens (test/maintenance hook)."""
|
|
_TOKEN_CACHE.clear()
|
|
|
|
|
|
def _generate_app_jwt() -> str:
|
|
"""Generate a short-lived JWT signed with the GitHub App private key."""
|
|
now = int(time.time())
|
|
payload = {
|
|
"iat": now - 60, # issued 60s ago to account for clock skew
|
|
"exp": now + 540, # expires in 9 minutes (max is 10)
|
|
"iss": GITHUB_APP_ID,
|
|
}
|
|
private_key = GITHUB_APP_PRIVATE_KEY.replace("\\n", "\n")
|
|
return jwt.encode(payload, private_key, algorithm="RS256")
|
|
|
|
|
|
async def get_github_app_installation_token(
|
|
*,
|
|
repository_ids: Sequence[int] | None = None,
|
|
repositories: Sequence[str] | None = None,
|
|
permissions: PermissionMap | None = None,
|
|
log_errors: bool = True,
|
|
) -> str | None:
|
|
"""Exchange the GitHub App JWT for an installation access token."""
|
|
token, _ = await get_github_app_installation_token_with_expiry(
|
|
repository_ids=repository_ids,
|
|
repositories=repositories,
|
|
permissions=permissions,
|
|
log_errors=log_errors,
|
|
)
|
|
return token
|
|
|
|
|
|
async def get_github_app_installation_token_with_expiry(
|
|
*,
|
|
repository_ids: Sequence[int] | None = None,
|
|
repositories: Sequence[str] | None = None,
|
|
permissions: PermissionMap | None = None,
|
|
log_errors: bool = True,
|
|
) -> tuple[str | None, str | None]:
|
|
"""Exchange the GitHub App JWT for an installation access token and its expiry."""
|
|
if not GITHUB_APP_ID or not GITHUB_APP_PRIVATE_KEY or not GITHUB_APP_INSTALLATION_ID:
|
|
logger.debug("GitHub App env vars not fully configured, skipping app token")
|
|
return None, None
|
|
|
|
key = _scope_key(repository_ids, repositories, permissions)
|
|
now = datetime.now(UTC)
|
|
cached = _cached_token(key, now=now)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
body: dict[str, Any] = {}
|
|
if repository_ids:
|
|
body["repository_ids"] = list(repository_ids)
|
|
elif repositories:
|
|
body["repositories"] = list(repositories)
|
|
permission_key = normalize_permissions(permissions)
|
|
if permission_key:
|
|
body["permissions"] = dict(permission_key)
|
|
|
|
try:
|
|
app_jwt = _generate_app_jwt()
|
|
async with httpx.AsyncClient(timeout=DEFAULT_HTTP_TIMEOUT) as client:
|
|
response = await client.post(
|
|
f"https://api.github.com/app/installations/{GITHUB_APP_INSTALLATION_ID}/access_tokens",
|
|
headers={
|
|
"Authorization": f"Bearer {app_jwt}",
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
json=body or None,
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
token, expires_at = data.get("token"), data.get("expires_at")
|
|
parsed = _parse_expiry(expires_at)
|
|
if isinstance(token, str) and token and parsed is not None:
|
|
_TOKEN_CACHE[key] = (token, expires_at, parsed - _TOKEN_CACHE_MARGIN)
|
|
return token, expires_at
|
|
except httpx.HTTPStatusError as exc:
|
|
status = exc.response.status_code if exc.response is not None else None
|
|
if status == 422:
|
|
# Expected when the installation hasn't granted a requested permission:
|
|
# the ladder caller descends to a smaller scope. Not a transient error,
|
|
# so keep it at debug even on the terminal rung.
|
|
logger.debug(
|
|
"GitHub App token mint rejected for requested scope (HTTP 422)", exc_info=True
|
|
)
|
|
elif log_errors:
|
|
logger.exception("Failed to get GitHub App installation token")
|
|
else:
|
|
# A non-422 failure is NOT a missing grant; surface it so a transient
|
|
# error doesn't silently downscope a run that would otherwise qualify.
|
|
logger.warning(
|
|
"GitHub App token mint failed (HTTP %s); scope may degrade transiently",
|
|
status,
|
|
exc_info=True,
|
|
)
|
|
return None, None
|
|
except Exception:
|
|
if log_errors:
|
|
logger.exception("Failed to get GitHub App installation token")
|
|
else:
|
|
logger.warning(
|
|
"GitHub App token mint failed unexpectedly; scope may degrade transiently",
|
|
exc_info=True,
|
|
)
|
|
return None, None
|