"""Block author mutations from reviewer-bound MCP namespaces (#209). Every mutation must prove that the active profile role, inferred MCP namespace, and declared task role align. Reviewer sessions cannot silently perform author-side work (especially PR creation). """ from __future__ import annotations import gitea_config import role_session_router def infer_mcp_namespace(profile_name: str | None) -> str: """Map a runtime profile name to its MCP namespace label.""" lower = (profile_name or "").strip().lower() if "reviewer" in lower and "author" not in lower: return "gitea-reviewer" if "author" in lower: return "gitea-author" return profile_name or "gitea-default" def derive_role_kind(allowed, forbidden=()) -> str: """Classify the active profile the same way as ``mcp_server._role_kind``.""" def can(op): return gitea_config.check_operation(op, allowed, forbidden)[0] review = can("gitea.pr.approve") or can("gitea.pr.merge") author = can("gitea.pr.create") or can("gitea.branch.push") if review and author: return "mixed" if review: return "reviewer" if author: return "author" return "limited" def check_author_mutation_namespace( mutation_task: str, profile: dict, ) -> tuple[bool, list[str]]: """Return (allowed, reasons). Fail closed on reviewer/author mismatch.""" required_role = role_session_router.required_role_for_task(mutation_task) if required_role != "author": return True, [] allowed = profile.get("allowed_operations") or [] forbidden = profile.get("forbidden_operations") or [] active_role = derive_role_kind(allowed, forbidden) profile_name = profile.get("profile_name") or "" namespace = infer_mcp_namespace(profile_name) if mutation_task == "create_pr": if active_role == "reviewer" or namespace == "gitea-reviewer": return False, [ "author mutation 'create_pr' blocked in reviewer MCP namespace " f"({namespace}); launch gitea-author", ] if active_role == "reviewer" or namespace == "gitea-reviewer": if mutation_task == "create_issue": ok, _ = gitea_config.check_operation( "gitea.issue.create", allowed, forbidden) if ok: return True, [] return False, [ f"author mutation '{mutation_task}' blocked: active session is " f"reviewer-bound ({profile_name} / {namespace})", ] return True, [] def check_author_role_kind( mutation_task: str, profile: dict, ) -> tuple[bool, list[str]]: """Author-exclusive wall for durable-lock mutations (#953 F1). ``check_author_mutation_namespace`` walls off reviewer-bound sessions, which is the whole gate for tasks whose required permission is itself author-only (``gitea.pr.create``, ``gitea.repo.commit``). It is *not* sufficient for a task gated on ``gitea.issue.comment``, which every configured role holds: a merger, controller, or reconciler session would clear both the namespace check and the permission gate and still reach the durable write. Opt-in per call site and additive. It refuses any active profile whose derived role kind is not exactly ``author`` for a task the router declares author-required, and grants nothing to anyone — a ``mixed`` profile is refused rather than admitted. """ required_role = role_session_router.required_role_for_task(mutation_task) if required_role != "author": return True, [] allowed = profile.get("allowed_operations") or [] forbidden = profile.get("forbidden_operations") or [] active_role = derive_role_kind(allowed, forbidden) if active_role == "author": return True, [] profile_name = profile.get("profile_name") or "" namespace = infer_mcp_namespace(profile_name) return False, [ f"author mutation '{mutation_task}' blocked: active session role kind is " f"'{active_role}', not 'author' ({profile_name} / {namespace}); this " "operation writes a durable author issue lock and is author-exclusive", ] def mutation_audit_context(mutation_task: str, profile: dict, *, remote=None, repository=None) -> dict: """Structured mutation metadata for audit records (#209).""" allowed = profile.get("allowed_operations") or [] forbidden = profile.get("forbidden_operations") or [] return { "mcp_namespace": infer_mcp_namespace(profile.get("profile_name")), "profile_name": profile.get("profile_name"), "task_role": role_session_router.required_role_for_task(mutation_task), "operation": mutation_task, "remote": remote, "repository": repository, }