"""Session-immutable MCP mutation context (#714). Pins profile, remote, host, repository, identity, and role for the life of an MCP process (or until an explicit ``gitea_activate_profile`` re-bind). Capability resolution and mutation gates must evaluate only the active profile for the requested remote. Silent cross-host / cross-profile substitution is forbidden. """ from __future__ import annotations import os import urllib.parse from typing import Any # Process-local only — never a shared file (same rationale as mutation authority). _SESSION_CONTEXT: dict[str, Any] | None = None def clear_session_context() -> None: """Reset session context (tests / process rebind).""" global _SESSION_CONTEXT _SESSION_CONTEXT = None def get_session_context() -> dict[str, Any] | None: """Return a shallow copy of the bound context, or None if unbound.""" if _SESSION_CONTEXT is None: return None return dict(_SESSION_CONTEXT) def profile_host(profile: dict | None) -> str | None: """Hostname from profile base_url, lowercased, or None.""" if not profile: return None base = (profile.get("base_url") or "").strip() if not base: return None try: parsed = urllib.parse.urlparse(base) host = (parsed.netloc or parsed.path or "").strip().lower() return host or None except Exception: return None def remote_host(remote: str | None, remotes: dict | None) -> str | None: """Hostname for a known remote key.""" if not remote or not remotes: return None entry = remotes.get(remote) or {} return (entry.get("host") or "").strip().lower() or None def profile_matches_remote( profile: dict | None, remote: str | None, remotes: dict | None, *, contexts: dict | None = None, ) -> bool: """True when *profile* is bound to the same host/context as *remote*.""" if not profile or not remote: return False r_host = remote_host(remote, remotes) p_host = profile_host(profile) if r_host and p_host: return r_host == p_host # Fall back to context name heuristics when base_url missing. ctx = (profile.get("context") or "").strip().lower() if not ctx or not contexts: return False ctx_data = contexts.get(ctx) or {} gitea = ctx_data.get("gitea") or {} base = (gitea.get("base_url") or "").strip() if not base or not r_host: return False try: parsed = urllib.parse.urlparse(base) c_host = (parsed.netloc or parsed.path or "").strip().lower() except Exception: return False return bool(c_host) and c_host == r_host def bind_session_context( *, profile_name: str, remote: str | None, host: str | None, identity: str | None, repository: str | None = None, org: str | None = None, role_kind: str | None = None, expected_username: str | None = None, source: str = "bind", ) -> dict[str, Any]: """Bind or re-bind the immutable session context (explicit activate path).""" global _SESSION_CONTEXT _SESSION_CONTEXT = { "profile_name": (profile_name or "").strip() or None, "remote": (remote or "").strip() or None, "host": (host or "").strip().lower() or None, "identity": (identity or "").strip() or None, "repository": (repository or "").strip() or None, "org": (org or "").strip() or None, "role_kind": (role_kind or "").strip() or None, "expected_username": (expected_username or "").strip() or None, "source": source, "pid": os.getpid(), } return get_session_context() # type: ignore[return-value] def seed_session_context_if_unbound( *, profile_name: str, remote: str | None, host: str | None, identity: str | None, repository: str | None = None, org: str | None = None, role_kind: str | None = None, expected_username: str | None = None, source: str = "seed", active_profile_override: str | None = None, env_profile: str | None = None, ) -> dict[str, Any]: """Bind when unbound, or rebind when the static env profile changes. Mid-session ``_active_profile_override`` changes that did **not** go through ``gitea_activate_profile`` keep the prior bound context so assess/mutation gates can detect drift and fail closed. """ global _SESSION_CONTEXT live = (profile_name or "").strip() or None if _SESSION_CONTEXT is None or _SESSION_CONTEXT.get("pid") != os.getpid(): return bind_session_context( profile_name=profile_name, remote=remote, host=host, identity=identity, repository=repository, org=org, role_kind=role_kind, expected_username=expected_username, source=source, ) bound_profile = _SESSION_CONTEXT.get("profile_name") if live and bound_profile and live != bound_profile: # Static env profile switch (tests / launcher) without override: rebind. # Override present but bound not updated: leave bound for drift detection. if active_profile_override is None and ( env_profile is None or env_profile == live ): return bind_session_context( profile_name=profile_name, remote=remote, host=host, identity=identity, repository=repository, org=org, role_kind=role_kind, expected_username=expected_username, source=f"{source}-env-rebind", ) return get_session_context() # type: ignore[return-value] def assess_session_context( *, profile_name: str | None, remote: str | None, host: str | None = None, identity: str | None = None, repository: str | None = None, org: str | None = None, expected_username: str | None = None, require_bound: bool = False, ) -> dict[str, Any]: """Compare live values against the bound session context. Returns ``proven`` / ``block`` / ``reasons``. When unbound and ``require_bound`` is false, does not block (caller may seed). When unbound and ``require_bound`` is true, fails closed. """ reasons: list[str] = [] ctx = _SESSION_CONTEXT if ctx is None or ctx.get("pid") != os.getpid(): if require_bound: reasons.append( "session mutation context is unbound; call gitea_whoami or " "gitea_activate_profile before mutating (fail closed)" ) return _assessment(False, reasons, ctx) return _assessment(True, reasons, ctx) live_profile = (profile_name or "").strip() or None live_remote = (remote or "").strip() or None live_host = (host or "").strip().lower() or None live_identity = (identity or "").strip() or None live_repo = (repository or "").strip() or None live_org = (org or "").strip() or None if ctx.get("profile_name") and live_profile and live_profile != ctx.get("profile_name"): reasons.append( f"profile drift: live '{live_profile}' != bound " f"'{ctx.get('profile_name')}' (fail closed)" ) if ctx.get("remote") and live_remote and live_remote != ctx.get("remote"): reasons.append( f"remote drift: live '{live_remote}' != bound " f"'{ctx.get('remote')}' (fail closed)" ) if ctx.get("host") and live_host and live_host != ctx.get("host"): reasons.append( f"host drift: live '{live_host}' != bound " f"'{ctx.get('host')}' (fail closed)" ) if ( ctx.get("identity") and live_identity and live_identity != ctx.get("identity") ): reasons.append( f"identity drift: live '{live_identity}' != bound " f"'{ctx.get('identity')}' (fail closed)" ) if ctx.get("repository") and live_repo and live_repo != ctx.get("repository"): reasons.append( f"repository drift: live '{live_repo}' != bound " f"'{ctx.get('repository')}' (fail closed)" ) if ctx.get("org") and live_org and live_org != ctx.get("org"): reasons.append( f"org drift: live '{live_org}' != bound " f"'{ctx.get('org')}' (fail closed)" ) expected = expected_username or ctx.get("expected_username") if expected and live_identity and live_identity != expected: reasons.append( f"identity mismatch: authenticated '{live_identity}' != " f"profile expected '{expected}' (fail closed)" ) return _assessment(not reasons, reasons, ctx) def assess_identity_match( *, authenticated: str | None, expected_username: str | None, ) -> dict[str, Any]: """Fail closed when profile declares a username that does not match live auth.""" reasons: list[str] = [] auth = (authenticated or "").strip() or None expected = (expected_username or "").strip() or None if expected and auth and auth != expected: reasons.append( f"identity mismatch: authenticated '{auth}' != " f"profile expected '{expected}' (fail closed)" ) return { "proven": not reasons, "block": bool(reasons), "reasons": reasons, "authenticated": auth, "expected_username": expected, } def filter_profiles_for_remote( config: dict | None, remote: str | None, remotes: dict | None, ) -> list[str]: """Profile names whose host/context matches *remote* (enabled only).""" if not config or not remote: return [] profiles = config.get("profiles") or {} contexts = config.get("contexts") or {} names: list[str] = [] for name, data in profiles.items(): if not isinstance(data, dict): continue if not data.get("enabled", True): continue if profile_matches_remote(data, remote, remotes, contexts=contexts): names.append(name) return sorted(names) def profile_allowed_for_remote( profile: dict | None, remote: str | None, remotes: dict | None, *, contexts: dict | None = None, ) -> dict[str, Any]: """Assess whether the active profile may serve *remote*.""" reasons: list[str] = [] if not profile: reasons.append("active profile unresolved (fail closed)") return {"proven": False, "block": True, "reasons": reasons} if not remote: return {"proven": True, "block": False, "reasons": reasons} if not profile_matches_remote(profile, remote, remotes, contexts=contexts): p_name = profile.get("profile_name") or profile.get("name") or "(unknown)" p_host = profile_host(profile) or "(none)" r_host = remote_host(remote, remotes) or "(none)" reasons.append( f"cross-host profile denial: profile '{p_name}' (host '{p_host}') " f"cannot serve remote '{remote}' (host '{r_host}') (fail closed)" ) return {"proven": not reasons, "block": bool(reasons), "reasons": reasons} def mutation_context_audit_fields( ctx: dict[str, Any] | None = None, ) -> dict[str, Any]: """Fields to include in pre-mutation audit records.""" data = ctx if ctx is not None else get_session_context() if not data: return { "session_context_bound": False, "session_profile": None, "session_remote": None, "session_host": None, "session_identity": None, "session_repository": None, "session_org": None, } return { "session_context_bound": True, "session_profile": data.get("profile_name"), "session_remote": data.get("remote"), "session_host": data.get("host"), "session_identity": data.get("identity"), "session_repository": data.get("repository"), "session_org": data.get("org"), "session_role_kind": data.get("role_kind"), "session_context_source": data.get("source"), } def _assessment( proven: bool, reasons: list[str], ctx: dict[str, Any] | None ) -> dict[str, Any]: return { "proven": proven, "block": not proven, "reasons": list(reasons), "bound_context": dict(ctx) if ctx else None, "audit": mutation_context_audit_fields(ctx), }