diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index a3fed3a..0c3aacc 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -5150,6 +5150,21 @@ def gitea_recover_incomplete_bootstrap_lock( if not ok: return _author_mutation_block(block_reasons) + # #953 F1: the namespace/session wall every author state-creating mutation + # carries, and which this tool — the structural neighbour of + # gitea_recover_dirty_orphaned_issue_worktree, writing the same durable + # lock — was the only one to omit. Exact-owner claimant matching inside + # assess_bootstrap_lock_recovery is a later layer, not a substitute: it + # refuses one commit too late and leaves no BLOCKED audit record of the + # attempt. author_role_exclusive is required here because this task is gated + # on gitea.issue.comment, which merger, controller, and reconciler profiles + # also hold. + blocked = _namespace_mutation_block( + task, remote=remote, author_role_exclusive=True + ) + if blocked: + return blocked + blocked = _profile_permission_block( task_capability_map.required_permission(task), issue_number=issue_number, @@ -15490,8 +15505,21 @@ def _profile_permission_block(required_operation: str, **extra_fields) -> dict | ) -def _namespace_mutation_block(mutation_task: str, **extra_fields) -> dict | None: - """Reviewer/author namespace alignment gate (#209).""" +def _namespace_mutation_block( + mutation_task: str, + *, + author_role_exclusive: bool = False, + **extra_fields, +) -> dict | None: + """Reviewer/author namespace alignment gate (#209). + + ``author_role_exclusive`` additionally requires the active profile's derived + role kind to be exactly ``author`` (#953 F1). Off by default, so the six + pre-existing call sites are unchanged. Tools whose required permission is + ``gitea.issue.comment`` — which every configured role holds — opt in, since + the reviewer-namespace check alone would let a merger, controller, or + reconciler session through to a durable author lock write. + """ required_permission = task_capability_map.required_permission(mutation_task) required_role = task_capability_map.required_role(mutation_task) # #714: evaluate active profile only — never auto-switch. @@ -15513,6 +15541,9 @@ def _namespace_mutation_block(mutation_task: str, **extra_fields) -> dict | None } ok, reasons = role_namespace_gate.check_author_mutation_namespace( mutation_task, profile) + if ok and author_role_exclusive: + ok, reasons = role_namespace_gate.check_author_role_kind( + mutation_task, profile) if ok: return None blocked = { diff --git a/role_namespace_gate.py b/role_namespace_gate.py index 7ed1073..a5e3b25 100644 --- a/role_namespace_gate.py +++ b/role_namespace_gate.py @@ -74,6 +74,43 @@ def check_author_mutation_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).""" diff --git a/role_session_router.py b/role_session_router.py index e557266..6b0a6de 100644 --- a/role_session_router.py +++ b/role_session_router.py @@ -75,6 +75,10 @@ AUTHOR_TASKS = frozenset({ "push_branch", "bootstrap_author_issue_worktree", "gitea_bootstrap_author_issue_worktree", + # #953: recovery of an incomplete bootstrap lock is an author-only durable + # state mutation and belongs to the same class as bootstrap itself. + "recover_incomplete_bootstrap_lock", + "gitea_recover_incomplete_bootstrap_lock", "create_pr", "comment_pr", "address_pr_change_requests", @@ -112,6 +116,12 @@ TASK_REQUIRED_ROLE = { "claim_issue": "author", "create_branch": "author", "push_branch": "author", + # #953: without this entry ``required_role_for_task`` returns None and + # ``role_namespace_gate.check_author_mutation_namespace`` short-circuits to + # "allowed" — the namespace wall on the recovery tool would be inert. The + # capability map already records the same role; both tables must agree. + "recover_incomplete_bootstrap_lock": "author", + "gitea_recover_incomplete_bootstrap_lock": "author", "create_pr": "author", "comment_pr": "author", "address_pr_change_requests": "author",