Files
Gitea-Tools/session_context_binding.py
sysadmin dc899d23c8 fix(session): require config-backed mutation authority and workspace-bound targets (#714)
Close the #714 remediation gap left at 943d402 where env-only mutation
profiles, REMOTES Timesheet defaults treated as explicit caller input, and
machine-dependent Git remotes produced false greens.

- Fail closed when mutations lack a config-backed profile with non-empty
  allowed_repositories; env ops cannot invent mutation authority.
- Preserve omitted-vs-explicit org/repo provenance through the mutation
  gate; omitted targets bind to the verified workspace repository.
- Prefer workspace-aligned Git remotes over historical Timesheet defaults
  in MCP _resolve and CLI resolve_remote.
- Centralize deterministic config-backed mutation fixtures; migrate
  mutation tests off env-only authority without weakening security
  assertions.

Full suite: 2744 passed, 6 skipped.
2026-07-15 21:13:21 -04:00

596 lines
20 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 re
import threading
import urllib.parse
from dataclasses import dataclass
from typing import Any, Mapping
@dataclass(frozen=True, slots=True)
class _SessionContext:
"""One atomic, immutable process-session binding."""
profile_name: str | None
remote: str | None
host: str | None
identity: str | None
repository: str | None
org: str | None
role_kind: str | None
expected_username: str | None
source: str
pid: int
def as_dict(self) -> dict[str, Any]:
return {
"profile_name": self.profile_name,
"remote": self.remote,
"host": self.host,
"identity": self.identity,
"repository": self.repository,
"org": self.org,
"role_kind": self.role_kind,
"expected_username": self.expected_username,
"source": self.source,
"pid": self.pid,
}
# Process-local only — never a shared file (same rationale as mutation authority).
# The frozen value prevents partial mutation, while the lock makes first-bind and
# sanctioned rebind atomic across concurrent MCP calls.
_SESSION_CONTEXT: _SessionContext | None = None
_SESSION_CONTEXT_LOCK = threading.RLock()
def _reset_session_context_for_testing() -> None:
"""Reset at a pytest test boundary; unavailable to production callers.
Production sessions transition only through process startup/PID change or
the explicit profile-activation rebind. Keeping this helper private and
requiring pytest's per-test marker prevents it from becoming an MCP/runtime
bypass.
"""
if "PYTEST_CURRENT_TEST" not in os.environ:
raise RuntimeError("session context reset is restricted to pytest boundaries")
global _SESSION_CONTEXT
with _SESSION_CONTEXT_LOCK:
_SESSION_CONTEXT = None
def get_session_context() -> dict[str, Any] | None:
"""Return a detached snapshot of the bound context, or None if unbound."""
with _SESSION_CONTEXT_LOCK:
if _SESSION_CONTEXT is None:
return None
return _SESSION_CONTEXT.as_dict()
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]:
"""Atomically bind/re-bind context (the explicit activation path)."""
with _SESSION_CONTEXT_LOCK:
return _bind_session_context_unlocked(
profile_name=profile_name,
remote=remote,
host=host,
identity=identity,
repository=repository,
org=org,
role_kind=role_kind,
expected_username=expected_username,
source=source,
)
def _bind_session_context_unlocked(
*,
profile_name: str,
remote: str | None,
host: str | None,
identity: str | None,
repository: str | None,
org: str | None,
role_kind: str | None,
expected_username: str | None,
source: str,
) -> dict[str, Any]:
"""Store a complete immutable context while the caller holds the lock."""
global _SESSION_CONTEXT
_SESSION_CONTEXT = _SessionContext(
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 _SESSION_CONTEXT.as_dict()
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",
) -> dict[str, Any]:
"""Atomically bind only when this process has no current context.
A changed environment or an interleaved call is not a session boundary and
therefore cannot replace an established binding. A newly started/forked
process is recognized by PID; explicit ``gitea_activate_profile`` uses
:func:`bind_session_context` as its sanctioned logical-session transition.
"""
with _SESSION_CONTEXT_LOCK:
if _SESSION_CONTEXT is None or _SESSION_CONTEXT.pid != os.getpid():
return _bind_session_context_unlocked(
profile_name=profile_name,
remote=remote,
host=host,
identity=identity,
repository=repository,
org=org,
role_kind=role_kind,
expected_username=expected_username,
source=source,
)
return _SESSION_CONTEXT.as_dict()
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,
require_complete: 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. ``require_complete``
additionally fails closed when the binding carries no verified
repository/organization identity, so a mutation can never run against an
unknown repository (#714).
"""
reasons: list[str] = []
with _SESSION_CONTEXT_LOCK:
bound = _SESSION_CONTEXT
ctx = bound.as_dict() if bound is not None else None
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)
if require_complete and not (ctx.get("repository") and ctx.get("org")):
reasons.append(
"session repository/organization identity is unverified "
f"(repository={ctx.get('repository')!r}, org={ctx.get('org')!r}); "
"a mutation cannot proceed without a verified workspace "
"repository (fail closed)"
)
return _assessment(False, 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,
}
_REPO_SLUG_RE = re.compile(r"^\s*(?P<org>[^/\s]+)\s*/\s*(?P<repo>[^/\s]+?)(?:\.git)?\s*$")
def parse_repository_slug(slug: str | None) -> tuple[str, str] | None:
"""Split a canonical ``owner/repository`` slug, or None when unparseable."""
match = _REPO_SLUG_RE.match(slug or "")
if not match:
return None
return match.group("org"), match.group("repo")
def format_repository_slug(org: str | None, repo: str | None) -> str | None:
"""``owner/repository`` from parts, or None when either side is missing."""
left = (org or "").strip()
right = (repo or "").strip()
if not left or not right:
return None
return f"{left}/{right}"
def declared_allowed_repositories(
profile: Mapping[str, Any] | None,
*,
strict: bool = False,
) -> list[str]:
"""Canonical ``owner/repository`` authorization boundary declared by *profile*.
This list is an authorization boundary, never the session binding itself:
the verified workspace selects exactly one entry (see
:func:`assess_repository_scope`).
When *strict* is true (mutation path), missing profile, non-list values, or
any malformed entry raise ``ValueError`` instead of silently collapsing to
an empty scope (which would fail open). Read-only diagnostics may use
strict=False.
"""
if not profile:
if strict:
raise ValueError(
"profile unresolved; cannot declare allowed_repositories "
"(fail closed)"
)
return []
raw = profile.get("allowed_repositories")
if raw is None:
return []
if not isinstance(raw, (list, tuple)):
if strict:
raise ValueError(
"allowed_repositories must be a list of owner/repository slugs "
"(fail closed)"
)
return []
slugs: list[str] = []
errors: list[str] = []
for entry in raw:
if not isinstance(entry, str):
errors.append(f"non-string allowed_repositories entry {entry!r}")
continue
parsed = parse_repository_slug(entry)
if not parsed:
errors.append(
f"malformed allowed_repositories entry {entry!r} "
"(expected owner/repository)"
)
continue
slugs.append(f"{parsed[0]}/{parsed[1]}")
if strict and errors:
raise ValueError("; ".join(errors) + " (fail closed)")
return slugs
def assess_repository_scope(
*,
workspace_slug: str | None,
allowed: list[str] | None,
profile_name: str | None = None,
require_scope: bool = False,
) -> dict[str, Any]:
"""Authorize the verified workspace repository against the profile allowlist.
The workspace-derived slug is the only candidate: a profile that authorizes
several repositories still binds to the single verified one, and never to
the list as a whole.
*require_scope* (mutation path): missing/empty allowlist fails closed. The
workspace remote cannot self-authorize without a configured boundary.
"""
scope = list(allowed or [])
reasons: list[str] = []
name = profile_name or "(active profile)"
if not scope:
if require_scope:
reasons.append(
f"mutation denied: profile '{name}' has no non-empty "
"allowed_repositories authorization boundary (fail closed)"
)
return {
"proven": False,
"block": True,
"reasons": reasons,
"scope_enforced": True,
"workspace_slug": workspace_slug,
"allowed_repositories": [],
}
return {
"proven": True,
"block": False,
"reasons": reasons,
"scope_enforced": False,
"workspace_slug": workspace_slug,
"allowed_repositories": [],
}
if not workspace_slug:
reasons.append(
f"no verified workspace repository could be established for profile "
f"'{name}'; repository scope cannot be authorized (fail closed)"
)
elif workspace_slug.lower() not in {entry.lower() for entry in scope}:
reasons.append(
f"repository scope denial: workspace repository '{workspace_slug}' "
f"is not authorized by profile '{name}' allowed_repositories "
f"{sorted(scope)} (fail closed)"
)
return {
"proven": not reasons,
"block": bool(reasons),
"reasons": reasons,
"scope_enforced": True,
"workspace_slug": workspace_slug,
"allowed_repositories": sorted(scope),
}
def assess_repository_override(
*,
requested_org: str | None,
requested_repo: str | None,
bound_org: str | None,
bound_repo: str | None,
) -> dict[str, Any]:
"""Caller-supplied org/repo must agree with the immutable binding.
A mutation request is never allowed to establish, complete, or replace the
binding — it may only be checked against it.
"""
reasons: list[str] = []
req_org = (requested_org or "").strip() or None
req_repo = (requested_repo or "").strip() or None
if req_repo and bound_repo and req_repo.lower() != bound_repo.lower():
reasons.append(
f"repository override denial: request targets '{req_repo}' but the "
f"session is bound to '{bound_repo}' (fail closed)"
)
if req_org and bound_org and req_org.lower() != bound_org.lower():
reasons.append(
f"organization override denial: request targets '{req_org}' but the "
f"session is bound to '{bound_org}' (fail closed)"
)
return {"proven": not reasons, "block": bool(reasons), "reasons": reasons}
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}
# Legacy env-only profiles have no configured base URL or v2 context. Their
# first call may establish the process remote/host pin; after that,
# assess_session_context rejects any drift. Configured v2 profiles still
# require positive host/context alignment here.
if not profile_host(profile) and not (profile.get("context") or "").strip():
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: Mapping[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: Mapping[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),
}