Capability resolution and mutation gates no longer auto-switch profiles. Session profile/remote/host/identity are bound after pin or first seed, and drift or cross-host resolution fails closed before writes. Co-Authored-By: Grok <[email protected]>
359 lines
12 KiB
Python
359 lines
12 KiB
Python
"""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),
|
|
}
|