fix(author): gate bootstrap-lock recovery on the namespace mutation wall (#953)
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]>
This commit is contained in:
+33
-2
@@ -5150,6 +5150,21 @@ def gitea_recover_incomplete_bootstrap_lock(
|
|||||||
if not ok:
|
if not ok:
|
||||||
return _author_mutation_block(block_reasons)
|
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(
|
blocked = _profile_permission_block(
|
||||||
task_capability_map.required_permission(task),
|
task_capability_map.required_permission(task),
|
||||||
issue_number=issue_number,
|
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:
|
def _namespace_mutation_block(
|
||||||
"""Reviewer/author namespace alignment gate (#209)."""
|
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_permission = task_capability_map.required_permission(mutation_task)
|
||||||
required_role = task_capability_map.required_role(mutation_task)
|
required_role = task_capability_map.required_role(mutation_task)
|
||||||
# #714: evaluate active profile only — never auto-switch.
|
# #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(
|
ok, reasons = role_namespace_gate.check_author_mutation_namespace(
|
||||||
mutation_task, profile)
|
mutation_task, profile)
|
||||||
|
if ok and author_role_exclusive:
|
||||||
|
ok, reasons = role_namespace_gate.check_author_role_kind(
|
||||||
|
mutation_task, profile)
|
||||||
if ok:
|
if ok:
|
||||||
return None
|
return None
|
||||||
blocked = {
|
blocked = {
|
||||||
|
|||||||
@@ -74,6 +74,43 @@ def check_author_mutation_namespace(
|
|||||||
return True, []
|
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, *,
|
def mutation_audit_context(mutation_task: str, profile: dict, *,
|
||||||
remote=None, repository=None) -> dict:
|
remote=None, repository=None) -> dict:
|
||||||
"""Structured mutation metadata for audit records (#209)."""
|
"""Structured mutation metadata for audit records (#209)."""
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ AUTHOR_TASKS = frozenset({
|
|||||||
"push_branch",
|
"push_branch",
|
||||||
"bootstrap_author_issue_worktree",
|
"bootstrap_author_issue_worktree",
|
||||||
"gitea_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",
|
"create_pr",
|
||||||
"comment_pr",
|
"comment_pr",
|
||||||
"address_pr_change_requests",
|
"address_pr_change_requests",
|
||||||
@@ -112,6 +116,12 @@ TASK_REQUIRED_ROLE = {
|
|||||||
"claim_issue": "author",
|
"claim_issue": "author",
|
||||||
"create_branch": "author",
|
"create_branch": "author",
|
||||||
"push_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",
|
"create_pr": "author",
|
||||||
"comment_pr": "author",
|
"comment_pr": "author",
|
||||||
"address_pr_change_requests": "author",
|
"address_pr_change_requests": "author",
|
||||||
|
|||||||
Reference in New Issue
Block a user