_verify_role_mutation_workspace dropped org/repo, so anti-stomp resolved bare
remote=prgs to Timesheet and failed closed wrong_repo on Gitea-Tools. Forward
explicit org/repo for merger/reviewer lease acquire, adopt, review, merge, and
lease release. Add regression for merger acquire forwarding.
Fixes#718
Refs #723
gitea_acquire_merger_pr_lease previously continued when GET /pulls returned
an empty head SHA. Fail closed so exact-head scoping never proceeds with an
unresolvable live head. Add regression coverage for empty head payloads.
Fixes#718
Refs #723
Reviewed-on: #680
Reviewed-by: sysadmin <[email protected]>
Operator break-glass merge record
PR #680 was independently assessed at head `7c77df0c527f06ae4196e010e65537d361805a7b`.
* Gitea reports the PR open and mergeable.
* The focused anti-stomp suite passed: 43 tests.
* The technical review found the change approve-worthy.
* The previous approval is stale because it applies to an earlier head.
* A fresh formal approval could not be submitted because every MCP namespace remained bound to pre-#694 startup SHA `f65069d…`, while live master is `3e78db5…`.
This is an operator-authorized break-glass merge caused by stale MCP runtime parity, not an unresolved code finding. Proceeding with **Create merge commit** only if the PR head remains exactly `7c77df0c527f06ae4196e010e65537d361805a7b`, Gitea still reports it mergeable, and no new blocking checks or feedback appear.
Prevent approved PRs from stalling when master advances. Add
gitea_assess_pr_sync_status and gitea_update_pr_branch_by_merge with
head/base pinning, author-only updates, conflict handoff, and approval
invalidation after head changes. Wire task capability map, sequential
controller routing in review-merge workflow, and hermetic AC tests.
Reviewed-on: #679
Reviewed-by: sysadmin <[email protected]>
Operator break-glass merge of PR #679 authorized because the native MCP
gitea_adopt_merger_pr_lease path returned non-retryable internal_error
despite an authoritative active reviewer lease. PR #679 was formally
approved by Review #446 at exact head
78cc37a977 and was conflict-free and
mergeable. No LLM received credentials or used a direct API/CLI fallback.
Harden gitea_acquire_merger_pr_lease with required candidate_head, live-head
match, and fail-closed POST handling. Add capability-map aliases for reviewer
and merger lease acquire/adopt. Make unknown tasks return structured
unknown_task (no ValueError escape). Expand tests for roles, head scoping,
aliases, and unknown_task.
Closes#718. Addresses #723 resolver unknown_task and lease task mapping.
This implements the native merger lease acquisition tool to allow a merger session to acquire its own lease when a reviewer lease does not exist or has expired, unblocking #679 adoption.
Stale-runtime detection remains fail-closed, but gitea_resolve_task_capability
must never touch mcp_config.json, spawn recovery threads, or call os._exit.
- Remove _trigger_mcp_auto_restart from the read-only diagnostics path
- Return blocker_kind=runtime_reconnect_required with mutation_performed=false
- Keep exact_safe_next_action pointing at IDE/client reconnect when stale
- Add regression suite across author/reviewer/merger/reconciler profiles
EXCEPTIONAL OPERATOR-AUTHORIZED BREAK-GLASS RECOVERY - incident #722.
Commit 970e68b ("fix: adopt_merger_pr_lease requires merger role") was an
unreviewed direct push that flipped 11 task_capability_map.py entries to
role="merger" instead of the intended 1. Merger profiles forbid the review
permissions, so no configured profile could resolve review_pr / approve_pr /
request_changes_pr (matching_configured_profile was empty repository-wide)
and no fix PR could be formally reviewed. The operator resolved the catch-22
with a one-time break-glass authorization recorded on issue #722
(comment 11918); this commit is the authorized minimum and nothing more.
Changes:
- task_capability_map.py restored to blob 02826af (the preserved intended
correction, local commit c071f8a1): the 10 regressive entries return to
role="reviewer"; adopt_merger_pr_lease keeps role="merger"; merge_pr is
untouched.
- tests/test_task_capability_role_invariants.py added (#723 AC1/AC2):
profile-coverage invariant for formal review tasks, map/router agreement,
merger-only adopt_merger_pr_lease and merge_pr, merger profiles cannot
hold review permissions.
Validation: focused role-mapping tests 72 passed (+22 subtests); full suite
2900 passed, 6 skipped, 1 known warning, 195 subtests.
Recovery step 1 for incident #722; code-defect follow-ups tracked in #723.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EnHNSVQvJ8nCk7KZ9kL4Ym
Terminal review-decision ledgers are recovery-critical provenance, not
disposable session cache. A generic four-hour TTL previously made
fresh_review_on_current_head_allowed unreachable after age expiry on open
PRs (PR #616 / review 443 reproduction).
- Classify KIND_DECISION_LOCK as RECOVERY_CRITICAL so load/mark_final work
after >4h without hand-editing session-state files
- Stamp kind + recovery_critical on save for compatibility
- Add inspect_state_envelope so assessment reports on-disk evidence instead
of silent "no lock" when TTL would hide non-critical kinds
- Surface disk_inspect on stale decision-lock cleanup assessment
Preserves same-head #332 hard-stop, #620 head-scoped fresh review, #594
moot cleanup, and #709 irrecoverable authorization (no ordinary-profile
permission grant).
Require branch-scoped post-delete not-found (not generic/repo/host 404),
emit consistent top-level cleanup fields, fail closed on ownership inventory
errors, never auto-reclaim expired control-plane leases, include active
comment-backed reviewer leases, apply the same gates to reconcile_merged_cleanups,
and match ownership with normalized host identity.
Require authoritative not-found readback after merged-PR branch DELETE, and
block cleanup when active author/reviewer/merger/controller/reconciler
session, lease, or worktree-binding ownership still uses the target branch.
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.
The independent review reproduced a real hole: the production first-bind path
never pinned repository/org, so the drift checks it feeds were skipped.
Root cause
----------
gitea_whoami, gitea_get_runtime_context, gitea_resolve_task_capability and
gitea_activate_profile all seeded the session context without repository or
org. First-bind-wins then made the mutation gate's later seed a no-op, and
assess_session_context only compares repository/org when the bound fields are
non-null. Result, reproduced in production order with the real REMOTES:
after whoami: repository=None org=None
same-host repo=Other-Tools -> NOT BLOCKED
same-host org=Other-Org -> NOT BLOCKED
cross-host -> blocked (this part always worked)
Binding REMOTES defaults would not fix it: REMOTES['prgs'].repo is 'Timesheet'
(a default *target*, not an authorization scope, see #530), so pinning it fails
closed on every legitimate Gitea-Tools mutation. There was no trusted source
for repository scope, so this adds one.
Design
------
- New optional profile field `allowed_repositories`: canonical owner/repository
slugs. An authorization boundary, never the binding itself. Config-only —
no env var can widen or forge it.
- The session repository is derived from the verified, workspace-aligned git
remote, never from caller-supplied org/repo, and validated against that
allowlist. The session binds immutably to exactly one canonical slug; org is
derived from it. A profile authorizing several repositories still binds to
the single verified one.
- Every first-bind entry point now pins the same complete context. Activation
rejects an unauthorized/unverifiable workspace. Mutations fail closed when
repository/org is unverified, and a tool-level org/repo override that
disagrees with the binding is rejected before the write.
- A mutation request can no longer establish, complete, or replace the binding
(it previously seeded from `org or REMOTES[...]`, i.e. caller-controlled).
- Enforcement is opt-in per profile: a profile without the field keeps prior
behaviour, so existing static-profile namespaces stay functional.
First-bind-wins, immutability, the RLock, profile isolation, host/identity
validation and the private pytest-only reset are unchanged.
gitea_auth.get_profile() built an explicit whitelist dict and silently dropped
`allowed_repositories`; the new integration tests caught that the scope was
never enforced through the real path.
Tests
-----
New tests/test_issue_714_production_first_bind.py drives the real entry points
in production order rather than constructing _SessionContext directly. Covers
complete first bind via each entry point, same-host repo/org drift, Timesheet
and other-repo/other-org rejection, unverified and unauthorized workspaces,
caller values unable to establish the binding, concurrent init selecting one
repository, and the PR #715 author path staying functional. Mutation-verified:
disabling trusted derivation, override validation, the scope check, or the
get_profile passthrough each fails these tests (9/8/4/5 failures).
No security assertion was weakened, removed, skipped, or rewritten.
Focused: 26 passed. Issue #714 total: 46 passed. Prior failing modules: 240
passed. Config/profile/remote modules: 130 passed. Full suite: 2739 passed,
6 skipped, 1 pre-existing warning, 161 subtests in 25.80s.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The formal REQUEST_CHANGES review on PR #715 was resolved by the test
isolation commit (29ffe14); the full suite is green. Auditing the required
session-lifecycle coverage surfaced three gaps that the suite did not prove:
- Every cross-host test pinned mdcps-reviewer and denied prgs. The reverse
direction (a pinned MDCPS profile must never serve a prgs request, nor be
swapped for prgs-author) was unproven.
- test_parallel_calls_cannot_overwrite_established_binding binds first and
then races, so concurrent *initialization* from an unbound context — N
threads racing to first-bind, exactly one winner, no torn context — was
unproven.
- assess_session_context checks repository and org drift, but no test bound a
repository/org and asserted a same-host mismatch fails closed.
Adds three tests covering those cases. Each was mutation-verified: disabling
the first-bind-wins guard, the cross-host denial, and the repository/org drift
checks makes the corresponding test fail.
Additive only — no production code changed and no existing security assertion
weakened, skipped, or rewritten.
Focused: 20 passed. Full suite: 2713 passed, 6 skipped, 1 pre-existing warning,
161 subtests passed in 23.05s.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Address formal review 438 REQUEST_CHANGES on PR #710 (F6/F7/F8).
F6 — HMAC key-version validation fails closed:
- verify_authorization_artifact validates key_version BEFORE any MAC work, so an
attacker-chosen version can never select the signing key.
- Require exactly one nonempty, well-formed key-version field; missing, empty,
unknown, malformed, duplicated (including identical-valued and nested
aliases), and mismatched versions all fail. No versionless legacy fallback.
- Artifact version must equal the configured active version; production now
requires GITEA_IRRECOVERABLE_AUTH_HMAC_KEY_VERSION explicitly (an implicit
default made rotation ambiguous). Version stays inside the signed material.
F7 — strictly canonical incident evidence:
- Replace substring/first-match parsing with an exact schema: marker on line 1,
every field once, fixed order, no duplicate/unknown/empty/conflicting fields
in or outside the signed block. The parsed body is re-rendered and compared
for exact equality before acceptance.
- content_digest now binds the full recovery scope: repository identity, PR,
decision-lock identity, destroyed subject, recovery action, recorded and
expected head, incident issue, evidence author, minting actor, key version,
nonce and issued_at.
- Actor identity is the immutable user id with login consistency; conflicting
ids/logins and display-name-only identities fail closed. Edited comments are
rejected. The independent-author rule is preserved and enforced by stable id.
- build_canonical_incident_body is the single source of the accepted format and
refuses to emit ambiguous evidence.
F8 — archival is a prerequisite for clearing terminal evidence:
- _clear_decision_lock_for_profile no longer swallows archive failures. It
requires a successful write plus a durable read-back matching the PR/head,
and otherwise returns a structured, retry-safe failure that retains the lock
and records actionable recovery evidence.
- Fixes a latent bug the read-back exposed: the archive payload inherited the
source lock's session_profile_lock, so save_state keyed the archive under the
reviewer profile instead of the archive identity and it never read back.
Adversarial regressions added for every listed case: key-version missing/empty/
unknown/malformed/duplicate/rotation/wrong-key-after-restart, reordered fields,
duplicate identical and conflicting fields, conflicting actor ids/names,
digest-preserving substitution, decision-lock and recovery-action substitution,
cross-PR/repo/org/remote/head replay, archive exception/timeout/false/empty/
partial-readback with proof the lock survives, exactly-one permitted clear, and
retry after archive failure. No existing assertion was weakened.
Validation: focused 150 passed in three module orders; full tests/ 2809 passed,
6 skipped, 1 warning (pre-existing StarletteDeprecationWarning in
tests/test_webui_audit.py:8), 161 subtests passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Capability resolution and mutation gates no longer auto-switch profiles.
Session profile/remote/host/identity are bound after pin or first seed,
and drift or cross-host resolution fails closed before writes.
Co-Authored-By: Grok <[email protected]>