Review 632 F1. `gitea_recover_incomplete_bootstrap_lock` writes the same durable author issue lock as `gitea_recover_dirty_orphaned_issue_worktree` but gated only on the reviewer-stop router check and the profile permission block. Every other author state-creating mutation carries a third gate, `_namespace_mutation_block`, and this tool was the outlier. The surviving gates did not cover the gap: the task's required permission is `gitea.issue.comment`, which every configured role holds, and `_ensure_matching_profile` returns a profile name both call sites discard, so it refuses nothing. A reviewer-bound session reached the exact-owner claimant comparison inside `assess_bootstrap_lock_recovery` and was refused there — one layer too late, with no namespace evaluation and no BLOCKED audit record of the attempt. Adding the call alone would have been inert. `check_author_mutation_namespace` routes through `role_session_router.required_role_for_task`, which reads `TASK_REQUIRED_ROLE` — not `task_capability_map` — and that table had no entry for this task, so the check short-circuited to "allowed" for every caller. The task is therefore registered in `TASK_REQUIRED_ROLE` and `AUTHOR_TASKS`, matching the role the capability map already records. The reviewer-namespace check alone is also insufficient for a task gated on `gitea.issue.comment`, since merger, controller, and reconciler profiles hold it too. `role_namespace_gate.check_author_role_kind` is added as an opt-in, additive wall requiring the active profile's derived role kind to be exactly `author`; `mixed` is refused rather than admitted. `_namespace_mutation_block` gains a keyword-only `author_role_exclusive` flag, default off, so the six pre-existing call sites are byte-for-byte unchanged. Refusals produce the standard structured denial (`namespace_block: true`, `mcp_namespace`) and the standard BLOCKED audit record, and no lock, lease, generation, branch, worktree, issue, or PR is touched. Valid `prgs-author` execution is unaffected. No reviewer permission is broadened and no existing role wall is weakened. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
126 lines
4.7 KiB
Python
126 lines
4.7 KiB
Python
"""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,
|
|
} |