Compare commits

..
Author SHA1 Message Date
sysadminandClaude Opus 4.8 61c3a57df5 fix(mcp): consume canonical target root in cross-repository operations (Closes #741)
#706 introduced the immutable `canonical_repository_root` and #739/#740 routed
three consumption paths through it. The paths #740 left behind all shared one
shape: the *filesystem* guards had been migrated to the canonical root, but
*repository identity* still bottomed out in `_local_git_remote_url`, which ran
`git remote get-url` with `cwd=PROJECT_ROOT` — always the Gitea-Tools install
checkout. The two halves of a single assessment therefore described two
different repositories.

For a namespace bound to another repository this inverts the guards rather than
merely weakening them: an operation naming the genuinely bound target is
rejected, while one naming Gitea-Tools is accepted.

Central helper
--------------
Adds `_canonical_local_git_root()` as the single place a target-repository root
is resolved: the configured canonical root when one is declared, else
`PROJECT_ROOT`. A configured-but-invalid root is never silently replaced by the
install checkout. Single-repository behaviour is byte-for-byte unchanged.

Defects fixed
-------------
* `_local_git_remote_url` now runs in the canonical target root, which corrects
  every downstream identity consumer at once (`_resolve`, the #530 remote/repo
  guard, the anti-stomp org/repo fill, `_workspace_repository_slug`).
* `_verify_role_mutation_workspace` omitted `configured_canonical_root`, so the
  #274 branches-only / worktree-membership guards validated
  `Gitea-Tools/branches/` instead of the bound target. It now threads the root
  exactly as `_resolve_namespace_mutation_context` does.
* `_resolve` derived omitted coordinates by looking a remote up *by name*. A
  target checkout commonly names its remote `origin` rather than `prgs`, so the
  lookup returned None and the coordinates fell through to the remote-wide
  default target — an unrelated repository. It now prefers
  `_canonical_repository_slug`, which probes candidate remote names.
* Explicit `org`/`repo` short-circuit the #530 match check, so caller
  coordinates bypassed validation entirely. They may now only *confirm* a
  canonical binding, never override it, and fail closed in both directions.
* Reconciler ancestry, fetch, worktree-inventory and cleanup call sites, the
  author worktree derivation in `gitea_lock_issue`/`gitea_create_pr`, and the
  `control_clean` porcelain probe now use the canonical target root.
* `gitea_config`: v2-`environments` silently *dropped* `canonical_repository_root`
  during flattening, so such a namespace fell back to the install root — a
  fail-open. It is now validated and propagated. The v1 path validates it too,
  so all three loaders behave identically.

Preserved as installation-scoped
--------------------------------
Server parity (`master_parity_gate`), workflow/schema/skill loading, self-code
hashing and stale-runtime detection, and `mirror_refs.sh` lookup remain anchored
to `PROJECT_ROOT`. The `server_implementation` vs `target_repository` parity
dimensions from #740 stay separately labelled, and only the server dimension
gates mutations.

Tests
-----
`tests/test_issue_741_canonical_root_consumers.py` (51 tests, 52 subtests) drives
a role-by-operation matrix over author/reviewer/merger/reconciler against:
correct cross-repository target, wrong repository, wrong worktree, explicit
matching and mismatched coordinates, missing/invalid canonical root, immutable
first-bind, and request-override attempts — plus both cross-repository
directions and all three config loaders. Real git repositories, no network, no
branch deletion, no merge.

The module reinstalls the genuine `_local_git_remote_url` per test: an autouse
conftest fixture permanently reassigns it to a working-directory-independent
stub, which is precisely the behaviour under test.

Full suite: 3408 passed, 6 skipped, 2 failed. Both failures
(`test_issue_702_review_findings_f1_f6`, `test_reconciler_supersession_close`)
reproduce identically on clean master c908ed6050 and are pre-existing.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01AYGdWAwuA6UNDc9c3CGipU
2026-07-18 12:02:31 -04:00
sysadmin c908ed6050 Merge pull request 'fix(mcp): complete canonical-root consumption for cross-repository namespaces (Closes #739)' (#740) from feat/issue-739-canonical-root-consumption into master 2026-07-18 10:34:10 -05:00
sysadmin 0a38d92382 Merge pull request 'fix(mcp): accept acquired merger-lease provenance and add owner finalization (Closes #742)' (#743) from fix/issue-742-merger-lease-provenance into master 2026-07-18 10:12:27 -05:00
sysadminandClaude Opus 4.8 fde95b9266 fix(mcp): chain-scoped newest-wins reviewer lease reads in pr_work_lease (#742)
Second author remediation on PR #743. Fixes the full-ledger defect surfaced by
the first remediation.

Root cause: pr_work_lease.find_active_reviewer_lease iterated the reviewer
markers newest-first and used `continue` on a terminal marker. On a realistic
append-only ledger (claimed -> validating -> terminal) it therefore stepped
over the terminal marker and returned the older claim of the very chain that
marker had just ended. reviewer_pr_lease got strict newest-wins under #577;
pr_work_lease never did, so the two modules disagreed for released, blocked,
done, and abandoned alike, and merger owner finalization did not leave "no
active lease" for pr_work_lease consumers (conflict-fix acquire, PR sync
inventory).

Fix: a claim is skipped when a later marker terminates its own chain. Chain
identity is (repo, pr_number, candidate_head, session_id, reviewer_identity,
profile) via the new _reviewer_chain_key; _chain_terminated_after scans only
markers appended after the candidate. Consequences:

- a valid terminal marker ends its matching earlier claim (no resurrection);
- a foreign-session, wrong-repo, wrong-PR, wrong-head, wrong-identity, or
  wrong-profile terminal marker cannot cancel another session's active lease,
  which strict newest-wins alone would have allowed;
- a malformed terminal marker has no provable chain key, so it cancels nothing
  and cannot hide a valid active claim;
- expiry, freshness, phase sets, ownership and integrity checks, both parsers,
  and find_active_conflict_fix_lease are untouched;
- history stays append-only; no marker is deleted or rewritten.

Reviewer lease markers carry no token field, so token-fingerprint validation
remains where it already lives (session provenance, merger finalization) and is
not weakened here.

Tests: new TestFullLedgerNewestWins in tests/test_merger_lease_finalization.py
covers claimed->released/blocked/done/abandoned, claimed->validating->terminal,
foreign-session and mismatched repo/PR/head/identity/profile terminals, the
malformed terminal marker, a newer active chain surviving an older terminated
chain, newest-valid-chain selection across multiple histories, all-chains-
terminated, single-marker parity between the two modules across eight phases,
expired-claim/freshness non-regression, and the real ledger written by
gitea_release_merger_pr_lease reading as terminal in both modules with the
prior marker preserved. TestCrossModuleTerminalPhaseAgreement's scope-limited
placeholder test is replaced by a real both-modules full-ledger assertion.

Verified 10 of the new tests fail at the prior head 7ae5f3a and pass here.

Validation: focused TestFullLedgerNewestWins 14 passed / 21 subtests;
tests/test_merger_lease_finalization.py 59 passed / 42 subtests; targeted
pr_work_lease + reviewer/merger lease + adoption + provenance + acquire/release
+ anti-stomp + capability-map + decision-lock + report-validator suites 309
passed / 41 subtests; full suite 3326 passed, 6 skipped, 2 failed. Both
failures (test_issue_702_review_findings_f1_f6 F1 recovery, reconciler
supersession close) reproduce identically on clean baseline master a8d2087 and
are pre-existing #737 org/repo-forwarding drift. git diff --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 10:12:49 -04:00
sysadminandClaude Opus 4.8 7ae5f3a541 fix(mcp): mirror abandoned terminal phase into pr_work_lease (#742 review 460)
Addresses REQUEST_CHANGES review 460 on PR #743 @ 22d0fdd.

reviewer_pr_lease._TERMINAL_PHASES gained "abandoned" for owner-session merger
finalization, but pr_work_lease._TERMINAL_REVIEWER_PHASES did not. The two
modules parse the same append-only lease markers, so the same comment read as
terminal through reviewer_pr_lease and active through pr_work_lease, leaving
the conflict-fix acquire and PR-sync inventory readers with a stale active
lease after an abandoned finalization.

Fix: add "abandoned" to pr_work_lease._TERMINAL_REVIEWER_PHASES, with a comment
recording that the two sets must stay mirrored.

Reproduced before the fix (single abandoned marker):
  reviewer_pr_lease active=False, pr_work_lease active=True
After: both False; released/blocked/done unchanged in both modules.

Tests: new TestCrossModuleTerminalPhaseAgreement in
tests/test_merger_lease_finalization.py proves the abandoned marker is inactive
in pr_work_lease, that both modules agree for released/blocked/done/abandoned,
that claimed/validating stay active in both, that the two phase sets are
mirrored, that the default 'released' finalization outcome and reason are
unchanged, and that finalization appends without rewriting or deleting the
prior marker.

Separately pinned, NOT fixed here: on a claim-then-terminal ledger,
pr_work_lease.find_active_reviewer_lease skips the terminal marker and walks
back to the older claim, so it still reports an active lease. That newest-wins
gap is pre-existing on clean baseline a8d2087 for released, blocked, and done
alike — the #577 fix landed in reviewer_pr_lease only — and is out of scope for
this bounded remediation. test_abandoned_matches_preexisting_terminal_phases_on_full_ledger
pins that "abandoned" introduces no behavior of its own, so all four phases can
be reconciled in one separate change.

Validation: focused TestCrossModuleTerminalPhaseAgreement 7 passed / 9 subtests;
tests/test_merger_lease_finalization.py 45 passed / 20 subtests; related
pr_work_lease + reviewer/merger lease + adoption + provenance + capability-map +
anti-stomp + report-validator suites 261 passed / 41 subtests; full suite 3312
passed, 6 skipped, 2 failed. Both failures (test_issue_702_review_findings_f1_f6
F1 recovery, reconciler supersession close) reproduce identically on clean
baseline master a8d2087 and are pre-existing #737 org/repo-forwarding drift.
git diff --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 09:53:34 -04:00
sysadminandClaude Opus 4.8 22d0fdd251 fix(mcp): accept acquired merger-lease provenance and add owner finalization (Closes #742)
Root cause (confirmed on PR #740, session 33780-7168cbeeba58, marker 12354):

merger_lease_adoption.SANCTIONED_PROVENANCE_SOURCES already contained
SOURCE_ACQUIRE_MERGER, so the membership check passed, but
is_sanctioned_session_lease() branched only for SOURCE_ADOPT and for
{SOURCE_ACQUIRE, SOURCE_HEARTBEAT} and fell through to `return False` for a
lease minted by gitea_acquire_merger_pr_lease. assess_mutation_lease_gate()
therefore appended "in-session lease lacks sanctioned provenance" and
gitea_merge_pr fail-closed on the merger's own freshly acquired lease.
describe_session_lease_proof() likewise had no branch for that source and
reported the lease as lease_proof_kind=unsanctioned. Separately, no
merger-role owner-session operation existed to end that lease, so a failed
merger lease could only expire.

A. Acquired merger provenance

is_sanctioned_session_lease() now accepts SOURCE_ACQUIRE_MERGER, but only via
the new assess_acquired_merger_lease_integrity(), which fails closed unless
the in-session record is complete and self-consistent: comment marker present
and matching between provenance and session, non-empty session id, holder
identity, merger profile, repository, valid PR number, and a normalized
40-hex candidate_head. describe_session_lease_proof() reports the explicit
kind sanctioned_acquire_merger. Manual _SESSION_LEASE seeding, forged or
incomplete records, mismatched fields, and unknown provenance all remain
rejected; reviewer-acquire, reviewer-heartbeat, and merger-adoption paths are
untouched.

B. Merger owner-session finalization

New native tool gitea_release_merger_pr_lease terminally releases or abandons
a merger's own comment-backed lease when the merge does not occur. It is
merger-only (gitea.read + gitea.pr.comment + gitea.pr.merge; reviewer profiles
lack pr.merge) with an explicit capability-map task release_merger_pr_lease /
gitea_release_merger_pr_lease bound to gitea.pr.comment + role merger, and it
is listed as role-exclusive in the resolver. assess_merger_lease_finalization()
verifies exact session ownership, repository, PR, candidate head vs live head,
profile, identity, marker presence on the thread, and the native runtime token
fingerprint recorded at acquisition; foreign-session release, reviewer-profile
use, and any mismatch fail closed. Finalization appends a terminal lease marker
and never edits or deletes ledger history; an already-terminal lease returns
already_terminal and posts nothing. The PR, approval, decision lock, branch,
and worktree are all preserved. gitea_release_reviewer_pr_lease is unchanged
and is not repurposed for merger sessions.

"abandoned" is added to reviewer_pr_lease._TERMINAL_PHASES; without it an
abandoned marker would fall through the generic non-empty phase branch and
keep re-arming the lease as active.

Tests: new tests/test_merger_lease_finalization.py (38 tests, 11 subtests)
covers all twelve acceptance criteria — provenance sanctioning, explicit proof
kind, merge-gate acceptance, manual-seed and unknown-provenance rejection,
profile/role/session/head/repository/fingerprint mismatches, owner release,
foreign-session refusal, reviewer refusal, append-only idempotent
finalization, no surviving lease after finalization, and no regression in
adoption or reviewer-lease behavior. The defect was reproduced on clean
baseline a8d2087 first (is_sanctioned=False, proof_kind=unsanctioned, no
finalization path).

Validation: targeted lease/capability suites 272 passed. Full suite
3305 passed, 6 skipped, 2 failed — both failures
(test_issue_702_review_findings_f1_f6 F1 recovery, reconciler supersession
close) reproduce identically on clean baseline master a8d2087 in a throwaway
worktree and are pre-existing #737 org/repo-forwarding drift, unrelated to
this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 09:33:58 -04:00
sysadminandClaude Opus 4.8 15a8a76e99 fix(mcp): complete canonical-root consumption for cross-repo namespaces (Closes #739)
#706 routed the #274 filesystem guards and the session repository slug through
the configured canonical_repository_root. Three consumption paths were left
deriving from the Gitea-Tools installation checkout.

F1 — gitea_get_runtime_context did not normalize its `remote` argument, while
gitea_whoami did. On a prgs-hosted namespace whose first native call was the
runtime-context path, the 'dadeschools' argument default was pinned: identity
resolved against the wrong host, and because first-bind is first-write-wins a
later correct gitea_whoami(remote="prgs") could not repair the binding. It now
calls _effective_remote before any host lookup, identity resolution, or session
seeding. Explicit remotes pass through untouched and a dadeschools-hosted
profile still resolves to dadeschools.

F2 — _delete_branch_repository_binding_block derived its expected slug from
_workspace_repository_slug, which reads _local_git_remote_url in PROJECT_ROOT
(always Gitea-Tools). For a cross-repository namespace this inverted the guard:
the genuinely bound target was rejected and Gitea-Tools was accepted. The
canonical-root branch already present in _trusted_session_repository is
extracted as _canonical_repository_slug and shared by both call sites. A
configured-but-unresolvable root now fails closed instead of falling back to
the installation identity; unconfigured namespaces keep the install-derived
default unchanged.

F3 — gitea_assess_master_parity measures Gitea-Tools server implementation
parity only. That is intentional and is preserved: startup_head, current_head,
in_parity, stale, and restart_required keep their existing meaning and values,
and only the server dimension gates mutations. Two separately labelled
dimensions are added — server_implementation (installation root and commit) and
target_repository (canonical root, repository slug, checkout head, last-known
remote master head, staleness) — so cross-repository commissioning evidence can
distinguish them. The target assessment makes no network call: the remote side
is read from the existing remote-tracking ref, and an unfetched target is
reported indeterminate rather than guessed at.

Verified intentional and left unchanged: the #274 workspace-membership,
author-mutation-worktree, and branches-only guards already validate against the
configured canonical root; explicit org/repo may confirm but never authorize a
binding; and the four-role repository-specific configuration surface already
passes audit and bind-time validation with a wrong root failing closed.

Tests: 31 new cases in tests/test_cross_repo_canonical_consumption.py using
real git repositories and no network. Full suite 3298 passed / 6 skipped
against a clean-baseline 3267; the 2 failures
(test_issue_702_review_findings_f1_f6, test_reconciler_supersession_close)
reproduce identically on unmodified master a8d2087 and are pre-existing.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013jUxaLVLkPHuTQwAzFzztW
2026-07-18 03:44:28 -04:00
sysadmin a8d2087b4a Merge pull request 'feat(mcp): immutable canonical_repository_root for cross-repo namespaces (Closes #706)' (#736) from feat/issue-706-canonical-repository-root into master 2026-07-18 01:36:03 -05:00
sysadminandClaude Opus 4.8 61c0d73cd1 fix(mcp): seed cross-repo session identity from configured canonical root (#706 F1)
Addresses REQUEST_CHANGES review 457 on PR #736.

Root cause: _trusted_session_repository derived the session org/repository
solely from _workspace_repository_slug -> _local_git_remote_url, which runs
`git remote get-url` in PROJECT_ROOT (the Gitea-Tools install checkout). A
cross-repository namespace with a configured canonical_repository_root then
pinned the Gitea-Tools identity while #274 filesystem membership bound the
external target, so _enforce_canonical_repository_root failed closed on a
self-inflicted identity mismatch and the mcp-control-plane / eagenda
namespaces stayed blocked end-to-end.

Fix: when a canonical_repository_root is configured (env over profile) and
passes existence / git-toplevel / resolvable-remote-identity validation, the
session repository slug is derived from repository_identity_slug(configured
root) instead of the install remote. The derived identity is still authorized
by the profile allowed_repositories allowlist (never self-authorizing); the
canonical filesystem root and org/repo identity remain immutable for the
session; invalid / non-git / unresolvable / unallowlisted / forged / drift
cases fail closed; unconfigured single-repo Gitea-Tools namespaces keep the
install-derived slug unchanged.

Tests: new tests/test_issue_706_f1_seed_identity_integration.py drives the
real _seed_session_context -> _enforce_canonical_repository_root path with two
real temporary git repositories (install=Gitea-Tools, configured target=
mcp-control-plane): env + profile config, reviewer + merger role kinds, and
fail-closed cases (nonexistent / non-git / unallowlisted / forged identity
drift). Reproduces the F1 block before the fix; green after.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 01:27:26 -04:00
jcwalker3 6a0d7bbef4 Merge branch 'master' into feat/issue-706-canonical-repository-root 2026-07-17 23:12:55 -05:00
sysadmin 29d96c8946 Merge pull request 'fix(mcp): forward explicit org/repo through author mutation preflight (Closes #735)' (#737) from fix/issue-735-author-org-repo-forwarding into master 2026-07-17 22:50:28 -05:00
sysadmin 8d8d2d8f81 fix(mcp): forward explicit org/repo through author mutation preflight (Closes #735)
Session-bound author mutations (create_issue, lock_issue, create_pr, etc.)
called verify_preflight_purity without org/repo, so anti-stomp filled the
remote-wide REMOTES default (prgs → Timesheet) and false-positived wrong_repo
against a Gitea-Tools checkout even when callers passed explicit coordinates.

Mirror _resolve: prefer workspace-derived identity for omitted coordinates on
session-bound tasks; keep delete_branch's explicit-target contract (#733).
Also forward org/repo at author mutation call sites. Add cross-repo regression
coverage. Do not change REMOTES["prgs"]["repo"].
2026-07-17 23:45:44 -04:00
sysadminandClaude Opus 4.8 0d8a2c2b1d feat(mcp): immutable canonical_repository_root for cross-repo namespaces (Closes #706)
The MCP server derived the canonical repository root from the install
checkout the server script lives in (PROJECT_ROOT), so any namespace that
ran the server against an external repository (e.g. eagenda-author, or the
mcp-control-plane reviewer/merger namespaces) failed every mutation: the
branches-only / worktree-membership guards (#274) compared the task
workspace against the Gitea-Tools .git directory it can never belong to.

Separate the two concepts:
- PROJECT_ROOT stays the immutable code/install root.
- A new, optional, namespace-scoped canonical_repository_root binds the
  session to the working root of the target repository.

Implementation:
- canonical_repository_root.py: resolve the binding from profile/env
  (GITEA_CANONICAL_REPOSITORY_ROOT overrides the profile field), validate
  existence + git identity, fail closed on missing/conflicting/forged
  bindings. Preserves the single-repo default when unconfigured.
- session_context_binding.py: pin canonical_repository_root into the
  immutable #714 session context; drift fails closed.
- namespace_workspace_binding.py: route the configured target root through
  resolve_namespace_mutation_context so #274 / membership guards evaluate
  against the target repository.
- gitea_mcp_server.py: seed + enforce the binding in the mutation preflight
  (both purity-order and FORCE_PRODUCTION_GUARDS paths); validate repository
  identity and git common-directory membership.
- gitea_config.py: validate the optional absolute-path profile field.
- gitea_auth.py: surface the config-sourced field through get_profile.

No weakening of root-checkout, remote/repository, or anti-stomp guards; the
single-repo Gitea-Tools path is unchanged.

Tests: two distinct real git repositories/worktrees prove cross-repository
bindings resolve, enforce membership, and fail closed on forged/conflicting
identity and simultaneous prgs/mdcps isolation.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-17 23:31:39 -04:00
sysadmin 11d1d2e99f Merge pull request 'fix(reconciler): forward explicit repository through delete_branch anti-stomp preflight (Closes #733)' (#734) from fix/issue-733-delete-branch-repo-forwarding into master 2026-07-17 22:03:24 -05:00
sysadminandClaude Opus 4.8 3990fc684f fix(reconciler): forward explicit repository through delete_branch anti-stomp preflight (Closes #733)
gitea_delete_branch accepted explicit org/repo but did not propagate them
through the anti-stomp preflight. During a deletion targeting
Scaled-Tech-Consulting/Gitea-Tools, preflight resolved the remote-wide prgs
default (Scaled-Tech-Consulting/Timesheet) and failed closed with wrong_repo
before any deletion — because verify_preflight_purity was called without
org/repo and the shared #604 anti-stomp resolution fell back to the REMOTES
default.

Fix (delete_branch only; REMOTES untouched):

1. Forward the explicit org/repo into verify_preflight_purity so the shared
   #604 anti-stomp resolution validates the *targeted* repository instead of
   the remote-wide default. Explicit Scaled-Tech-Consulting/Gitea-Tools now
   propagates through role/workspace verification, verify_preflight_purity,
   anti-stomp repository resolution, and the final native deletion request.

2. Add a workspace-derived repository-binding gate
   (_delete_branch_repository_binding_block) that validates explicit
   coordinates against the immutable, workspace-aligned repository identity.
   Wrong, substituted, or unverified coordinates fail closed — independent of
   the anti-stomp remote/repo guard, which by the #530 contract trusts explicit
   caller intent. REMOTES defaults are never consulted as an authorization
   scope.

Preserved: reconciler-only ownership of gitea.branch.delete; author/reviewer/
merger denial; protected and preservation/evidence gates; missing coordinates
still fail closed via the anti-stomp default resolution.

Regression matrix (tests/test_issue_733_delete_branch_repo_forwarding.py):
Timesheet-default + explicit Gitea-Tools permits an eligible deletion and
forwards the coordinates; wrong/substituted/unverified coordinates fail closed;
author/reviewer/merger remain denied; protected and preservation branches
remain blocked; anti-stomp resolution marks explicit coordinates authoritative
and fails closed on the remote-wide default for omitted coordinates; the
task-capability and role-routing maps stay consistent.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UVDxVKANhuGYzZgayDU3QS
2026-07-17 22:45:18 -04:00
jcwalker3 daf60266d0 Merge pull request 'fix(mcp): surface safe diagnostics for opaque mutation internal_error' (#732) from fix/mutation-error-diagnostics into master
Reviewed-on: #732
2026-07-17 19:52:46 -05:00
sysadminandClaude Opus 4.8 7e5eca08b3 fix(mcp): surface safe diagnostics for opaque mutation internal_error
Mutation tools (create_issue, create_pr, delete_branch, issue-lock) returned
reason_code=internal_error / retryable=false with no class, message, HTTP
status, or actionable detail. The #699/#701 tool-error boundary intentionally
collapses every non-typed exception to a fixed "Internal tool error" and drops
the class and message, so a mutation failure is undiagnosable — and the #695
runtime guard blocks standalone reproduction. The true failing stage was
therefore invisible by two independent mechanisms.

Make the failure observable and actionable without weakening the secret-free
contract:

- Boundary (internal_error path ONLY): capture a safe exception class (a Python
  type identifier, never instance text) plus a strictly-redacted `detail`
  (token/Authorization/Bearer credentials, JSON/kv secret values incl. short
  passwords, raw URLs/hostnames, and absolute filesystem paths all stripped;
  whitespace collapsed; length-bounded) and an optional `mutation_stage`. Typed
  auth/authz/network/config/http paths are unchanged and still emit no detail.
- Convert raw exceptions into typed structured errors via a safe
  `gitea_reason_code` attribute contract: server raisers may self-declare a
  known reason and the boundary emits it (fixed message) instead of an opaque
  internal_error. Pre-flight order violations now raise `_PreflightOrderError`
  (a RuntimeError subclass → preflight_order_violation).
- Add a `_mutation_stage` context manager tagging escaping exceptions with the
  stage name (preflight_purity / resolve_auth / api_*), applied to
  delete_branch, create_issue, create_pr.

Fail-closed identity/repo/worktree/role/permission/lock/lease/ancestry gates
are untouched; only error *reporting* changed. New suite
test_mutation_error_diagnostics.py (18 tests) pins the redaction, class capture,
stage tagging, typed conversion, and adversarial no-leak contract. Existing
boundary suite (25), core server (209), and preflight (92) suites stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-17 20:42:25 -04:00
jcwalker3 39e27dc63f Merge pull request 'fix(auth): route delete_branch capability to the reconciler role (Closes #729)' (#730) from fix/issue-729-delete-branch-reconciler-role into master
Reviewed-on: #730
2026-07-17 18:59:19 -05:00
sysadminandClaude Opus 4.8 7eb4884658 fix(auth): route delete_branch capability to the reconciler role (Closes #729)
Remote post-merge branch deletion was blocked because the capability
resolver classified delete_branch as an author-role task while
gitea.branch.delete is granted only to prgs-reconciler. No configured
profile could satisfy both the permission gate and the role gate, so
every deletion failed closed with performed=false.

Re-home delete_branch from the author role to the reconciler role in
both single-source-of-truth maps:
  - task_capability_map.TASK_CAPABILITY_MAP["delete_branch"].role
  - role_session_router: TASK_REQUIRED_ROLE + AUTHOR_TASKS/RECONCILER_TASKS

resolve_task_capability("delete_branch") now resolves to prgs-reconciler.
In gitea_delete_branch, the reconciler is now the delete-capable role and
performs raw deletion through the existing reconciliation-cleanup-phase
authorization gate (operator approval + safe_to_delete proof) plus the
preservation/protected guards; the prior reconciler->cleanup redirect is
removed as it would orphan that guarded flow. Author, reviewer, and
merger stay denied by the permission gate and the required-role gate.
gitea_cleanup_merged_pr_branch remains a separate reconciler-owned path
with independent merged/ancestry/ownership verification.

Tests: reconciler resolves + performs eligible deletion; author/reviewer/
merger denied even when holding the permission; protected/preservation
branches remain fail-closed; both role maps asserted in agreement.
Updated pre-existing tests that encoded the superseded author-delete /
#687 reconciler-redirect contract. Full suite: 3190 passed, 6 skipped,
1 pre-existing unrelated failure (test_issue_702 create_pr stale-binding).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UracxyRHQw49rZBaexHdft
2026-07-17 19:42:52 -04:00
sysadmin 3e761206e5 Merge pull request 'fix(session): pin immutable MCP context; ban cross-host profile substitution (Closes #714)' (#715) from fix/issue-714-session-context-immutability into master 2026-07-17 13:30:48 -05:00
sysadmin 0801e2455b Merge master into fix/issue-714-session-context-immutability
Resolve gitea_mcp_server.py conflicts after master advanced with #724
(side-effect-free resolver), #725 (merger lease), #728 (PR-sync), and
#702 stale-binding recovery:

- Keep #714 fail-closed session immutability: no auto profile switch
  (_ensure_matching_profile / _try_auto_switch_for_operation).
- Retain master #709 _authenticated_actor identity helper.
- Preserve resolve-path auto_recover=False (#685) and report-only
  stale_binding_recovery coexistence with runtime_reconnect_required.
- Cross-host / cross-repository substitution still fails closed without
  mutating the pinned session context.
- Regression coverage for merge coexistence, dual-module identity-cache
  isolation in conftest, and structured unknown_task fail-closed.

Closes #714 conflict remediation path for PR #715.
2026-07-17 14:25:08 -04:00
sysadmin ec2433f13b Merge pull request 'fix(mcp): make capability resolver side-effect free (Closes #685)' (#724) from fix/issue-685-side-effect-free-resolver into master 2026-07-17 13:05:51 -05:00
sysadmin 899ef8ec7f Merge branch 'master' into fix/issue-685-side-effect-free-resolver
Resolve gitea_resolve_task_capability conflict by keeping both #685
runtime_reconnect_required blocker and #702 stale_binding_recovery
annotation. Keep resolve-path stale-binding assessment report-only
(auto_recover=False) so capability resolution never mutates env or
session state. Preserve #725 merger-lease and #728 PR-sync code from
master. Regression: resolve uses report-only binding assessment.
2026-07-17 14:04:45 -04:00
sysadmin 58d6b5844f Merge pull request 'feat(controller): route outdated and conflicting PRs through synchronization lifecycle' (#728) from feat/issue-727-pr-sync-status into master 2026-07-17 12:58:35 -05:00
sysadmin c1c61e9b15 Merge branch 'master' into feat/issue-727-pr-sync-status
Resolve task_capability_map conflict by keeping PR-sync entries and #725
merger-lease map entries. Fix author ownership for PR #728 (issue #727):
prove lock via linked issue/session/branch instead of issue_number==pr_number.
Add regression tests for 727/728 ownership and unrelated issue rejection.
2026-07-17 13:57:22 -04:00
sysadmin fca3296883 Merge pull request 'fix(mcp): acquire merger PR lease with structured capability resolution (Fixes #718)' (#725) from issue-718-fix-merger-lease into master 2026-07-17 12:52:21 -05:00
jcwalker3 d7b0b0e772 Merge branch 'master' into feat/issue-727-pr-sync-status 2026-07-17 12:30:54 -05:00
sysadmin 83bc7246ff fix(mcp): forward explicit org/repo into lease/review anti-stomp
_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
2026-07-17 13:30:52 -04:00
sysadmin 29c2cf2ef6 fix(mcp): fail closed when merger lease cannot resolve live PR head
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
2026-07-17 13:25:34 -04:00
jcwalker3 4a160e0e0c Merge branch 'master' into issue-718-fix-merger-lease 2026-07-17 12:21:36 -05:00
sysadmin 14d885b2d2 Merge pull request 'feat: preserve and resume prepared review verdict drafts (#609)' (#611) from feat/issue-609-prepared-review-verdict-resume into master 2026-07-17 12:19:50 -05:00
jcwalker3 a3dbf668fd Merge branch 'master' into feat/issue-609-prepared-review-verdict-resume 2026-07-17 12:15:31 -05:00
sysadmin fb9191e559 Merge pull request 'feat(recovery): stale worktree binding demotion and crash-orphan lease recovery (Closes #702)' (#703) from fix/issue-702-stale-binding-lease-recovery into master 2026-07-17 10:55:44 -05:00
jcwalker3 07083eae09 Merge branch 'master' into fix/issue-702-stale-binding-lease-recovery 2026-07-17 10:33:46 -05:00
jcwalker3 d867acd9db Merge branch 'master' into feat/issue-609-prepared-review-verdict-resume 2026-07-17 10:33:33 -05:00
jcwalker3 a19af4de22 Merge pull request 'feat: common anti-stomp preflight before every mutation tool (Closes #604)' (#680) from feat/issue-604-anti-stomp-preflight into master
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.
2026-07-17 10:33:13 -05:00
jcwalker3 751403d12b Merge branch 'master' into feat/issue-604-anti-stomp-preflight 2026-07-17 10:31:22 -05:00
jcwalker3 3e78db5d28 Merge pull request 'fix(review): cross-PR decision-lock isolation and recovery diagnosis (Closes #693)' (#694) from fix/issue-693-review-decision-lock-recovery into master
Reviewed-on: #694
Reviewed-by: sysadmin <[email protected]>
2026-07-17 10:25:21 -05:00
jcwalker3 e33d8874a5 merge(master): resolve gitea_mcp_server.py conflict for PR #703
Keep #702 boot-time stale GITEA_ACTIVE_WORKTREE recovery and #606 Sentry
init side-by-side in the daemon entrypoint. No force-push; merge commit only.
2026-07-17 11:10:59 -04:00
sysadmin 98fbfb3de7 feat(pr-sync): native assess and author update-by-merge lifecycle
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.
2026-07-17 10:42:53 -04:00
jcwalker3 8814c04e3a Merge branch 'master' into feat/issue-609-prepared-review-verdict-resume 2026-07-17 09:32:42 -05:00
jcwalker3 7c77df0c52 Merge branch 'master' into feat/issue-604-anti-stomp-preflight 2026-07-17 09:32:35 -05:00
jcwalker3 c54f41c38d Merge branch 'master' into fix/issue-693-review-decision-lock-recovery 2026-07-17 09:32:27 -05:00
jcwalker3 f34ec86b90 Merge branch 'master' into fix/issue-685-side-effect-free-resolver 2026-07-17 09:32:07 -05:00
jcwalker3 7cb65028fd Merge branch 'master' into issue-718-fix-merger-lease 2026-07-17 09:31:59 -05:00
jcwalker3 f65069d22d Merge pull request 'feat(observability): optional self-hosted Sentry instrumentation for MCP workflow failures (Closes #606)' (#679) from feat/issue-606-sentry-observability into master
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.
2026-07-17 09:30:26 -05:00
jcwalker3 9617b64a77 Merge branch 'master' into feat/issue-606-sentry-observability 2026-07-17 09:29:49 -05:00
sysadmin 4f894009f6 fix(mcp): complete merger lease acquire + structured capability resolve (#718, #723)
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.
2026-07-17 10:09:36 -04:00
sysadmin acf2eaec01 fix(pr-694): resolve master merge conflicts for #693 decision-lock isolation
Preserve #693 classification/correction helpers and integrate master
#709 recovery provenance APIs; keep both capability-map entry sets.
2026-07-17 08:44:32 -04:00
sysadmin e8ca5202a0 fix(pr-680): resolve master merge conflicts for anti-stomp preflight
Integrate #604 anti-stomp kwargs and preflight into #683 production
guard structure; keep both lease and quarantine capability-map entries.
2026-07-17 07:44:36 -04:00
sysadmin 99640f90d0 fix(pr-611): resolve master merge conflicts for #609 draft feature
Preserve KIND_REVIEW_DRAFT and draft-resume tests while integrating
master recovery-critical session kinds and RECOVERY_CRITICAL_KINDS.
2026-07-17 06:44:40 -04:00
sysadmin 1421fa8568 Fix #718: Implement gitea_acquire_merger_pr_lease
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.
2026-07-17 01:30:12 -04:00
sysadmin 9e144db75c merge(master): resolve conflicts for PR #703 onto current master
Preserve #702 recovery kinds (session lease shadow, stale-binding audit)
and instance_id shadow keys while integrating master #709/#720
decision-lock archive, irrecoverable provenance, and TTL-exempt
KIND_DECISION_LOCK. Keep list_states (#702) and cross-profile load APIs
(#709).
2026-07-16 22:16:24 -04:00
sysadmin c780ded653 fix(mcp): make capability resolver side-effect free (Closes #685)
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
2026-07-16 21:21:39 -04:00
sysadmin 67e4a2b5e9 Merge pull request 'fix(session): keep KIND_DECISION_LOCK durable past generic 4h TTL (Closes #720)' (#721) from fix/issue-720-expired-decision-lock into master 2026-07-16 19:48:07 -05:00
sysadminandClaude Opus 4.8 ba7915452e fix(capability-map): restore reviewer role on 10 review tasks (break-glass, incident #722)
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
2026-07-16 18:38:17 -04:00
jcwalker3andsysadmin 293808b42d docs: ADR for stable MCP control runtime vs dev runtime (#615) (#616)
Co-authored-by: jcwalker3 <[email protected]>
2026-07-16 14:58:43 -05:00
sysadmin 970e68bddb fix: adopt_merger_pr_lease requires merger role 2026-07-16 15:52:58 -04:00
sysadmin 80f59b334e fix(session): keep KIND_DECISION_LOCK durable past generic 4h TTL (Closes #720)
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).
2026-07-16 15:47:52 -04:00
sysadmin 77746b08fc Merge pull request 'feat(profiles): support reconciler cleanup capability and migration (Closes #687)' (#688) from feat/issue-687-reconciler-branch-delete into master 2026-07-16 03:23:03 -05:00
sysadmin 1c37e62014 fix(cleanup): harden readback scope and ownership fail-closed (#687)
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.
2026-07-16 01:21:18 -04:00
sysadmin 137426f7ad fix(cleanup): post-delete readback and active ownership gates (#687)
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.
2026-07-16 00:57:30 -04:00
sysadmin 5b0d7aed0a Merge pull request 'fix: map Gitea auth failures to structured MCP tool errors (Closes #699)' (#701) from fix/issue-699-structured-auth-mcp-errors into master 2026-07-15 23:48:06 -05:00
sysadmin 1ab384adc9 Merge pull request 'fix(workflow): cross-profile decision-lock cleanup and irrecoverable provenance (Closes #709)' (#710) from fix/issue-709-decision-lock-cross-profile into master 2026-07-15 21:24:42 -05:00
sysadmin dc899d23c8 fix(session): require config-backed mutation authority and workspace-bound targets (#714)
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.
2026-07-15 21:13:21 -04:00
sysadminandClaude Opus 4.8 943d40270e fix(session): bind workspace-verified repository scope at first bind (#714)
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]>
2026-07-15 16:50:41 -04:00
sysadminandClaude Opus 4.8 d936da5c87 test(session): cover reverse cross-host denial, concurrent init, repo drift (#714)
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]>
2026-07-15 15:51:52 -04:00
sysadmin 29ffe1407f fix: isolate immutable session context tests (#714) 2026-07-15 02:26:27 -04:00
sysadminandGrok 632c568865 fix(session): pin immutable MCP context; ban cross-host profile substitution (Closes #714)
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]>
2026-07-14 23:05:41 -04:00
sysadminandClaude Fable 5 c2fc2683b9 fix(recovery): address PR #703 review findings F1-F6 (#702)
Fixes the six defects from the formal REQUEST_CHANGES review at
889931d553:

- F1: run sanctioned stale-binding recovery in
  gitea_resolve_task_capability BEFORE the terminal launcher probe, so a
  worktree removed mid-session can no longer wedge mutation resolution on
  'missing cwd' before recovery runs. The fail-closed probe block now also
  carries the binding classification evidence.
- F2: _resolve_preflight_workspace_path resolves with the same
  verify_paths existence checks as the canonical mutation context, and
  _get_workspace_porcelain returns a synthetic tracked-dirty sentinel when
  the workspace is missing or git fails — an uninspectable workspace can
  never read as clean/empty porcelain.
- F3: the orphaned_expired_superseded_head cleanup matrix now requires
  affirmative safe worktree evidence (worktree_clean=true or
  worktree_exists=false), aligned with the sibling orphaned_owner_missing
  gate and the diagnosis path; unknown evidence fails closed.
- F4: reviewer-session-lease shadows and stale-binding audit records are
  recovery-critical kinds exempt from the 4h session-state TTL; they
  persist until a sanctioned clear terminally reconciles them.
- F5: session-lease shadows are keyed by lease session id (collision-safe
  identity) with a list_states enumeration API; concurrent same-profile
  sessions can no longer overwrite or misattribute each other's crash
  evidence. Legacy single-slot records remain readable.
- F6: the durable audit record is persisted (status=pending) BEFORE the
  environment binding is cleared; if audit persistence fails the clear
  does not happen and the failure is reported explicitly.

30 new regression tests cover deleted-worktree recovery ordering, missing/
deleted/symlinked/mid-evaluation path changes, unknown-evidence cleanup,
TTL boundary (before/at/after), interleaved and reconnected concurrent
sessions, and audit-write failure/retry/idempotence/ordering.

Issue #704 dotenv load-path prevention is intentionally NOT included.

Full suite: 2695 passed, 6 skipped, 161 subtests passed.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-13 16:40:58 -04:00
sysadminandClaude Fable 5 889931d553 feat(recovery): stale worktree binding demotion and crash-orphan lease recovery (Closes #702)
An unhandled daemon crash skips teardown: the durable reviewer lease
comment survives, the in-memory session lease dies, and the
auto-reconnected daemon inherits a stale GITEA_ACTIVE_WORKTREE from its
parent environment (PR #701 / branches/review-pr-654 incident).

AC2 — safe clear/re-bind of stale bindings:
- new stale_binding_recovery.py: classifies the active env binding
  (corroborated / unverified_inherited / provably_stale_missing_path /
  superseded_by_session_lease) and produces a fail-closed recovery plan;
  only provably stale bindings are clear-eligible
- gitea_resolve_task_capability and daemon boot run the sanctioned
  recovery (durable audit via session state); runtime context surfaces
  the classification read-only
- namespace_workspace_binding.resolve_namespace_workspace(verify_paths=…)
  demotes env-bound worktrees whose path no longer exists; mutation and
  runtime-context resolution always verify

AC3 — recoverable lease cleanup after unexpected exit:
- durable session-lease shadow (KIND_REVIEWER_SESSION_LEASE) written on
  sanctioned record/heartbeat, removed on sanctioned clear; a crash
  leaves provable orphan evidence (owner pid + session id)
- assess_crashed_session_lease_orphan derives tri-state
  owner_process_alive: only a dead recorded owner pid proves exit; PID
  liveness is never ownership proof
- diagnose/cleanup wrappers consume the shadow instead of passing
  owner_process_alive=None
- new classification orphaned_expired_superseded_head: an expired
  superseded-head lease with no terminal review stops being a permanent
  ambiguous/wait; post-expiry it unlocks fresh acquisition, and guarded
  cleanup becomes eligible only with owner-exit evidence + clean/absent
  worktree + controller authorization + confirmation. Pre-expiry
  behavior is unchanged (fail-closed wait, no steal).

Validation: tests/test_issue_702_stale_binding_lease_recovery.py (29
tests covering lost in-session leases, old-head leases, runtime/worktree
mismatch, managed reconnect, safe expiry); full suite 2665 passed,
6 skipped, 161 subtests.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-13 14:35:15 -04:00
sysadmin 6b675f5c83 fix: harden structured auth MCP errors against reviewer findings (#699)
Address PR #701 request-changes-class defects:

1. Fixed messages only — never embed HTTP bodies, Keychain, or exception
   text in tool results or daemon logs; sanitization fails closed.
2. Narrow Tool.run boundary wraps original success path; re-raises
   UrlElicitationRequiredError; install is idempotent.
3. No RuntimeError substring heuristics; only typed client failures
   are classified as auth/authz/network/config.
4. Central classify_http_status — every HTTP 403 is GiteaAuthzError.

Regressions cover adversarial secrets, stdio survival, elicitation,
parser RuntimeError, generic 403, repeated install, profiles, provenance.

Closes #699
2026-07-13 13:40:38 -04:00
sysadmin b4d0cb22e4 fix: map Gitea auth failures to structured MCP tool errors (Closes #699)
HTTP 401/scope-403/network failures become typed client errors with stable
reason codes. The FastMCP Tool.run boundary returns CallToolResult isError
payloads so stdio transport survives; daemon logs use sanitized reason codes
only. Regression tests cover author/reconciler profiles, transport survival,
and non-misclassification of unexpected exceptions.

Cross-links: #685, #695, #697, #698, PR #696 (scopes not absorbed).
2026-07-13 13:10:00 -04:00
sysadmin 1844e29880 fix(review): cross-PR decision-lock isolation and recovery diagnosis (Closes #693)
Prevent foreign open-PR terminals on the durable review-decision lock from
blocking fresh formal reviews. Scope correction authorization to the named
prior review's PR/head, add read-only diagnosis with exact next_action and
durable handoff payloads, require thread-visible correction audits, and
cover the PR #688#692 recovery sequence in regression tests.
2026-07-13 02:29:46 -04:00
sysadmin 4a63578003 fix(profiles): canonical reconciler ops and guard raw branch delete (#687)
Address REQUEST_CHANGES on PR #688:

- migrate_profiles: emit fully canonical reconciler/author/reviewer defaults;
  canonicalize explicit ops; fail if reconciler loses required pr.close/read
- gitea_delete_branch: deny reconciler (and non-author roles); refuse
  preservation/protected branches before any API call
- gitea_cleanup_merged_pr_branch: require reconciler role only; block
  preservation/evidence branches via branch_cleanup_guard
- docs: end-to-end operator runbook for profile migrate/apply/reconnect/cleanup
- tests: migration canonicalization, idempotency, raw-delete denial, guarded
  cleanup success and unmerged/preserve rejections

Closes #687.
2026-07-12 22:35:14 -04:00
sysadmin c7a444eb4b feat(profiles): support reconciler role in migrate_profiles (Closes #687) 2026-07-12 21:32:01 -04:00
sysadminandClaude Opus 4.8 8cac50b2e7 feat(profiles): make gitea.branch.delete a documented reconciler-owned capability
Merged-PR source-branch cleanup is reconciler work (task_capability_map maps
cleanup_merged_pr_branch -> reconciler / gitea.branch.delete), but the
reconciler profile schema, execution-profile docs, and tests never covered
the permission, so no configured profile could run the guarded
gitea_cleanup_merged_pr_branch path.

- reconciler_profile.py: add gitea.branch.delete to
  RECONCILER_RECOMMENDED_OPERATIONS (not required; not forbidden)
- docs/gitea-execution-profiles.md: document merged-branch cleanup
  ownership, least-privilege constraints, and the no-alias caveat
- tests/test_reconciler_profile.py: reconciler profile with branch.delete
  stays valid and classified reconciler; missing grant is reported as
  missing-recommended
- tests/test_branch_cleanup_guard.py: author- and merger-shaped profiles
  without gitea.branch.delete fail closed on gitea_cleanup_merged_pr_branch

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-12 21:31:00 -04:00
sysadmin 3fd02a3c63 fix: anti-stomp capability auth, inventory wiring, side-effect proof (#604)
Address REQUEST_CHANGES on PR #680:

Blocker A — role vs capability agreement
- authorization_compatible allows reviewer/merger when they hold the task's
  required permission (e.g. gitea.issue.comment on comment_issue/mark_issue/
  set_issue_labels/lock_issue) without broad role-bypass.
- Unauthorized escalation without the permission remains fail closed.
- Regression coverage for allowed and denied reviewer/merger cases.

Blocker B — MUTATION_TASKS matches runtime wiring
- Remove unenforced entries; document dedicated-gate exclusions
  (mark_final_review_decision, save/resume_review_draft, etc.).
- Wire non-closing gitea_edit_pr as edit_pr; acquire lease uses
  acquire_reviewer_pr_lease task through verify_preflight_purity.
- Inventory↔wiring consistency tests replace set-membership-only coverage.

Blocker C — entrypoint side-effect ordering
- Behavioral tests for merge_pr, submit_pr_review, and comment_issue prove
  the assessor block aborts before Gitea API mutation and local durable writes.

Validation: 43 focused anti-stomp + related suites green; full suite
2639 passed / 6 skipped.

Closes #604
2026-07-12 09:00:45 -04:00
sysadmin ea8e186549 feat: common anti-stomp preflight before mutation tools (Closes #604)
Add a shared fail-closed anti-stomp preflight that mutation tools invoke
before create/comment/lease/review/approve/request-changes/merge/cleanup/
label mutations. Composes repo/role/root/worktree/lease/terminal-lock/
head-SHA/stale-runtime/workflow-hash/contamination checks into a typed
blocker with an exact next action. No agent bypass flags.
2026-07-12 05:10:30 -04:00
sysadminandClaude Opus 4.8 78cc37a977 feat(observability): optional self-hosted Sentry instrumentation for MCP workflow failures (Closes #606)
Add an env-var-gated, off-by-default Sentry SDK integration so MCP runtime
errors, fail-closed workflow blockers, lease/terminal-lock/stale-runtime
collisions, and recurring watchdog check-ins are visible in a self-hosted
Sentry at https://sentry.prgs.cc/. Gitea stays the source of truth; Sentry is
observe-only.

New module `sentry_observability.py` mirrors the `gitea_audit` conventions
(env-gated, best-effort, redacting):

- Config from MCP_SENTRY_ENABLED / SENTRY_DSN / SENTRY_ENVIRONMENT /
  SENTRY_RELEASE / MCP_SENTRY_TRACES_SAMPLE_RATE / MCP_SENTRY_ENABLE_LOGS.
  Active only when enabled AND a DSN is present; otherwise a hard no-op.
- Fail OPEN for observability (never blocks a tool success path) and fail
  CLOSED for redaction (drop a field rather than risk leaking it).
- `scrub_event` before_send/before_send_log hook + allowlisted tags: no
  tokens/passwords/keychain ids/DSNs/cookies, no raw session-state or full
  prompt bodies (session_id -> 12-char hash), no full filesystem paths
  (worktree path -> coarse category). Reuses incident_bridge + gitea_audit
  scrubbers.
- capture_exception, capture_workflow_blocker (with canonical next action),
  and monitor_checkin with six stable cron slugs (stale lease scan, terminal
  lock scan, allocator health, namespace health, dashboard freshness,
  reconciler cleanup).
- `sentry_sdk` is a lazily-imported optional dependency; the module imports
  and no-ops cleanly when the package is absent.

Wiring in gitea_mcp_server.py (additive, guarded, best-effort):
- init_sentry() in __main__ before mcp.run.
- capture_exception in the `_audited` failure path; capture_workflow_blocker
  in `_audit_pr_result` BLOCKED/FAILED path.
- allocator + namespace-health watchdog check-ins at their MCP tool sites
  (domain modules left pure).

Also: pin `sentry-sdk==2.20.0` (optional), document the six env vars in
`.env.example`, and add `docs/observability/sentry-integration.md` covering
project creation in https://sentry.prgs.cc/, DSN handling, local/dev/prod
config, redaction guarantees, and coexistence with the #612 incident bridge.

Tests: tests/test_sentry_observability.py (36 cases) cover disabled / enabled /
missing-DSN / missing-SDK, redaction, exception capture, workflow-blocker
capture, and cron check-in behaviour. Full suite: 2632 passed, 6 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-12 02:35:47 -04:00
sysadmin 23e366d9d6 feat: preserve and resume prepared review verdict drafts (#609)
Add non-terminal review draft save/resume tools so a fully validated
reviewer verdict can survive MCP disconnect or stale-runtime restart
without counting as a formal review.

- gitea_save_review_draft: durable draft with PR, head SHA, target SHA,
  worktree, validation evidence, intended verdict, and blocker reason
- gitea_resume_review_draft: live revalidation of head/target/#332/lease/
  identity/worktree before mark-ready or optional submit
- Drafts never substitute for formal approval without revalidation

Closes #609
2026-07-09 18:58:12 -04:00
101 changed files with 25069 additions and 818 deletions
+21
View File
@@ -39,6 +39,27 @@ GITEA_AUDIT_LOG=/path/to/gitea-mcp-audit.log
# only — never the token value. Surfaced by gitea_get_profile.
GITEA_TOKEN_SOURCE=GITEA_TOKEN
# ── Optional self-hosted Sentry observability (#606) ────────────────────────
# Emits runtime errors, fail-closed workflow blockers, lease/terminal-lock/
# stale-runtime collisions, and watchdog cron check-ins to a SELF-HOSTED Sentry
# (https://sentry.prgs.cc/) — never Sentry Cloud. Gitea stays the source of
# truth; Sentry is observe-only. OFF by default: with MCP_SENTRY_ENABLED unset
# or SENTRY_DSN empty, nothing is initialised and no events are sent.
#
# Master gate. Truthy = 1/true/yes/on. Both this AND SENTRY_DSN are required.
MCP_SENTRY_ENABLED=0
# DSN for the self-hosted project (create a `gitea-tools-mcp` project in
# https://sentry.prgs.cc/ and copy its DSN). Never commit a real DSN.
SENTRY_DSN=
# Deployment environment tag (local/dev/prod). Defaults to "development".
SENTRY_ENVIRONMENT=development
# Optional release identifier (e.g. a git SHA or version string).
SENTRY_RELEASE=
# Performance-trace sample rate, 0.01.0 (clamped). Default 0.0 (traces off).
MCP_SENTRY_TRACES_SAMPLE_RATE=0.0
# Set to 1 to forward Python logs to Sentry as structured logs. Default off.
MCP_SENTRY_ENABLE_LOGS=0
# Optional canonical runtime-profile config (#19). Instead of the fields above,
# point every LLM launcher at ONE JSON file of named profiles and select one.
# Secrets are referenced (keychain id / env var name), never inlined. See
+807
View File
@@ -0,0 +1,807 @@
"""Common anti-stomp preflight for every MCP mutation tool (#604).
Even with leases, mutation tools need a **shared** preflight that prevents
stale sessions, wrong worktrees, wrong repos, old prompts, terminal locks,
foreign leases, contaminated approvals, and root-checkout mutations from
slipping through.
This module is the pure assessment core. Callers (MCP mutation entrypoints)
gather live facts and pass them in — nothing here performs git, network, or
durable-state I/O. Existing happy-path guards remain authoritative; this
module composes them into one typed, fail-closed result.
Required checks (issue #604):
* repo/org verification
* profile/role verification
* root checkout clean and not used for mutation (when role requires branches/)
* worktree under branches when required
* active lease ownership (when lease is required for the mutation)
* terminal lock status (when applicable)
* expected head SHA (when pinned)
* stale runtime status
* workflow hash (when a workflow load is required)
* source contamination status
* no manual state/mtime/source-file bypass
Failure returns a typed blocker and an exact next action. There is **no**
agent-facing bypass flag.
"""
from __future__ import annotations
from typing import Any
import author_mutation_worktree
import master_parity_gate
import remote_repo_guard
import root_checkout_guard
import stable_branch_push_guard
# ── public constants ──────────────────────────────────────────────────────────
# Typed blocker kinds returned in the structured result.
BLOCKER_WRONG_REPO = "wrong_repo"
BLOCKER_WRONG_ROLE = "wrong_role"
BLOCKER_ROOT_CHECKOUT = "root_checkout_mutation"
BLOCKER_WRONG_WORKTREE = "wrong_worktree"
BLOCKER_FOREIGN_LEASE = "foreign_lease"
BLOCKER_TERMINAL_LOCK = "terminal_lock"
BLOCKER_HEAD_SHA = "head_sha_mismatch"
BLOCKER_STALE_RUNTIME = "stale_runtime"
BLOCKER_WORKFLOW_HASH = "workflow_hash"
BLOCKER_SOURCE_CONTAMINATION = "source_contamination"
BLOCKER_MANUAL_BYPASS = "manual_bypass"
BLOCKER_KINDS = frozenset({
BLOCKER_WRONG_REPO,
BLOCKER_WRONG_ROLE,
BLOCKER_ROOT_CHECKOUT,
BLOCKER_WRONG_WORKTREE,
BLOCKER_FOREIGN_LEASE,
BLOCKER_TERMINAL_LOCK,
BLOCKER_HEAD_SHA,
BLOCKER_STALE_RUNTIME,
BLOCKER_WORKFLOW_HASH,
BLOCKER_SOURCE_CONTAMINATION,
BLOCKER_MANUAL_BYPASS,
})
# Mutation tasks that must invoke the shared anti-stomp preflight before
# acting (issue #604 AC1). Every member must appear as a live task= kwarg
# to verify_preflight_purity / _run_anti_stomp_preflight (or the review
# anti_task map) in gitea_mcp_server.py. Inventory↔wiring tests fail if
# a declared task is not wired.
MUTATION_TASKS = frozenset({
"create_issue",
"comment_issue",
"close_issue",
"mark_issue",
"lock_issue",
"set_issue_labels",
"create_label",
"create_pr",
"close_pr",
"edit_pr",
"commit_files",
"gitea_commit_files",
"delete_branch",
"cleanup_merged_pr_branch",
"cleanup_stale_claims",
"reconcile_merged_cleanups",
"reconcile_already_landed_pr",
"reconcile_close_superseded_pr",
"post_heartbeat",
"acquire_reviewer_pr_lease",
"gitea_acquire_reviewer_pr_lease",
"adopt_merger_pr_lease",
"review_pr",
"submit_pr_review",
"approve_pr",
"request_changes_pr",
"merge_pr",
})
# Intentionally excluded from MUTATION_TASKS. Each has a dedicated
# fail-closed gate that preserves decision-lock ownership, workflow-hash,
# head-SHA, and repository consistency. Do not re-add without either
# wiring shared preflight or updating this rationale (#604 Blocker B).
DEDICATED_GATE_MUTATIONS: dict[str, str] = {
"mark_final_review_decision": (
"Local review-decision lock only. Enforced by session lock ownership, "
"workflow-hash, expected head SHA, PR work lease, eligibility, and "
"terminal-lock gates. No Gitea review POST; shared anti-stomp would "
"duplicate without side-effect-ordering value."
),
"save_review_draft": (
"Local session-state draft save with live PR head/base consistency "
"checks. Not a Gitea review/merge mutation; dedicated head-SHA and "
"worktree resolution gates apply."
),
"resume_review_draft": (
"Live-state resume with terminal lock, lease ownership, head/base SHA, "
"and parity checks. When submit=True, delegates to gitea_submit_pr_review "
"which runs shared anti-stomp before the Gitea review POST."
),
"cleanup_stale_review_decision_lock": (
"Moot-lock cleanup with identity match, live PR merged/closed proof, "
"and reviewer capability gate (#594). Distinct from shared anti-stomp."
),
"gitea_cleanup_stale_review_decision_lock": (
"Alias of cleanup_stale_review_decision_lock; dedicated #594 path."
),
"heartbeat_reviewer_pr_lease": (
"Lease heartbeat posts via verify_preflight_purity(task='review_pr') "
"plus in-session lease ownership checks; not a separate mutation class."
),
"release_reviewer_pr_lease": (
"Lease release uses workspace binding + ownership checks; posts only "
"when the session owns the active lease."
),
}
# Roles that must mutate from a branches/ worktree (not the control checkout).
_BRANCHES_REQUIRED_ROLES = frozenset({"author"})
# Reviewer/merger mutations that require an owned lease + head pinning when
# the caller supplies those facts.
_LEASE_AWARE_TASKS = frozenset({
"review_pr",
"submit_pr_review",
"approve_pr",
"request_changes_pr",
"merge_pr",
"acquire_reviewer_pr_lease",
"gitea_acquire_reviewer_pr_lease",
"adopt_merger_pr_lease",
})
_NEXT_ACTIONS: dict[str, str] = {
BLOCKER_WRONG_REPO: (
"Pass explicit org= and repo= matching the local git remote "
"(e.g. org=Scaled-Tech-Consulting repo=Gitea-Tools) and retry."
),
BLOCKER_WRONG_ROLE: (
"Call gitea_resolve_task_capability, switch to the required "
"namespace/profile, re-verify with gitea_whoami, then retry."
),
BLOCKER_ROOT_CHECKOUT: (
"Leave the root control checkout on clean master; create or switch "
"to a session-owned worktree under branches/ and retry the mutation "
"with worktree_path set."
),
BLOCKER_WRONG_WORKTREE: (
"Create or bind a session-owned worktree under branches/, set "
"worktree_path (or GITEA_ACTIVE_WORKTREE), and retry."
),
BLOCKER_FOREIGN_LEASE: (
"Stop. Do not stomp a foreign lease. Wait for expiry, request "
"takeover through the sanctioned path, or hand off to the owner."
),
BLOCKER_TERMINAL_LOCK: (
"Stop. A terminal review-decision lock is active for this head. "
"Do not re-approve/merge; follow the #332/#620 recovery path."
),
BLOCKER_HEAD_SHA: (
"Re-fetch the live PR head with gitea_view_pr, re-pin "
"expected_head_sha to the current head, re-validate, then retry."
),
BLOCKER_STALE_RUNTIME: (
"Restart the Gitea MCP server so it reloads master's capability "
"gates, re-run gitea_whoami + gitea_resolve_task_capability, then retry."
),
BLOCKER_WORKFLOW_HASH: (
"Reload the canonical workflow via gitea_load_review_workflow, then "
"rerun the full review-merge workflow from inventory (no approve/merge replay)."
),
BLOCKER_SOURCE_CONTAMINATION: (
"Stop. Session is source-contaminated. Hand off to a reconciler for "
"audit/clear; do not clear markers by deleting session-state files."
),
BLOCKER_MANUAL_BYPASS: (
"Stop. Manual state/mtime/source-file bypass is forbidden. Use "
"sanctioned MCP tools only; never delete or rewrite session-state "
"or lock files by hand."
),
}
def is_mutation_task(task: str | None) -> bool:
"""True when *task* is in the #604 mutation set (normalized)."""
name = (task or "").strip().lower().removeprefix("gitea_")
if not name:
return False
if name in MUTATION_TASKS:
return True
# Accept gitea_ prefixed membership for aliases already in the set.
return f"gitea_{name}" in MUTATION_TASKS
def roles_compatible(active_role: str | None, required_role: str | None) -> bool:
"""Whether *active_role* may perform a task that maps to *required_role*.
Exact match always passes. Reconcilers may run author-class mutations
(comment/close/cleanup) that the capability map stamps as ``author`` —
matching the long-standing reconciler exemption for control-checkout
work. No other cross-role substitutions are allowed.
Capability-authorized multi-role tasks (e.g. reviewer holding
``gitea.issue.comment`` for ``comment_issue``) are handled by
:func:`authorization_compatible`, not by expanding this role matrix.
"""
active = (active_role or "").strip().lower()
required = (required_role or "").strip().lower()
if not active or not required:
return True
if active == required:
return True
if active == "reconciler" and required in {"author", "reconciler"}:
return True
return False
def authorization_compatible(
active_role: str | None,
required_role: str | None,
*,
required_permission: str | None = None,
allowed_operations: Any = None,
) -> bool:
"""Whether the active session may run a task given role *and* capability.
Order:
1. :func:`roles_compatible` (exact role or narrow reconciler→author).
2. Capability possession: when *required_permission* is non-empty and
present in *allowed_operations*, allow even if the nominal task role
differs (e.g. reviewer/merger with ``gitea.issue.comment`` on
``comment_issue`` / ``mark_issue`` / ``set_issue_labels`` /
``lock_issue``).
Fail closed otherwise. This is **not** a broad reviewer→author or
merger→author role rewrite: without the specific permission the check
still denies. Missing *allowed_operations* when a permission is required
also fails closed (callers must supply the live profile op list).
"""
if roles_compatible(active_role, required_role):
return True
perm = (required_permission or "").strip()
if not perm:
return False
if allowed_operations is None:
return False
try:
ops = {str(o).strip() for o in allowed_operations if o is not None}
except TypeError:
return False
return perm in ops
def _blocker(
kind: str,
reasons: list[str],
*,
exact_next_action: str | None = None,
detail: dict | None = None,
) -> dict[str, Any]:
if kind not in BLOCKER_KINDS:
raise ValueError(f"unknown anti-stomp blocker kind: {kind!r}")
return {
"kind": kind,
"reasons": list(reasons),
"exact_next_action": exact_next_action or _NEXT_ACTIONS[kind],
"detail": dict(detail or {}),
}
def _allowed_result(checks: dict[str, Any]) -> dict[str, Any]:
return {
"allowed": True,
"block": False,
"blockers": [],
"reasons": [],
"exact_next_action": "proceed",
"blocker_kind": None,
"checks": checks,
}
def _blocked_result(
blockers: list[dict[str, Any]],
checks: dict[str, Any],
) -> dict[str, Any]:
primary = blockers[0]
all_reasons: list[str] = []
for b in blockers:
for r in b.get("reasons") or []:
if r not in all_reasons:
all_reasons.append(r)
return {
"allowed": False,
"block": True,
"blockers": blockers,
"reasons": all_reasons,
"exact_next_action": primary["exact_next_action"],
"blocker_kind": primary["kind"],
"checks": checks,
}
def assess_anti_stomp_preflight(
*,
task: str | None = None,
# repo/org
remote: str | None = None,
resolved_org: str | None = None,
resolved_repo: str | None = None,
local_remote_url: str | None = None,
org_explicit: bool = False,
repo_explicit: bool = False,
check_repo: bool = True,
# profile/role + capability
profile_name: str | None = None,
profile_role: str | None = None,
required_role: str | None = None,
required_permission: str | None = None,
allowed_operations: Any = None,
check_role: bool = True,
# root checkout + worktree
workspace_path: str | None = None,
project_root: str | None = None,
current_branch: str | None = None,
root_head_sha: str | None = None,
root_porcelain: str | None = None,
remote_master_sha: str | None = None,
check_root_checkout: bool = True,
check_worktree: bool = True,
# stale runtime (master parity)
startup_head: str | None = None,
current_code_head: str | None = None,
check_stale_runtime: bool = True,
# lease ownership
lease_required: bool = False,
foreign_lease: bool | None = None,
lease_owner_session: str | None = None,
active_session_id: str | None = None,
lease_owner_identity: str | None = None,
active_identity: str | None = None,
lease_reasons: list[str] | None = None,
# terminal lock
terminal_lock_blocks: bool | None = None,
terminal_lock_reasons: list[str] | None = None,
# expected head SHA
require_head_sha: bool = False,
expected_head_sha: str | None = None,
live_head_sha: str | None = None,
# workflow hash
workflow_hash_valid: bool | None = None,
workflow_hash_reasons: list[str] | None = None,
# source contamination (stable-branch push / approval contamination)
source_contaminated: bool | None = None,
contamination_reasons: list[str] | None = None,
# manual bypass
manual_bypass_attempted: bool = False,
manual_bypass_reasons: list[str] | None = None,
) -> dict[str, Any]:
"""Fail-closed common anti-stomp assessment (pure).
Only checks for which facts are supplied (or which are required by the
mutation category) are evaluated. Unsupplied optional facts are recorded
as ``skipped`` so callers can see coverage without inventing defaults.
Returns a structured dict with ``allowed``/``block``, typed ``blockers``,
aggregated ``reasons``, ``exact_next_action``, and per-check ``checks``.
"""
task_name = (task or "").strip()
role = (profile_role or "").strip().lower() or None
req_role = (required_role or "").strip().lower() or None
req_perm = (required_permission or "").strip() or None
checks: dict[str, Any] = {
"task": task_name or None,
"profile_name": profile_name,
"profile_role": role,
"required_role": req_role,
"required_permission": req_perm,
}
blockers: list[dict[str, Any]] = []
# ── manual bypass (always first; never skippable when attempted) ─────────
if manual_bypass_attempted:
reasons = list(manual_bypass_reasons or [
"manual state/mtime/source-file bypass attempt detected (fail closed)"
])
blockers.append(_blocker(BLOCKER_MANUAL_BYPASS, reasons))
checks["manual_bypass"] = {"block": True, "reasons": reasons}
else:
checks["manual_bypass"] = {"block": False, "skipped": False}
# ── repo/org ─────────────────────────────────────────────────────────────
if check_repo and resolved_org is not None and resolved_repo is not None:
repo_assessment = remote_repo_guard.assess_remote_repo_match(
remote=remote or "",
resolved_org=resolved_org,
resolved_repo=resolved_repo,
local_remote_url=local_remote_url,
org_explicit=org_explicit,
repo_explicit=repo_explicit,
)
checks["repo"] = {
"block": bool(repo_assessment.get("block")),
"reasons": list(repo_assessment.get("reasons") or []),
"resolved_org": resolved_org,
"resolved_repo": resolved_repo,
}
if repo_assessment.get("block"):
blockers.append(
_blocker(
BLOCKER_WRONG_REPO,
list(repo_assessment.get("reasons") or ["repo/org mismatch"]),
detail={
"resolved_org": resolved_org,
"resolved_repo": resolved_repo,
"local_remote_url": local_remote_url,
},
)
)
else:
checks["repo"] = {"block": False, "skipped": True}
# ── profile/role + capability ────────────────────────────────────────────
# Role match OR possession of the task's required permission (Blocker A).
# Reviewer/merger may run issue-comment-class tasks when they hold
# gitea.issue.comment; unauthorized escalation without the permission
# still fails closed.
if check_role and req_role and role:
authorized = authorization_compatible(
role,
req_role,
required_permission=req_perm,
allowed_operations=allowed_operations,
)
if not authorized:
perm_clause = (
f", required_permission='{req_perm}'" if req_perm else ""
)
reasons = [
f"active profile role '{role}' is not authorized for task "
f"'{task_name or '(unknown)'}' (required_role='{req_role}'"
f"{perm_clause}; profile={profile_name or '(unknown)'})"
]
checks["role"] = {
"block": True,
"reasons": reasons,
"required_permission": req_perm,
"capability_authorized": False,
}
blockers.append(
_blocker(
BLOCKER_WRONG_ROLE,
reasons,
detail={
"profile_name": profile_name,
"profile_role": role,
"required_role": req_role,
"required_permission": req_perm,
},
)
)
else:
checks["role"] = {
"block": False,
"reasons": [],
"required_permission": req_perm,
"capability_authorized": bool(
req_perm
and allowed_operations is not None
and req_perm in {
str(o).strip() for o in (allowed_operations or [])
if o is not None
}
and not roles_compatible(role, req_role)
),
}
else:
checks["role"] = {"block": False, "skipped": True}
# ── stale runtime (master parity) ────────────────────────────────────────
if check_stale_runtime and (
startup_head is not None or current_code_head is not None
):
parity = master_parity_gate.assess_master_parity(
{"startup_head": startup_head},
current_code_head,
)
stale_reasons = master_parity_gate.parity_block_reasons(parity)
checks["stale_runtime"] = {
"block": bool(stale_reasons),
"reasons": list(stale_reasons),
"startup_head": startup_head,
"current_code_head": current_code_head,
"stale": bool(parity.get("stale")),
}
if stale_reasons:
blockers.append(
_blocker(
BLOCKER_STALE_RUNTIME,
list(stale_reasons),
detail={
"startup_head": startup_head,
"current_code_head": current_code_head,
},
)
)
else:
checks["stale_runtime"] = {"block": False, "skipped": True}
# ── root checkout ────────────────────────────────────────────────────────
if (
check_root_checkout
and workspace_path is not None
and project_root is not None
and root_porcelain is not None
):
root_assessment = root_checkout_guard.assess_root_checkout_guard(
workspace_path=workspace_path,
canonical_repo_root=project_root,
current_branch=current_branch,
head_sha=root_head_sha,
porcelain_status=root_porcelain,
remote_master_sha=remote_master_sha,
resolved_role=req_role or role,
actual_role=role,
)
checks["root_checkout"] = {
"block": bool(root_assessment.get("block")),
"reasons": list(root_assessment.get("reasons") or []),
}
if root_assessment.get("block"):
blockers.append(
_blocker(
BLOCKER_ROOT_CHECKOUT,
list(root_assessment.get("reasons") or [
"control checkout is not clean master"
]),
detail={
"workspace_path": workspace_path,
"project_root": project_root,
"current_branch": current_branch,
},
)
)
else:
checks["root_checkout"] = {"block": False, "skipped": True}
# ── worktree under branches (author) ─────────────────────────────────────
worktree_role = role or req_role
if (
check_worktree
and workspace_path is not None
and project_root is not None
and worktree_role in _BRANCHES_REQUIRED_ROLES
):
wt = author_mutation_worktree.assess_author_mutation_worktree(
workspace_path=workspace_path,
project_root=project_root,
current_branch=current_branch,
)
checks["worktree"] = {
"block": bool(wt.get("block")),
"reasons": list(wt.get("reasons") or []),
"under_branches": wt.get("under_branches"),
}
if wt.get("block"):
blockers.append(
_blocker(
BLOCKER_WRONG_WORKTREE,
list(wt.get("reasons") or [
"mutation worktree is not under branches/"
]),
detail={
"workspace_path": workspace_path,
"project_root": project_root,
},
)
)
else:
checks["worktree"] = {
"block": False,
"skipped": worktree_role not in _BRANCHES_REQUIRED_ROLES
or workspace_path is None,
}
# ── foreign lease ────────────────────────────────────────────────────────
lease_needed = lease_required or (
task_name.removeprefix("gitea_") in {
t.removeprefix("gitea_") for t in _LEASE_AWARE_TASKS
}
and foreign_lease is not None
)
if lease_needed or foreign_lease is True:
is_foreign = bool(foreign_lease)
if foreign_lease is None and lease_required:
# Ownership must be proven when lease_required; missing ownership
# facts fail closed.
owner_ok = (
(not lease_owner_session and not active_session_id)
or (
lease_owner_session
and active_session_id
and lease_owner_session == active_session_id
)
)
identity_ok = (
(not lease_owner_identity and not active_identity)
or (
lease_owner_identity
and active_identity
and lease_owner_identity == active_identity
)
)
if not owner_ok or not identity_ok:
is_foreign = True
reasons = list(lease_reasons or [])
if is_foreign and not reasons:
reasons = [
"active lease is owned by a foreign session or identity "
"(anti-stomp fail closed)"
]
checks["lease"] = {
"block": is_foreign,
"reasons": reasons if is_foreign else [],
"lease_owner_session": lease_owner_session,
"active_session_id": active_session_id,
}
if is_foreign:
blockers.append(
_blocker(BLOCKER_FOREIGN_LEASE, reasons)
)
else:
checks["lease"] = {"block": False, "skipped": True}
# ── terminal lock ────────────────────────────────────────────────────────
if terminal_lock_blocks is not None:
reasons = list(terminal_lock_reasons or [])
if terminal_lock_blocks and not reasons:
reasons = [
"terminal review-decision lock is active for this head "
"(anti-stomp fail closed)"
]
checks["terminal_lock"] = {
"block": bool(terminal_lock_blocks),
"reasons": reasons if terminal_lock_blocks else [],
}
if terminal_lock_blocks:
blockers.append(_blocker(BLOCKER_TERMINAL_LOCK, reasons))
else:
checks["terminal_lock"] = {"block": False, "skipped": True}
# ── expected head SHA ────────────────────────────────────────────────────
if require_head_sha or (
expected_head_sha is not None and live_head_sha is not None
):
exp = (expected_head_sha or "").strip().lower()
live = (live_head_sha or "").strip().lower()
mismatch = False
reasons: list[str] = []
if require_head_sha and not exp:
mismatch = True
reasons.append(
"expected_head_sha is required before this mutation "
"(anti-stomp fail closed)"
)
elif exp and live and exp != live:
mismatch = True
reasons.append(
f"expected_head_sha '{exp}' does not match live PR head "
f"'{live}' (anti-stomp fail closed; stale prompt data)"
)
elif require_head_sha and exp and not live:
mismatch = True
reasons.append(
"live PR head SHA could not be determined to corroborate "
"expected_head_sha (anti-stomp fail closed)"
)
checks["head_sha"] = {
"block": mismatch,
"reasons": reasons,
"expected_head_sha": expected_head_sha,
"live_head_sha": live_head_sha,
}
if mismatch:
blockers.append(_blocker(BLOCKER_HEAD_SHA, reasons))
else:
checks["head_sha"] = {"block": False, "skipped": True}
# ── workflow hash ────────────────────────────────────────────────────────
if workflow_hash_valid is not None:
reasons = list(workflow_hash_reasons or [])
if not workflow_hash_valid and not reasons:
reasons = [
"workflow load proof missing or hash stale "
"(anti-stomp fail closed)"
]
checks["workflow_hash"] = {
"block": not bool(workflow_hash_valid),
"reasons": reasons if not workflow_hash_valid else [],
}
if not workflow_hash_valid:
blockers.append(_blocker(BLOCKER_WORKFLOW_HASH, reasons))
else:
checks["workflow_hash"] = {"block": False, "skipped": True}
# ── source contamination ─────────────────────────────────────────────────
if source_contaminated is not None:
reasons = list(contamination_reasons or [])
if source_contaminated and not reasons:
reasons = [
"session is source-contaminated (stable-branch push or "
"contaminated approval path); anti-stomp fail closed"
]
checks["source_contamination"] = {
"block": bool(source_contaminated),
"reasons": reasons if source_contaminated else [],
}
if source_contaminated:
blockers.append(
_blocker(BLOCKER_SOURCE_CONTAMINATION, reasons)
)
else:
checks["source_contamination"] = {"block": False, "skipped": True}
if blockers:
return _blocked_result(blockers, checks)
return _allowed_result(checks)
def format_anti_stomp_error(assessment: dict[str, Any]) -> str:
"""Single RuntimeError message for MCP mutation gates."""
kind = assessment.get("blocker_kind") or "unknown"
reasons = "; ".join(
assessment.get("reasons")
or ["anti-stomp preflight blocked the mutation"]
)
next_action = assessment.get("exact_next_action") or "stop and diagnose"
return (
f"Anti-stomp preflight (#604) blocked mutation "
f"[{kind}]: {reasons}. "
f"exact_next_action: {next_action}"
)
def block_response(
assessment: dict[str, Any],
**extra_fields: Any,
) -> dict[str, Any]:
"""Structured tool-return payload for a blocked mutation."""
payload = {
"success": False,
"performed": False,
"blocked": True,
"anti_stomp": True,
"blocker_kind": assessment.get("blocker_kind"),
"blockers": list(assessment.get("blockers") or []),
"reasons": list(assessment.get("reasons") or []),
"exact_next_action": assessment.get("exact_next_action"),
"checks": assessment.get("checks") or {},
}
payload.update(extra_fields)
return payload
def contamination_from_stable_marker(
marker: dict | None,
*,
task: str | None,
actual_role: str | None,
) -> tuple[bool, list[str]]:
"""Derive source-contamination facts from a #671 durable marker."""
if not marker:
return False, []
gate = stable_branch_push_guard.assess_contamination_gate(
marker,
task=task,
actual_role=actual_role,
)
if gate.get("block"):
return True, list(gate.get("reasons") or [])
return False, []
+505
View File
@@ -7,6 +7,19 @@ from typing import Any
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
# Evidence / preservation branches must never be removed by cleanup tools
# (e.g. chore/issue-681-preserve-review-session-wip).
_PRESERVATION_MARKERS = ("preserve", "preservation", "evidence")
def is_preservation_or_evidence_branch(branch: str | None) -> bool:
"""Return True when *branch* is a preservation/evidence ref that must stay."""
if not branch:
return False
name = str(branch).lower()
return any(marker in name for marker in _PRESERVATION_MARKERS)
_RAW_BRANCH_DELETE_PATTERNS = (
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+branch\s+-[dD]\b[^\n\r]*", re.I),
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s--delete\b[^\n\r]*", re.I),
@@ -72,6 +85,11 @@ def assess_merged_pr_branch_cleanup(
reasons.append("PR head branch is missing")
if head_branch in protected:
reasons.append(f"branch '{head_branch}' is protected")
if is_preservation_or_evidence_branch(head_branch):
reasons.append(
f"branch '{head_branch}' is a preservation/evidence branch and "
"cannot be deleted through merged-PR cleanup"
)
if head_branch in open_pr_heads:
reasons.append("an open PR still references this head branch")
if head_on_target is False:
@@ -96,3 +114,490 @@ def assess_merged_pr_branch_cleanup(
"block_reasons": reasons,
"recommended_action": "delete_remote_branch" if safe else "keep_remote_branch",
}
# ---------------------------------------------------------------------------
# #687 remediation: post-delete readback + active ownership protection
# ---------------------------------------------------------------------------
READBACK_NOT_FOUND = "not_found"
READBACK_EXISTS = "exists"
READBACK_AUTHENTICATION = "authentication_error"
READBACK_AUTHORIZATION = "authorization_error"
READBACK_TRANSPORT = "transport_error"
READBACK_UNEXPECTED = "unexpected_response"
READBACK_AMBIGUOUS_404 = "ambiguous_not_found"
# Scope of a 404: only branch-scoped absence may set verified_absent.
NOT_FOUND_SCOPE_BRANCH = "branch"
NOT_FOUND_SCOPE_REPOSITORY = "repository"
NOT_FOUND_SCOPE_HOST = "host"
NOT_FOUND_SCOPE_UNKNOWN = "unknown"
ERROR_CLASS_AUTHENTICATION = "authentication"
ERROR_CLASS_AUTHORIZATION = "authorization"
ERROR_CLASS_TRANSPORT = "transport"
ERROR_CLASS_UNEXPECTED = "unexpected"
OWNERSHIP_CATEGORY_AUTHOR_SESSION = "author_session"
OWNERSHIP_CATEGORY_AUTHOR_LEASE = "author_lease"
OWNERSHIP_CATEGORY_REVIEWER_LEASE = "reviewer_lease"
OWNERSHIP_CATEGORY_MERGER_LEASE = "merger_lease"
OWNERSHIP_CATEGORY_CONTROLLER_LEASE = "controller_lease"
OWNERSHIP_CATEGORY_RECONCILER_LEASE = "reconciler_lease"
OWNERSHIP_CATEGORY_WORKTREE_BINDING = "worktree_binding"
OWNERSHIP_CATEGORY_INVENTORY_ERROR = "ownership_inventory_error"
_ROLE_TO_OWNERSHIP_CATEGORY = {
"author": OWNERSHIP_CATEGORY_AUTHOR_LEASE,
"reviewer": OWNERSHIP_CATEGORY_REVIEWER_LEASE,
"merger": OWNERSHIP_CATEGORY_MERGER_LEASE,
"controller": OWNERSHIP_CATEGORY_CONTROLLER_LEASE,
"reconciler": OWNERSHIP_CATEGORY_RECONCILER_LEASE,
}
_ACTIVE_OWNERSHIP_STATUSES = frozenset(
{"active", "live", "claimed", "in_progress", "working", "pushing", "pushed"}
)
_TERMINAL_OWNERSHIP_STATUSES = frozenset(
{"released", "abandoned", "done", "blocked", "terminal", "closed"}
)
_EXPIRED_STATUSES = frozenset({"expired"})
_STALE_STATUSES = frozenset({"stale", "stale_dead_process", "stale_missing_worktree"})
def _norm_str(value: Any) -> str:
return str(value or "").strip()
def normalize_host(host: str | None) -> str:
"""Normalize a host identity for ownership matching (no credentials)."""
text = _norm_str(host).lower()
for prefix in ("https://", "http://"):
if text.startswith(prefix):
text = text[len(prefix) :]
# Drop path/query if a full URL slipped through.
text = text.split("/", 1)[0]
text = text.split("?", 1)[0]
return text.rstrip(".")
def ownership_category_for_role(role: str | None) -> str:
"""Map a role kind to a non-secret ownership category label."""
key = _norm_str(role).lower()
return _ROLE_TO_OWNERSHIP_CATEGORY.get(key, f"{key or 'unknown'}_lease")
def classify_branch_readback_http_status(
status_code: int | None,
*,
not_found_scope: str | None = None,
) -> dict[str, Any]:
"""Classify a GET-branch HTTP status into a secret-free readback result.
R1: A bare/generic/repository/wrong-host 404 never yields
``verified_absent=True``. Only an authoritative *branch-scoped* not-found
(``not_found_scope='branch'``) may verify deletion.
"""
if status_code == 404:
scope = _norm_str(not_found_scope).lower() or NOT_FOUND_SCOPE_UNKNOWN
if scope == NOT_FOUND_SCOPE_BRANCH:
return {
"status": READBACK_NOT_FOUND,
"error_class": None,
"verified_absent": True,
"branch_present": False,
"not_found_scope": NOT_FOUND_SCOPE_BRANCH,
"reasons": [],
}
# repository / host / unknown / generic 404 — not verified absence
reason = {
NOT_FOUND_SCOPE_REPOSITORY: (
"post-delete readback 404 is repository-scoped, not branch absence"
),
NOT_FOUND_SCOPE_HOST: (
"post-delete readback 404 is host-scoped, not branch absence"
),
}.get(
scope,
"post-delete readback 404 is ambiguous (not branch-scoped); "
"cannot verify absence",
)
return {
"status": READBACK_AMBIGUOUS_404,
"error_class": ERROR_CLASS_UNEXPECTED,
"verified_absent": False,
"branch_present": None,
"not_found_scope": scope,
"reasons": [reason],
}
if status_code in (401, 407):
return {
"status": READBACK_AUTHENTICATION,
"error_class": ERROR_CLASS_AUTHENTICATION,
"verified_absent": False,
"branch_present": None,
"reasons": ["post-delete branch readback authentication failed"],
}
if status_code == 403:
return {
"status": READBACK_AUTHORIZATION,
"error_class": ERROR_CLASS_AUTHORIZATION,
"verified_absent": False,
"branch_present": None,
"reasons": ["post-delete branch readback authorization failed"],
}
if status_code is not None and 200 <= int(status_code) < 300:
return {
"status": READBACK_EXISTS,
"error_class": None,
"verified_absent": False,
"branch_present": True,
"reasons": ["post-delete readback found branch still present"],
}
if status_code is not None and int(status_code) >= 500:
return {
"status": READBACK_TRANSPORT,
"error_class": ERROR_CLASS_TRANSPORT,
"verified_absent": False,
"branch_present": None,
"reasons": ["post-delete branch readback transport/upstream failure"],
}
return {
"status": READBACK_UNEXPECTED,
"error_class": ERROR_CLASS_UNEXPECTED,
"verified_absent": False,
"branch_present": None,
"reasons": ["post-delete branch readback returned an unexpected response"],
"http_status": status_code,
}
def _extract_http_status(exc: BaseException) -> int | None:
"""Extract an HTTP status code from an exception chain (secret-free)."""
seen: set[int] = set()
current: BaseException | None = exc
while current is not None and id(current) not in seen:
seen.add(id(current))
for attr in ("code", "status", "status_code"):
value = getattr(current, attr, None)
if isinstance(value, int) and 100 <= value <= 599:
return value
text = str(current) if current is not None else ""
match = re.match(r"HTTP\s+(\d{3})\b", text)
if match:
return int(match.group(1))
current = current.__cause__ or current.__context__
return None
def classify_branch_readback_exception(
exc: BaseException,
*,
not_found_scope: str | None = None,
) -> dict[str, Any]:
"""Classify a GET-branch exception without leaking credentials or bodies.
R1: substring ``404`` / ``not found`` alone never becomes verified_absent.
Callers must pass ``not_found_scope='branch'`` only after authoritative
proof that the repository/host is still reachable and the 404 is branch-level.
"""
status_code = _extract_http_status(exc)
if status_code is None:
lower = str(exc).lower() if exc is not None else ""
if any(
token in lower
for token in (
"timed out",
"timeout",
"connection",
"network",
"temporarily unavailable",
"name or service not known",
"nodename nor servname",
)
):
return {
"status": READBACK_TRANSPORT,
"error_class": ERROR_CLASS_TRANSPORT,
"verified_absent": False,
"branch_present": None,
"reasons": [
"post-delete branch readback transport/upstream failure"
],
}
if "unauthorized" in lower:
status_code = 401
elif "forbidden" in lower:
status_code = 403
elif "404" in lower or "not found" in lower:
# Ambiguous: do NOT treat as branch absence without scope proof.
status_code = 404
if not_found_scope is None:
not_found_scope = NOT_FOUND_SCOPE_UNKNOWN
if status_code is not None:
return classify_branch_readback_http_status(
status_code, not_found_scope=not_found_scope
)
return {
"status": READBACK_UNEXPECTED,
"error_class": ERROR_CLASS_UNEXPECTED,
"verified_absent": False,
"branch_present": None,
"reasons": ["post-delete branch readback returned an unexpected response"],
}
def assess_post_delete_readback(readback: dict[str, Any] | None) -> dict[str, Any]:
"""Decide whether DELETE success may be reported after branch readback.
Success requires authoritative branch-scoped not-found
(``verified_absent=True``). DELETE HTTP success alone is never enough.
Generic/repository/host 404 cannot verify absence (R1).
"""
payload = dict(readback or {})
status = _norm_str(payload.get("status")) or READBACK_UNEXPECTED
# Only explicit verified_absent flag counts — never infer from status alone
# when status is a bare not_found without branch scope proof.
verified = bool(payload.get("verified_absent")) is True
if verified and status == READBACK_NOT_FOUND:
return {
"ok": True,
"success": True,
"verified_absent": True,
"readback": {
"status": READBACK_NOT_FOUND,
"verified_absent": True,
"branch_present": False,
"error_class": None,
"not_found_scope": NOT_FOUND_SCOPE_BRANCH,
},
"reasons": [],
}
reasons = list(payload.get("reasons") or [])
if not reasons:
if status == READBACK_EXISTS:
reasons = ["post-delete readback found branch still present"]
elif status == READBACK_AUTHENTICATION:
reasons = ["post-delete branch readback authentication failed"]
elif status == READBACK_AUTHORIZATION:
reasons = ["post-delete branch readback authorization failed"]
elif status == READBACK_TRANSPORT:
reasons = ["post-delete branch readback transport/upstream failure"]
elif status == READBACK_AMBIGUOUS_404:
reasons = [
"post-delete readback 404 is not branch-scoped; "
"cannot verify absence"
]
else:
reasons = ["post-delete branch readback could not verify deletion"]
return {
"ok": False,
"success": False,
"verified_absent": False,
"readback": {
"status": status,
"verified_absent": False,
"branch_present": payload.get("branch_present"),
"error_class": payload.get("error_class"),
"not_found_scope": payload.get("not_found_scope"),
},
"reasons": reasons,
"blocker_kind": "post_delete_readback_failed",
}
def cleanup_result_envelope(
*,
success: bool,
performed: bool,
delete_acknowledged: bool,
verified_absent: bool,
**extra: Any,
) -> dict[str, Any]:
"""R2: consistent top-level cleanup result fields on every return path."""
out: dict[str, Any] = {
"success": bool(success),
"performed": bool(performed),
"delete_acknowledged": bool(delete_acknowledged),
"verified_absent": bool(verified_absent),
}
out.update(extra)
return out
def _repo_matches(
record: dict[str, Any],
*,
remote: str,
org: str,
repo: str,
host: str | None = None,
) -> bool:
"""Match ownership record to target remote/org/repo and normalized host."""
if (
_norm_str(record.get("remote")).lower() != _norm_str(remote).lower()
or _norm_str(record.get("org")).lower() != _norm_str(org).lower()
or _norm_str(record.get("repo")).lower() != _norm_str(repo).lower()
):
return False
expected_host = normalize_host(host)
record_host = normalize_host(record.get("host") or record.get("host_name"))
# When both sides declare a host, they must agree after normalization.
# A record host that disagrees with the expected host is out of scope.
# Legacy records without host still match when remote/org/repo agree.
if expected_host and record_host and expected_host != record_host:
return False
return True
def _branch_matches(record: dict[str, Any], branch: str) -> bool:
return _norm_str(record.get("branch")) == _norm_str(branch)
def assess_ownership_record_activity(record: dict[str, Any]) -> dict[str, Any]:
"""Classify one ownership record as blocking or non-blocking.
Distinguishes active ownership from expired/released/terminal/stale records.
Expired/stale records block unless reclaim_allowed is *explicitly* True
(O2: never treat missing/unknown reclaim as auto-allowed).
"""
status = _norm_str(record.get("status")).lower()
category = _norm_str(record.get("category")) or "unknown"
reclaim_allowed = record.get("reclaim_allowed")
if category == OWNERSHIP_CATEGORY_INVENTORY_ERROR:
return {
"blocks": True,
"status": status or "unknown",
"category": category,
"reason": "ownership inventory failed closed",
}
if status in _TERMINAL_OWNERSHIP_STATUSES:
return {
"blocks": False,
"status": status,
"category": category,
"reason": f"{category} ownership is terminal/released ({status})",
}
if status in _ACTIVE_OWNERSHIP_STATUSES:
return {
"blocks": True,
"status": status,
"category": category,
"reason": f"active {category} ownership still uses the target branch",
}
if status in _EXPIRED_STATUSES or status in _STALE_STATUSES:
# O2: only explicit reclaim_allowed=True skips the block.
if reclaim_allowed is True:
return {
"blocks": False,
"status": status,
"category": category,
"reason": (
f"{category} ownership is {status} and reclaimable; "
"does not block deletion"
),
}
return {
"blocks": True,
"status": status,
"category": category,
"reason": (
f"{status} {category} ownership still protects the target "
"branch (reclaim not proven; fail closed)"
),
}
# Unknown status → fail closed
return {
"blocks": True,
"status": status or "unknown",
"category": category,
"reason": (
f"unclassified {category} ownership status "
f"'{status or 'unknown'}'; fail closed"
),
}
def assess_active_branch_ownership(
*,
remote: str,
org: str,
repo: str,
branch: str,
host: str | None = None,
records: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Assess whether any active ownership still uses *branch* in *repo*.
Matching requires remote/org/repo/branch and, when provided, normalized
host identity. Other repositories, hosts, or branches never false-block.
"""
target_branch = _norm_str(branch)
expected_host = normalize_host(host)
considered: list[dict[str, Any]] = []
blocking: list[dict[str, Any]] = []
ignored: list[dict[str, Any]] = []
for raw in records or []:
if not isinstance(raw, dict):
continue
if not _repo_matches(
raw, remote=remote, org=org, repo=repo, host=expected_host or None
):
ignored.append(
{
"category": _norm_str(raw.get("category")) or "unknown",
"reason": "different repository or host scope",
}
)
continue
if not _branch_matches(raw, target_branch):
ignored.append(
{
"category": _norm_str(raw.get("category")) or "unknown",
"reason": "different branch",
}
)
continue
activity = assess_ownership_record_activity(raw)
entry = {
"category": activity["category"],
"status": activity["status"],
"blocks": activity["blocks"],
"reason": activity["reason"],
}
considered.append(entry)
if activity["blocks"]:
blocking.append(entry)
block = bool(blocking)
categories = sorted({b["category"] for b in blocking})
reasons = [
(
"active ownership protects branch "
f"'{target_branch}': " + "; ".join(b["reason"] for b in blocking)
)
] if block else []
return {
"block": block,
"safe_to_delete": not block,
"remote": remote,
"org": org,
"repo": repo,
"host": expected_host or None,
"branch": target_branch,
"blocking_categories": categories,
"blocking": blocking,
"considered": considered,
"ignored_out_of_scope": ignored,
"reasons": reasons,
"blocker_kind": "active_branch_ownership" if block else None,
"recommended_action": "keep_remote_branch" if block else "delete_remote_branch",
}
+267
View File
@@ -0,0 +1,267 @@
"""Immutable canonical repository root for cross-repository namespaces (#706).
The Gitea-Tools MCP server historically derived the ``canonical_repo_root`` from
the *install checkout* the server script lives in
(``PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))``). A namespace
that runs the same server script against an *external* repository (e.g.
``eagenda-author`` targeting ``eAgenda``) then failed every mutation: the
branches-only / worktree-membership guards (#274) compared the task workspace
against the Gitea-Tools ``.git`` directory, which it can never belong to.
This module separates two distinct concepts:
* the immutable code/install root (``PROJECT_ROOT``) — where the server lives, and
* the namespace-scoped **canonical repository root** — the working root of the
repository whose issues/PRs the namespace mutates.
The canonical repository root is configured per namespace (profile field or an
environment variable, typically set alongside the namespace ``cwd`` in the MCP
config). It is validated (existence, git identity, git common-directory
membership) and pinned immutably into the session context so a later call cannot
forge or swap it. When *no* binding is configured the single-repo default is
preserved unchanged: the canonical root is derived from the process checkout.
"""
from __future__ import annotations
import os
import subprocess
from typing import Mapping
import remote_repo_guard
# Namespace-scoped override, typically exported next to the server ``cwd`` in the
# MCP config for a cross-repository namespace.
CANONICAL_ROOT_ENV = "GITEA_CANONICAL_REPOSITORY_ROOT"
# Candidate git remote names probed when deriving repository identity.
_IDENTITY_REMOTE_CANDIDATES = ("prgs", "origin", "dadeschools", "mdcps")
def configured_canonical_root(
profile: Mapping | None,
env: Mapping | None,
) -> tuple[str | None, str | None]:
"""Return ``(value, source)`` for the declared canonical repository root.
Precedence: the ``GITEA_CANONICAL_REPOSITORY_ROOT`` environment variable
(namespace-scoped) overrides the profile ``canonical_repository_root``
field. Blank values are treated as unset. Returns ``(None, None)`` when no
binding is declared (the single-repo default).
"""
env_map = env if env is not None else os.environ
env_val = (env_map.get(CANONICAL_ROOT_ENV) or "").strip()
if env_val:
return env_val, f"{CANONICAL_ROOT_ENV} environment variable"
if profile:
prof_val = (profile.get("canonical_repository_root") or "").strip()
if prof_val:
return prof_val, "profile canonical_repository_root"
return None, None
def resolve_repo_toplevel(path: str) -> str | None:
"""Realpath of the git working-tree top level for *path*, or None."""
text = (path or "").strip()
if not text:
return None
try:
res = subprocess.run(
["git", "-C", text, "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=True,
)
except Exception:
return None
top = (res.stdout or "").strip()
return os.path.realpath(top) if top else None
def repository_identity_slug(path: str, *, remote: str | None = None) -> str | None:
"""``owner/repository`` derived from a git remote configured at *path*.
Tries the caller-named remote first, then a small set of known remote names,
then whatever remote the repository actually has. Returns None when no remote
URL is parseable (identity cannot be proven).
"""
text = (path or "").strip()
if not text:
return None
ordered: list[str] = []
for name in (remote, *_IDENTITY_REMOTE_CANDIDATES):
clean = (name or "").strip()
if clean and clean not in ordered:
ordered.append(clean)
try:
listed = subprocess.run(
["git", "-C", text, "remote"],
capture_output=True,
text=True,
check=True,
).stdout.split()
except Exception:
listed = []
for name in listed:
if name and name not in ordered:
ordered.append(name)
for name in ordered:
try:
url = subprocess.run(
["git", "-C", text, "remote", "get-url", name],
capture_output=True,
text=True,
check=True,
).stdout.strip()
except Exception:
continue
parsed = remote_repo_guard.parse_org_repo_from_remote_url(url)
if parsed:
return f"{parsed[0]}/{parsed[1]}"
return None
def assess_canonical_repository_root(
*,
configured_value: str | None,
source: str | None,
expected_slug: str | None,
process_project_root: str,
remote: str | None = None,
require_binding: bool = False,
) -> dict:
"""Validate the canonical repository root binding, failing closed on forgery.
Returns a dict with ``proven`` / ``block`` / ``reasons`` plus the resolved
``canonical_repo_root`` (the value downstream guards must use),
``configured`` (whether a cross-repo binding was declared),
``resolved_slug`` and ``source``.
Without a configured binding the single-repo default is preserved: the
canonical root is derived from *process_project_root* and never blocks
(unless *require_binding* explicitly demands one).
With a configured binding the path must exist, be a git repository, and —
when *expected_slug* is known — carry a matching repository identity. A
mismatched or (when *require_binding*) unprovable identity is a forged or
conflicting binding and fails closed.
"""
process_root = os.path.realpath(process_project_root)
declared = (configured_value or "").strip()
if not declared:
if require_binding:
return _assessment(
proven=False,
reasons=[
"no canonical_repository_root configured for a cross-repository "
f"namespace; set {CANONICAL_ROOT_ENV} or the profile "
"canonical_repository_root field (fail closed)"
],
configured=False,
canonical_repo_root=process_root,
resolved_slug=None,
source=None,
)
# Single-repo default: canonical root follows the install checkout.
derived = resolve_repo_toplevel(process_root) or process_root
return _assessment(
proven=True,
reasons=[],
configured=False,
canonical_repo_root=derived,
resolved_slug=None,
source=None,
)
real = os.path.realpath(os.path.abspath(declared))
if not os.path.isdir(real):
return _assessment(
proven=False,
reasons=[
f"configured canonical repository root '{real}' does not exist "
"or is not a directory (fail closed)"
],
configured=True,
canonical_repo_root=real,
resolved_slug=None,
source=source,
)
toplevel = resolve_repo_toplevel(real)
if not toplevel:
return _assessment(
proven=False,
reasons=[
f"configured canonical repository root '{real}' is not a git "
"repository (fail closed)"
],
configured=True,
canonical_repo_root=real,
resolved_slug=None,
source=source,
)
resolved_slug = repository_identity_slug(toplevel, remote=remote)
reasons: list[str] = []
expected = (expected_slug or "").strip() or None
if expected:
if resolved_slug and resolved_slug.lower() != expected.lower():
reasons.append(
f"canonical repository root identity mismatch: '{toplevel}' resolves "
f"to repository '{resolved_slug}' but the session is authorized for "
f"'{expected}' (forged or conflicting binding, fail closed)"
)
elif not resolved_slug and require_binding:
reasons.append(
f"canonical repository root '{toplevel}' has no resolvable git "
f"remote identity to confirm authorization for '{expected}' "
"(fail closed)"
)
return _assessment(
proven=not reasons,
reasons=reasons,
configured=True,
canonical_repo_root=toplevel,
resolved_slug=resolved_slug,
source=source,
)
def format_canonical_repository_root_error(assessment: Mapping) -> str:
"""Single RuntimeError message for MCP preflight gates."""
root = assessment.get("canonical_repo_root") or "(unknown)"
source = assessment.get("source") or "(unconfigured)"
reasons = "; ".join(
assessment.get("reasons") or ["unknown canonical repository root violation"]
)
return (
f"Canonical repository root guard (#706): {reasons}. "
f"binding source: {source}; canonical repository root: {root}. "
"Configure a valid canonical_repository_root for the target repository "
"and relaunch; do not point it at the Gitea-Tools install checkout."
)
def _assessment(
*,
proven: bool,
reasons: list[str],
configured: bool,
canonical_repo_root: str,
resolved_slug: str | None,
source: str | None,
) -> dict:
return {
"proven": proven,
"block": not proven,
"reasons": list(reasons),
"configured": configured,
"canonical_repo_root": canonical_repo_root,
"resolved_slug": resolved_slug,
"source": source,
}
@@ -0,0 +1,198 @@
# ADR: Stable control runtime vs dev runtime (Gitea MCP)
- **Status:** Accepted (policy effective immediately for LLM sessions; tooling may lag)
- **Date:** 2026-07-09
- **Tracking issue:** [#615](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/615)
- **Related:**
- [#543](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/543) / `docs/mcp-namespace-health.md` — client-namespace health
- `docs/mcp-namespace-eof-recovery.md` — reconnect-only EOF recovery (no PID kill)
- [#558](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/558) / `docs/mcp-daemon-import-guard.md` — sanctioned daemon
- [#557](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/557) / `docs/bootstrap-review-path.md` — controller bootstrap for self-hosted fixes
- Allocator / control-plane ADR: `docs/architecture/mcp-allocator-control-plane-observability-adr.md` (#613 / PR #614)
## 1. Context
The Gitea MCP server is the **control plane** for real issue/PR mutations (create, comment, lock, review, merge, etc.). When author/reviewer/merger/reconciler sessions kill or restart that process, relaunch it from a feature worktree, or edit the checkout that process loads, operators observe:
- Mid-session identity/preflight resets
- Stale-runtime vs master parity failures
- IDE transport EOF / “tool not found” while code on disk has changed
- Accidental production mutations from experimental code
This ADR separates **stable control runtime** from **dev/test runtime** and defines promotion proof.
## 2. Decision
### 2.1 Stable control runtime
The Gitea MCP server used for **real workflow mutations** is the **stable control runtime**.
Characteristics:
- Loads a known, promoted revision of Gitea-Tools (or the packaged release layout operators designate)
- Registered in the IDE/client as the production namespaces (`gitea-tools`, `gitea-reviewer`, `gitea-merger`, `gitea-reconciler`, etc.)
- Holds production profile credentials via sanctioned keychain/env paths only
### 2.2 Dev / test runtime
MCP **server code** development and testing:
- Happens in isolated **`branches/`** worktrees (or other non-stable checkouts)
- May use a **separate** dev/test MCP runtime/process when process-level testing is required
- **Must not** be used for real Gitea mutations on production issues/PRs
### 2.3 Forbidden actions (normal sessions)
Normal **author, reviewer, merger, and reconciler** LLM sessions **must not**:
| Forbidden | Why |
|-----------|-----|
| Kill the running MCP server process | Drops all concurrent sessions; loses preflight state |
| Restart / relaunch the MCP server process | Same as kill; causes stale/identity churn mid-workflow |
| Relaunch MCP from a development worktree | Runs unpromoted code against production mutations |
| Edit files in the stable runtime checkout | Hot-mutates control plane under concurrent users |
| Use experimental/dev MCP for real Gitea mutations | Bypasses promotion proof and audit expectations |
| Bypass or self-reset a stale master-parity gate | The gate is fail-closed; only an operator reload restores parity |
**LLM-allowed vs operator-owned (authoritative split):**
| Actor | May do | Must not do |
|-------|--------|-------------|
| **LLM session** | Call tools on the already-running stable namespaces; **client reconnect** after transport EOF (no process kill); pass `worktree_path` / role worktree args; report blockers and stop mutations when unhealthy/stale | Kill, restart, or relaunch any MCP process; bump config mtimes to force reload; edit the stable checkout; switch to a dev MCP for production mutations |
| **Operator / release-manager** | Supervised restart/reload of the **stable** control runtime; dual-namespace client configuration; §2.4 promotions; incident recovery | — |
EOF / transport recovery for LLM sessions: **client reconnect only** (see `docs/mcp-namespace-eof-recovery.md`). Do not “fix” health by killing PIDs or bumping MCP config mtimes as a normal session procedure.
This ADR **supersedes** any older runbook wording that told the LLM to relaunch or restart the client/MCP as a self-service step. Where `docs/llm-workflow-runbooks.md` (or wiki runbooks) discuss dual-namespace setup or workspace rebind, **process restart/relaunch is operator-owned**; the LLM stops, reports, and waits.
### 2.4 Promotion (operator / release-manager only)
Promotion of a new revision into the stable control runtime is an **explicit operator/release-manager action**, not an LLM self-service step.
A promotion **must record** (issue comment, release note, or promotion ledger):
| Field | Description |
|-------|-------------|
| **previous runtime SHA** | Commit previously loaded by stable runtime |
| **promoted runtime SHA** | Commit after promotion |
| **source branch/PR** | Where the change was reviewed |
| **restart/reload method** | How the process was cycled (e.g. supervised restart, client reload) |
| **health check proof** | Client-namespace probe success (`gitea_whoami` / namespace health) |
| **identity/profile proof** | Expected profile(s) and username(s) after reload |
| **workspace/root proof** | Stable checkout path / root matches intended layout |
| **mutation capability proof** | Required permissions for the target role present; forbidden ops still forbidden |
| **rollback instructions** | How to restore previous SHA and re-verify health |
Suggested durable marker:
```text
## MCP STABLE RUNTIME PROMOTION (#615)
Status: COMPLETED | ROLLED_BACK | ABORTED
Previous-SHA: <full sha>
Promoted-SHA: <full sha>
Source-PR: <number>
Source-Branch: <name>
Reload-Method: <text>
Health-Proof: client_namespace whoami OK / assess_mcp_namespace_health OK
Identity-Proof: profile=<name> user=<name>
Workspace-Proof: root=<path>
Mutation-Proof: allowed_ops include <…>; forbidden include <…>
Rollback: checkout <previous sha>; reload method <…>; re-run health/identity proofs
Operator: <username>
Timestamp: <ISO-8601>
```
### 2.5 Unhealthy stable runtime → stop work
If the stable MCP runtime is **unhealthy**, including any of:
- client-namespace probes fail
- wrong identity / wrong profile
- wrong workspace root
- missing mutation capability for the intended role
- persistent EOF after **client reconnect**
- **master parity is stale** (`startup_head` behind on-disk `master` / `restart_required` from the parity gate)
then:
1. **Normal PR / review / merge / issue-mutation work must stop immediately.**
2. The session **reports** the unhealthy/stale state (tool error, CTH, or operator handoff) with startup vs current head when known.
3. Do **not** improvise: no LLM process kill/restart, no dev-worktree MCP for production mutations, no env escape hatches, no manual gate bypass.
4. Resume only after:
- an **operator** restores the runtime (see §2.6 for routine post-merge parity reload), **or**
- a **controlled** promotion/rollback completes with the §2.4 promotion record, **or**
- a controller invokes the narrow **bootstrap review path** (#557) when the defect is self-hosted and documented,
- **and** the session re-verifies health (and master parity when applicable) before the next mutation.
### 2.6 Routine post-merge master-parity staleness (operator reload, not promotion)
**Symptom:** After merges land on `master`, a long-lived stable MCP process still runs the pre-merge `startup_head`. The master-parity gate marks the server **stale** / `restart_required` and **blocks mutations**.
**Sanctioned response (authoritative):**
| Step | Actor | Action |
|------|-------|--------|
| 1 | LLM session | Mutations stop immediately when the gate reports stale. |
| 2 | LLM session | Report the stale state (startup head, current master head, that operator reload is required). Do not retry mutations. |
| 3 | **Operator** | Reload/restart the **stable** control MCP so it loads current `master` (supervised client/daemon reload). |
| 4 | LLM session | Resume only after startup/current-head parity is verified (e.g. `gitea_get_runtime_context` / parity assessment shows in parity). |
**Not a §2.4 promotion:** Catching the already-designated stable control checkout up to a newly advanced `master` is a **routine operator reload** of the stable runtime. It does **not** require the nine-field promotion ledger. Use §2.4 only when **changing which unpromoted/dev revision** becomes the stable control runtime (new source branch/PR into the stable designation).
**Code note:** `master_parity_gate.py` may still say “restart the server” in machine-facing reason strings. That string names the **operator recovery action**, not an LLM self-service instruction. This ADR and the runbooks define the actor split.
## 3. Relationship to other controls
| Doc / mechanism | Interaction |
|-----------------|-------------|
| Namespace health (#543) | Proves IDE client can call tools; does not authorize restart |
| EOF recovery | Reconnect only; no process kill |
| Daemon import guard (#558) | Mutations require sanctioned daemon; not a bare shell import |
| Bootstrap path (#557) | Only controller-authorized exception when live runtime cannot review its own fix |
| Allocator / control-plane ADR | Coordination DB is separate; still depends on a healthy MCP surface for Gitea writes |
## 4. Consequences
### Positive
- Predictable control plane for concurrent LLMs
- Clear operator-only promotion gate with rollback
- Aligns session behavior with health/EOF docs already landed
### Costs
- LLM sessions must wait when runtime is sick (no DIY restart)
- Operators must maintain promotion discipline and dual-runtime config if they use a dev MCP
### Non-goals
- Does not ban operator-supervised restarts during incidents
- Does not replace CI or code review for MCP changes
- Does not authorize editing stable checkout “because tests need a quick fix”
## 5. Implementation follow-ups (optional tooling)
These may land in later issues; the **policy binds sessions now**:
1. Session preflight that refuses mutations if workspace root equals a `branches/` feature worktree configured as “dev only.”
2. Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata.
3. Promotion checklist script that emits the durable promotion marker fields.
**Not optional (issue #615 acceptance criterion 2):** operator guide and runbooks **must** cross-link this ADR (see §6). Cross-links are documentation acceptance, not deferred tooling.
## 6. Acceptance for this ADR
1. Document merged under `docs/architecture/mcp-stable-control-runtime-policy-adr.md`.
2. **Operator guide / runbooks cross-link this ADR** (`docs/wiki/Operator-Guide.md`, `docs/wiki/Runbooks.md`, `docs/llm-workflow-runbooks.md`).
3. Issue #615 references this path.
4. LLM/operator runbooks treat kill/restart/relaunch-from-worktree as **LLM violations**; process restart is **operator-owned**.
5. Unhealthy runtime (including **stale master parity**) stops normal mutation work until operator restore/reload, promotion/rollback, or #557 bootstrap — then re-verify parity before mutating.
6. Routine post-merge parity reload is documented as operator reload (§2.6), not an LLM self-restart and not a full §2.4 promotion.
## 7. Document history
| Date | Change |
|------|--------|
| 2026-07-09 | Initial ADR: stable vs dev runtime, forbidden session actions, promotion proof fields, stop-work rule |
| 2026-07-16 | Review 443 remediation (#615 / PR #616): mandatory cross-links; LLM vs operator restart split; routine post-merge parity staleness (§2.6); stale parity in §2.5 unhealthy triggers |
+153
View File
@@ -0,0 +1,153 @@
# Installation root vs canonical target repository root
## Purpose
This document (tracked as issue #741, building on #706 and #739/#740) explains
the two distinct filesystem roots the Gitea-Tools MCP server reasons about, why
conflating them silently targets the wrong repository, and which rule applies
when you add a new consumer.
It is the repository-scope companion to
[`gitea-execution-profiles.md`](gitea-execution-profiles.md) (the profile model)
and [`gitea-dual-namespace-deployment.md`](gitea-dual-namespace-deployment.md)
(the per-role namespace model).
## The two roots
| | Installation root | Canonical target repository root |
|---|---|---|
| What it is | The checkout the server *code* lives in | The working root of the repository whose issues/PRs/branches the namespace *mutates* |
| How it is derived | `PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))` | Configured per namespace, then pinned immutably into the session |
| Configured by | Nothing — it follows the script | `canonical_repository_root` profile field, or the `GITEA_CANONICAL_REPOSITORY_ROOT` environment variable |
| Changes at runtime? | No | No — first bind wins for the life of the process |
| Accessor | `PROJECT_ROOT` | `_canonical_local_git_root()` (filesystem) / `_canonical_repository_slug()` (identity) |
For a **single-repository** namespace — every Gitea-Tools namespace today — the
two roots are the same path, and nothing about the existing behaviour changes.
The distinction only becomes observable once a namespace is pointed at a
different repository.
## Which root does my code need?
Ask what the operation is *about*, not where the file happens to sit.
**Use the installation root (`PROJECT_ROOT`)** when the operation concerns the
Gitea-Tools software itself:
- server implementation / version parity (`master_parity_gate`, the
`startup_head` vs `current_head` staleness gate);
- loading the server's own workflow, schema and skill files;
- self-code hashing and stale-runtime detection;
- locating installed scripts such as `mirror_refs.sh`.
These are intentionally install-scoped. Do not "fix" them.
**Use the canonical target root (`_canonical_local_git_root()`)** when the
operation concerns the repository being worked on:
- `git remote get-url` for repository identity;
- branch creation, push, and commit;
- ancestry and merge-base proofs;
- worktree inventory, cleanup, and branch deletion;
- any local git subprocess whose result feeds a mutation guard.
**If you cannot tell, fail closed.** An ambiguous consumer that guesses the
install root is the exact defect class #741 exists to eliminate.
## Why conflating them inverts the guards
Before #741, `_local_git_remote_url()` ran `git remote get-url` with
`cwd=PROJECT_ROOT` unconditionally. Every consumer of repository *identity*
`_resolve`, the #530 remote/repo guard, the anti-stomp org/repo fill,
`_workspace_repository_slug` — therefore read the Gitea-Tools remote and called
it "the workspace", no matter which repository the namespace was bound to.
For a namespace whose canonical root points elsewhere, this **inverts** the
guard rather than merely weakening it:
- an operation naming the genuinely bound target repository is **rejected**,
because that slug does not appear in the Gitea-Tools remote URL;
- an operation naming Gitea-Tools is **accepted**.
The filesystem guards (#274 branches-only and worktree membership) had already
been migrated to the canonical root by #706, so the two halves of a single
assessment described two different repositories.
A related subtlety: repository identity must not be derived by looking a remote
up by *name*. A target checkout commonly names its remote `origin` rather than
`prgs`, so a name-keyed lookup returns nothing and the omitted coordinates fall
through to the remote-wide default *target* — an unrelated repository.
`_canonical_repository_slug()` probes candidate remote names against the
canonical root instead.
`_canonical_local_git_root()` is now the one place a target root is resolved.
Do not re-derive it; new code that needs a target root calls that helper.
## Configuration
Declare the binding on the profile, alongside `allowed_repositories`:
```json
{
"profiles": {
"example-author": {
"role": "author",
"canonical_repository_root": "/absolute/path/to/target-repo",
"allowed_repositories": ["Example-Org/target-repo"]
}
}
}
```
The namespace-scoped environment variable
`GITEA_CANONICAL_REPOSITORY_ROOT` overrides the profile field, and is normally
exported next to the server `cwd` in the MCP client configuration.
Validation is layered, and each layer fails closed:
1. **Config load.** The path must be a non-empty absolute string. All supported
loaders — v1, v2-`environments`, and v2-`contexts` — validate it identically.
(Before #741 only the v2-`contexts` loader validated it, and
v2-`environments` silently *dropped* the field during flattening, so the
namespace fell back to the install root — a fail-open.)
2. **Bind time.** The path must exist, be a git repository, and resolve to a
repository identity matching the session's authorized slug. A configured but
unresolvable root is never replaced by the install identity.
3. **Every mutation.** The pinned root is compared against the live configured
value; a mismatch is treated as a forged or conflicting binding.
`allowed_repositories` remains a separate authorization boundary (#714): the
canonical root determines *which* repository is derived, and
`allowed_repositories` determines whether the session may act on it. A root that
resolves to a repository outside that list fails closed.
## Explicit coordinates confirm, never override
Explicit `org`/`repo` arguments may **confirm** an existing canonical binding.
They can never establish, complete, or replace one. A request naming a
repository that contradicts the binding fails closed, in both directions:
- a Gitea-Tools-rooted namespace cannot mutate another repository;
- a namespace rooted at another repository cannot mutate Gitea-Tools.
This matters because both-explicit coordinates short-circuit the #530
remote/repo match check, so without this rule a caller could name any repository
and skip validation entirely.
No request-supplied workspace, remote, owner, repository, or worktree can
replace the immutable root.
## Parity is reported per dimension
`gitea_assess_master_parity` reports two separately labelled dimensions:
- `server_implementation` — the Gitea-Tools installation checkout. Its
`startup_head` / `current_head` / `stale` / `restart_required` fields keep
their original meaning, and **only this dimension gates mutations**: the
running process executes the code it started with, so a merged fix is not live
until the daemon restarts.
- `target_repository` — the configured canonical target checkout and its
last-known remote master.
"In parity" is a statement about one dimension, never about the whole system.
Read the dimension you actually care about.
+227
View File
@@ -45,6 +45,7 @@ authenticated capability set. Each profile defines the following fields:
| `authenticated_username` | string | The Gitea login this profile authenticates as (verified at runtime via `gitea_whoami`, not trusted from config). |
| `allowed_operations` | list | Operation categories this profile may perform. |
| `forbidden_operations` | list | Operation categories this profile must never perform. |
| `allowed_repositories` | list | Optional. Canonical `owner/repository` slugs this profile may bind to. An authorization boundary, not the binding itself — see [Repository scope](#repository-scope-714). |
| `token_source_name` | string | The *name* of the secret source (e.g. env var name or secret key). **Never the token value.** |
| `audit_label` | string | Short label attached to audit records for actions by this profile. |
| `can_approve_prs` | bool | May submit an approving PR review. |
@@ -57,6 +58,47 @@ authenticated capability set. Each profile defines the following fields:
name), never the token itself. Token values are never part of a profile object,
never logged, never returned by a tool, and never committed.
## Repository scope (#714)
`allowed_repositories` declares the canonical `owner/repository` slugs a profile
may operate on:
```json
"prgs-author": {
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"]
}
```
It is an **authorization boundary, not the session binding**. The binding is
derived and enforced like this:
1. The session repository is derived from the **verified, workspace-aligned git
remote** — never from a caller-supplied `org`/`repo` argument, and never from
the `REMOTES` table (whose entries are default *targets*: `prgs` defaults to
`Timesheet`, which is not this project).
2. That workspace-derived slug is validated against `allowed_repositories`.
3. The session binds immutably to that one canonical `owner/repository`. The
organization is derived from the slug; there is no independent,
caller-controlled organization value.
4. If a profile authorizes several repositories, the verified workspace still
selects exactly one. A session never binds to the whole list and never
switches between entries.
Fail-closed rules:
- Activation is rejected when the workspace repository is absent from the
allowlist.
- A mutation is rejected when no verified workspace repository can be
established.
- A tool-level `org`/`repo` override that disagrees with the binding is rejected
before the mutation runs.
- A mutation request can never establish, complete, or replace the binding.
The field is **config-only**: no environment variable can widen or forge it. It
is optional — a profile that omits it keeps the previous behaviour, so existing
static-profile namespaces stay functional until an operator provisions the
scope. Provisioning it is what activates enforcement for that profile.
## Example profiles
The following are the reference profiles. Booleans express intended capability
@@ -238,11 +280,43 @@ narrow operation set:
- `gitea.issue.comment`
- `gitea.issue.close`
- `gitea.pr.close`
- `gitea.branch.delete` (merged-branch cleanup only — see below)
Forbidden on reconciler profiles: `gitea.pr.approve`, `gitea.pr.merge`,
`gitea.pr.review`, `gitea.pr.create`, `gitea.branch.push`, and
`gitea.repo.commit`.
### Merged-branch cleanup ownership (`gitea.branch.delete`)
The reconciler is the repository-supported owner of merged-PR source-branch
cleanup: `task_capability_map` maps `cleanup_merged_pr_branch` (and
`reconciliation_cleanup`) to role `reconciler` with permission
`gitea.branch.delete`. Post-merge branch lifecycle is reconciliation work —
it happens after the author, reviewer, and merger roles have completed, and
it must not be reachable from those roles.
Least-privilege constraints:
- `gitea.branch.delete` is granted **only** to reconciler profiles. Author,
reviewer, and merger profiles must never hold it; `gitea_delete_branch`
and `gitea_cleanup_merged_pr_branch` fail closed on any profile without
the permission.
- Even with the permission, reconciler deletion is only supported through the
guarded `gitea_cleanup_merged_pr_branch` path (#514 / #687): the PR must be
merged, the head an ancestor of the target, the branch not protected
(`master`/`main`/`dev`), the branch not a preservation/evidence ref (e.g.
`chore/issue-681-preserve-review-session-wip`), no open PR may still use the
head, and an explicit `CLEANUP MERGED PR <n> BRANCH <branch>` confirmation is
required. Raw `gitea_delete_branch` is **denied** to reconciler even when
`gitea.branch.delete` is present.
- Raw `git branch -d` / `git push --delete` cleanup remains blocked by
`branch_cleanup_guard` and the final-report validator regardless of
profile permissions.
- `gitea.branch.delete` has no short alias in `GITEA_OPERATION_ALIASES`;
write it fully qualified in `allowed_operations`. Migration must emit
canonical names such as `gitea.pr.close` (never bare `pr.close` /
`issue.close`, which the production normalizer rejects or drops).
Launch a static `gitea-reconciler` MCP namespace with
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
`reconciler_profile.assess_reconciler_profile` (#304). Use the
@@ -251,6 +325,159 @@ Launch a static `gitea-reconciler` MCP namespace with
fresh target-branch fetch, recorded target SHA, and ancestor proof. PRs whose
heads are not already landed cannot be closed through this path.
### Operational runbook: grant reconciler `gitea.branch.delete` (#687)
Merging a code PR that updates `migrate_profiles.py` / `reconciler_profile.py`
**does not** change the live operator profile on disk. Apply the profile
change deliberately, then reconnect the client-managed namespace.
1. **Approved migration / profile-update command** (from the repo root, using
the project venv if present):
```bash
# Dry-run first (default): validates v2 output, writes nothing
python3 migrate_profiles.py -i ~/.config/gitea-tools/profiles.json
# Apply: creates backup then writes migrated v2 config
python3 migrate_profiles.py -i ~/.config/gitea-tools/profiles.json -w
# Optional explicit paths:
# python3 migrate_profiles.py -i ~/.config/gitea-tools/profiles.json \
# -o ~/.config/gitea-tools/profiles.json \
# --backup ~/.config/gitea-tools/profiles.json.bak -w
```
If the live file is already v2, edit the reconciler identitys
`allowed_operations` / `forbidden_operations` under
`environments.<env>.services.gitea.identities.reconciler` (or the
`prgs-reconciler` alias target) so allowed includes the canonical set
below — then re-validate with a load of the config (see step 3).
2. **Inspect the generated (or edited) profile** — confirm the reconciler
identity, for example:
```bash
python3 - <<'PY'
import json
from pathlib import Path
cfg = json.loads(Path.home().joinpath(".config/gitea-tools/profiles.json").read_text())
# v2 environments shape:
ident = cfg["environments"]["prgs"]["services"]["gitea"]["identities"]["reconciler"]
print("role:", ident.get("role"))
print("allowed:", ident.get("allowed_operations"))
print("forbidden:", ident.get("forbidden_operations"))
PY
```
3. **Validate canonical operation names and least privilege**
Expected canonical **allowed** (defaults after migration):
- `gitea.read`
- `gitea.pr.close` (required)
- `gitea.pr.comment`
- `gitea.issue.comment`
- `gitea.issue.close`
- `gitea.branch.delete` (recommended; cleanup only)
Expected **forbidden** includes at least: `gitea.pr.approve`,
`gitea.pr.merge`, `gitea.pr.review`, `gitea.pr.create`,
`gitea.branch.push`, `gitea.repo.commit`.
No shorthand (`pr.close`, `issue.close`, `pr.comment`) may remain.
Validate with the production loader:
```bash
python3 - <<'PY'
import gitea_config, reconciler_profile
from pathlib import Path
path = str(Path.home() / ".config/gitea-tools/profiles.json")
gitea_config.load_config(path) # fails closed on invalid config
# Or assess the reconciler lists directly after extracting them:
# print(reconciler_profile.assess_reconciler_profile(allowed, forbidden))
PY
```
4. **Merging PR #688 (or any code PR) does not update the live profile.**
Code changes only the migration helper, schema, docs, and tests. The
operator must still run `migrate_profiles.py -w` or an equivalent
authorized edit of `~/.config/gitea-tools/profiles.json`.
5. **Supported apply method:** `python3 migrate_profiles.py … -w` (backup
created automatically) **or** operator-authorized edit of the live
profiles file after backup. Unsupported: silent mtime tricks, manual
process kill to “reload”, or undocumented env overrides.
6. **Backup and validation:** `-w` copies the input to
`<input_path>.bak` (or `--backup PATH`) before writing. Re-run
`load_config` / `assess_reconciler_profile` after write. Keep the
`.bak` until live whoami/capability checks pass.
7. **Client-managed namespace reconnect/reload:** reconnect or reload the
IDE MCP client so `gitea-reconciler` restarts from current `master` and
the updated `GITEA_MCP_PROFILE=prgs-reconciler` config. Do not hand-launch
`mcp_server.py` / `gitea_mcp_server.py` with ad hoc `GITEA_*` env
(see #686 / #630).
8. **Live reverification** (through the client-managed `gitea-reconciler`
namespace only):
- `gitea_whoami` → identity + profile `prgs-reconciler`
- `gitea_assess_master_parity` → `stale=false`, `restart_required=false`
- `gitea_resolve_task_capability(task="cleanup_merged_pr_branch")` →
`allowed_in_current_session=true` only when permission and role match
- `gitea_resolve_task_capability(task="delete_branch")` →
**not** allowed for reconciler (role denial must be enforced)
9. **Guarded cleanup usage** (example for a merged PR whose source branch
remains on the remote):
```text
gitea_cleanup_merged_pr_branch(
pr_number=<N>,
branch=<exact PR head branch>,
confirmation="CLEANUP MERGED PR <N> BRANCH <exact PR head branch>",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path="<path under branches/>",
)
```
The tool refuses unmerged PRs, protected branches, preservation/evidence
branches, open-PR heads, mismatched branch names, and wrong confirmation.
10. **Prohibitions**
- No raw `git push --delete`, `git branch -d` / `-D`, or delete refspecs
- No arbitrary `gitea_delete_branch` from reconciler
- No unsupported profile switching mid-run without full re-preflight
- No ad hoc hand-edits of live profiles **unless** operator-authorized,
backed up, and revalidated as above
Canonical migrated reconciler example:
```json
{
"role": "reconciler",
"allowed_operations": [
"gitea.read",
"gitea.pr.close",
"gitea.pr.comment",
"gitea.issue.comment",
"gitea.issue.close",
"gitea.branch.delete"
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.review",
"gitea.pr.create",
"gitea.branch.push",
"gitea.repo.commit"
]
}
```
## Identity and fail-closed rules
Before **any** mutating action, a workflow must know both:
+13 -8
View File
@@ -195,7 +195,7 @@ To avoid the bottleneck of relaunching/restarting the MCP server to switch betwe
`gitea_reconcile_already_landed_pr` after ancestry proof — not for normal
review or author workflows.
* **Fallback:** If the dual-profile MCP launcher pattern is not supported or configured in the client, the LLM must relaunch or restart the client/MCP with the correct profile environment variable before claiming or working on any tasks.
* **Fallback (operator-owned):** If the dual-profile MCP launcher pattern is not supported or configured in the client, **do not** have the LLM relaunch or restart the client/MCP. The LLM **stops** role-switching work, reports that the correct static namespace is missing, and waits for an **operator** to configure dual namespaces or reload the client with the correct `GITEA_MCP_PROFILE` for that role. Process restart/relaunch is operator-owned under the [stable control runtime ADR](architecture/mcp-stable-control-runtime-policy-adr.md) (#615).
## Setup runbook — interactive menu
@@ -1200,10 +1200,13 @@ When a mutation blocks on workspace binding:
1. Read the error — it names the **resolved workspace path**, **role
namespace**, and **binding source** (tool arg, env var, or process root).
2. Reconnect or relaunch the correct namespace MCP server from the intended
workspace (or set the role-specific env var before launch).
3. Pass `worktree_path` on reviewer/merger mutation tools when the active
branches/ worktree differs from the MCP process root.
2. **LLM-allowed:** pass `worktree_path` on reviewer/merger mutation tools when
the active `branches/` worktree differs from the MCP process root; **client
reconnect** after transport EOF only (no process kill).
3. **Operator-owned:** if the wrong namespace process was launched, or a role-
specific `GITEA_*_WORKTREE` must be set at process start, an **operator**
reloads/relaunches the correct static namespace MCP. LLM sessions must not
kill or restart MCP processes (see [stable control runtime ADR](architecture/mcp-stable-control-runtime-policy-adr.md)).
4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
own namespace — that destroys another agent's WIP.
@@ -1213,9 +1216,10 @@ When posting a Canonical Thread Handoff after a binding blocker:
- State which namespace was active (author / reviewer / merger / reconciler).
- Quote the resolved workspace path and binding source from the error.
- Name the safe reconnect action (relaunch MCP from `branches/...`, set
`GITEA_*_WORKTREE`, or pass `worktree_path`).
- Explicitly note that foreign worktrees must not be cleaned to unblock.
- Name the safe next action: pass `worktree_path` if that unblocks the tool, or
request an **operator** reload of the correct namespace MCP / env binding.
- Explicitly note that foreign worktrees must not be cleaned to unblock, and
that the LLM must not self-restart the MCP process.
## Safety notes
@@ -1226,6 +1230,7 @@ When posting a Canonical Thread Handoff after a binding blocker:
## Related documents
- [`architecture/mcp-stable-control-runtime-policy-adr.md`](architecture/mcp-stable-control-runtime-policy-adr.md) — stable control runtime vs dev runtime; LLM must not kill/restart MCP; operator-owned reload and promotions; routine post-merge parity staleness (#615).
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
+138
View File
@@ -0,0 +1,138 @@
# Self-hosted Sentry observability for the Gitea MCP server (#606)
Optional, **off-by-default** instrumentation that reports MCP runtime errors,
fail-closed workflow blockers, lease / terminal-lock / stale-runtime
collisions, and recurring watchdog check-ins to a **self-hosted** Sentry at
`https://sentry.prgs.cc/`.
> **Gitea remains the source of truth.** Sentry is observe-only. It never
> approves, merges, closes, or otherwise mutates Gitea workflow state, and it
> never bypasses leases, #332, workflow roles, or the MCP gates. Sentry alerts
> may only feed the *sanctioned* Gitea issue/comment path via the #612 incident
> bridge — never a direct write.
Implemented by [`sentry_observability.py`](../../sentry_observability.py).
---
## 1. Create the Sentry project
1. Sign in to the self-hosted Sentry at **`https://sentry.prgs.cc/`** (this is
**not** Sentry Cloud — do not use `*.ingest.sentry.io`).
2. Create a new **Python** project named **`gitea-tools-mcp`**.
3. Open **Settings → Projects → gitea-tools-mcp → Client Keys (DSN)** and copy
the DSN. It looks like `https://<publickey>@sentry.prgs.cc/<project-id>`.
4. **Never commit the DSN.** It is a runtime secret supplied via env var only.
## 2. Configure the environment
All configuration is env-var driven (see [`.env.example`](../../.env.example)):
| Variable | Purpose | Default |
|----------|---------|---------|
| `MCP_SENTRY_ENABLED` | Master gate (`1/true/yes/on`). Required. | off |
| `SENTRY_DSN` | Self-hosted DSN. Required. | *(empty)* |
| `SENTRY_ENVIRONMENT` | `local` / `dev` / `prod` tag. | `development` |
| `SENTRY_RELEASE` | Release id (git SHA or version). | *(none)* |
| `MCP_SENTRY_TRACES_SAMPLE_RATE` | Perf-trace sample rate `0.01.0` (clamped). | `0.0` |
| `MCP_SENTRY_ENABLE_LOGS` | Forward Python logs as structured logs. | off |
**The feature stays completely off unless `MCP_SENTRY_ENABLED` is truthy *and*
`SENTRY_DSN` is non-empty.** With either missing, `init_sentry()` is a no-op,
the SDK is never initialised, and no events are sent — existing tool behaviour
and API-call patterns are unchanged.
### Per-environment examples
```bash
# local (quiet: capture errors/blockers, no traces)
export MCP_SENTRY_ENABLED=1
export SENTRY_DSN="https://<key>@sentry.prgs.cc/<id>"
export SENTRY_ENVIRONMENT=local
# dev (light tracing + logs)
export MCP_SENTRY_ENABLED=1
export SENTRY_DSN="https://<key>@sentry.prgs.cc/<id>"
export SENTRY_ENVIRONMENT=dev
export MCP_SENTRY_TRACES_SAMPLE_RATE=0.2
export MCP_SENTRY_ENABLE_LOGS=1
# prod (errors/blockers + low-rate tracing, release-tagged)
export MCP_SENTRY_ENABLED=1
export SENTRY_DSN="https://<key>@sentry.prgs.cc/<id>"
export SENTRY_ENVIRONMENT=prod
export SENTRY_RELEASE="$(git rev-parse --short HEAD)"
export MCP_SENTRY_TRACES_SAMPLE_RATE=0.05
```
The optional SDK is pinned in [`requirements.txt`](../../requirements.txt)
(`sentry-sdk==2.20.0`). It is imported lazily: if the package is absent, the
module still imports and every entry point is a safe no-op.
## 3. What is instrumented
| Signal | Where | Notes |
|--------|-------|-------|
| Startup init | `gitea_mcp_server.py` `__main__`, before `mcp.run` | Prints a redaction-safe status line to stderr. |
| Failing mutations (exceptions) | `_audited(...)` context manager | `capture_exception` with scrubbed tags. |
| Fail-closed blockers / failed mutations | `_audit_pr_result(...)` (BLOCKED/FAILED) | Structured `capture_workflow_blocker` event incl. the canonical next action when available (criterion 7). |
| Allocator watchdog check-ins | `gitea_allocate_next_work` tool | `allocator_health`, `stale_lease_scan`, `terminal_lock_scan`. |
| Namespace-health check-in | `gitea_assess_mcp_namespace_health` tool | `namespace_health`. |
All capture paths are **best-effort / fail open**: a Sentry outage or capture
error never breaks an MCP tool success path.
## 4. Cron / watchdog monitors
`sentry_observability.MONITOR_SLUGS` defines stable check-in slugs:
| Registry key | Sentry monitor slug | Wired at |
|--------------|--------------------|----------|
| `stale_lease_scan` | `gitea-mcp-stale-lease-scan` | allocator run (global lease expiry) |
| `terminal_lock_scan` | `gitea-mcp-terminal-lock-scan` | allocator run (terminal-lock lookup) |
| `allocator_health` | `gitea-mcp-allocator-health` | allocator run |
| `namespace_health` | `gitea-mcp-namespace-health` | namespace-health probe |
| `dashboard_freshness` | `gitea-mcp-dashboard-freshness` | call `monitor_checkin("dashboard_freshness", ...)` from the dashboard refresh job (#605) |
| `reconciler_cleanup` | `gitea-mcp-reconciler-cleanup` | call `monitor_checkin("reconciler_cleanup", ...)` from the reconciler cleanup entrypoint |
Create matching Cron monitors in Sentry with those slugs. Emit an
`in_progress` check-in at job start and `ok`/`error` at completion via
`sentry_observability.monitor_checkin(slug_key, status)`.
## 5. Redaction guarantees (fail closed)
Redaction fails *closed*: if a field cannot be proven safe it is dropped rather
than sent. The `before_send` (and `before_send_log`) hook `scrub_event`
recursively redacts every outgoing event; on any error it drops the event
entirely. Guarantees, proven by `tests/test_sentry_observability.py`:
- **No** tokens, passwords, keychain IDs, DSNs, cookies, or `user:pass@host`.
- **No** raw session-state or full prompt/comment bodies — `session_id` is only
ever surfaced as a 12-char `session_id_hash`.
- **No** private config contents or raw credential headers.
- **No** full local filesystem paths — a worktree path collapses to a coarse
`worktree_category` (`author` / `reviewer` / `merger` / `reconciler` /
`branches` / `root` / `other`).
- Only the allowlisted tag keys in `ALLOWED_TAG_KEYS` are ever attached.
## 6. Coexistence with GlitchTip / the #612 incident bridge
This is the **outbound** path (MCP → Sentry SDK). It complements — it does not
replace — the **inbound** [`incident_bridge.py`](../../incident_bridge.py)
(#612), which turns Sentry/GlitchTip *observations* into durable Gitea issues
and `incident_links` rows.
- Prefer **one** observability path per environment. Point the MCP server's
`SENTRY_DSN` at the same self-hosted `gitea-tools-mcp` project that the #612
bridge reconciles from, so an MCP-reported error and its Gitea issue line up.
- GlitchTip is Sentry-protocol compatible; if an existing GlitchTip DSN is in
use, either migrate it to `https://sentry.prgs.cc/` or document the split
(MCP → Sentry, legacy → GlitchTip) explicitly for operators.
- The bridge remains the **only** sanctioned route from an alert back into
Gitea workflow state.
## 7. Non-goals
- Sentry must **not** become the workflow source of truth.
- Sentry must **not** approve, merge, close, or mutate Gitea workflow state.
- Sentry must **not** bypass leases, #332, workflow roles, or the MCP gates.
+3 -1
View File
@@ -14,6 +14,7 @@ Handbook for LLM operators and human developers using the Gitea-Tools MCP server
4. **No self-review / no self-merge** — The authenticated Gitea user must not approve or merge a PR they authored.
5. **Follow the gates** — Prompts express intent; MCP tools enforce safety. Never bypass gates via prompt instructions.
6. **Global LLM Worktree Rule** — Main checkout stays on `master`/`main`/`dev`; all mutations happen under `branches/`. Prove project root, `cwd`, branch, stable main-checkout branch, and session worktree path before editing. No exceptions.
7. **Stable control runtime** — Real Gitea mutations use only the **stable** MCP control runtime. LLM sessions must not kill, restart, or relaunch MCP processes, edit the stable checkout, or use a dev MCP for production mutations. See the policy ADR: [mcp-stable-control-runtime-policy-adr.md](../architecture/mcp-stable-control-runtime-policy-adr.md) (#615).
## Supported Gitea instances
@@ -29,4 +30,5 @@ Always pass `remote` explicitly on tool calls. The server default is `dadeschool
1. `gitea_whoami` — confirm authenticated user and profile.
2. `gitea_get_runtime_context` — allowed/forbidden operations for this session.
3. `gitea_resolve_task_capability` — prove the session may perform the planned task.
4. For reviewer work: dry-run validation (`gitea_dry_run_pr_review`) before live review mutations.
4. For reviewer work: dry-run validation (`gitea_dry_run_pr_review`) before live review mutations.
5. Confirm master parity / runtime health when tools report stale or unhealthy control runtime — stop mutations and request an **operator** reload of the stable MCP (see [stable control runtime ADR](../architecture/mcp-stable-control-runtime-policy-adr.md)).
+11
View File
@@ -12,6 +12,17 @@
4. `gitea_mark_final_review_decision` → approve via `gitea_review_pr`.
5. `gitea_merge_pr` with pinned head SHA and `confirmation="MERGE PR <n>"`.
## Stable MCP control runtime (#615)
Policy ADR: [mcp-stable-control-runtime-policy-adr.md](../architecture/mcp-stable-control-runtime-policy-adr.md).
| Situation | Who acts | What to do |
|-----------|----------|------------|
| Transport EOF / missing tools | LLM | **Client reconnect only** — do not kill PIDs |
| Wrong profile / dual-namespace missing | Operator | Configure or reload the correct static namespace(s) |
| Master parity stale after merge | LLM stops + reports; **operator** reloads stable MCP | Resume only after parity re-verified |
| Promote unpromoted MCP code to stable | Operator / release-manager only | Full §2.4 promotion record |
## Gitea Wiki sync
The Gitea Wiki mirrors `docs/wiki/` (source of truth). After merging wiki changes:
+1
View File
@@ -41,6 +41,7 @@
"execution_profile": "example-author",
"audit_label": "example-author",
"auth": { "type": "keychain", "id": "example-gitea-author-token" },
"allowed_repositories": ["Example-Org/Example-Repo"],
"allowed_operations": ["read", "branch", "commit", "push", "open_pr", "comment", "issue.comment"],
"forbidden_operations": ["approve", "request_changes", "merge"]
},
+258 -22
View File
@@ -182,11 +182,51 @@ def get_auth_header(host):
def resolve_remote(args):
"""Given parsed argparse args with --remote/--host/--org/--repo,
return (host, org, repo) with overrides applied."""
return (host, org, repo) with overrides applied.
#714 / #530: when the caller omits org and/or repo, prefer the
workspace-aligned git remote over REMOTES defaults (e.g. bare
``--remote prgs`` must not force Timesheet when the checkout is
Gitea-Tools). Explicit --org/--repo always win.
"""
profile = REMOTES[args.remote]
host = args.host or profile["host"]
org = args.org or profile["org"]
repo = args.repo or profile["repo"]
org_explicit = getattr(args, "org", None) is not None
repo_explicit = getattr(args, "repo", None) is not None
org = args.org if org_explicit else profile["org"]
repo = args.repo if repo_explicit else profile["repo"]
if not org_explicit or not repo_explicit:
try:
import remote_repo_guard
import subprocess
# Prefer the named remote URL when present; fall back to origin.
url = None
for remote_name in (args.remote, "origin"):
try:
proc = subprocess.run(
["git", "remote", "get-url", remote_name],
capture_output=True,
text=True,
timeout=5,
check=False,
)
if proc.returncode == 0 and (proc.stdout or "").strip():
url = proc.stdout.strip()
break
except Exception:
continue
# Allow tests to inject a deterministic remote URL without Git.
env_url = os.environ.get("GITEA_TEST_WORKSPACE_REMOTE_URL")
if env_url:
url = env_url
parsed = remote_repo_guard.parse_org_repo_from_remote_url(url)
if parsed:
if not org_explicit:
org = parsed[0]
if not repo_explicit:
repo = parsed[1]
except Exception:
pass
return host, org, repo
@@ -243,6 +283,192 @@ def _redact(text):
return str(text)
# ── Classified client failures (#699) ─────────────────────────────────────────
# Subclasses of RuntimeError preserve existing ``except RuntimeError`` call
# sites. Exception *messages* are fixed constants only — HTTP response bodies,
# Keychain material, and arbitrary exception text are never stored on the
# exception or re-emitted to tool results / daemon logs.
# Fixed messages (must match mcp_tool_error_boundary.FIXED_MESSAGES keys used here).
_MSG_AUTH_INVALID = "Gitea authentication failed: invalid or revoked credentials"
_MSG_AUTH_FAILED = "Gitea authentication failed"
_MSG_AUTHZ_SCOPE = "Gitea authorization failed: insufficient token scope"
_MSG_AUTHZ_DENIED = "Gitea authorization failed: access denied"
_MSG_NETWORK = "Network error contacting Gitea"
_MSG_CONFIG = "Gitea configuration or credential resolution failed"
_MSG_UPSTREAM = "Gitea upstream unavailable"
_MSG_HTTP = "Gitea HTTP request failed"
class GiteaClientError(RuntimeError):
"""Base for known Gitea client failures with stable reason_code metadata."""
reason_code = "client_error"
error_class = "client"
http_status = None
def __init__(self, message=None, *, reason_code=None, http_status=None):
if reason_code is not None:
self.reason_code = reason_code
if http_status is not None:
self.http_status = http_status
# Message is always a fixed constant; callers cannot inject bodies.
fixed = message if message is not None else _MSG_HTTP
super().__init__(fixed)
class GiteaAuthError(GiteaClientError):
"""Authentication failure (invalid/revoked credentials → typically HTTP 401)."""
reason_code = "auth_invalid_token"
error_class = "authentication"
http_status = 401
def __init__(self, message=None, *, reason_code=None, http_status=None):
super().__init__(
message if message is not None else _MSG_AUTH_INVALID,
reason_code=reason_code or "auth_invalid_token",
http_status=http_status if http_status is not None else 401,
)
class GiteaAuthzError(GiteaClientError):
"""Authorization failure (HTTP 403 — scope deficiency or access denied)."""
reason_code = "authz_denied"
error_class = "authorization"
http_status = 403
def __init__(self, message=None, *, reason_code=None, http_status=None):
code = reason_code or "authz_denied"
if code == "authz_insufficient_scope":
fixed = _MSG_AUTHZ_SCOPE
else:
fixed = _MSG_AUTHZ_DENIED
code = "authz_denied"
super().__init__(
message if message is not None else fixed,
reason_code=code,
http_status=http_status if http_status is not None else 403,
)
class GiteaNetworkError(GiteaClientError):
"""Transport / DNS / timeout failure contacting Gitea."""
reason_code = "network_error"
error_class = "network"
http_status = None
def __init__(self, message=None, *, reason_code=None, http_status=None):
super().__init__(
message if message is not None else _MSG_NETWORK,
reason_code=reason_code or "network_error",
http_status=http_status,
)
class GiteaConfigError(GiteaClientError):
"""Local configuration / credential resolution failure (not HTTP auth)."""
reason_code = "config_error"
error_class = "configuration"
http_status = None
def __init__(self, message=None, *, reason_code=None, http_status=None):
super().__init__(
message if message is not None else _MSG_CONFIG,
reason_code=reason_code or "config_error",
http_status=http_status,
)
class GiteaHttpError(GiteaClientError):
"""Non-auth HTTP failure with fixed message (no response body)."""
reason_code = "http_error"
error_class = "client"
http_status = None
def __init__(self, message=None, *, reason_code=None, http_status=None):
code = reason_code or "http_error"
if code == "upstream_unavailable":
fixed = _MSG_UPSTREAM
else:
fixed = _MSG_HTTP
code = "http_error"
super().__init__(
message if message is not None else fixed,
reason_code=code,
http_status=http_status,
)
def _looks_like_insufficient_scope(detail: str) -> bool:
"""Internal: inspect redacted body *only* to refine 403 reason_code.
The body is never stored on the exception or returned to callers.
"""
lower = (detail or "").lower()
markers = (
"insufficient scope",
"required scope",
"does not have at least one of required scope",
"token does not have",
"missing scope",
"scope(s)",
)
return any(m in lower for m in markers)
def classify_http_status(code: int, *, body_hint: str = "") -> tuple[type, str, int]:
"""Central HTTP status → (exception_class, reason_code, http_status).
Every HTTP 403 becomes authorization-class. Body text is used only as a
local hint for scope vs denied reason_code and is never returned.
"""
if code == 401:
return (GiteaAuthError, "auth_invalid_token", 401)
if code == 403:
if _looks_like_insufficient_scope(body_hint or ""):
return (GiteaAuthzError, "authz_insufficient_scope", 403)
return (GiteaAuthzError, "authz_denied", 403)
if code in (502, 503, 504):
return (GiteaHttpError, "upstream_unavailable", code)
return (GiteaHttpError, "http_error", code)
def raise_for_http_status(code: int, body: str = "") -> None:
"""Raise a typed client error for *code* without embedding *body*.
*body* may be inspected only to choose scope vs denied for 403; it is
never placed on the exception message.
"""
# Redact before any inspection; discard after classification.
try:
hint = _redact(body or "").strip()
except Exception:
hint = ""
exc_cls, reason, status = classify_http_status(code, body_hint=hint)
# Explicitly construct without passing body/hint into message.
if exc_cls is GiteaAuthError:
raise GiteaAuthError(reason_code=reason, http_status=status)
if exc_cls is GiteaAuthzError:
raise GiteaAuthzError(reason_code=reason, http_status=status)
if reason == "upstream_unavailable":
raise GiteaHttpError(
reason_code="upstream_unavailable",
http_status=status,
)
raise GiteaHttpError(reason_code="http_error", http_status=status)
def _raise_http_error(code: int, detail: str = "") -> None:
"""Backward-compatible alias — *detail* is never embedded in the error."""
raise_for_http_status(code, detail)
def _add_query(url, **params):
"""Return *url* with the given query parameters added or overridden.
@@ -315,23 +541,24 @@ def api_request(method, url, auth_header, payload=None, *,
"""Make an authenticated JSON request to the Gitea API.
Returns parsed JSON on success (or ``None`` for an empty body), and raises
``RuntimeError`` on failure.
a classified client error on failure.
On HTTP 429 the request is retried up to *max_retries* times: honoring a
valid ``Retry-After`` header (seconds or HTTP-date) when present, otherwise
using capped jittered exponential backoff. Successful responses are
unchanged.
All failures are converted to a ``RuntimeError`` with a clear, secret
-redacted message (no raw stack traces or credential material):
Failures raise typed exceptions with **fixed messages only** (#699). HTTP
response bodies are read solely for local 403 reason refinement and are
never stored on exceptions or returned to callers:
- Non-429 HTTP errors surface the status code and a redacted response body.
502/503/504 upstream errors get an explicit "Gitea upstream unavailable"
message.
- Timeouts and network/DNS failures (``URLError`` / ``TimeoutError``) surface
a generic "network error contacting Gitea" message.
- A malformed (non-JSON) success body surfaces a "malformed JSON response"
message rather than a raw decode error.
- HTTP 401 → :class:`GiteaAuthError` (``auth_invalid_token``)
- HTTP 403 → :class:`GiteaAuthzError` (scope or denied)
- 502/503/504 → :class:`GiteaHttpError` (``upstream_unavailable``)
- Other non-429 HTTP → :class:`GiteaHttpError` (``http_error``)
- Timeouts / DNS / ``URLError`` → :class:`GiteaNetworkError`
- Malformed success JSON → plain ``RuntimeError`` (programming/protocol;
not reclassified as authentication)
The ``*_func`` parameters and ``timeout`` are injection points for
deterministic testing.
@@ -369,22 +596,23 @@ def api_request(method, url, auth_header, payload=None, *,
error_body = e.read().decode("utf-8", errors="replace")
except Exception:
error_body = ""
detail = _redact(error_body).strip()
if e.code in (502, 503, 504):
msg = f"HTTP {e.code}: Gitea upstream unavailable"
raise RuntimeError(f"{msg}: {detail}" if detail else msg) from e
raise RuntimeError(f"HTTP {e.code}: {detail}") from e
# Classify from status (+ local body hint). Body is not embedded.
try:
raise_for_http_status(e.code, error_body)
except GiteaClientError:
raise
# Defensive: raise_for_http_status always raises.
raise GiteaHttpError(http_status=e.code) from e # pragma: no cover
except (urllib.error.URLError, TimeoutError) as e:
reason = getattr(e, "reason", e)
raise RuntimeError(
f"network error contacting Gitea: {_redact(reason)}"
) from e
# Fixed message only — do not embed URLError reason (may leak paths).
raise GiteaNetworkError(reason_code="network_error") from e
if not body:
return None
try:
return json.loads(body)
except ValueError as e:
# Programming/protocol failure — not authentication.
raise RuntimeError("malformed JSON response from Gitea") from e
@@ -570,6 +798,14 @@ def get_profile():
"profile_name": name,
"allowed_operations": ops,
"forbidden_operations": forbidden,
# #714 repository authorization boundary. Config-only on purpose: an
# environment variable must never widen or forge the set of
# repositories a session may bind to.
"allowed_repositories": _json_list("allowed_repositories"),
# #706 cross-repository canonical root binding. Config-sourced here (the
# namespace-scoped GITEA_CANONICAL_REPOSITORY_ROOT env override is applied
# by canonical_repository_root.configured_canonical_root, not widened here).
"canonical_repository_root": jp.get("canonical_repository_root") or None,
"audit_label": audit_label,
"token_source_name": token_source,
"auth_source_type": auth_type,
+72
View File
@@ -272,6 +272,15 @@ def load_config(path=None):
)
if not isinstance(data.get("profiles"), dict):
raise ConfigError(f"{path} must be a JSON object with a 'profiles' object")
# #741: the v1 path returns `data` unflattened, so nothing else validates
# the cross-repository binding before gitea_auth.get_profile() reads it.
# Validate it here so every supported loader treats the field identically
# (a relative or blank path must never reach the runtime guard).
for _name, _profile in data["profiles"].items():
if isinstance(_profile, dict):
_validate_canonical_repository_root(
_name, _profile.get("canonical_repository_root")
)
return data
@@ -358,6 +367,14 @@ def _flatten_identity(env_name, svc_name, svc, ident_name, ident):
for key in ("role", "username", "execution_profile", "audit_label"):
if ident.get(key):
profile[key] = ident[key]
# #741: the cross-repository binding must survive flattening. Previously
# this key was silently dropped here, so a v2-environments namespace that
# declared canonical_repository_root fell back to the *installation* root
# and mutated Gitea-Tools instead of its target repository — a fail-open.
# Validate it exactly as the v2-contexts loader does before propagating.
_validate_canonical_repository_root(addr, ident.get("canonical_repository_root"))
if ident.get("canonical_repository_root"):
profile["canonical_repository_root"] = ident["canonical_repository_root"]
return addr, profile
@@ -475,6 +492,57 @@ def _require_enabled(kind, name, obj):
return enabled
_REPO_SCOPE_RE = re.compile(r"^[^/\s]+/[^/\s]+$")
def _validate_allowed_repositories(name, raw):
"""Validate the optional per-profile repository authorization scope (#714).
``allowed_repositories`` is an authorization boundary of canonical
``owner/repository`` slugs. It is not the session binding: the verified
workspace repository selects exactly one entry at bind time. Absent means
"no repository scope configured" and is allowed, so existing profiles keep
working until an operator provisions the field.
"""
if raw is None:
return
if not isinstance(raw, list):
raise ConfigError(
f"profile '{name}' allowed_repositories must be a list of "
"'owner/repository' strings"
)
for entry in raw:
if not isinstance(entry, str) or not _REPO_SCOPE_RE.match(entry.strip()):
raise ConfigError(
f"profile '{name}' allowed_repositories entry {entry!r} is not "
"a canonical 'owner/repository' slug"
)
def _validate_canonical_repository_root(name, raw):
"""Validate the optional per-profile canonical repository root (#706).
``canonical_repository_root`` binds a cross-repository namespace to the
working root of its target repository (separate from the immutable
Gitea-Tools install checkout). It is an absolute filesystem path; existence
and git identity are validated at bind time by the runtime guard, not here
(config validation stays filesystem-independent). Absent means the
single-repo default and is allowed.
"""
if raw is None:
return
if not isinstance(raw, str) or not raw.strip():
raise ConfigError(
f"profile '{name}' canonical_repository_root must be a non-empty "
"absolute path string to the target repository working root"
)
if not os.path.isabs(raw.strip()):
raise ConfigError(
f"profile '{name}' canonical_repository_root {raw!r} must be an "
"absolute path"
)
def _reject_inline_secrets(kind, name, obj):
for key in _INLINE_SECRET_KEYS:
if key in obj:
@@ -549,6 +617,10 @@ def _load_v2_contexts(data, path):
forbidden = raw.get("forbidden_operations") or []
if not isinstance(allowed, list) or not isinstance(forbidden, list):
raise ConfigError(f"profile '{name}' operation fields must be lists")
_validate_allowed_repositories(name, raw.get("allowed_repositories"))
_validate_canonical_repository_root(
name, raw.get("canonical_repository_root")
)
allowed_n = {_normalize_op("gitea", op, name) for op in allowed}
forbidden_n = {_normalize_op("gitea", op, name) for op in forbidden}
# Reviewer-identity deadlock rule (#100/#103) applies here unchanged.
+4130 -348
View File
File diff suppressed because it is too large Load Diff
+125
View File
@@ -166,3 +166,128 @@ def format_parity(assessment: dict) -> str:
if not assessment.get("determinable"):
return "parity indeterminate (baseline or current HEAD unknown)"
return f"in parity at {_short(assessment.get('current_head'))}"
# ---------------------------------------------------------------------------
# Target-repository parity (#739 F3)
#
# Everything above measures ONE dimension: the Gitea-Tools server's own
# implementation commit, comparing the SHA this process was loaded from against
# the SHA now on disk at PROJECT_ROOT. That is deliberate and is left untouched
# — it is what proves the in-memory capability gates are current.
#
# It is not, however, a statement about the repository a cross-repository
# namespace actually mutates. The assessment below is a separate, separately
# labelled dimension for the configured canonical target repository. It never
# feeds the mutation gate and never changes startup_head/current_head.
# ---------------------------------------------------------------------------
DEFAULT_TARGET_TRACKING_REF = "refs/remotes/origin/master"
def _git_capture(root: str, *args: str) -> str | None:
"""Run a read-only git command in *root*; ``None`` on any failure.
Deliberately does not honour ``GITEA_TEST_CURRENT_HEAD``: that override
exists to pin the *server's* HEAD, and applying it here would make a target
repository silently report the server's forced SHA.
"""
if not root:
return None
try:
res = subprocess.run(
["git", "-C", root, *args],
capture_output=True,
text=True,
check=False,
)
except Exception:
return None
if res.returncode != 0:
return None
return (res.stdout or "").strip() or None
def assess_target_repository_parity(
*,
canonical_root: str | None,
source: str | None,
tracking_ref: str = DEFAULT_TARGET_TRACKING_REF,
) -> dict:
"""Assess the configured cross-repository target checkout.
Reports the target's canonical root, repository identity, checked-out
commit, and last-known remote master commit, plus whether the checkout is
behind that ref. No network call is made: the remote side is read from the
existing remote-tracking ref, so a target that has never been fetched is
reported as indeterminate rather than guessed at.
An unconfigured namespace is ``configured=False`` and never ``stale`` — the
single-repository default has no second dimension to be stale about. A
configured root that cannot be read is ``determinable=False`` with reasons.
"""
result = {
"configured": bool(canonical_root),
"canonical_repository_root": None,
"source": source,
"repository_slug": None,
"checkout_head": None,
"tracking_ref": tracking_ref,
"remote_tracking_head": None,
"determinable": False,
"stale": False,
"reasons": [],
}
if not canonical_root:
result["determinable"] = True
return result
result["canonical_repository_root"] = canonical_root
if not os.path.isdir(canonical_root):
result["reasons"].append(
f"configured canonical repository root '{canonical_root}' "
f"does not exist or is not a directory"
)
return result
toplevel = _git_capture(canonical_root, "rev-parse", "--show-toplevel")
if not toplevel:
result["reasons"].append(
f"configured canonical repository root '{canonical_root}' "
f"is not a git checkout"
)
return result
head = _git_capture(canonical_root, "rev-parse", "HEAD")
if not head:
result["reasons"].append(
f"target repository HEAD could not be read at '{canonical_root}'"
)
return result
result["checkout_head"] = head
result["determinable"] = True
remote_url = _git_capture(canonical_root, "remote", "get-url", "origin")
if remote_url:
# Local import keeps this module dependency-light for its startup role.
import remote_repo_guard
parsed = remote_repo_guard.parse_org_repo_from_remote_url(remote_url)
if parsed:
result["repository_slug"] = f"{parsed[0]}/{parsed[1]}"
if not result["repository_slug"]:
result["reasons"].append(
"target repository identity could not be derived from its git remote"
)
tracking_head = _git_capture(canonical_root, "rev-parse", tracking_ref)
if not tracking_head:
result["reasons"].append(
f"remote-tracking ref '{tracking_ref}' is unknown in the target "
f"checkout; target staleness is indeterminate (no fetch is "
f"performed by this assessment)"
)
return result
result["remote_tracking_head"] = tracking_head
result["stale"] = tracking_head != head
return result
+215 -10
View File
@@ -36,6 +36,15 @@ KIND_REVIEW_DRAFT = "review_draft"
# other session proofs; a contaminated session fails closed on gated mutations
# until a reconciler audits and clears it.
KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination"
# Durable shadow of the in-memory reviewer session lease (#702). Written on
# every sanctioned record/heartbeat and removed on sanctioned clear, so a
# daemon that dies without teardown leaves provable orphan evidence (owner
# pid + session id) for the guarded lease-cleanup path. Never a substitute
# for the in-session lease: mutation gates ignore it.
KIND_REVIEWER_SESSION_LEASE = "reviewer_session_lease"
# Durable audit record written whenever the daemon's sanctioned stale-binding
# recovery clears an inherited GITEA_ACTIVE_WORKTREE binding (#702).
KIND_STALE_BINDING_RECOVERY = "stale_binding_recovery"
# #709: archive prior terminal decision ledgers instead of silent overwrite.
KIND_DECISION_LOCK_ARCHIVE = "review_decision_lock_archive"
# #709: post-merge cleanup/audit reconciliation-required durable record.
@@ -46,12 +55,22 @@ KIND_IRRECOVERABLE_DECISION_PROVENANCE = "irrecoverable_decision_provenance"
KIND_IRRECOVERABLE_PROVENANCE_AUTH = "irrecoverable_provenance_authorization"
# Kinds that must survive the default session-state TTL (forensic / recovery).
#
# KIND_DECISION_LOCK is recovery-critical (#720): terminal review provenance is
# not disposable cache. A generic four-hour TTL must not drop old-head evidence
# or make ``fresh_review_on_current_head_allowed`` unreachable. Same-head / same-
# run #332 protections still apply once the ledger is loadable. Other session
# kinds (workflow load, drafts, etc.) remain TTL-bound.
RECOVERY_CRITICAL_KINDS = frozenset(
{
KIND_DECISION_LOCK,
KIND_DECISION_LOCK_ARCHIVE,
KIND_POST_MERGE_DECISION_RECOVERY,
KIND_IRRECOVERABLE_DECISION_PROVENANCE,
KIND_IRRECOVERABLE_PROVENANCE_AUTH,
# #702 crash-orphan evidence (must outlive TTL; F4)
KIND_REVIEWER_SESSION_LEASE,
KIND_STALE_BINDING_RECOVERY,
}
)
@@ -131,6 +150,7 @@ def state_key(
org: str | None = None,
repo: str | None = None,
profile_identity: str | None = None,
instance_id: str | None = None,
) -> str:
"""Build durable filename key.
@@ -138,17 +158,20 @@ def state_key(
so the primary key is kind + profile identity. Remote/org/repo are stored
inside the payload and validated on load (#559), which lets a later daemon
process recover state without already knowing the remote argument.
*instance_id* extends the key for kinds where one-per-profile collides —
concurrent same-profile sessions each own their reviewer-lease shadow
(#702 F5), keyed by their lease session id so a later heartbeat can never
overwrite or misattribute another session's crash evidence.
"""
# Keep remote/org/repo parameters for API stability / future kinds; they are
# intentionally not part of the filename for session-scoped proofs.
_ = (remote, org, repo)
return "-".join(
_sanitize_segment(part)
for part in (
kind,
profile_identity or "unknown-profile",
)
)
parts = [kind, profile_identity or "unknown-profile"]
instance = (instance_id or "").strip()
if instance:
parts.append(instance)
return "-".join(_sanitize_segment(part) for part in parts)
def state_file_path(
@@ -159,6 +182,7 @@ def state_file_path(
repo: str | None = None,
profile_identity: str | None = None,
state_dir: str | None = None,
instance_id: str | None = None,
) -> str:
root = (state_dir or default_state_dir()).strip()
name = state_key(
@@ -167,6 +191,7 @@ def state_file_path(
org=org,
repo=repo,
profile_identity=profile_identity,
instance_id=instance_id,
)
return os.path.join(root, f"{name}.json")
@@ -245,6 +270,16 @@ def _write_json(path: str, data: dict[str, Any]) -> None:
pass
def is_recovery_critical_record(record: dict[str, Any] | None, kind: str | None = None) -> bool:
"""True when a durable record must outlive the generic session-state TTL."""
if not record and not kind:
return False
record_kind = ((record or {}).get("kind") or kind or "").strip()
if record_kind in RECOVERY_CRITICAL_KINDS:
return True
return bool((record or {}).get("recovery_critical"))
def identity_match_reasons(
record: dict[str, Any] | None,
*,
@@ -288,9 +323,7 @@ def identity_match_reasons(
else:
age = _now_utc() - recorded_at
kind = (record.get("kind") or "").strip()
ttl_exempt = kind in RECOVERY_CRITICAL_KINDS or bool(
record.get("recovery_critical")
)
ttl_exempt = is_recovery_critical_record(record, kind=kind)
if age > timedelta(hours=ttl_hours()) and not ttl_exempt:
reasons.append(
f"session state expired after {ttl_hours():g}h (fail closed)"
@@ -300,6 +333,113 @@ def identity_match_reasons(
return reasons
def inspect_state_envelope(
*,
kind: str,
remote: str | None = None,
org: str | None = None,
repo: str | None = None,
profile_identity: str | None = None,
state_dir: str | None = None,
) -> dict[str, Any]:
"""Read-only disk inspection for assessment when TTL would otherwise hide state (#720).
Does **not** apply identity/TTL rejection to the returned presence flags.
Callers use this to distinguish:
* no file on disk
* file present but TTL would reject a non-critical kind
* recovery-critical ledger (e.g. KIND_DECISION_LOCK) still loadable
Never mutates files. Never returns secrets.
"""
profile = current_profile_identity(profile_identity=profile_identity)
path = state_file_path(
kind=kind,
remote=remote,
org=org,
repo=repo,
profile_identity=profile,
state_dir=state_dir,
)
result: dict[str, Any] = {
"kind": kind,
"profile_identity": profile,
"path_basename": os.path.basename(path),
"on_disk": False,
"has_payload": False,
"recorded_at": None,
"updated_at": None,
"age_hours": None,
"ttl_hours": ttl_hours(),
"age_exceeds_default_ttl": False,
"recovery_critical": kind in RECOVERY_CRITICAL_KINDS,
"ttl_exempt": kind in RECOVERY_CRITICAL_KINDS,
"would_ttl_reject": False,
"identity_reasons": [],
"summary": "no session-state file on disk",
}
if not path or not os.path.exists(path):
return result
result["on_disk"] = True
envelope = _read_json(path)
if not envelope:
result["summary"] = "session-state file present but unreadable or empty"
return result
payload = envelope.get("payload")
merged: dict[str, Any] = dict(payload) if isinstance(payload, dict) else {}
result["has_payload"] = isinstance(payload, dict)
for key in (
"kind",
"remote",
"org",
"repo",
"profile_identity",
"session_profile_lock",
"recorded_at",
"updated_at",
"writer_pid",
"recovery_critical",
):
if key in envelope and key not in merged:
merged[key] = envelope[key]
if not merged.get("kind"):
merged["kind"] = kind
recorded_at = _parse_iso(merged.get("recorded_at") or merged.get("updated_at"))
result["recorded_at"] = merged.get("recorded_at") or merged.get("updated_at")
result["updated_at"] = merged.get("updated_at") or merged.get("recorded_at")
if recorded_at is not None:
age = _now_utc() - recorded_at
age_hours = age.total_seconds() / 3600.0
result["age_hours"] = age_hours
result["age_exceeds_default_ttl"] = age > timedelta(hours=ttl_hours())
ttl_exempt = is_recovery_critical_record(merged, kind=kind)
result["recovery_critical"] = ttl_exempt
result["ttl_exempt"] = ttl_exempt
identity_reasons = identity_match_reasons(
merged,
remote=remote,
org=org,
repo=repo,
profile_identity=profile,
)
result["identity_reasons"] = list(identity_reasons)
result["would_ttl_reject"] = any("expired" in r for r in identity_reasons)
if result["has_payload"] and ttl_exempt:
result["summary"] = (
"recovery-critical session-state present on disk and TTL-exempt; "
"load via load_state for full payload"
)
elif result["has_payload"] and result["would_ttl_reject"]:
result["summary"] = (
"session-state file present on disk but generic TTL would reject load "
f"(age_hours={result.get('age_hours')!r}, ttl={ttl_hours():g}h)"
)
elif result["has_payload"]:
result["summary"] = "session-state file present and within TTL / identity gates"
else:
result["summary"] = "session-state file present without a dict payload"
return result
def load_state(
*,
kind: str,
@@ -308,6 +448,7 @@ def load_state(
repo: str | None = None,
profile_identity: str | None = None,
state_dir: str | None = None,
instance_id: str | None = None,
) -> dict[str, Any] | None:
"""Load durable state payload when identity checks pass."""
profile = current_profile_identity(profile_identity=profile_identity)
@@ -318,6 +459,7 @@ def load_state(
repo=repo,
profile_identity=profile,
state_dir=state_dir,
instance_id=instance_id,
)
lock_path = f"{path}.lock"
with _exclusive_file_lock(lock_path):
@@ -363,6 +505,7 @@ def save_state(
repo: str | None = None,
profile_identity: str | None = None,
state_dir: str | None = None,
instance_id: str | None = None,
) -> dict[str, Any] | None:
"""Persist or clear durable state for the given session identity key."""
profile = current_profile_identity(
@@ -375,6 +518,11 @@ def save_state(
key_remote = remote if remote is not None else (payload or {}).get("remote")
key_org = org if org is not None else (payload or {}).get("org")
key_repo = repo if repo is not None else (payload or {}).get("repo")
key_instance = (
(instance_id or "").strip()
or str((payload or {}).get("instance_id") or "").strip()
or None
)
root = _ensure_state_dir(state_dir)
path = state_file_path(
@@ -384,6 +532,7 @@ def save_state(
repo=key_repo,
profile_identity=profile,
state_dir=root,
instance_id=key_instance,
)
lock_path = f"{path}.lock"
with _exclusive_file_lock(lock_path):
@@ -413,6 +562,8 @@ def save_state(
body["repo"] = key_repo
# Stamp session-state authority used for this write (#695 AC2 / AC6).
body.setdefault("session_state_dir", root)
if key_instance:
body["instance_id"] = key_instance
try:
import mcp_daemon_guard
@@ -446,6 +597,8 @@ def save_state(
"transport": body.get("transport"),
"payload": body,
}
if key_instance:
envelope["instance_id"] = key_instance
_write_json(path, envelope)
return dict(body)
@@ -458,6 +611,7 @@ def clear_state(
repo: str | None = None,
profile_identity: str | None = None,
state_dir: str | None = None,
instance_id: str | None = None,
) -> None:
save_state(
kind=kind,
@@ -467,8 +621,59 @@ def clear_state(
repo=repo,
profile_identity=profile_identity,
state_dir=state_dir,
instance_id=instance_id,
)
def list_states(
*,
kind: str,
profile_identity: str | None = None,
state_dir: str | None = None,
) -> list[dict[str, Any]]:
"""Enumerate durable records of *kind* across all instance keys.
Crash recovery cannot know which session id left a shadow behind, so it
must be able to enumerate every record of a kind (#702 F5). Identity
checks (profile / TTL policy) apply per record exactly as in
:func:`load_state`; records that fail closed are omitted.
"""
root = (state_dir or default_state_dir()).strip()
if not root or not os.path.isdir(root):
return []
profile = current_profile_identity(profile_identity=profile_identity)
results: list[dict[str, Any]] = []
try:
names = sorted(os.listdir(root))
except OSError:
return []
for name in names:
if not name.endswith(".json") or name.startswith("."):
continue
envelope = _read_json(os.path.join(root, name))
if not envelope or envelope.get("kind") != kind:
continue
payload = envelope.get("payload")
if not isinstance(payload, dict):
continue
merged = dict(payload)
for key in (
"kind",
"remote",
"org",
"repo",
"profile_identity",
"session_profile_lock",
"recorded_at",
"updated_at",
"writer_pid",
):
if key in envelope and key not in merged:
merged[key] = envelope[key]
if identity_match_reasons(merged, profile_identity=profile):
continue
results.append(merged)
return results
def list_decision_lock_profile_identities(
state_dir: str | None = None,
) -> list[str]:
+634
View File
@@ -0,0 +1,634 @@
"""MCP tool-boundary error mapping for known Gitea client failures (#699).
Known authentication / authorization / network / configuration failures leave
the tool boundary as a sanitized structured ``CallToolResult`` with
``isError=True``. Stdio transport remains connected.
Design constraints (reviewer-ratified, #699 / PR #701):
1. **No secret material** in tool results or daemon logs. Messages are fixed
constants keyed by ``reason_code``; HTTP bodies / exception text never
surface. Sanitization failure fails closed to ``internal_error``.
2. **Narrow boundary** wraps the original FastMCP ``Tool.run`` success path;
re-raises framework control-flow exceptions (``UrlElicitationRequiredError``);
maps only typed client failures. Installation is idempotent.
3. **No RuntimeError substring heuristics.** Only explicit typed
authentication / authorization / network / configuration exceptions
receive those labels.
4. **HTTP classification** lives in ``gitea_auth.classify_http_status``;
every 403 is authorization-class.
"""
from __future__ import annotations
import json
import logging
import re
import sys
from typing import Any
logger = logging.getLogger("gitea_mcp.tool_error_boundary")
# Stable reason codes (#699).
REASON_AUTH_FAILED = "auth_failed"
REASON_AUTH_INVALID_TOKEN = "auth_invalid_token"
REASON_AUTHZ_INSUFFICIENT_SCOPE = "authz_insufficient_scope"
REASON_AUTHZ_DENIED = "authz_denied"
REASON_NETWORK_ERROR = "network_error"
REASON_CONFIG_ERROR = "config_error"
REASON_INTERNAL_ERROR = "internal_error"
REASON_UPSTREAM_UNAVAILABLE = "upstream_unavailable"
REASON_HTTP_ERROR = "http_error"
# Typed structured errors for previously-opaque raw mutation failures.
REASON_PREFLIGHT_ORDER = "preflight_order_violation"
REASON_MALFORMED_RESPONSE = "malformed_response"
ERROR_CLASS_AUTHENTICATION = "authentication"
ERROR_CLASS_AUTHORIZATION = "authorization"
ERROR_CLASS_NETWORK = "network"
ERROR_CLASS_CONFIGURATION = "configuration"
ERROR_CLASS_INTERNAL = "internal"
ERROR_CLASS_UPSTREAM = "upstream"
ERROR_CLASS_PRECONDITION = "precondition"
# Fixed, secret-free operator messages. Never interpolate HTTP bodies,
# Keychain contents, tokens, or arbitrary exception text.
FIXED_MESSAGES: dict[str, str] = {
REASON_AUTH_FAILED: "Gitea authentication failed",
REASON_AUTH_INVALID_TOKEN: (
"Gitea authentication failed: invalid or revoked credentials"
),
REASON_AUTHZ_INSUFFICIENT_SCOPE: (
"Gitea authorization failed: insufficient token scope"
),
REASON_AUTHZ_DENIED: "Gitea authorization failed: access denied",
REASON_NETWORK_ERROR: "Network error contacting Gitea",
REASON_CONFIG_ERROR: "Gitea configuration or credential resolution failed",
REASON_INTERNAL_ERROR: "Internal tool error",
REASON_UPSTREAM_UNAVAILABLE: "Gitea upstream unavailable",
REASON_HTTP_ERROR: "Gitea HTTP request failed",
REASON_PREFLIGHT_ORDER: (
"Mutation blocked: pre-flight order violation (fail closed)"
),
REASON_MALFORMED_RESPONSE: "Malformed response from Gitea",
}
# Error class for a self-declared safe reason code (``gitea_reason_code``).
_REASON_ERROR_CLASS: dict[str, str] = {
REASON_AUTH_FAILED: ERROR_CLASS_AUTHENTICATION,
REASON_AUTH_INVALID_TOKEN: ERROR_CLASS_AUTHENTICATION,
REASON_AUTHZ_INSUFFICIENT_SCOPE: ERROR_CLASS_AUTHORIZATION,
REASON_AUTHZ_DENIED: ERROR_CLASS_AUTHORIZATION,
REASON_NETWORK_ERROR: ERROR_CLASS_NETWORK,
REASON_CONFIG_ERROR: ERROR_CLASS_CONFIGURATION,
REASON_UPSTREAM_UNAVAILABLE: ERROR_CLASS_UPSTREAM,
REASON_HTTP_ERROR: ERROR_CLASS_INTERNAL,
REASON_PREFLIGHT_ORDER: ERROR_CLASS_PRECONDITION,
REASON_MALFORMED_RESPONSE: ERROR_CLASS_INTERNAL,
REASON_INTERNAL_ERROR: ERROR_CLASS_INTERNAL,
}
# Bounds for the safe diagnostic fields added to the ``internal_error`` path.
_DETAIL_LIMIT = 200
_CLASS_LIMIT = 120
_STAGE_LIMIT = 64
# Absolute-path prefixes stripped from diagnostic detail (never leak layout).
_ABS_PATH_RE = re.compile(r"/(?:Users|home|private|var|tmp|opt|etc|root)/[^\s'\"]*")
_DETAIL_SECRET_PREFIXES = ("token ", "Basic ", "Bearer ", "Authorization: ")
_SECRET_KEY = (
r"token|password|passwd|secret|authorization|api[_-]?key|"
r"access[_-]?token|refresh[_-]?token|session|cookie"
)
_SECRET_JSON_RE = re.compile(
r'"(' + _SECRET_KEY + r')"\s*:\s*"[^"]*"', re.IGNORECASE
)
_SECRET_KV_RE = re.compile(
r"\b(" + _SECRET_KEY + r")\s*=\s*[^\s&\"']+", re.IGNORECASE
)
_MUTATION_STAGE_ATTR = "_gitea_mutation_stage"
_SELF_DECLARED_REASON_ATTR = "gitea_reason_code"
_INSTALL_FLAG = "_gitea_auth_boundary_installed"
_ORIGINAL_ATTR = "_gitea_auth_boundary_original"
def _safe_str(exc: BaseException) -> str:
"""``str(exc)`` that never raises (a poisoned ``__str__`` must not escape)."""
try:
return str(exc)
except Exception:
return ""
def _safe_exception_class(exc: BaseException) -> str:
"""Fully-qualified type identifier for *exc* — never instance/message text."""
try:
t = type(exc)
mod = getattr(t, "__module__", "") or ""
name = getattr(t, "__qualname__", None) or getattr(t, "__name__", "") or ""
ident = f"{mod}.{name}" if mod else name
return ident[:_CLASS_LIMIT]
except Exception:
return "unknown"
def _redact_detail(text: Any, limit: int = _DETAIL_LIMIT) -> str:
"""Strictly redact free-form exception text for safe diagnostics.
Removes token/Authorization credentials, raw URLs and hostnames (via the
shared audit redactor), and absolute filesystem paths, then collapses
whitespace and truncates. Never raises; on any failure returns "".
"""
if not text:
return ""
try:
out = str(text)
# Drop credential-prefixed runs (token/Basic/Bearer/Authorization).
for prefix in _DETAIL_SECRET_PREFIXES:
idx = 0
while True:
i = out.find(prefix, idx)
if i == -1:
break
j = i + len(prefix)
while j < len(out) and not out[j].isspace():
j += 1
out = out[:i] + prefix + "[REDACTED]" + out[j:]
idx = i + len(prefix) + len("[REDACTED]")
# Secret VALUES carried in JSON ("key":"value") or kv (key=value) form,
# even short ones (e.g. a password), keyed by a sensitive field name.
out = _SECRET_JSON_RE.sub(r'"\1":"[REDACTED]"', out)
out = _SECRET_KV_RE.sub(r"\1=[REDACTED]", out)
# URLs / query secrets / hostnames.
try:
import gitea_audit
out = gitea_audit.redact_urls(out)
except Exception:
pass
# Absolute filesystem paths (workspace layout is not for LLM output).
out = _ABS_PATH_RE.sub("[PATH]", out)
# Any bare hostname the URL redactor missed (defence in depth).
out = re.sub(r"\b[\w.-]+\.(?:cc|net|com|org|io|dev|local)\b", "[HOST]", out)
out = " ".join(out.split())
return out[:limit]
except Exception:
return ""
def _safe_mutation_stage(exc: BaseException) -> str | None:
"""Return the bounded, redacted mutation stage tagged on *exc* (or None)."""
try:
raw = getattr(exc, _MUTATION_STAGE_ATTR, None)
if not raw:
return None
return _redact_detail(raw, limit=_STAGE_LIMIT) or None
except Exception:
return None
def fixed_message(reason_code: str) -> str:
"""Return the fixed sanitized message for *reason_code* (fail closed)."""
return FIXED_MESSAGES.get(reason_code, FIXED_MESSAGES[REASON_INTERNAL_ERROR])
def _safe_profile_name() -> str | None:
try:
from gitea_auth import get_profile
name = (get_profile() or {}).get("profile_name")
if name is None:
return None
text = str(name).strip()
# Profile names are non-secret identifiers; still bound length.
return text[:80] if text else None
except Exception:
return None
def _is_framework_control_flow(exc: BaseException) -> bool:
"""True for exceptions the MCP framework must re-raise unchanged."""
try:
from mcp.shared.exceptions import UrlElicitationRequiredError
if isinstance(exc, UrlElicitationRequiredError):
return True
except Exception:
pass
# BaseException subclasses that must never become tool isError payloads.
if isinstance(exc, (KeyboardInterrupt, SystemExit, GeneratorExit)):
return True
return False
def is_known_client_failure(exc: BaseException) -> bool:
"""True only for explicit typed Gitea client / config failures.
Never true for arbitrary ``RuntimeError`` (reviewer finding #3).
"""
try:
import gitea_auth
if isinstance(
exc,
(
gitea_auth.GiteaAuthError,
gitea_auth.GiteaAuthzError,
gitea_auth.GiteaNetworkError,
gitea_auth.GiteaConfigError,
gitea_auth.GiteaHttpError,
),
):
return True
except Exception:
return False
try:
import gitea_config
if isinstance(exc, gitea_config.ConfigError):
return True
except Exception:
pass
return False
def classify_exception(exc: BaseException) -> dict[str, Any]:
"""Return structured classification with **fixed** messages only.
Only typed authentication / authorization / network / configuration
failures receive those labels. Unexpected programming failures are
``internal_error``. HTTP bodies and exception text are never copied
into ``message``.
"""
try:
return _classify_exception_impl(exc)
except Exception:
# Fail closed: sanitization / classification failure must not leak.
return {
"reason_code": REASON_INTERNAL_ERROR,
"error_class": ERROR_CLASS_INTERNAL,
"http_status": None,
"message": fixed_message(REASON_INTERNAL_ERROR),
"transport_survives": True,
}
def _self_declared_classification(exc: BaseException) -> dict[str, Any] | None:
"""Honor a safe ``gitea_reason_code`` attribute set by server-side raisers.
Converts a raw exception that self-declares a **known** reason code into a
typed structured error (fixed message) instead of an opaque internal_error.
Unknown / non-string / secret values are ignored (fail closed to internal).
"""
reason = getattr(exc, _SELF_DECLARED_REASON_ATTR, None)
if not isinstance(reason, str) or reason not in FIXED_MESSAGES:
return None
if reason == REASON_INTERNAL_ERROR:
return None
return {
"reason_code": reason,
"error_class": _REASON_ERROR_CLASS.get(reason, ERROR_CLASS_INTERNAL),
"http_status": None,
"message": fixed_message(reason),
"transport_survives": True,
}
def _classify_exception_impl(exc: BaseException) -> dict[str, Any]:
import gitea_auth
declared = _self_declared_classification(exc)
if declared is not None:
return declared
if isinstance(exc, gitea_auth.GiteaAuthError):
code = getattr(exc, "reason_code", None) or REASON_AUTH_INVALID_TOKEN
if code not in (
REASON_AUTH_FAILED,
REASON_AUTH_INVALID_TOKEN,
):
code = REASON_AUTH_INVALID_TOKEN
return {
"reason_code": code,
"error_class": ERROR_CLASS_AUTHENTICATION,
"http_status": getattr(exc, "http_status", None) or 401,
"message": fixed_message(code),
"transport_survives": True,
}
if isinstance(exc, gitea_auth.GiteaAuthzError):
code = getattr(exc, "reason_code", None) or REASON_AUTHZ_DENIED
if code not in (REASON_AUTHZ_DENIED, REASON_AUTHZ_INSUFFICIENT_SCOPE):
code = REASON_AUTHZ_DENIED
return {
"reason_code": code,
"error_class": ERROR_CLASS_AUTHORIZATION,
"http_status": getattr(exc, "http_status", None) or 403,
"message": fixed_message(code),
"transport_survives": True,
}
if isinstance(exc, gitea_auth.GiteaNetworkError):
return {
"reason_code": REASON_NETWORK_ERROR,
"error_class": ERROR_CLASS_NETWORK,
"http_status": getattr(exc, "http_status", None),
"message": fixed_message(REASON_NETWORK_ERROR),
"transport_survives": True,
}
if isinstance(exc, gitea_auth.GiteaConfigError):
return {
"reason_code": REASON_CONFIG_ERROR,
"error_class": ERROR_CLASS_CONFIGURATION,
"http_status": getattr(exc, "http_status", None),
"message": fixed_message(REASON_CONFIG_ERROR),
"transport_survives": True,
}
if isinstance(exc, gitea_auth.GiteaHttpError):
code = getattr(exc, "reason_code", None) or REASON_HTTP_ERROR
if code == REASON_UPSTREAM_UNAVAILABLE:
error_class = ERROR_CLASS_UPSTREAM
else:
error_class = ERROR_CLASS_INTERNAL
code = REASON_HTTP_ERROR
return {
"reason_code": code,
"error_class": error_class,
"http_status": getattr(exc, "http_status", None),
"message": fixed_message(code),
"transport_survives": True,
}
try:
import gitea_config
if isinstance(exc, gitea_config.ConfigError):
return {
"reason_code": REASON_CONFIG_ERROR,
"error_class": ERROR_CLASS_CONFIGURATION,
"http_status": None,
"message": fixed_message(REASON_CONFIG_ERROR),
"transport_survives": True,
}
except Exception:
pass
# Unwrap FastMCP ToolError cause when the original was a typed failure.
cause = getattr(exc, "__cause__", None)
if cause is not None and cause is not exc and is_known_client_failure(cause):
return _classify_exception_impl(cause)
# No message-substring authentication heuristics (reviewer finding #3).
# Unexpected failure → internal_error, now carrying SAFE diagnostics so the
# failure is actionable: a type identifier + strictly-redacted detail +
# optional mutation-stage tag. The operator ``message`` stays the fixed
# constant; none of these fields carry secrets/bodies/paths/env.
result = {
"reason_code": REASON_INTERNAL_ERROR,
"error_class": ERROR_CLASS_INTERNAL,
"http_status": None,
"message": fixed_message(REASON_INTERNAL_ERROR),
"transport_survives": True,
"exception_class": _safe_exception_class(exc),
"detail": _redact_detail(_safe_str(exc)),
}
stage = _safe_mutation_stage(exc)
if stage:
result["mutation_stage"] = stage
return result
def build_structured_error_payload(
classification: dict[str, Any],
*,
tool_name: str | None = None,
profile_name: str | None = None,
) -> dict[str, Any]:
"""LLM-safe structured payload — fixed message + typed metadata only."""
try:
reason = str(classification.get("reason_code") or REASON_INTERNAL_ERROR)
message = fixed_message(reason)
# Refuse to emit any classification message that is not the fixed constant.
if classification.get("message") != message:
message = fixed_message(reason)
payload: dict[str, Any] = {
"success": False,
"isError": True,
"reason_code": reason if reason in FIXED_MESSAGES else REASON_INTERNAL_ERROR,
"error_class": classification.get("error_class") or ERROR_CLASS_INTERNAL,
"message": message,
"transport_survives": True,
"retryable": classification.get("error_class")
in {
ERROR_CLASS_AUTHENTICATION,
ERROR_CLASS_NETWORK,
ERROR_CLASS_CONFIGURATION,
},
}
status = classification.get("http_status")
if isinstance(status, int):
payload["http_status"] = status
if tool_name and isinstance(tool_name, str):
# Tool names are identifiers, not secrets; bound length.
payload["tool"] = tool_name[:120]
if profile_name and isinstance(profile_name, str):
payload["profile"] = profile_name[:80]
# Safe diagnostics — internal_error path only. These make an otherwise
# opaque crash actionable without leaking secrets. Re-derived here from
# the fixed message gate above; typed reasons never carry them.
if payload["reason_code"] == REASON_INTERNAL_ERROR:
exc_cls = classification.get("exception_class")
if isinstance(exc_cls, str) and exc_cls:
payload["exception_class"] = exc_cls[:_CLASS_LIMIT]
detail = classification.get("detail")
if isinstance(detail, str):
payload["detail"] = _redact_detail(detail)
stage = classification.get("mutation_stage")
if isinstance(stage, str) and stage:
payload["mutation_stage"] = stage[:_STAGE_LIMIT]
return payload
except Exception:
return {
"success": False,
"isError": True,
"reason_code": REASON_INTERNAL_ERROR,
"error_class": ERROR_CLASS_INTERNAL,
"message": fixed_message(REASON_INTERNAL_ERROR),
"transport_survives": True,
"retryable": False,
}
def log_sanitized_daemon_reason(
classification: dict[str, Any],
*,
tool_name: str | None = None,
stream=None,
) -> None:
"""Daemon log: reason codes only — never exception text or response bodies."""
stream = stream if stream is not None else sys.stderr
try:
reason = classification.get("reason_code") or REASON_INTERNAL_ERROR
error_class = classification.get("error_class") or ERROR_CLASS_INTERNAL
# Only emit known tokens; never classification['message'] from callers
# that might have been poisoned.
if reason not in FIXED_MESSAGES:
reason = REASON_INTERNAL_ERROR
error_class = ERROR_CLASS_INTERNAL
parts = [
"mcp_tool_error",
f"reason_code={reason}",
f"error_class={error_class}",
]
if tool_name and isinstance(tool_name, str):
parts.append(f"tool={tool_name[:120]}")
status = classification.get("http_status")
if isinstance(status, int):
parts.append(f"http_status={status}")
# Safe diagnostics — internal_error path ONLY. Typed reasons still emit
# no detail= (secrets lived there). class/detail/stage are re-redacted
# here as defence in depth.
if reason == REASON_INTERNAL_ERROR:
exc_cls = classification.get("exception_class")
if isinstance(exc_cls, str) and exc_cls:
parts.append(f"exception_class={exc_cls[:_CLASS_LIMIT]}")
stage = classification.get("mutation_stage")
if isinstance(stage, str) and stage:
parts.append(
f"mutation_stage={_redact_detail(stage, limit=_STAGE_LIMIT)}"
)
detail = classification.get("detail")
if isinstance(detail, str) and detail:
parts.append(f"detail={_redact_detail(detail)}")
line = " ".join(parts)
stream.write(line + "\n")
if hasattr(stream, "flush"):
stream.flush()
logger.warning(line)
except Exception:
# Fail closed: never fall back to logging the exception.
try:
stream.write(
"mcp_tool_error reason_code=internal_error "
"error_class=internal\n"
)
if hasattr(stream, "flush"):
stream.flush()
except Exception:
pass
def to_call_tool_result(
exc: BaseException,
*,
tool_name: str | None = None,
profile_name: str | None = None,
log: bool = True,
) -> Any:
"""Build a FastMCP ``CallToolResult`` with ``isError=True`` for *exc*."""
from mcp.types import CallToolResult, TextContent
try:
classification = classify_exception(exc)
if log:
log_sanitized_daemon_reason(classification, tool_name=tool_name)
payload = build_structured_error_payload(
classification, tool_name=tool_name, profile_name=profile_name
)
text = json.dumps(payload, indent=2, sort_keys=True)
return CallToolResult(
content=[TextContent(type="text", text=text)],
structuredContent=payload,
isError=True,
)
except Exception:
# Absolute fail-closed path — no exception text.
fallback = {
"success": False,
"isError": True,
"reason_code": REASON_INTERNAL_ERROR,
"error_class": ERROR_CLASS_INTERNAL,
"message": fixed_message(REASON_INTERNAL_ERROR),
"transport_survives": True,
"retryable": False,
}
return CallToolResult(
content=[
TextContent(
type="text",
text=json.dumps(fallback, indent=2, sort_keys=True),
)
],
structuredContent=fallback,
isError=True,
)
def install_tool_run_boundary(Tool) -> bool:
"""Install a **narrow** error boundary around FastMCP ``Tool.run``.
Strategy (preserves framework semantics):
* Call the **original** ``Tool.run`` for the success path (async, return
types, convert_result, protocol behavior unchanged).
* Re-raise ``UrlElicitationRequiredError`` and other control-flow
exceptions without mapping to ``internal_error``.
* Map typed Gitea client failures (and ToolError whose ``__cause__`` is
typed) to structured ``CallToolResult(isError=True)``.
* Map remaining unexpected tool failures to fixed ``internal_error``
isError results (transport survival) without secret-bearing text.
* Idempotent: second install is a no-op and returns ``False``.
"""
if getattr(Tool.run, _INSTALL_FLAG, False):
return False
from mcp.server.fastmcp.exceptions import ToolError
from mcp.shared.exceptions import UrlElicitationRequiredError
original_run = Tool.run
async def run_boundary(
self,
arguments: dict[str, Any],
context=None,
convert_result: bool = False,
) -> Any:
try:
return await original_run(
self,
arguments,
context=context,
convert_result=convert_result,
)
except UrlElicitationRequiredError:
# Framework control-flow — must not become internal_error.
raise
except BaseException as exc:
if _is_framework_control_flow(exc):
raise
if not isinstance(exc, Exception):
raise
# Prefer typed cause under FastMCP ToolError wrappers.
target: BaseException = exc
if isinstance(exc, ToolError) and exc.__cause__ is not None:
target = exc.__cause__
# Known client failures → structured isError with reason codes.
# Unexpected failures → fixed internal_error isError (no secrets).
profile_name = _safe_profile_name()
return to_call_tool_result(
target,
tool_name=getattr(self, "name", None),
profile_name=profile_name,
)
setattr(run_boundary, _INSTALL_FLAG, True)
setattr(run_boundary, _ORIGINAL_ATTR, original_run)
Tool.run = run_boundary # type: ignore[method-assign]
return True
def boundary_is_installed(Tool) -> bool:
"""True when the #699 boundary is active on *Tool.run*."""
return bool(getattr(Tool.run, _INSTALL_FLAG, False))
+354
View File
@@ -18,16 +18,34 @@ DEFAULT_ADOPTION_REASON = "merger-handoff-approved-head"
SOURCE_ADOPT = "gitea_adopt_merger_pr_lease"
SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease"
SOURCE_ACQUIRE_MERGER = "gitea_acquire_merger_pr_lease"
SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease"
SOURCE_RELEASE_MERGER = "gitea_release_merger_pr_lease"
SANCTIONED_PROVENANCE_SOURCES = frozenset({
SOURCE_ADOPT,
SOURCE_ACQUIRE,
SOURCE_ACQUIRE_MERGER,
SOURCE_HEARTBEAT,
})
_MERGER_ADOPTABLE_FRESHNESS = frozenset({"active", "stale_warning"})
# #742: owner-session terminal finalization of a merger-held lease. Append-only
# marker; ledger history is never edited or deleted.
MERGER_FINALIZATION_MARKER = "<!-- mcp-merger-lease-final:v1 -->"
OUTCOME_RELEASED = "released"
OUTCOME_ABANDONED = "abandoned"
MERGER_FINALIZATION_OUTCOMES = frozenset({OUTCOME_RELEASED, OUTCOME_ABANDONED})
DEFAULT_MERGER_FINALIZATION_REASON = "merge-not-performed"
# Provenance sources whose in-session lease is merger-owned and therefore
# finalizable by its owning merger session.
MERGER_OWNED_PROVENANCE_SOURCES = frozenset({
SOURCE_ACQUIRE_MERGER,
SOURCE_ADOPT,
})
def format_adoption_body(
*,
@@ -95,6 +113,7 @@ def build_lease_provenance(
adopted_from_profile: str | None = None,
adopted_from_reviewer_identity: str | None = None,
adoption_reason: str | None = None,
native_token_fingerprint: str | None = None,
recorded_at: datetime | None = None,
) -> dict[str, Any]:
recorded_at = recorded_at or datetime.now(timezone.utc)
@@ -115,9 +134,76 @@ def build_lease_provenance(
proof["adopted_from_reviewer_identity"] = adopted_from_reviewer_identity
if adoption_reason:
proof["adoption_reason"] = adoption_reason
if native_token_fingerprint:
proof["native_token_fingerprint"] = native_token_fingerprint
return proof
def assess_acquired_merger_lease_integrity(
session: dict[str, Any] | None,
) -> list[str]:
"""Ownership/integrity reasons blocking a ``SOURCE_ACQUIRE_MERGER`` lease (#742).
A merger lease minted by ``gitea_acquire_merger_pr_lease`` authorizes an
irreversible merge, so it is sanctioned only when the in-session record is
complete and self-consistent: comment marker, exact session identity, merger
profile/role, repository, PR number, and pinned candidate head. Any missing
or contradictory field fails closed — an incomplete record is not proof.
"""
if not session:
return ["no in-session lease recorded"]
provenance = session.get("lease_provenance") or {}
if not isinstance(provenance, dict):
provenance = {}
reasons: list[str] = []
provenance_comment_id = provenance.get("comment_id")
session_comment_id = session.get("comment_id")
if not (provenance_comment_id or session_comment_id):
reasons.append(
"acquired merger lease has no comment marker id (comment-backed "
"proof required)"
)
elif (
provenance_comment_id is not None
and session_comment_id is not None
and provenance_comment_id != session_comment_id
):
reasons.append(
"acquired merger lease provenance comment_id does not match the "
"session lease comment_id"
)
if not (session.get("session_id") or "").strip():
reasons.append("acquired merger lease has no session_id")
if not (session.get("reviewer_identity") or "").strip():
reasons.append("acquired merger lease has no holder identity")
profile = (session.get("profile") or "").strip()
if not profile:
reasons.append("acquired merger lease has no profile")
elif "merger" not in profile.lower():
reasons.append(
f"acquired merger lease profile '{profile}' is not a merger profile "
"(merger-only; fail closed)"
)
if not (session.get("repo") or "").strip():
reasons.append("acquired merger lease has no repository")
pr_number = session.get("pr_number")
if not isinstance(pr_number, int) or isinstance(pr_number, bool) or pr_number <= 0:
reasons.append("acquired merger lease has no valid PR number")
if not leases._normalize_sha(session.get("candidate_head")):
reasons.append(
"acquired merger lease has no pinned candidate_head (exact-head "
"scoping required)"
)
return reasons
def is_sanctioned_session_lease(session: dict[str, Any] | None) -> bool:
if not session:
return False
@@ -129,6 +215,8 @@ def is_sanctioned_session_lease(session: dict[str, Any] | None) -> bool:
return bool(provenance.get("comment_id")) and bool(
provenance.get("adopted_from_session_id")
)
if source == SOURCE_ACQUIRE_MERGER:
return not assess_acquired_merger_lease_integrity(session)
if source in {SOURCE_ACQUIRE, SOURCE_HEARTBEAT}:
return bool(session.get("comment_id") or provenance.get("comment_id"))
return False
@@ -166,6 +254,8 @@ def describe_session_lease_proof(
if source == SOURCE_ADOPT and sanctioned:
kind = "sanctioned_adoption"
reason = reason or DEFAULT_ADOPTION_REASON
elif source == SOURCE_ACQUIRE_MERGER and sanctioned:
kind = "sanctioned_acquire_merger"
elif source == SOURCE_ACQUIRE and sanctioned:
kind = "sanctioned_acquire"
elif source == SOURCE_HEARTBEAT and sanctioned:
@@ -209,9 +299,12 @@ _SANCTIONED_LEASE_EVIDENCE_RE = re.compile(
r"(?is)\b("
r"gitea_adopt_merger_pr_lease|"
r"gitea_acquire_reviewer_pr_lease|"
r"gitea_acquire_merger_pr_lease|"
r"lease_proof_source\s*[:=]\s*gitea_adopt_merger_pr_lease|"
r"lease_proof_source\s*[:=]\s*gitea_acquire_reviewer_pr_lease|"
r"lease_proof_source\s*[:=]\s*gitea_acquire_merger_pr_lease|"
r"lease_proof_kind\s*[:=]\s*sanctioned_adoption|"
r"lease_proof_kind\s*[:=]\s*sanctioned_acquire_merger|"
r"lease_proof_kind\s*[:=]\s*sanctioned_acquire|"
r"adoption_comment_id\s*[:=]|"
r"sanctioned_adoption|"
@@ -243,6 +336,267 @@ def assess_manual_lease_proof_handoff(report_text: str) -> dict[str, Any]:
}
def format_merger_finalization_body(
*,
repo: str,
pr_number: int,
issue_number: int | None,
merger_identity: str,
merger_profile: str,
merger_session_id: str,
worktree: str,
candidate_head: str | None,
target_branch: str,
target_branch_sha: str | None,
outcome: str,
reason: str,
lease_comment_id: int | None,
finalized_at: datetime | None = None,
) -> str:
"""Render the append-only terminal marker for a merger-owned lease (#742).
The body carries a standard lease marker in a terminal phase, so the
existing newest-wins ledger (#577) ends the lease without editing or
deleting any prior comment.
"""
finalized_at = finalized_at or datetime.now(timezone.utc)
finalized_text = finalized_at.astimezone(timezone.utc).replace(
microsecond=0
).isoformat().replace("+00:00", "Z")
lease_body = leases.format_lease_body(
repo=repo,
pr_number=pr_number,
issue_number=issue_number,
reviewer_identity=merger_identity,
profile=merger_profile,
session_id=merger_session_id,
worktree=worktree,
phase=outcome,
candidate_head=candidate_head,
target_branch=target_branch,
target_branch_sha=target_branch_sha,
last_activity=finalized_at,
blocker=reason,
)
lines = [
MERGER_FINALIZATION_MARKER,
f"finalized_at: {finalized_text}",
f"finalized_by_identity: {merger_identity}",
f"finalized_by_profile: {merger_profile}",
f"finalized_by_session_id: {merger_session_id}",
f"finalization_outcome: {outcome}",
f"finalization_reason: {reason}",
f"finalized_lease_comment_id: {lease_comment_id or 'none'}",
lease_body,
]
return "\n".join(lines)
def is_merger_finalization_comment(body: str) -> bool:
return MERGER_FINALIZATION_MARKER in (body or "")
def assess_merger_lease_finalization(
comments: list[dict],
*,
pr_number: int,
session: dict[str, Any] | None,
actor_identity: str,
actor_profile: str,
actor_session_id: str | None,
repo: str,
worktree: str,
candidate_head: str | None,
live_head_sha: str | None = None,
outcome: str = OUTCOME_RELEASED,
reason: str | None = None,
runtime_token_fingerprint: str | None = None,
issue_number: int | None = None,
target_branch: str = "master",
target_branch_sha: str | None = None,
now: datetime | None = None,
) -> dict[str, Any]:
"""Decide whether a merger may terminally finalize its own lease (#742).
Owner-session only: the caller must hold the exact comment-backed merger
lease it is finalizing. Foreign sessions, reviewer profiles, and mismatched
repository/PR/head/token-fingerprint callers fail closed. Already-terminal
leases return ``already_terminal`` so repeat calls are idempotent and post
no second marker.
"""
now = now or datetime.now(timezone.utc)
reasons: list[str] = []
outcome = (outcome or "").strip().lower()
finalization_reason = (reason or "").strip() or DEFAULT_MERGER_FINALIZATION_REASON
if outcome not in MERGER_FINALIZATION_OUTCOMES:
reasons.append(
f"outcome '{outcome or 'none'}' is not a terminal merger "
f"finalization outcome ({sorted(MERGER_FINALIZATION_OUTCOMES)})"
)
if "merger" not in (actor_profile or "").lower():
reasons.append(
f"profile '{actor_profile or 'unknown'}' is not a merger profile; "
"merger lease finalization is merger-only (fail closed). Reviewer "
"sessions use gitea_release_reviewer_pr_lease."
)
session = session or None
provenance = (session or {}).get("lease_provenance") or {}
if not isinstance(provenance, dict):
provenance = {}
source = (provenance.get("source") or "").strip()
if not session:
reasons.append(
"no in-session merger lease recorded; only the owning session may "
"finalize a merger lease"
)
elif source not in MERGER_OWNED_PROVENANCE_SOURCES:
reasons.append(
f"in-session lease provenance '{source or 'none'}' is not a "
"merger-owned lease; refusing to finalize"
)
elif not is_sanctioned_session_lease(session):
reasons.extend(
assess_acquired_merger_lease_integrity(session)
if source == SOURCE_ACQUIRE_MERGER
else ["in-session merger lease lacks sanctioned provenance"]
)
pinned = leases._normalize_sha(candidate_head)
live = leases._normalize_sha(live_head_sha)
if not pinned:
reasons.append(
"candidate_head is required for merger lease finalization "
"(exact-head scoping; fail closed)"
)
if live and pinned and live != pinned:
reasons.append(
"candidate_head does not match live PR head (fail closed)"
)
if session:
session_sid = (session.get("session_id") or "").strip()
actor_sid = (actor_session_id or "").strip()
if not actor_sid or session_sid != actor_sid:
reasons.append(
"session_id does not match the in-session merger lease owner; "
"foreign-session release is not permitted (fail closed)"
)
if session.get("pr_number") != pr_number:
reasons.append(
f"in-session merger lease is for PR #{session.get('pr_number')}, "
f"not #{pr_number}"
)
session_repo = (session.get("repo") or "").strip()
if session_repo and session_repo != (repo or "").strip():
reasons.append(
f"in-session merger lease repository '{session_repo}' does not "
f"match '{repo}' (fail closed)"
)
session_head = leases._normalize_sha(session.get("candidate_head"))
if pinned and session_head and session_head != pinned:
reasons.append(
"in-session merger lease candidate_head does not match the "
"supplied candidate_head (fail closed)"
)
session_identity = (session.get("reviewer_identity") or "").strip()
if session_identity and session_identity != (actor_identity or "").strip():
reasons.append(
"authenticated identity does not match the in-session merger "
"lease holder (fail closed)"
)
session_profile = (session.get("profile") or "").strip()
if session_profile and session_profile != (actor_profile or "").strip():
reasons.append(
"active profile does not match the in-session merger lease "
"profile (fail closed)"
)
recorded_fingerprint = (
provenance.get("native_token_fingerprint") or ""
).strip()
live_fingerprint = (runtime_token_fingerprint or "").strip()
if (
recorded_fingerprint
and live_fingerprint
and recorded_fingerprint != live_fingerprint
):
reasons.append(
"native runtime token fingerprint does not match the one "
"recorded when the merger lease was acquired (fail closed)"
)
lease_comment_id = (session or {}).get("comment_id") or provenance.get("comment_id")
entries = leases._lease_entries(comments, pr_number=pr_number)
if session and lease_comment_id is not None:
if not any(entry.get("comment_id") == lease_comment_id for entry in entries):
reasons.append(
f"lease marker comment {lease_comment_id} is not present on PR "
f"#{pr_number} (fail closed)"
)
active = leases.find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
already_terminal = False
if active:
owner_session = (active.get("session_id") or "").strip()
if owner_session and owner_session != (actor_session_id or "").strip():
reasons.append(
f"active PR lease is owned by session_id={owner_session}; a "
"merger may only finalize its own lease (fail closed)"
)
else:
newest = entries[-1] if entries else None
newest_phase = ((newest or {}).get("phase") or "").strip().lower()
if newest and newest_phase in leases._TERMINAL_PHASES:
already_terminal = True
elif not entries:
reasons.append(
f"no comment-backed lease marker found on PR #{pr_number}"
)
finalize_allowed = not reasons and not already_terminal
body = None
if finalize_allowed and session:
body = format_merger_finalization_body(
repo=(session.get("repo") or repo),
pr_number=pr_number,
issue_number=(
issue_number
if issue_number is not None
else session.get("issue_number")
),
merger_identity=actor_identity,
merger_profile=actor_profile,
merger_session_id=(actor_session_id or ""),
worktree=worktree or (session.get("worktree") or ""),
candidate_head=pinned,
target_branch=(
session.get("target_branch") or target_branch or "master"
),
target_branch_sha=(
session.get("target_branch_sha") or target_branch_sha
),
outcome=outcome,
reason=finalization_reason,
lease_comment_id=lease_comment_id,
finalized_at=now,
)
return {
"finalize_allowed": finalize_allowed,
"already_terminal": already_terminal,
"reasons": reasons,
"outcome": outcome,
"finalization_reason": finalization_reason,
"active_lease": active,
"finalization_body": body,
"lease_comment_id": lease_comment_id,
"candidate_head": pinned,
}
def assess_adopt_merger_lease(
comments: list[dict],
*,
+127 -8
View File
@@ -20,12 +20,114 @@ if PROJECT_ROOT not in sys.path:
import gitea_config
AUTHOR_DEFAULT_ALLOWED = ["read", "branch", "commit", "push", "open_pr", "comment"]
AUTHOR_DEFAULT_FORBIDDEN = ["approve", "request_changes", "merge"]
REVIEWER_DEFAULT_ALLOWED = [
"read", "review", "comment", "approve", "request_changes", "merge"
# Defaults emit *canonical* operation names only. Shorthand that is not in
# gitea_config.GITEA_OPERATION_ALIASES (e.g. ``pr.close``, ``issue.close``)
# is silently dropped by the production loader and must never appear here.
AUTHOR_DEFAULT_ALLOWED = [
"gitea.read",
"gitea.branch.create",
"gitea.repo.commit",
"gitea.branch.push",
"gitea.pr.create",
"gitea.pr.comment",
]
REVIEWER_DEFAULT_FORBIDDEN = ["branch", "commit", "push", "open_pr"]
AUTHOR_DEFAULT_FORBIDDEN = [
"gitea.pr.approve",
"gitea.pr.request_changes",
"gitea.pr.merge",
]
REVIEWER_DEFAULT_ALLOWED = [
"gitea.read",
"gitea.pr.review",
"gitea.pr.comment",
"gitea.pr.approve",
"gitea.pr.request_changes",
"gitea.pr.merge",
]
REVIEWER_DEFAULT_FORBIDDEN = [
"gitea.branch.create",
"gitea.repo.commit",
"gitea.branch.push",
"gitea.pr.create",
]
# Required reconciler ops (read + pr.close) plus recommended comment/close and
# branch.delete for guarded merged-PR cleanup. All names must normalize via
# gitea_config.normalize_operation without being dropped.
RECONCILER_DEFAULT_ALLOWED = [
"gitea.read",
"gitea.pr.close",
"gitea.pr.comment",
"gitea.issue.comment",
"gitea.issue.close",
"gitea.branch.delete",
]
RECONCILER_DEFAULT_FORBIDDEN = [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.review",
"gitea.pr.create",
"gitea.branch.push",
"gitea.repo.commit",
]
# Migration-only expansions for common shorthands that are *not* in
# GITEA_OPERATION_ALIASES. Emitted output is always the canonical form so a
# second canonicalize pass is a no-op (idempotent).
_MIGRATION_ONLY_ALIASES = {
"pr.close": "gitea.pr.close",
"pr.comment": "gitea.pr.comment",
"issue.close": "gitea.issue.close",
"branch.delete": "gitea.branch.delete",
}
# Reconciler required ops that must survive migration (from reconciler_profile).
RECONCILER_REQUIRED_CANONICAL = ("gitea.read", "gitea.pr.close")
def canonicalize_operation(op: str) -> str:
"""Return a canonical operation name accepted by the production loader.
Fail closed on unknown/ambiguous spellings so required permissions cannot
be silently dropped by ``check_operation`` later.
"""
if not isinstance(op, str) or not op.strip():
raise ValueError("operation must be a non-empty string (fail closed)")
op = op.strip()
try:
return gitea_config.normalize_operation(op)
except gitea_config.ConfigError:
pass
if op in _MIGRATION_ONLY_ALIASES:
return _MIGRATION_ONLY_ALIASES[op]
raise ValueError(
f"operation {op!r} cannot be canonicalized for migration "
"(unknown/ambiguous; fail closed — production loader would drop it)"
)
def canonicalize_operations(ops, *, context: str = "operations") -> list[str]:
"""Canonicalize a list of operations; preserve order, drop duplicates."""
if not isinstance(ops, list):
raise ValueError(f"{context} must be a list (fail closed)")
out: list[str] = []
seen: set[str] = set()
for entry in ops:
canon = canonicalize_operation(entry)
if canon not in seen:
seen.add(canon)
out.append(canon)
return out
def _assert_reconciler_required_survive(allowed: list[str], profile_name: str) -> None:
"""Fail visibly when migration would leave a reconciler without required ops."""
missing = [op for op in RECONCILER_REQUIRED_CANONICAL if op not in set(allowed)]
if missing:
raise ValueError(
f"Profile '{profile_name}' (reconciler) is missing required "
f"operation(s) after migration: {missing}. Refusing to emit a "
"profile that would silently fail pr.close / read (fail closed)."
)
def infer_role(name, execution_profile):
@@ -90,9 +192,11 @@ def migrate_v1_to_v2(v1_data):
ident_name = "reviewer"
elif role == "author":
ident_name = "author"
elif role == "reconciler":
ident_name = "reconciler"
else:
role = prof.get("role")
if role not in (None, "author", "reviewer"):
if role not in (None, "author", "reviewer", "reconciler"):
raise ValueError(
f"Profile '{name}' has unsupported role {role!r}"
)
@@ -124,20 +228,35 @@ def migrate_v1_to_v2(v1_data):
raise ValueError(
f"Profile '{name}' operation fields must be lists"
)
identity_data["allowed_operations"] = list(allowed)
identity_data["forbidden_operations"] = list(forbidden)
try:
identity_data["allowed_operations"] = canonicalize_operations(
allowed, context=f"profile '{name}' allowed_operations"
)
identity_data["forbidden_operations"] = canonicalize_operations(
forbidden, context=f"profile '{name}' forbidden_operations"
)
except ValueError as exc:
raise ValueError(f"Profile '{name}': {exc}") from exc
elif role == "author":
identity_data["allowed_operations"] = list(AUTHOR_DEFAULT_ALLOWED)
identity_data["forbidden_operations"] = list(AUTHOR_DEFAULT_FORBIDDEN)
elif role == "reviewer":
identity_data["allowed_operations"] = list(REVIEWER_DEFAULT_ALLOWED)
identity_data["forbidden_operations"] = list(REVIEWER_DEFAULT_FORBIDDEN)
elif role == "reconciler":
identity_data["allowed_operations"] = list(RECONCILER_DEFAULT_ALLOWED)
identity_data["forbidden_operations"] = list(RECONCILER_DEFAULT_FORBIDDEN)
else:
raise ValueError(
f"Profile '{name}' has no explicit operation lists and no "
"unambiguous author/reviewer role marker (fail closed)"
)
if role == "reconciler":
_assert_reconciler_required_survive(
identity_data["allowed_operations"], name
)
# Nest inside environments/services structure
env = environments.setdefault(env_name, {})
services = env.setdefault("services", {})
+53 -12
View File
@@ -56,23 +56,46 @@ def resolve_namespace_workspace(
env: dict[str, str] | os._Environ | None = None,
session_lease_worktree: str | None = None,
profile_name: str | None = None,
demotions: list[str] | None = None,
verify_paths: bool = False,
) -> tuple[str, str]:
"""Return ``(resolved_path, binding_source)`` for *role_kind*."""
"""Return ``(resolved_path, binding_source)`` for *role_kind*.
With *verify_paths*, env-sourced candidates whose path no longer exists
are demoted (#702): a binding to a deleted worktree can never name a
valid task workspace, so resolution falls through to the next candidate.
Explicit arguments are never demoted — a caller-declared path must fail
loudly downstream rather than silently rebind. Demotion notes are
appended to *demotions* when provided. Runtime-context and mutation
guards resolve through :func:`resolve_namespace_mutation_context`, which
always verifies.
"""
env_map = env if env is not None else os.environ
role = normalize_role_kind(role_kind, profile_name=profile_name)
role_env_key = ROLE_WORKTREE_ENVS[role]
for candidate, source in (
(worktree_path, "worktree_path argument"),
(worktree, "worktree argument"),
(_env_value(env_map, ACTIVE_WORKTREE_ENV), f"{ACTIVE_WORKTREE_ENV} environment variable"),
(_env_value(env_map, role_env_key), f"{role_env_key} environment variable"),
for candidate, source, env_sourced in (
(worktree_path, "worktree_path argument", False),
(worktree, "worktree argument", False),
(_env_value(env_map, ACTIVE_WORKTREE_ENV),
f"{ACTIVE_WORKTREE_ENV} environment variable", True),
(_env_value(env_map, role_env_key),
f"{role_env_key} environment variable", True),
(session_lease_worktree if role in {"reviewer", "merger"} else None,
"reviewer PR lease worktree"),
"reviewer PR lease worktree", False),
):
text = (candidate or "").strip()
if text:
return os.path.realpath(os.path.abspath(text)), source
if not text:
continue
real = os.path.realpath(os.path.abspath(text))
if verify_paths and env_sourced and not os.path.isdir(real):
if demotions is not None:
demotions.append(
f"{source} '{real}' demoted: path no longer exists "
"(stale binding, #702)"
)
continue
return real, source
return os.path.realpath(process_project_root), "MCP server process root (default)"
@@ -86,8 +109,18 @@ def resolve_namespace_mutation_context(
session_lease_worktree: str | None = None,
worktree: str | None = None,
profile_name: str | None = None,
configured_canonical_root: str | None = None,
) -> dict:
"""Shared workspace resolution for runtime_context and mutation guards."""
"""Shared workspace resolution for runtime_context and mutation guards.
When *configured_canonical_root* is supplied (a cross-repository namespace
bound to an external target repository, #706), the canonical repository root
is that configured target rather than the MCP install checkout. This keeps
the branches-only / worktree-membership guards (#274) evaluating against the
repository the namespace actually mutates. Without it the single-repo
default is preserved: the canonical root follows the process checkout.
"""
demotions: list[str] = []
workspace, binding_source = resolve_namespace_workspace(
role_kind=role_kind,
worktree_path=worktree_path,
@@ -96,6 +129,8 @@ def resolve_namespace_mutation_context(
env=env,
session_lease_worktree=session_lease_worktree,
profile_name=profile_name,
demotions=demotions,
verify_paths=True,
)
process_root = os.path.realpath(process_project_root)
role = normalize_role_kind(role_kind, profile_name=profile_name)
@@ -106,12 +141,16 @@ def resolve_namespace_mutation_context(
env=env,
profile_name=profile_name,
)
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
configured = (configured_canonical_root or "").strip()
if configured:
canonical_root = os.path.realpath(configured)
else:
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
return {
"workspace_path": workspace,
"workspace_binding_source": binding_source,
"workspace_role_kind": role,
"ignored_bindings": pollution.get("ignored_bindings") or [],
"ignored_bindings": demotions + (pollution.get("ignored_bindings") or []),
"process_project_root": process_root,
"canonical_repo_root": canonical_root,
"roots_aligned": canonical_root == process_root,
@@ -232,6 +271,7 @@ def assess_namespace_mutation_workspace(
session_lease_worktree: str | None = None,
profile_name: str | None = None,
current_branch: str | None = None,
configured_canonical_root: str | None = None,
) -> dict:
"""Evaluate namespace workspace binding before preflight/mutation."""
ctx = resolve_namespace_mutation_context(
@@ -242,6 +282,7 @@ def assess_namespace_mutation_workspace(
env=env,
session_lease_worktree=session_lease_worktree,
profile_name=profile_name,
configured_canonical_root=configured_canonical_root,
)
mutation_workspace = ctx["workspace_path"]
binding_source = ctx["workspace_binding_source"]
+648
View File
@@ -0,0 +1,648 @@
"""PR synchronization and conflict-remediation lifecycle (#PR-SYNC).
Pure assessment helpers for:
* ``gitea_assess_pr_sync_status`` — recommend the next sanctioned action for an
open PR when the base branch advances, approvals go stale, or conflicts appear.
* ``gitea_update_pr_branch_by_merge`` preflight — author-only, fail-closed pin
of both PR head and live base head; never rebase/force-push.
All recommendation logic is hermetic (no network) so unit tests cover every
acceptance path without Gitea credentials.
"""
from __future__ import annotations
import re
from typing import Any
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
# Recommended next actions (controller / skill vocabulary).
ACTION_MERGE_NOW = "merge_now"
ACTION_UPDATE_BRANCH_BY_MERGE = "update_branch_by_merge"
ACTION_AUTHOR_CONFLICT_REMEDIATION = "author_conflict_remediation"
ACTION_FRESH_REVIEW_REQUIRED = "fresh_review_required"
ACTION_BLOCKED = "blocked"
_VALID_ACTIONS = frozenset({
ACTION_MERGE_NOW,
ACTION_UPDATE_BRANCH_BY_MERGE,
ACTION_AUTHOR_CONFLICT_REMEDIATION,
ACTION_FRESH_REVIEW_REQUIRED,
ACTION_BLOCKED,
})
# Author-only mutation; reviewer/merger must never update author branches.
_AUTHOR_UPDATE_ROLES = frozenset({"author"})
_DENIED_UPDATE_ROLES = frozenset({"reviewer", "merger", "reconciler", "mixed", "limited"})
# Allowed Gitea update style — merge only (never rebase).
UPDATE_STYLE_MERGE = "merge"
_FORBIDDEN_UPDATE_STYLES = frozenset({"rebase", "rebase-merge", "squash", "force"})
def _normalize_sha(value: str | None) -> str | None:
text = (value or "").strip().lower()
if not text:
return None
return text if _FULL_SHA.match(text) else None
def _normalize_role(role: str | None) -> str:
return (role or "").strip().lower() or "unknown"
def assess_pr_sync_status(
*,
host: str | None = None,
org: str | None = None,
repo: str | None = None,
pr_number: int,
pr_state: str | None = None,
source_branch: str | None = None,
pr_head_sha: str | None = None,
base_head_sha: str | None = None,
commits_behind: int | None = None,
mergeable: bool | None = None,
has_conflicts: bool | None = None,
branch_protection_requires_current_base: bool | None = None,
approval_at_current_head: bool | None = None,
checks_status: str | None = None,
active_author_lock: bool | None = None,
active_reviewer_lease: bool | None = None,
active_merger_lease: bool | None = None,
prepared_verdict_head_sha: str | None = None,
checks_required: bool = True,
) -> dict[str, Any]:
"""Recommend the next sanctioned PR lifecycle action.
Decision order (fail closed):
1. Incomplete identity / open state / head SHAs → ``blocked``
2. Conflicts (or mergeable false with conflict signal) →
``author_conflict_remediation``
3. Head changed after approval / prepared verdict for former head →
``fresh_review_required`` (unless conflicts already routed author work)
4. Behind live base + branch protection requires current base +
auto-mergeable → ``update_branch_by_merge``
5. Approval at current head + mergeable + checks ok (+ update not
required) → ``merge_now``
6. Otherwise ``blocked`` with structured reasons
"""
reasons: list[str] = []
pr_head = _normalize_sha(pr_head_sha)
base_head = _normalize_sha(base_head_sha)
prepared_head = _normalize_sha(prepared_verdict_head_sha)
state = (pr_state or "").strip().lower()
checks = (checks_status or "unknown").strip().lower()
behind = commits_behind if isinstance(commits_behind, int) and commits_behind >= 0 else None
requires_current = bool(branch_protection_requires_current_base)
approval_ok = bool(approval_at_current_head)
# Derive conflict when caller only supplies mergeable=False.
if has_conflicts is None and mergeable is False:
has_conflicts = True
conflicts = bool(has_conflicts) if has_conflicts is not None else None
result: dict[str, Any] = {
"host": (host or "").strip() or None,
"org": (org or "").strip() or None,
"repo": (repo or "").strip() or None,
"pr_number": pr_number,
"pr_state": state or None,
"source_branch": (source_branch or "").strip() or None,
"pr_head_sha": pr_head,
"base_head_sha": base_head,
"commits_behind": behind,
"mergeable": mergeable,
"has_conflicts": conflicts,
"branch_protection_requires_current_base": requires_current,
"approval_at_current_head": approval_ok if approval_at_current_head is not None else None,
"checks_status": checks,
"active_locks_and_leases": {
"author_lock": bool(active_author_lock) if active_author_lock is not None else None,
"reviewer_lease": bool(active_reviewer_lease) if active_reviewer_lease is not None else None,
"merger_lease": bool(active_merger_lease) if active_merger_lease is not None else None,
},
"prepared_verdict_head_sha": prepared_head,
"recommended_next_action": ACTION_BLOCKED,
"approval_valid_for_merge": False,
"stale_approval": False,
"stale_prepared_verdict": False,
"reasons": reasons,
"success": True,
"performed": False,
}
if not isinstance(pr_number, int) or pr_number <= 0:
reasons.append("pr_number must be a positive integer (fail closed)")
return result
if state and state not in ("open",):
reasons.append(f"PR is not open (state={state}); cannot synchronize or merge")
result["recommended_next_action"] = ACTION_BLOCKED
return result
if not state:
reasons.append("PR state missing (fail closed)")
return result
if pr_head is None:
reasons.append(
"exact PR head SHA missing or not a full 40-char hex SHA (fail closed)"
)
if base_head is None:
reasons.append(
"exact live base head SHA missing or not a full 40-char hex SHA (fail closed)"
)
if behind is None:
reasons.append("commits_behind missing or invalid (fail closed)")
if mergeable is None:
reasons.append("mergeable signal missing (fail closed)")
if approval_at_current_head is None:
reasons.append("approval_at_current_head missing (fail closed)")
if branch_protection_requires_current_base is None:
reasons.append(
"branch_protection_requires_current_base missing (fail closed)"
)
if reasons:
result["recommended_next_action"] = ACTION_BLOCKED
return result
# Stale prepared verdict / approval across heads.
stale_prepared = bool(prepared_head and prepared_head != pr_head)
result["stale_prepared_verdict"] = stale_prepared
stale_approval = not approval_ok
result["stale_approval"] = stale_approval
result["approval_valid_for_merge"] = bool(approval_ok and not stale_prepared)
# ── Conflicts: author remediation in dedicated worktree ──────────────
if conflicts is True or mergeable is False:
if conflicts is True or mergeable is False:
# Distinguish pure non-mergeable without explicit conflict flag
# still routes author remediation (Gitea cannot auto-update).
reasons.append(
f"PR #{pr_number} has conflicts or is not mergeable at head "
f"{pr_head}; route author conflict remediation in the existing "
"issue/PR worktree under branches/ (never force-push or rebase)"
)
if stale_approval:
reasons.append(
"any approval at a former head is invalid; after remediation "
"require a fresh independent review at the new exact head"
)
result["recommended_next_action"] = ACTION_AUTHOR_CONFLICT_REMEDIATION
result["approval_valid_for_merge"] = False
return result
# ── Stale approval / prepared verdict at former head ─────────────────
if not approval_ok or stale_prepared:
if behind and behind > 0 and requires_current and mergeable is True:
# Must update first; update will also invalidate approval.
reasons.append(
f"PR is {behind} commit(s) behind live base and branch protection "
"requires current base; automatic merge-from-base is available"
)
reasons.append(
"approval is not valid at the current head (or prepared verdict "
"is head-scoped to a former SHA); after update require fresh review"
)
result["recommended_next_action"] = ACTION_UPDATE_BRANCH_BY_MERGE
result["approval_valid_for_merge"] = False
return result
reasons.append(
"approval is not valid at the exact current PR head "
f"({pr_head}); never reuse a former-head approval for merge"
)
if stale_prepared:
reasons.append(
f"prepared verdict pinned to former head {prepared_head} cannot "
f"authorize review/merge at current head {pr_head}"
)
if active_reviewer_lease:
reasons.append(
"active reviewer lease must be released or superseded for the "
"new head before a fresh independent review"
)
if active_merger_lease:
reasons.append(
"active merger lease cannot cross heads; release or re-acquire "
"at the new exact head"
)
result["recommended_next_action"] = ACTION_FRESH_REVIEW_REQUIRED
result["approval_valid_for_merge"] = False
return result
# ── Outdated + protection requires current base, auto-updatable ──────
if behind and behind > 0 and requires_current:
if mergeable is True and conflicts is not True:
reasons.append(
f"PR is {behind} commit(s) behind live base {base_head}; "
"branch protection requires the PR branch to contain current base; "
"Gitea can merge base into the PR branch without conflicts"
)
reasons.append(
"route author-only update_branch_by_merge; never rebase or force-push; "
"new head invalidates prior approval and requires fresh independent review"
)
result["recommended_next_action"] = ACTION_UPDATE_BRANCH_BY_MERGE
# Approval today is at old head that will change — not mergeable yet
# for final merge until re-reviewed, but assess marks update path.
result["approval_valid_for_merge"] = False
result["stale_approval"] = True # will become stale after update
return result
reasons.append(
"update required by branch protection but automatic merge is not available"
)
result["recommended_next_action"] = ACTION_BLOCKED
return result
# ── Checks gate for merge_now ────────────────────────────────────────
if checks_required and checks not in ("success", "passed", "ok", "none", "skipped", "not_required"):
if checks in ("pending", "running", "queued"):
reasons.append(f"required checks are not finished (status={checks})")
result["recommended_next_action"] = ACTION_BLOCKED
return result
if checks in ("failure", "failed", "error", "cancelled"):
reasons.append(f"required checks failed (status={checks})")
result["recommended_next_action"] = ACTION_BLOCKED
return result
# unknown — fail closed when checks_required
if checks == "unknown":
reasons.append("checks status unknown (fail closed)")
result["recommended_next_action"] = ACTION_BLOCKED
return result
# ── Ready to merge without update ────────────────────────────────────
# Includes: current with approval; outdated when update is NOT required.
if approval_ok and mergeable is True and conflicts is not True:
if behind and behind > 0 and not requires_current:
reasons.append(
f"PR is {behind} commit(s) behind live base but branch protection "
"does not require the latest base; preserve the approved head"
)
else:
reasons.append(
"PR has a valid approval at the exact current head, is conflict-free "
"and mergeable; do not update the branch unnecessarily"
)
reasons.append("route directly to the sanctioned merger workflow (merge_now)")
result["recommended_next_action"] = ACTION_MERGE_NOW
result["approval_valid_for_merge"] = True
return result
reasons.append("no sanctioned next action matched the observed PR state (fail closed)")
result["recommended_next_action"] = ACTION_BLOCKED
return result
def assess_update_pr_branch_preflight(
*,
role_kind: str | None,
expected_pr_head_sha: str | None,
live_pr_head_sha: str | None,
expected_base_head_sha: str | None,
live_base_head_sha: str | None,
has_conflicts: bool | None = None,
mergeable: bool | None = None,
style: str | None = UPDATE_STYLE_MERGE,
has_author_lock: bool | None = None,
worktree_path: str | None = None,
worktree_owns_source_branch: bool | None = None,
control_checkout_clean: bool | None = None,
master_parity_ok: bool | None = None,
force_push: bool = False,
rebase: bool = False,
) -> dict[str, Any]:
"""Author-only preflight for update-by-merge; fail closed on any race.
When conflicts exist, returns a structured author-remediation handoff and
sets ``mutation_allowed=False`` so no partial remote update is performed.
"""
reasons: list[str] = []
role = _normalize_role(role_kind)
exp_pr = _normalize_sha(expected_pr_head_sha)
live_pr = _normalize_sha(live_pr_head_sha)
exp_base = _normalize_sha(expected_base_head_sha)
live_base = _normalize_sha(live_base_head_sha)
style_norm = (style or "").strip().lower() or UPDATE_STYLE_MERGE
conflicts = has_conflicts
if conflicts is None and mergeable is False:
conflicts = True
result: dict[str, Any] = {
"mutation_allowed": False,
"performed": False,
"style": style_norm,
"role_kind": role,
"expected_pr_head_sha": exp_pr,
"live_pr_head_sha": live_pr,
"expected_base_head_sha": exp_base,
"live_base_head_sha": live_base,
"head_race": False,
"base_race": False,
"has_conflicts": conflicts,
"author_remediation_handoff": False,
"approval_invalidated_for_former_head": False,
"force_push": bool(force_push),
"rebase": bool(rebase),
"reasons": reasons,
"success": True,
}
# Role gate — author only.
if role not in _AUTHOR_UPDATE_ROLES:
reasons.append(
f"role '{role}' cannot update an author PR branch "
"(author-only; reviewer/merger denial)"
)
return result
if force_push:
reasons.append("force-push is forbidden for PR branch update (fail closed)")
return result
if rebase or style_norm in _FORBIDDEN_UPDATE_STYLES:
reasons.append(
f"update style '{style_norm}' forbidden; only style=merge is allowed "
"(never rebase)"
)
return result
if style_norm != UPDATE_STYLE_MERGE:
reasons.append(
f"unknown update style '{style_norm}'; expected '{UPDATE_STYLE_MERGE}'"
)
return result
if not exp_pr or not live_pr:
reasons.append(
"expected and live PR head SHAs must be full 40-char hex (fail closed)"
)
if not exp_base or not live_base:
reasons.append(
"expected and live base head SHAs must be full 40-char hex (fail closed)"
)
if reasons:
return result
if exp_pr != live_pr:
result["head_race"] = True
reasons.append(
f"PR head race: expected {exp_pr} but live is {live_pr} "
"(fail closed; no partial mutation)"
)
return result
if exp_base != live_base:
result["base_race"] = True
reasons.append(
f"base head race: expected {exp_base} but live is {live_base} "
"(fail closed; no partial mutation)"
)
return result
if has_author_lock is not True:
reasons.append(
"existing issue/PR lock required before update_branch_by_merge (fail closed)"
)
wt = (worktree_path or "").strip()
if not wt:
reasons.append("existing PR worktree path required under branches/ (fail closed)")
else:
norm = wt.replace("\\", "/")
if "/branches/" not in norm and not norm.startswith("branches/"):
reasons.append(
f"worktree_path '{wt}' must be under branches/ (fail closed)"
)
if worktree_owns_source_branch is False:
reasons.append(
"worktree does not own the PR source branch (fail closed)"
)
if control_checkout_clean is False:
reasons.append("control checkout is not clean (fail closed)")
if master_parity_ok is False:
reasons.append("runtime/master parity failed (fail closed)")
if conflicts is True:
result["author_remediation_handoff"] = True
reasons.append(
"conflicts present: return structured author-remediation handoff "
"without creating a partial remote update"
)
reasons.append(
"resolve conflicts explicitly in the existing dedicated worktree, "
"run focused and regression tests, commit/push via sanctioned author "
"workflow, then route the new exact head to independent review"
)
return result
if mergeable is False and conflicts is not False:
result["author_remediation_handoff"] = True
reasons.append(
"PR is not mergeable; refuse automatic update and hand off to "
"author conflict remediation (fail closed)"
)
return result
if reasons:
return result
result["mutation_allowed"] = True
reasons.append(
"preflight passed: author may merge live base into the PR branch via "
"native MCP update (style=merge only)"
)
return result
def assess_post_update_head_transition(
*,
former_pr_head_sha: str | None,
new_pr_head_sha: str | None,
former_approval_head_sha: str | None = None,
former_reviewer_lease_head_sha: str | None = None,
former_merger_lease_head_sha: str | None = None,
prepared_verdict_head_sha: str | None = None,
) -> dict[str, Any]:
"""After a successful update-by-merge, invalidate cross-head artifacts.
The new head never inherits approval, reviewer/merger leases, or prepared
verdicts from the former head.
"""
former = _normalize_sha(former_pr_head_sha)
new = _normalize_sha(new_pr_head_sha)
reasons: list[str] = []
result: dict[str, Any] = {
"former_pr_head_sha": former,
"new_pr_head_sha": new,
"head_changed": bool(former and new and former != new),
"approval_invalidated": False,
"reviewer_lease_superseded": False,
"merger_lease_superseded": False,
"prepared_verdict_invalidated": False,
"recommended_next_action": ACTION_BLOCKED,
"reasons": reasons,
"success": True,
}
if not former or not new:
reasons.append("former and new PR head SHAs required (fail closed)")
return result
if former == new:
reasons.append(
"PR head unchanged after update; unexpected for merge-from-base"
)
result["recommended_next_action"] = ACTION_BLOCKED
return result
result["head_changed"] = True
# Approval at former head is always void at new head.
former_approval = _normalize_sha(former_approval_head_sha) or former
if former_approval != new:
result["approval_invalidated"] = True
reasons.append(
f"approval for former head {former_approval} is invalid at new head "
f"{new}; never preserve approval across a head change"
)
rev_lease = _normalize_sha(former_reviewer_lease_head_sha)
if rev_lease and rev_lease != new:
result["reviewer_lease_superseded"] = True
reasons.append(
f"reviewer lease for head {rev_lease} cannot cross to {new}; "
"release or supersede before fresh review"
)
elif rev_lease is None:
# Unknown lease head still must not authorize at new head.
result["reviewer_lease_superseded"] = True
reasons.append(
"any reviewer lease scoped to the former head is superseded at the new head"
)
mer_lease = _normalize_sha(former_merger_lease_head_sha)
if mer_lease and mer_lease != new:
result["merger_lease_superseded"] = True
reasons.append(
f"merger lease for head {mer_lease} cannot cross to {new}"
)
else:
result["merger_lease_superseded"] = True
reasons.append(
"any merger lease scoped to the former head is superseded at the new head"
)
prepared = _normalize_sha(prepared_verdict_head_sha)
if prepared and prepared != new:
result["prepared_verdict_invalidated"] = True
reasons.append(
f"prepared verdict for head {prepared} cannot authorize the new head {new}"
)
elif prepared is None:
result["prepared_verdict_invalidated"] = True
reasons.append(
"prepared verdicts associated with the former head are invalidated"
)
result["recommended_next_action"] = ACTION_FRESH_REVIEW_REQUIRED
reasons.append(
"route the new exact head to independent review "
f"({ACTION_FRESH_REVIEW_REQUIRED})"
)
return result
def assess_sequential_queue_step(
*,
just_merged: bool,
master_refreshed: bool,
next_pr_number: int | None = None,
) -> dict[str, Any]:
"""Controller sequential processing: reassess only after merge + master refresh."""
reasons: list[str] = []
if just_merged and not master_refreshed:
reasons.append(
"master must be refreshed after each merge before reassessing the next PR "
"(do not update every PR simultaneously)"
)
return {
"reassess_allowed": False,
"process_next": False,
"next_pr_number": next_pr_number,
"reasons": reasons,
"success": True,
}
if not just_merged:
reasons.append("no merge completed; sequential step does not advance")
return {
"reassess_allowed": False,
"process_next": False,
"next_pr_number": next_pr_number,
"reasons": reasons,
"success": True,
}
if next_pr_number:
reasons.append(
"merge completed and live master refreshed; reassess the next PR "
f"#{next_pr_number}"
)
else:
reasons.append(
"merge completed and live master refreshed; reassess the next PR"
)
return {
"reassess_allowed": True,
"process_next": True,
"next_pr_number": next_pr_number,
"post_merge_cleanup_handoff": True,
"reasons": reasons,
"success": True,
}
def merge_approval_usable_at_head(
*,
current_head_sha: str | None,
approved_head_sha: str | None,
) -> dict[str, Any]:
"""Stale approval cannot authorize merge (head-scoped only)."""
current = _normalize_sha(current_head_sha)
approved = _normalize_sha(approved_head_sha)
ok = bool(current and approved and current == approved)
reasons: list[str] = []
if not ok:
reasons.append(
f"stale approval: approved head '{approved or '(none)'}' does not "
f"match current head '{current or '(none)'}'; cannot authorize merge"
)
return {
"approval_usable": ok,
"current_head_sha": current,
"approved_head_sha": approved,
"reasons": reasons,
}
def lease_usable_at_head(
*,
lease_kind: str,
lease_head_sha: str | None,
current_head_sha: str | None,
) -> dict[str, Any]:
"""Reviewer/merger leases cannot cross heads."""
current = _normalize_sha(current_head_sha)
lease_head = _normalize_sha(lease_head_sha)
kind = (lease_kind or "").strip().lower() or "unknown"
ok = bool(current and lease_head and current == lease_head)
reasons: list[str] = []
if not ok:
reasons.append(
f"stale {kind} lease: lease head '{lease_head or '(none)'}' cannot "
f"authorize work at current head '{current or '(none)'}'"
)
return {
"lease_usable": ok,
"lease_kind": kind,
"lease_head_sha": lease_head,
"current_head_sha": current,
"reasons": reasons,
}
+54 -3
View File
@@ -20,7 +20,11 @@ _FIELD_RE = re.compile(
re.IGNORECASE | re.MULTILINE,
)
_TERMINAL_REVIEWER_PHASES = frozenset({"done", "released", "blocked"})
# Must mirror reviewer_pr_lease._TERMINAL_PHASES: both modules read the same
# append-only lease markers, so a phase that is terminal in one and active in
# the other yields two conflicting truths for the same comment (#742 review
# 460). "abandoned" is the owner-session merger finalization outcome.
_TERMINAL_REVIEWER_PHASES = frozenset({"done", "released", "blocked", "abandoned"})
_ACTIVE_REVIEWER_PHASES = frozenset({
"claimed",
"validating",
@@ -153,25 +157,72 @@ def _lease_phase_active(lease: dict, *, active_phases: frozenset[str]) -> bool:
))
def _reviewer_chain_key(lease: dict) -> tuple | None:
"""Identity of the lease chain a reviewer marker belongs to (#742).
A chain is one session's claim → heartbeat → terminal sequence, keyed by
repository, PR, candidate head, session id, identity, and profile. Returns
None when any component is missing: an incomplete or malformed marker has
no provable chain, so it can neither be cancelled by nor cancel anything.
"""
raw = lease.get("raw_fields") or {}
repo = (raw.get("repo") or "").strip().lower()
session_id = (lease.get("session_id") or "").strip()
identity = (lease.get("reviewer_identity") or "").strip()
profile = (lease.get("profile") or "").strip()
head = lease.get("candidate_head")
pr_number = lease.get("pr_number")
if not (repo and session_id and identity and profile and head and pr_number):
return None
return (repo, pr_number, head, session_id, identity, profile)
def _chain_terminated_after(entries: list[dict], index: int) -> bool:
"""True when a later marker terminates the chain of ``entries[index]``.
Append-only newest-wins (#577 semantics, chain-scoped for #742): a terminal
marker ends only its *own* claim, so a foreign, forged, or malformed
terminal marker cannot cancel another session's valid active lease.
"""
key = _reviewer_chain_key(entries[index])
if key is None:
return False
for later in entries[index + 1:]:
if (later.get("phase") or "").strip().lower() not in _TERMINAL_REVIEWER_PHASES:
continue
if _reviewer_chain_key(later) == key:
return True
return False
def find_active_reviewer_lease(
comments: list[dict],
*,
pr_number: int,
now: datetime | None = None,
) -> dict[str, Any] | None:
"""Return the newest unexpired reviewer lease for *pr_number*, if any."""
"""Return the newest unexpired, non-terminated reviewer lease for *pr_number*.
Walking backward past a terminal marker used to resurrect the older claim of
the very chain that marker ended, so a released/abandoned finalization still
read as an active lease here while ``reviewer_pr_lease`` reported it ended
(#742). A claim is now skipped when a later marker terminates its own chain.
"""
now = now or datetime.now(timezone.utc)
candidates = [
entry for entry in _comment_entries(comments, pr_number=pr_number)
if entry.get("lease_kind") == "reviewer"
]
for lease in reversed(candidates):
for index in range(len(candidates) - 1, -1, -1):
lease = candidates[index]
if _lease_expired(lease, now=now):
continue
phase = (lease.get("phase") or "").strip().lower()
if phase in _TERMINAL_REVIEWER_PHASES:
continue
if phase in _ACTIVE_REVIEWER_PHASES or phase:
if _chain_terminated_after(candidates, index):
continue
return lease
return None
+5
View File
@@ -18,6 +18,11 @@ RECONCILER_RECOMMENDED_OPERATIONS = (
"gitea.pr.comment",
"gitea.issue.comment",
"gitea.issue.close",
# Merged-branch cleanup is reconciler-owned (task_capability_map maps
# cleanup_merged_pr_branch -> reconciler). The permission is only
# exercisable through the guarded gitea_cleanup_merged_pr_branch path
# (#514): merged proof, protected-branch refusal, explicit confirmation.
"gitea.branch.delete",
)
RECONCILER_FORBIDDEN_OPERATIONS = (
+1
View File
@@ -31,6 +31,7 @@ python-multipart==0.0.32
referencing==0.37.0
rich==15.0.0
rpds-py==2026.5.1
sentry-sdk==2.20.0
shellingham==1.5.4
sse-starlette==3.4.5
starlette==1.3.1
+239 -1
View File
@@ -16,7 +16,10 @@ _FIELD_RE = re.compile(
)
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
_TERMINAL_PHASES = frozenset({"done", "released", "blocked"})
# "abandoned" is the owner-session merger finalization outcome (#742). Without
# it here, an abandoned marker would fall through to the generic non-empty
# phase branch and keep re-arming the lease as active.
_TERMINAL_PHASES = frozenset({"done", "released", "blocked", "abandoned"})
_ACTIVE_PHASES = frozenset({
"claimed",
"validating",
@@ -407,18 +410,194 @@ def record_session_lease(
if lease_provenance:
stored["lease_provenance"] = dict(lease_provenance)
_SESSION_LEASE = stored
_persist_session_lease_shadow(stored)
return dict(_SESSION_LEASE)
def clear_session_lease() -> None:
global _SESSION_LEASE
prior = _SESSION_LEASE
_SESSION_LEASE = None
_persist_session_lease_shadow(None, prior=prior)
def get_session_lease() -> dict[str, Any] | None:
return dict(_SESSION_LEASE) if _SESSION_LEASE else None
def _persist_session_lease_shadow(
lease: dict[str, Any] | None,
*,
prior: dict[str, Any] | None = None,
) -> None:
"""Best-effort durable shadow of the in-session lease (#702).
A daemon that dies without teardown leaves this record behind, giving a
later process provable orphan evidence (owner pid + session id) for the
guarded cleanup path. Observability only mutation gates never read it,
and failures here must never block lease operations.
Shadows are keyed by lease session id (#702 F5): concurrent same-profile
sessions each own a distinct record, so one session's heartbeat can never
overwrite or misattribute another's crash evidence. A sanctioned clear is
the terminal reconciliation of that record's lifecycle.
"""
try:
import mcp_session_state as mss
if lease is None:
sid = ((prior or {}).get("session_id") or "").strip() or None
if sid:
mss.clear_state(
kind=mss.KIND_REVIEWER_SESSION_LEASE, instance_id=sid
)
# Legacy single-slot record from pre-F5 code: clearing it is safe
# because collision-safe writes never target that key again.
mss.clear_state(kind=mss.KIND_REVIEWER_SESSION_LEASE)
return
mss.save_state(
kind=mss.KIND_REVIEWER_SESSION_LEASE,
instance_id=((lease.get("session_id") or "").strip() or None),
payload={
"pr_number": lease.get("pr_number"),
"session_id": lease.get("session_id"),
"comment_id": lease.get("comment_id"),
"candidate_head": lease.get("candidate_head"),
"worktree": lease.get("worktree"),
"phase": lease.get("phase"),
"reviewer_identity": lease.get("reviewer_identity"),
"profile": lease.get("profile"),
"lease_repo": lease.get("repo"),
"owner_pid": os.getpid(),
},
)
except Exception:
pass
def load_session_lease_shadow(
session_id: str | None = None,
) -> dict[str, Any] | None:
"""Load the durable session-lease shadow left by this profile identity.
With *session_id*, load that session's collision-safe record (#702 F5),
falling back to the legacy single-slot record only when its payload names
the same session. Without *session_id*, return the legacy record or the
sole surviving instance record ambiguity (multiple candidates) returns
``None`` because a shadow that cannot be attributed proves nothing.
"""
try:
import mcp_session_state as mss
sid = (session_id or "").strip()
if sid:
record = mss.load_state(
kind=mss.KIND_REVIEWER_SESSION_LEASE, instance_id=sid
)
if record is not None:
return record
legacy = mss.load_state(kind=mss.KIND_REVIEWER_SESSION_LEASE)
if legacy and (legacy.get("session_id") or "").strip() == sid:
return legacy
return None
legacy = mss.load_state(kind=mss.KIND_REVIEWER_SESSION_LEASE)
if legacy is not None:
return legacy
records = mss.list_states(kind=mss.KIND_REVIEWER_SESSION_LEASE)
if len(records) == 1:
return records[0]
return None
except Exception:
return None
def _pid_alive(pid: int) -> bool:
"""Probe process existence; fail closed toward 'alive' when unsure."""
try:
os.kill(int(pid), 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
except Exception:
return True
def assess_crashed_session_lease_orphan(
shadow: dict[str, Any] | None,
*,
lease: dict[str, Any] | None,
current_pid: int | None = None,
pid_alive: bool | None = None,
) -> dict[str, Any]:
"""Derive owner-process evidence for a durable lease from the shadow (#702).
Returns ``owner_process_alive`` tri-state. Only a provably dead recorded
owner pid yields ``False`` (a dead pid cannot still be running the owning
daemon); an alive pid is *not* ownership proof and stays ``None`` unless
it is this very process. Session ids must match exactly the shadow of a
different lease proves nothing.
"""
reasons: list[str] = []
if not shadow or not lease:
return {
"orphan_evidence": False,
"owner_process_alive": None,
"reasons": ["no durable session-lease shadow evidence available"],
}
shadow_sid = (shadow.get("session_id") or "").strip()
lease_sid = (lease.get("session_id") or "").strip()
if not shadow_sid or not lease_sid or shadow_sid != lease_sid:
return {
"orphan_evidence": False,
"owner_process_alive": None,
"reasons": [
"session-lease shadow session_id does not match the durable "
f"lease (shadow={shadow_sid or None}, lease={lease_sid or None})"
],
}
owner_pid = shadow.get("owner_pid") or shadow.get("writer_pid")
try:
owner_pid = int(owner_pid)
except (TypeError, ValueError):
return {
"orphan_evidence": False,
"owner_process_alive": None,
"reasons": ["session-lease shadow has no usable owner pid (fail closed)"],
}
this_pid = current_pid if current_pid is not None else os.getpid()
if owner_pid == this_pid:
return {
"orphan_evidence": False,
"owner_process_alive": True,
"owner_pid": owner_pid,
"reasons": ["shadow owner pid is the current process"],
}
alive = pid_alive if pid_alive is not None else _pid_alive(owner_pid)
if alive:
reasons.append(
f"shadow owner pid {owner_pid} observed alive; PID liveness is not "
"ownership proof — owner evidence stays unknown (fail closed)"
)
return {
"orphan_evidence": False,
"owner_process_alive": None,
"owner_pid": owner_pid,
"reasons": reasons,
}
reasons.append(
f"shadow owner pid {owner_pid} is dead: the daemon that recorded the "
"matching session lease exited without teardown (crash orphan, #702)"
)
return {
"orphan_evidence": True,
"owner_process_alive": False,
"owner_pid": owner_pid,
"reasons": reasons,
}
def assess_mutation_lease_gate(
*,
pr_number: int,
@@ -557,6 +736,7 @@ _HANDOFF_CLASSIFICATIONS = frozenset({
"foreign_completed_superseded_head",
"foreign_expired_superseded_head",
"orphaned_owner_missing",
"orphaned_expired_superseded_head",
"ambiguous_conflicting_evidence",
"instructed_lease_missing_with_replacement",
"worktree_binding_mismatch",
@@ -850,6 +1030,38 @@ def assess_obsolete_reviewer_comment_lease_cleanup(
classification = "foreign_expired_superseded_head"
elif head_superseded and has_terminal and not past_expiry:
classification = "foreign_completed_superseded_head"
elif head_superseded and not has_terminal and past_expiry:
# Crash-orphan shape (#702): the lease outlived its head with no
# formal terminal review and has now passed canonical expiry, so
# it no longer gates fresh acquisition. Guarded cleanup of the
# ledger marker still demands provable owner-process-exit
# evidence and a safe worktree — never inferred.
classification = "orphaned_expired_superseded_head"
if owner_process_alive is True:
fail_closed_reasons.append(
"lease past expiry on superseded head but owner process "
"still alive; cleanup denied"
)
elif owner_process_alive is None:
fail_closed_reasons.append(
"expired superseded-head lease with no terminal review: "
"cleanup requires provable owner-process-exit evidence "
"(owner_process_alive=false); the expired lease no longer "
"gates fresh acquisition"
)
elif worktree_clean is False:
fail_closed_reasons.append(
"owner process absent but worktree dirty; cleanup denied"
)
elif not (worktree_clean is True or worktree_exists is False):
# #702 F3: unknown worktree evidence must fail closed, exactly
# like the sibling orphaned_owner_missing gate and the
# diagnosis path — cleanup needs affirmative safe evidence.
fail_closed_reasons.append(
"owner process absent but worktree evidence is unknown; "
"cleanup requires affirmative safe evidence "
"(worktree_clean=true or worktree_exists=false)"
)
elif head_superseded and not has_terminal:
classification = "ambiguous_conflicting_evidence"
fail_closed_reasons.append(
@@ -891,6 +1103,7 @@ def assess_obsolete_reviewer_comment_lease_cleanup(
"foreign_expired_superseded_head",
"foreign_expired_current_head",
"orphaned_owner_missing",
"orphaned_expired_superseded_head",
}:
fail_closed_reasons.append(
"controller/recovery capability required "
@@ -909,6 +1122,7 @@ def assess_obsolete_reviewer_comment_lease_cleanup(
"foreign_completed_superseded_head",
"foreign_expired_superseded_head",
"orphaned_owner_missing",
"orphaned_expired_superseded_head",
"foreign_expired_current_head",
}
# foreign_expired_current_head needs orphan/clean worktree + authority.
@@ -1181,6 +1395,30 @@ def diagnose_reviewer_pr_lease_handoff(
"foreign lease pinned to superseded head with completed formal "
"review; use guarded obsolete-lease cleanup (do not wait indefinitely)"
)
elif not is_own and head_superseded and not terminal and past_expiry:
# Crash-orphan shape after canonical expiry (#702): the expired
# marker no longer gates acquisition, so a fresh reviewer may
# acquire at the current head. Guarded ledger cleanup becomes
# available only with provable owner-exit evidence.
classification = "orphaned_expired_superseded_head"
if owner_process_alive is False and (
worktree_clean is True or worktree_exists is False
):
next_action = NEXT_ACTION_CLEANUP_OBSOLETE_LEASE
reasons.append(
"expired superseded-head lease with no terminal review and "
"provable owner-process exit (crash orphan, #702); guarded "
"obsolete-lease cleanup eligible"
)
else:
next_action = NEXT_ACTION_ACQUIRE
reasons.append(
"lease head superseded and lease past canonical expiry with "
"no formal terminal review (crash orphan, #702); expired "
"marker no longer gates acquisition — acquire a fresh lease "
"at the current head; guarded cleanup needs "
"owner_process_alive=false and a clean/absent worktree"
)
elif not is_own and head_superseded and not terminal:
classification = "ambiguous_conflicting_evidence"
next_action = NEXT_ACTION_WAIT
+6 -2
View File
@@ -76,7 +76,6 @@ AUTHOR_TASKS = frozenset({
"create_pr",
"comment_pr",
"address_pr_change_requests",
"delete_branch",
"work_issue",
"work-issue",
"reconcile_landed_pr",
@@ -84,6 +83,10 @@ AUTHOR_TASKS = frozenset({
RECONCILER_TASKS = frozenset({
"cleanup_merged_pr_branch",
# #729: delete_branch is reconciler-owned (gitea.branch.delete is granted
# only to the reconciler profile). Raw gitea_delete_branch redirects here to
# the guarded gitea_cleanup_merged_pr_branch path (#514/#687).
"delete_branch",
"reconcile_already_landed_pr",
"reconcile_already_landed",
"reconcile-landed-pr",
@@ -99,7 +102,8 @@ TASK_REQUIRED_ROLE = {
"create_pr": "author",
"comment_pr": "author",
"address_pr_change_requests": "author",
"delete_branch": "author",
# #729: reconciler-owned; see RECONCILER_TASKS and task_capability_map.
"delete_branch": "reconciler",
"review_pr": "reviewer",
"merge_pr": "merger",
"blind_pr_queue_review": "reviewer",
+535
View File
@@ -0,0 +1,535 @@
"""Optional self-hosted Sentry observability for the Gitea MCP server (#606).
Adds env-var-gated Sentry SDK instrumentation so runtime errors, fail-closed
workflow blockers, lease/terminal-lock/stale-runtime collisions, and recurring
watchdog check-ins are visible in a *self-hosted* Sentry at
``https://sentry.prgs.cc/`` never Sentry Cloud, and never as the workflow
source of truth (Gitea stays canonical).
Design constraints (mirror ``gitea_audit`` and the #612 incident bridge):
- **Off by default.** With ``MCP_SENTRY_ENABLED`` false/unset *or* ``SENTRY_DSN``
empty, ``init_sentry`` is a no-op and no events are ever sent existing tool
behaviour and API-call patterns are unchanged (acceptance criterion 1).
- **Fail *open* for observability.** A Sentry outage, a missing ``sentry_sdk``
package, or any capture error must never break an MCP tool success path. Every
public entry point swallows its own exceptions.
- **Fail *closed* for redaction.** If a field cannot be proven safe it is dropped
rather than sent. Tokens, passwords, keychain IDs, DSNs, private config, raw
session-state, full prompt bodies, and full filesystem paths never leave here.
- **No hard dependency.** ``sentry_sdk`` is imported lazily; the module is fully
importable and testable without it installed.
Sentry is observe-only: it must not approve, merge, close, or otherwise mutate
Gitea workflow state, nor bypass leases, #332, or MCP gates. Alerts may only feed
the sanctioned Gitea issue/comment path via the #612 incident bridge.
"""
from __future__ import annotations
import hashlib
import os
import re
from dataclasses import dataclass
from typing import Any
# Reuse the most comprehensive existing scrubber so redaction stays consistent
# with the #612 incident bridge (tokens, DSNs, cookies, bearer/basic, keychain
# ids, session ids, user:pass@host).
from incident_bridge import redact_text as _redact_text
# Second, complementary scrubber: catches bare ``token <value>`` /
# ``Bearer <value>`` / ``Basic <value>`` prefixes and raw URLs that the
# incident-bridge delimiter patterns miss.
from gitea_audit import _redact_str as _redact_prefixes
# ── Optional SDK (lazy, never a hard dependency) ────────────────────────────
try: # pragma: no cover - trivial import guard
import sentry_sdk # type: ignore
except Exception: # pragma: no cover - absence is a supported state
sentry_sdk = None # type: ignore
# ── Env var names (single source of truth) ──────────────────────────────────
ENV_ENABLED = "MCP_SENTRY_ENABLED"
ENV_DSN = "SENTRY_DSN"
ENV_ENVIRONMENT = "SENTRY_ENVIRONMENT"
ENV_RELEASE = "SENTRY_RELEASE"
ENV_TRACES_SAMPLE_RATE = "MCP_SENTRY_TRACES_SAMPLE_RATE"
ENV_ENABLE_LOGS = "MCP_SENTRY_ENABLE_LOGS"
_TRUTHY = frozenset({"1", "true", "yes", "on"})
REDACTED = "[REDACTED]"
REDACTED_PATH = "[REDACTED_PATH]"
# ── Cron / watchdog monitor slugs (acceptance criterion 6) ──────────────────
# Stable slugs for the recurring/watchdog jobs #606 wants check-ins for. The
# slug is the durable monitor identity in Sentry; the wiring call sites pass one
# of these keys (or an explicit slug) to ``monitor_checkin``.
MONITOR_SLUGS: dict[str, str] = {
"stale_lease_scan": "gitea-mcp-stale-lease-scan",
"terminal_lock_scan": "gitea-mcp-terminal-lock-scan",
"allocator_health": "gitea-mcp-allocator-health",
"namespace_health": "gitea-mcp-namespace-health",
"dashboard_freshness": "gitea-mcp-dashboard-freshness",
"reconciler_cleanup": "gitea-mcp-reconciler-cleanup",
}
_CHECKIN_STATUSES = frozenset({"in_progress", "ok", "error"})
# ── Tag allowlist (issue "Suggested Sentry tags/context") ───────────────────
# Only these keys are ever attached as Sentry tags. Anything else is dropped so
# a caller cannot accidentally leak a sensitive value through a tag.
ALLOWED_TAG_KEYS = frozenset({
"role",
"profile",
"namespace",
"repo",
"org",
"issue_number",
"pr_number",
"blocker_type",
"workflow_hash",
"session_id_hash", # hash only — never the raw session id
"pid",
"worktree_category", # category, never the full sensitive path
"lease_comment_id",
"expected_head_sha",
"current_head_sha",
"terminal_lock_state",
"capability",
"mutation_tool",
})
# Absolute-path shapes that must never be sent verbatim (macOS/Linux + temp).
_PATH_RE = re.compile(r"(?:/private)?/(?:Users|home|tmp|var|opt|Volumes)/[^\s\"']*")
# ``extra`` keys whose *full* contents are forbidden by the redaction rules
# (raw session-state, full prompt/comment bodies, private config blobs, raw
# headers).
_FORBIDDEN_EXTRA_KEYS = frozenset({
"prompt",
"prompt_body",
"next_prompt",
"body",
"raw_body",
"session_state",
"session_state_contents",
"config",
"config_contents",
"private_config",
"headers",
"authorization",
})
# ── Configuration ───────────────────────────────────────────────────────────
@dataclass(frozen=True)
class SentryConfig:
"""Immutable snapshot of the Sentry env configuration."""
enabled: bool = False
dsn: str | None = None
environment: str = "development"
release: str | None = None
traces_sample_rate: float = 0.0
enable_logs: bool = False
@property
def active(self) -> bool:
"""True only when the operator both opted in *and* supplied a DSN.
This is the single gate that keeps the feature off by default: enabling
the flag without a DSN (or vice versa) sends nothing.
"""
return bool(self.enabled and self.dsn)
def safe_summary(self) -> dict[str, Any]:
"""Operator-facing status with **no** DSN value (only presence)."""
return {
"enabled": self.enabled,
"dsn_present": bool(self.dsn),
"environment": self.environment,
"release": self.release,
"traces_sample_rate": self.traces_sample_rate,
"enable_logs": self.enable_logs,
"active": self.active,
}
def _env_bool(name: str, env: dict[str, str]) -> bool:
return (env.get(name) or "").strip().lower() in _TRUTHY
def _env_float(name: str, default: float, env: dict[str, str]) -> float:
raw = (env.get(name) or "").strip()
if not raw:
return default
try:
val = float(raw)
except (TypeError, ValueError):
return default
# Clamp to Sentry's valid [0.0, 1.0] sample-rate range.
if val < 0.0:
return 0.0
if val > 1.0:
return 1.0
return val
def load_config(env: dict[str, str] | None = None) -> SentryConfig:
"""Build a :class:`SentryConfig` from the environment (read at call time)."""
env = dict(os.environ if env is None else env)
dsn = (env.get(ENV_DSN) or "").strip() or None
return SentryConfig(
enabled=_env_bool(ENV_ENABLED, env),
dsn=dsn,
environment=(env.get(ENV_ENVIRONMENT) or "").strip() or "development",
release=(env.get(ENV_RELEASE) or "").strip() or None,
traces_sample_rate=_env_float(ENV_TRACES_SAMPLE_RATE, 0.0, env),
enable_logs=_env_bool(ENV_ENABLE_LOGS, env),
)
def sdk_available() -> bool:
"""True when the optional ``sentry_sdk`` package is importable."""
return sentry_sdk is not None
# ── Redaction (fail closed) ─────────────────────────────────────────────────
def sanitize_path(value: Any) -> str:
"""Reduce a filesystem path to a non-sensitive *category* token.
Full local paths must never be sent. We keep only a coarse worktree
category derived from the path shape (author/reviewer/merger/reconciler/
branches/root/other).
"""
text = "" if value is None else str(value)
low = text.lower()
if not text:
return "unknown"
# Order matters: more specific role markers before the generic "branches".
if "reconcile" in low:
return "reconciler"
if "review" in low:
return "reviewer"
if "merge" in low or "merger" in low:
return "merger"
if "author" in low or re.search(r"/branches/(?:feat|fix|docs|chore|issue)", low):
return "author"
if "/branches/" in low:
return "branches"
if low.rstrip("/").endswith("gitea-tools"):
return "root"
return "other"
def redact_value(value: Any) -> Any:
"""Recursively redact a JSON-able value: secret text, absolute paths, and
known-sensitive dict keys are removed. Fail closed any error drops the
value entirely rather than risk leaking it."""
try:
if isinstance(value, dict):
out: dict[str, Any] = {}
for k, v in value.items():
key = str(k)
low = key.lower()
if low in _FORBIDDEN_EXTRA_KEYS or any(
s in low
for s in ("token", "secret", "password", "cookie", "auth", "dsn", "keychain")
):
out[key] = REDACTED
continue
out[key] = redact_value(v)
return out
if isinstance(value, (list, tuple)):
return [redact_value(v) for v in value]
if isinstance(value, str):
scrubbed = _redact_text(value)
scrubbed = _redact_prefixes(scrubbed)
scrubbed = _PATH_RE.sub(REDACTED_PATH, scrubbed)
return scrubbed
return value
except Exception:
return REDACTED
def hash_session_id(session_id: Any) -> str:
"""Short, stable, non-reversible fingerprint of a session id."""
digest = hashlib.sha256(str(session_id).encode("utf-8", "replace")).hexdigest()
return digest[:12]
def build_tags(**kwargs: Any) -> dict[str, str]:
"""Return a scrubbed, allowlisted tag dict.
``session_id`` is accepted but only ever surfaced as ``session_id_hash``.
``worktree_path`` collapses to ``worktree_category``. Any non-allowlisted
key, or a value that still contains redacted material after scrubbing, is
dropped.
"""
raw: dict[str, Any] = dict(kwargs)
# Hash the session id — never emit it raw.
session_id = raw.pop("session_id", None)
if session_id and "session_id_hash" not in raw:
raw["session_id_hash"] = hash_session_id(session_id)
# A full worktree path collapses to a category tag.
wt = raw.pop("worktree_path", None)
if wt and "worktree_category" not in raw:
raw["worktree_category"] = sanitize_path(wt)
out: dict[str, str] = {}
for key, val in raw.items():
if key not in ALLOWED_TAG_KEYS:
continue
if val is None:
continue
scrubbed = redact_value(val)
text = str(scrubbed)
if not text or REDACTED in text or REDACTED_PATH in text:
continue
if len(text) > 200:
text = text[:200] + ""
out[key] = text
return out
def scrub_event(event: Any, hint: Any = None) -> dict[str, Any] | None:
"""Sentry ``before_send`` / ``before_send_log`` hook.
Recursively redacts the outgoing event. On *any* failure it returns ``None``
so the event is dropped rather than sent unscrubbed (fail closed for
redaction).
"""
try:
if not isinstance(event, dict):
return None
scrubbed = redact_value(event)
# Drop server_name if it leaked a hostname/path; PID is kept via tags.
scrubbed.pop("server_name", None)
return scrubbed
except Exception:
return None
# ── Event builders (pure, independently testable) ───────────────────────────
def build_blocker_event(
blocker_type: str,
*,
message: str | None = None,
next_action: str | None = None,
level: str = "warning",
tags: dict[str, Any] | None = None,
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build a redacted, structured Sentry event for a workflow blocker.
``next_action`` maps the issue's "canonical next action when available"
requirement (acceptance criterion 7).
"""
merged_tags = dict(tags or {})
merged_tags.setdefault("blocker_type", blocker_type)
safe_tags = build_tags(**merged_tags)
safe_extra = redact_value(dict(extra or {}))
if next_action:
# A short canonical next action is allowed (it is not a full prompt).
safe_extra["canonical_next_action"] = redact_value(str(next_action)[:500])
event: dict[str, Any] = {
"message": redact_value(message or blocker_type),
"level": level if level in ("debug", "info", "warning", "error", "fatal") else "warning",
"logger": "gitea-mcp.workflow",
"tags": safe_tags,
"extra": safe_extra,
"fingerprint": ["workflow-blocker", blocker_type],
}
return event
def build_checkin_payload(
monitor: str,
status: str,
*,
check_in_id: str | None = None,
duration: float | None = None,
) -> dict[str, Any]:
"""Build a Sentry cron check-in payload for one of :data:`MONITOR_SLUGS`.
``monitor`` may be a registry key (e.g. ``"stale_lease_scan"``) or an
explicit slug. Raises ``ValueError`` on an unknown status so callers cannot
silently send a malformed check-in.
"""
if status not in _CHECKIN_STATUSES:
raise ValueError(
f"invalid check-in status {status!r}; expected one of {sorted(_CHECKIN_STATUSES)}"
)
slug = MONITOR_SLUGS.get(monitor, monitor)
payload: dict[str, Any] = {"monitor_slug": slug, "status": status}
if check_in_id:
payload["check_in_id"] = str(check_in_id)
if duration is not None:
try:
payload["duration"] = float(duration)
except (TypeError, ValueError):
pass
return payload
# ── Runtime init + capture (fail open) ──────────────────────────────────────
_STATE: dict[str, Any] = {"initialized": False, "config": None}
def is_initialized() -> bool:
return bool(_STATE.get("initialized"))
def active_config() -> SentryConfig | None:
return _STATE.get("config")
def reset_for_tests() -> None:
"""Clear module init state. Test-only helper (never called in production)."""
_STATE["initialized"] = False
_STATE["config"] = None
def init_sentry(config: SentryConfig | None = None) -> dict[str, Any]:
"""Initialise the Sentry SDK if (and only if) enabled + DSN + SDK present.
Idempotent and never raises. Returns an operator-safe status dict (no DSN
value). Behaviour is unchanged when the feature is off.
"""
cfg = config or load_config()
status: dict[str, Any] = {"initialized": False, **cfg.safe_summary()}
try:
if not cfg.active:
status["reason"] = "disabled (MCP_SENTRY_ENABLED false or SENTRY_DSN empty)"
_STATE["config"] = cfg
return status
if not sdk_available():
status["reason"] = "sentry_sdk not installed"
_STATE["config"] = cfg
return status
init_kwargs: dict[str, Any] = {
"dsn": cfg.dsn,
"environment": cfg.environment,
"release": cfg.release,
"traces_sample_rate": cfg.traces_sample_rate,
"before_send": scrub_event,
"send_default_pii": False,
}
if cfg.enable_logs:
# sentry-sdk 2.x captures Python logs as structured logs when the
# experimental logs feature is enabled; scrub those too.
init_kwargs["_experiments"] = {
"enable_logs": True,
"before_send_log": scrub_event,
}
sentry_sdk.init(**init_kwargs) # type: ignore[union-attr]
_STATE["initialized"] = True
_STATE["config"] = cfg
status["initialized"] = True
status["reason"] = "sentry initialised"
except Exception as exc: # fail open: observability must not block startup
status["reason"] = f"init failed (ignored): {type(exc).__name__}"
_STATE["initialized"] = False
return status
def _set_scope_tags(scope: Any, tags: dict[str, str]) -> None:
for key, val in tags.items():
try:
scope.set_tag(key, val)
except Exception:
pass
def capture_workflow_blocker(
blocker_type: str,
*,
message: str | None = None,
next_action: str | None = None,
level: str = "warning",
tags: dict[str, Any] | None = None,
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Capture a fail-closed workflow blocker as a structured Sentry event.
Always returns the redacted event dict (so callers/tests can inspect it),
and sends it to Sentry only when initialised. Fail open.
"""
event = build_blocker_event(
blocker_type,
message=message,
next_action=next_action,
level=level,
tags=tags,
extra=extra,
)
try:
if is_initialized() and sdk_available():
sentry_sdk.capture_event(event) # type: ignore[union-attr]
except Exception:
pass
return event
def capture_exception(
exc: BaseException,
*,
tags: dict[str, Any] | None = None,
extra: dict[str, Any] | None = None,
) -> bool:
"""Capture a runtime exception with scrubbed tags. Fail open.
Returns True only when the event was handed to an initialised SDK.
"""
try:
if not (is_initialized() and sdk_available()):
return False
safe_tags = build_tags(**(tags or {}))
safe_extra = redact_value(dict(extra or {}))
with sentry_sdk.push_scope() as scope: # type: ignore[union-attr]
_set_scope_tags(scope, safe_tags)
for key, val in safe_extra.items():
try:
scope.set_extra(key, val)
except Exception:
pass
sentry_sdk.capture_exception(exc) # type: ignore[union-attr]
return True
except Exception:
return False
def monitor_checkin(
monitor: str,
status: str,
*,
check_in_id: str | None = None,
duration: float | None = None,
) -> dict[str, Any] | None:
"""Send a Sentry cron check-in for a watchdog job. Fail open.
Returns the payload (for inspection/tests), or ``None`` if the status was
invalid. Only transmits when initialised.
"""
try:
payload = build_checkin_payload(
monitor, status, check_in_id=check_in_id, duration=duration
)
except ValueError:
return None
try:
if is_initialized() and sdk_available() and hasattr(sentry_sdk, "capture_checkin"):
sentry_sdk.capture_checkin(**payload) # type: ignore[union-attr]
except Exception:
pass
return payload
+613
View File
@@ -0,0 +1,613 @@
"""Session-immutable MCP mutation context (#714).
Pins profile, remote, host, repository, identity, and role for the life of an
MCP process (or until an explicit ``gitea_activate_profile`` re-bind).
Capability resolution and mutation gates must evaluate only the active
profile for the requested remote. Silent cross-host / cross-profile
substitution is forbidden.
"""
from __future__ import annotations
import os
import re
import threading
import urllib.parse
from dataclasses import dataclass
from typing import Any, Mapping
@dataclass(frozen=True, slots=True)
class _SessionContext:
"""One atomic, immutable process-session binding."""
profile_name: str | None
remote: str | None
host: str | None
identity: str | None
repository: str | None
org: str | None
role_kind: str | None
expected_username: str | None
source: str
pid: int
canonical_repository_root: str | None = None
def as_dict(self) -> dict[str, Any]:
return {
"profile_name": self.profile_name,
"remote": self.remote,
"host": self.host,
"identity": self.identity,
"repository": self.repository,
"org": self.org,
"role_kind": self.role_kind,
"expected_username": self.expected_username,
"source": self.source,
"pid": self.pid,
"canonical_repository_root": self.canonical_repository_root,
}
# Process-local only — never a shared file (same rationale as mutation authority).
# The frozen value prevents partial mutation, while the lock makes first-bind and
# sanctioned rebind atomic across concurrent MCP calls.
_SESSION_CONTEXT: _SessionContext | None = None
_SESSION_CONTEXT_LOCK = threading.RLock()
def _reset_session_context_for_testing() -> None:
"""Reset at a pytest test boundary; unavailable to production callers.
Production sessions transition only through process startup/PID change or
the explicit profile-activation rebind. Keeping this helper private and
requiring pytest's per-test marker prevents it from becoming an MCP/runtime
bypass.
"""
if "PYTEST_CURRENT_TEST" not in os.environ:
raise RuntimeError("session context reset is restricted to pytest boundaries")
global _SESSION_CONTEXT
with _SESSION_CONTEXT_LOCK:
_SESSION_CONTEXT = None
def get_session_context() -> dict[str, Any] | None:
"""Return a detached snapshot of the bound context, or None if unbound."""
with _SESSION_CONTEXT_LOCK:
if _SESSION_CONTEXT is None:
return None
return _SESSION_CONTEXT.as_dict()
def profile_host(profile: dict | None) -> str | None:
"""Hostname from profile base_url, lowercased, or None."""
if not profile:
return None
base = (profile.get("base_url") or "").strip()
if not base:
return None
try:
parsed = urllib.parse.urlparse(base)
host = (parsed.netloc or parsed.path or "").strip().lower()
return host or None
except Exception:
return None
def remote_host(remote: str | None, remotes: dict | None) -> str | None:
"""Hostname for a known remote key."""
if not remote or not remotes:
return None
entry = remotes.get(remote) or {}
return (entry.get("host") or "").strip().lower() or None
def profile_matches_remote(
profile: dict | None,
remote: str | None,
remotes: dict | None,
*,
contexts: dict | None = None,
) -> bool:
"""True when *profile* is bound to the same host/context as *remote*."""
if not profile or not remote:
return False
r_host = remote_host(remote, remotes)
p_host = profile_host(profile)
if r_host and p_host:
return r_host == p_host
# Fall back to context name heuristics when base_url missing.
ctx = (profile.get("context") or "").strip().lower()
if not ctx or not contexts:
return False
ctx_data = contexts.get(ctx) or {}
gitea = ctx_data.get("gitea") or {}
base = (gitea.get("base_url") or "").strip()
if not base or not r_host:
return False
try:
parsed = urllib.parse.urlparse(base)
c_host = (parsed.netloc or parsed.path or "").strip().lower()
except Exception:
return False
return bool(c_host) and c_host == r_host
def bind_session_context(
*,
profile_name: str,
remote: str | None,
host: str | None,
identity: str | None,
repository: str | None = None,
org: str | None = None,
role_kind: str | None = None,
expected_username: str | None = None,
source: str = "bind",
canonical_repository_root: str | None = None,
) -> dict[str, Any]:
"""Atomically bind/re-bind context (the explicit activation path)."""
with _SESSION_CONTEXT_LOCK:
return _bind_session_context_unlocked(
profile_name=profile_name,
remote=remote,
host=host,
identity=identity,
repository=repository,
org=org,
role_kind=role_kind,
expected_username=expected_username,
source=source,
canonical_repository_root=canonical_repository_root,
)
def _bind_session_context_unlocked(
*,
profile_name: str,
remote: str | None,
host: str | None,
identity: str | None,
repository: str | None,
org: str | None,
role_kind: str | None,
expected_username: str | None,
source: str,
canonical_repository_root: str | None = None,
) -> dict[str, Any]:
"""Store a complete immutable context while the caller holds the lock."""
global _SESSION_CONTEXT
_SESSION_CONTEXT = _SessionContext(
profile_name=(profile_name or "").strip() or None,
remote=(remote or "").strip() or None,
host=(host or "").strip().lower() or None,
identity=(identity or "").strip() or None,
repository=(repository or "").strip() or None,
org=(org or "").strip() or None,
role_kind=(role_kind or "").strip() or None,
expected_username=(expected_username or "").strip() or None,
source=source,
pid=os.getpid(),
canonical_repository_root=(canonical_repository_root or "").strip() or None,
)
return _SESSION_CONTEXT.as_dict()
def seed_session_context_if_unbound(
*,
profile_name: str,
remote: str | None,
host: str | None,
identity: str | None,
repository: str | None = None,
org: str | None = None,
role_kind: str | None = None,
expected_username: str | None = None,
source: str = "seed",
canonical_repository_root: str | None = None,
) -> dict[str, Any]:
"""Atomically bind only when this process has no current context.
A changed environment or an interleaved call is not a session boundary and
therefore cannot replace an established binding. A newly started/forked
process is recognized by PID; explicit ``gitea_activate_profile`` uses
:func:`bind_session_context` as its sanctioned logical-session transition.
"""
with _SESSION_CONTEXT_LOCK:
if _SESSION_CONTEXT is None or _SESSION_CONTEXT.pid != os.getpid():
return _bind_session_context_unlocked(
profile_name=profile_name,
remote=remote,
host=host,
identity=identity,
repository=repository,
org=org,
role_kind=role_kind,
expected_username=expected_username,
source=source,
canonical_repository_root=canonical_repository_root,
)
return _SESSION_CONTEXT.as_dict()
def assess_session_context(
*,
profile_name: str | None,
remote: str | None,
host: str | None = None,
identity: str | None = None,
repository: str | None = None,
org: str | None = None,
expected_username: str | None = None,
canonical_repository_root: str | None = None,
require_bound: bool = False,
require_complete: bool = False,
) -> dict[str, Any]:
"""Compare live values against the bound session context.
Returns ``proven`` / ``block`` / ``reasons``. When unbound and
``require_bound`` is false, does not block (caller may seed). When
unbound and ``require_bound`` is true, fails closed. ``require_complete``
additionally fails closed when the binding carries no verified
repository/organization identity, so a mutation can never run against an
unknown repository (#714).
"""
reasons: list[str] = []
with _SESSION_CONTEXT_LOCK:
bound = _SESSION_CONTEXT
ctx = bound.as_dict() if bound is not None else None
if ctx is None or ctx.get("pid") != os.getpid():
if require_bound:
reasons.append(
"session mutation context is unbound; call gitea_whoami or "
"gitea_activate_profile before mutating (fail closed)"
)
return _assessment(False, reasons, ctx)
return _assessment(True, reasons, ctx)
if require_complete and not (ctx.get("repository") and ctx.get("org")):
reasons.append(
"session repository/organization identity is unverified "
f"(repository={ctx.get('repository')!r}, org={ctx.get('org')!r}); "
"a mutation cannot proceed without a verified workspace "
"repository (fail closed)"
)
return _assessment(False, reasons, ctx)
live_profile = (profile_name or "").strip() or None
live_remote = (remote or "").strip() or None
live_host = (host or "").strip().lower() or None
live_identity = (identity or "").strip() or None
live_repo = (repository or "").strip() or None
live_org = (org or "").strip() or None
live_canonical = (canonical_repository_root or "").strip() or None
if ctx.get("profile_name") and live_profile and live_profile != ctx.get("profile_name"):
reasons.append(
f"profile drift: live '{live_profile}' != bound "
f"'{ctx.get('profile_name')}' (fail closed)"
)
if ctx.get("remote") and live_remote and live_remote != ctx.get("remote"):
reasons.append(
f"remote drift: live '{live_remote}' != bound "
f"'{ctx.get('remote')}' (fail closed)"
)
if ctx.get("host") and live_host and live_host != ctx.get("host"):
reasons.append(
f"host drift: live '{live_host}' != bound "
f"'{ctx.get('host')}' (fail closed)"
)
if (
ctx.get("identity")
and live_identity
and live_identity != ctx.get("identity")
):
reasons.append(
f"identity drift: live '{live_identity}' != bound "
f"'{ctx.get('identity')}' (fail closed)"
)
if ctx.get("repository") and live_repo and live_repo != ctx.get("repository"):
reasons.append(
f"repository drift: live '{live_repo}' != bound "
f"'{ctx.get('repository')}' (fail closed)"
)
if ctx.get("org") and live_org and live_org != ctx.get("org"):
reasons.append(
f"org drift: live '{live_org}' != bound "
f"'{ctx.get('org')}' (fail closed)"
)
bound_canonical = ctx.get("canonical_repository_root")
if bound_canonical and live_canonical and live_canonical != bound_canonical:
reasons.append(
f"canonical repository root drift: live '{live_canonical}' != bound "
f"'{bound_canonical}' (forged or conflicting cross-repository "
"binding, fail closed)"
)
expected = expected_username or ctx.get("expected_username")
if expected and live_identity and live_identity != expected:
reasons.append(
f"identity mismatch: authenticated '{live_identity}' != "
f"profile expected '{expected}' (fail closed)"
)
return _assessment(not reasons, reasons, ctx)
def assess_identity_match(
*,
authenticated: str | None,
expected_username: str | None,
) -> dict[str, Any]:
"""Fail closed when profile declares a username that does not match live auth."""
reasons: list[str] = []
auth = (authenticated or "").strip() or None
expected = (expected_username or "").strip() or None
if expected and auth and auth != expected:
reasons.append(
f"identity mismatch: authenticated '{auth}' != "
f"profile expected '{expected}' (fail closed)"
)
return {
"proven": not reasons,
"block": bool(reasons),
"reasons": reasons,
"authenticated": auth,
"expected_username": expected,
}
_REPO_SLUG_RE = re.compile(r"^\s*(?P<org>[^/\s]+)\s*/\s*(?P<repo>[^/\s]+?)(?:\.git)?\s*$")
def parse_repository_slug(slug: str | None) -> tuple[str, str] | None:
"""Split a canonical ``owner/repository`` slug, or None when unparseable."""
match = _REPO_SLUG_RE.match(slug or "")
if not match:
return None
return match.group("org"), match.group("repo")
def format_repository_slug(org: str | None, repo: str | None) -> str | None:
"""``owner/repository`` from parts, or None when either side is missing."""
left = (org or "").strip()
right = (repo or "").strip()
if not left or not right:
return None
return f"{left}/{right}"
def declared_allowed_repositories(
profile: Mapping[str, Any] | None,
*,
strict: bool = False,
) -> list[str]:
"""Canonical ``owner/repository`` authorization boundary declared by *profile*.
This list is an authorization boundary, never the session binding itself:
the verified workspace selects exactly one entry (see
:func:`assess_repository_scope`).
When *strict* is true (mutation path), missing profile, non-list values, or
any malformed entry raise ``ValueError`` instead of silently collapsing to
an empty scope (which would fail open). Read-only diagnostics may use
strict=False.
"""
if not profile:
if strict:
raise ValueError(
"profile unresolved; cannot declare allowed_repositories "
"(fail closed)"
)
return []
raw = profile.get("allowed_repositories")
if raw is None:
return []
if not isinstance(raw, (list, tuple)):
if strict:
raise ValueError(
"allowed_repositories must be a list of owner/repository slugs "
"(fail closed)"
)
return []
slugs: list[str] = []
errors: list[str] = []
for entry in raw:
if not isinstance(entry, str):
errors.append(f"non-string allowed_repositories entry {entry!r}")
continue
parsed = parse_repository_slug(entry)
if not parsed:
errors.append(
f"malformed allowed_repositories entry {entry!r} "
"(expected owner/repository)"
)
continue
slugs.append(f"{parsed[0]}/{parsed[1]}")
if strict and errors:
raise ValueError("; ".join(errors) + " (fail closed)")
return slugs
def assess_repository_scope(
*,
workspace_slug: str | None,
allowed: list[str] | None,
profile_name: str | None = None,
require_scope: bool = False,
) -> dict[str, Any]:
"""Authorize the verified workspace repository against the profile allowlist.
The workspace-derived slug is the only candidate: a profile that authorizes
several repositories still binds to the single verified one, and never to
the list as a whole.
*require_scope* (mutation path): missing/empty allowlist fails closed. The
workspace remote cannot self-authorize without a configured boundary.
"""
scope = list(allowed or [])
reasons: list[str] = []
name = profile_name or "(active profile)"
if not scope:
if require_scope:
reasons.append(
f"mutation denied: profile '{name}' has no non-empty "
"allowed_repositories authorization boundary (fail closed)"
)
return {
"proven": False,
"block": True,
"reasons": reasons,
"scope_enforced": True,
"workspace_slug": workspace_slug,
"allowed_repositories": [],
}
return {
"proven": True,
"block": False,
"reasons": reasons,
"scope_enforced": False,
"workspace_slug": workspace_slug,
"allowed_repositories": [],
}
if not workspace_slug:
reasons.append(
f"no verified workspace repository could be established for profile "
f"'{name}'; repository scope cannot be authorized (fail closed)"
)
elif workspace_slug.lower() not in {entry.lower() for entry in scope}:
reasons.append(
f"repository scope denial: workspace repository '{workspace_slug}' "
f"is not authorized by profile '{name}' allowed_repositories "
f"{sorted(scope)} (fail closed)"
)
return {
"proven": not reasons,
"block": bool(reasons),
"reasons": reasons,
"scope_enforced": True,
"workspace_slug": workspace_slug,
"allowed_repositories": sorted(scope),
}
def assess_repository_override(
*,
requested_org: str | None,
requested_repo: str | None,
bound_org: str | None,
bound_repo: str | None,
) -> dict[str, Any]:
"""Caller-supplied org/repo must agree with the immutable binding.
A mutation request is never allowed to establish, complete, or replace the
binding it may only be checked against it.
"""
reasons: list[str] = []
req_org = (requested_org or "").strip() or None
req_repo = (requested_repo or "").strip() or None
if req_repo and bound_repo and req_repo.lower() != bound_repo.lower():
reasons.append(
f"repository override denial: request targets '{req_repo}' but the "
f"session is bound to '{bound_repo}' (fail closed)"
)
if req_org and bound_org and req_org.lower() != bound_org.lower():
reasons.append(
f"organization override denial: request targets '{req_org}' but the "
f"session is bound to '{bound_org}' (fail closed)"
)
return {"proven": not reasons, "block": bool(reasons), "reasons": reasons}
def filter_profiles_for_remote(
config: dict | None,
remote: str | None,
remotes: dict | None,
) -> list[str]:
"""Profile names whose host/context matches *remote* (enabled only)."""
if not config or not remote:
return []
profiles = config.get("profiles") or {}
contexts = config.get("contexts") or {}
names: list[str] = []
for name, data in profiles.items():
if not isinstance(data, dict):
continue
if not data.get("enabled", True):
continue
if profile_matches_remote(data, remote, remotes, contexts=contexts):
names.append(name)
return sorted(names)
def profile_allowed_for_remote(
profile: dict | None,
remote: str | None,
remotes: dict | None,
*,
contexts: dict | None = None,
) -> dict[str, Any]:
"""Assess whether the active profile may serve *remote*."""
reasons: list[str] = []
if not profile:
reasons.append("active profile unresolved (fail closed)")
return {"proven": False, "block": True, "reasons": reasons}
if not remote:
return {"proven": True, "block": False, "reasons": reasons}
# Legacy env-only profiles have no configured base URL or v2 context. Their
# first call may establish the process remote/host pin; after that,
# assess_session_context rejects any drift. Configured v2 profiles still
# require positive host/context alignment here.
if not profile_host(profile) and not (profile.get("context") or "").strip():
return {"proven": True, "block": False, "reasons": reasons}
if not profile_matches_remote(profile, remote, remotes, contexts=contexts):
p_name = profile.get("profile_name") or profile.get("name") or "(unknown)"
p_host = profile_host(profile) or "(none)"
r_host = remote_host(remote, remotes) or "(none)"
reasons.append(
f"cross-host profile denial: profile '{p_name}' (host '{p_host}') "
f"cannot serve remote '{remote}' (host '{r_host}') (fail closed)"
)
return {"proven": not reasons, "block": bool(reasons), "reasons": reasons}
def mutation_context_audit_fields(
ctx: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Fields to include in pre-mutation audit records."""
data = ctx if ctx is not None else get_session_context()
if not data:
return {
"session_context_bound": False,
"session_profile": None,
"session_remote": None,
"session_host": None,
"session_identity": None,
"session_repository": None,
"session_org": None,
}
return {
"session_context_bound": True,
"session_profile": data.get("profile_name"),
"session_remote": data.get("remote"),
"session_host": data.get("host"),
"session_identity": data.get("identity"),
"session_repository": data.get("repository"),
"session_org": data.get("org"),
"session_role_kind": data.get("role_kind"),
"session_context_source": data.get("source"),
"session_canonical_repository_root": data.get("canonical_repository_root"),
}
def _assessment(
proven: bool, reasons: list[str], ctx: Mapping[str, Any] | None
) -> dict[str, Any]:
return {
"proven": proven,
"block": not proven,
"reasons": list(reasons),
"bound_context": dict(ctx) if ctx else None,
"audit": mutation_context_audit_fields(ctx),
}
@@ -33,22 +33,34 @@ Steps:
*If the current identity does not match the required role (or is the PR author), STOP. Relaunch/switch to the correct profile first.*
2. Verify authenticated identity + active profile.
3. Confirm PR #<pr>: author (not you), state open, mergeable, review approved. Check if PR body uses `Closes #N` or `Fixes #N`; if it uses `Implements #N` or `Refs #N`, manual closing will be needed in step 29.
4. Capability evidence (#179): cite the exact gitea_resolve_task_capability
4. **PR sync assess (required):** call `gitea_assess_pr_sync_status` with
explicit `remote`/`org`/`repo`. Route on `recommended_next_action`:
- `merge_now` → continue merger path (do **not** update the branch)
- `update_branch_by_merge` → STOP; hand off to **author** for
`gitea_update_pr_branch_by_merge` (pins expected PR head + base head)
- `author_conflict_remediation` → STOP; author worktree conflict fix
- `fresh_review_required` → STOP; independent re-review at current head
- `blocked` → STOP and diagnose
Never merge on a former-head approval after update/remediation.
5. Capability evidence (#179): cite the exact gitea_resolve_task_capability
output (or runtime context) proving merge_pr is allowed — a bare
"capability checks passed" claim is downgraded.
5. Final live-state recheck (#179), immediately before the merge mutation —
6. Final live-state recheck (#179), immediately before the merge mutation —
re-read the live PR and prove:
- PR still open
- live head SHA still equals the pinned/reviewed head SHA
- base branch unchanged
- no undismissed REQUEST_CHANGES / blocking review state remains
If any recheck fails → STOP, re-pin, re-validate.
6. If any gate fails → STOP and report.
7. Merge with explicit confirmation (e.g. confirmation="MERGE PR <pr>"),
7. If any gate fails → STOP and report.
8. Merge with explicit confirmation (e.g. confirmation="MERGE PR <pr>"),
pinning the reviewed head SHA (expected_head_sha) and, where supported,
the changed-file set.
8. Confirm remote master now contains the merge commit (or the expected changes if squash merged).
9. Confirm remote master now contains the merge commit (or the expected changes if squash merged).
*Note: Gitea PR "closed" state is NOT equivalent to "merged". Do not assume a closed PR succeeded without verifying the actual landed changes.*
10. **Sequential queue:** after merge, refresh live `master`, run post-merge
cleanup handoff, then reassess the **next** PR (do not batch-update all
open PRs).
Post-merge cleanup (#517): merger sessions must NOT perform ad hoc cleanup.
- Record merge mutations separately from cleanup mutations in the controller handoff.
@@ -950,9 +950,53 @@ Final reports must state:
* final live head SHA before merge
* whether any push occurred during validation
## 26D. PR synchronization and conflict-remediation lifecycle
**Do not treat “approved” as the final readiness state.** For every approved
open PR, call `gitea_assess_pr_sync_status` (native MCP only) and route by
`recommended_next_action`:
| Action | Meaning | Next role / tools |
|--------|---------|-------------------|
| `merge_now` | Valid approval at exact current head; conflict-free; update not required; checks ok | Merger: sanctioned merge workflow only — **do not** update the branch |
| `update_branch_by_merge` | Behind live base; protection requires current base; Gitea can merge base without conflicts | **Author only:** `gitea_update_pr_branch_by_merge` with pinned `expected_pr_head_sha` + `expected_base_head_sha` |
| `author_conflict_remediation` | Conflicts / not auto-updatable | **Author only:** existing `branches/` worktree, issue lock, merge master, resolve, test, push; never force-push/rebase |
| `fresh_review_required` | Head changed after approval, or update/remediation produced a new head | Independent reviewer at the **new exact head**; old approvals/leases/verdicts are void |
| `blocked` | Other gate (checks, incomplete facts, closed PR, …) | Diagnose; do not merge or update |
### Hard rules
* Pin both expected PR head and expected base head for every update. Either
race → fail closed with no partial mutation.
* Never rebase or force-push author branches.
* Never update an author branch from a reviewer or merger profile.
* Never preserve approval, reviewer lease, merger lease, or prepared verdict
across a head change.
* Process PRs **sequentially**: synchronize/remediate one → review new head →
merge → refresh live `master` → reassess the next PR. Do not update every
open PR at once (each merge restales the rest).
* Conflict remediation only in the existing dedicated issue/PR worktree under
`branches/`. Do not delete that worktree until the updated PR is merged and
cleanup eligibility is proven.
* Post-merge: canonical cleanup handoff to reconciler (section 28).
### After `gitea_update_pr_branch_by_merge` succeeds
1. Treat former-head approval as invalidated.
2. Release/supersede obsolete reviewer and merger leases.
3. Route `recommended_next_action=fresh_review_required` at the new head.
4. Independent re-review, then merger lease/adopt + merge at the new head only.
All Gitea reads/mutations use native MCP. Never substitute direct API, curl,
tea/gh, Web UI mutation by an LLM, database changes, raw Git push, or token
access.
## 27. Merge rules
Before merge, rerun fresh live checks:
Before merge, call `gitea_assess_pr_sync_status` when the PR is approved/open
and may be behind base. Only proceed with merge when
`recommended_next_action` is `merge_now` and approval remains at the current
head. Then rerun fresh live checks:
* whoami
* active profile/runtime
+257
View File
@@ -0,0 +1,257 @@
"""Sanctioned recovery for stale env-bound task workspace bindings (#702).
When the MCP daemon dies from an unhandled exception it never runs teardown,
and the auto-reconnected daemon inherits ``GITEA_ACTIVE_WORKTREE`` from its
parent environment. That inherited value can permanently bind the fresh
session to a prior task's worktree (the PR #701 / ``review-pr-654`` incident).
This module classifies the active env binding against live session evidence
and produces a fail-closed recovery plan. Only two situations permit the
daemon to clear the binding on its own:
- the bound path no longer exists on disk (``provably_stale_missing_path``)
- the binding was inherited at daemon boot and a *sanctioned* in-session
reviewer/merger lease later bound a different worktree
(``superseded_by_session_lease``)
Everything else is surfaced (``unverified_inherited``) and left for the
managed reconnect path never cleared silently, never rebound to a guessed
worktree.
"""
from __future__ import annotations
import os
from typing import Any
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
CLASSIFICATION_UNBOUND = "unbound"
CLASSIFICATION_CORROBORATED = "corroborated"
CLASSIFICATION_UNVERIFIED_INHERITED = "unverified_inherited"
CLASSIFICATION_PROVABLY_STALE_MISSING_PATH = "provably_stale_missing_path"
CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE = "superseded_by_session_lease"
CLASSIFICATIONS = frozenset({
CLASSIFICATION_UNBOUND,
CLASSIFICATION_CORROBORATED,
CLASSIFICATION_UNVERIFIED_INHERITED,
CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE,
})
# Roles whose task workspace is owned by a lease lifecycle, so an inherited
# generic binding without a corroborating lease is suspect.
LEASE_BOUND_ROLES = frozenset({"reviewer", "merger"})
RECOVERY_ACTION_NONE = "none"
RECOVERY_ACTION_CLEAR_ENV = "clear_env"
def _norm(path: str | None) -> str:
text = (path or "").strip()
if not text:
return ""
return os.path.realpath(os.path.abspath(text))
def snapshot_boot_bindings(
env: dict[str, str] | os._Environ | None = None,
) -> dict[str, Any]:
"""Capture worktree env bindings present when the daemon booted.
A value present in this snapshot was inherited from the parent
environment, not established by any sanctioned tool in this daemon's
lifetime.
"""
env_map = env if env is not None else os.environ
return {
"active_worktree": (env_map.get(ACTIVE_WORKTREE_ENV) or "").strip() or None,
}
def classify_active_worktree_binding(
*,
active_value: str | None,
role_env_value: str | None = None,
session_lease_worktree: str | None = None,
boot_inherited: bool,
path_exists: bool | None,
role_kind: str | None = None,
) -> dict[str, Any]:
"""Classify the GITEA_ACTIVE_WORKTREE binding against live evidence.
Fail closed: unknown evidence never yields a clear-eligible class.
"""
reasons: list[str] = []
active = _norm(active_value)
if not active:
return {
"classification": CLASSIFICATION_UNBOUND,
"clear_eligible": False,
"active_worktree": None,
"reasons": ["no GITEA_ACTIVE_WORKTREE binding present"],
}
role = (role_kind or "").strip().lower()
role_env = _norm(role_env_value)
lease_wt = _norm(session_lease_worktree)
result: dict[str, Any] = {
"active_worktree": active,
"boot_inherited": bool(boot_inherited),
"role_kind": role or None,
"session_lease_worktree": lease_wt or None,
"role_env_worktree": role_env or None,
}
if path_exists is False:
reasons.append(
f"bound worktree '{active}' no longer exists on disk; the binding "
"cannot name a valid task workspace"
)
result.update({
"classification": CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
"clear_eligible": True,
"reasons": reasons,
})
return result
if lease_wt and lease_wt == active:
reasons.append("binding matches the sanctioned in-session lease worktree")
result.update({
"classification": CLASSIFICATION_CORROBORATED,
"clear_eligible": False,
"reasons": reasons,
})
return result
if lease_wt and lease_wt != active and boot_inherited:
reasons.append(
"boot-inherited binding disagrees with the sanctioned in-session "
f"lease worktree '{lease_wt}'; the lease is live authority"
)
result.update({
"classification": CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE,
"clear_eligible": True,
"reasons": reasons,
})
return result
if lease_wt and lease_wt != active and not boot_inherited:
# Set after boot by tooling; disagreement is surfaced, never auto-cleared.
reasons.append(
"post-boot binding disagrees with the in-session lease worktree; "
"surfacing only (fail closed, no automatic clear)"
)
result.update({
"classification": CLASSIFICATION_UNVERIFIED_INHERITED,
"clear_eligible": False,
"reasons": reasons,
})
return result
if role_env and role_env == active:
reasons.append("binding matches the role-specific worktree env binding")
result.update({
"classification": CLASSIFICATION_CORROBORATED,
"clear_eligible": False,
"reasons": reasons,
})
return result
if boot_inherited and role in LEASE_BOUND_ROLES and not lease_wt:
reasons.append(
"binding was inherited at daemon boot, no sanctioned in-session "
f"lease corroborates it, and the {role} role binds workspaces "
"through the lease lifecycle; treat as unverified until a fresh "
"lease or managed reconnect re-establishes the workspace"
)
result.update({
"classification": CLASSIFICATION_UNVERIFIED_INHERITED,
"clear_eligible": False,
"reasons": reasons,
})
return result
reasons.append("no contradicting evidence; binding treated as corroborated")
result.update({
"classification": CLASSIFICATION_CORROBORATED,
"clear_eligible": False,
"reasons": reasons,
})
return result
def plan_recovery(classification_result: dict[str, Any]) -> dict[str, Any]:
"""Turn a classification into an explicit, fail-closed recovery plan."""
classification = classification_result.get("classification")
clear_eligible = bool(classification_result.get("clear_eligible"))
reasons = list(classification_result.get("reasons") or [])
if classification not in CLASSIFICATIONS:
return {
"action": RECOVERY_ACTION_NONE,
"clear_allowed": False,
"classification": classification,
"reasons": reasons + ["unknown classification (fail closed)"],
}
if clear_eligible and classification in {
CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE,
}:
return {
"action": RECOVERY_ACTION_CLEAR_ENV,
"clear_allowed": True,
"classification": classification,
"active_worktree": classification_result.get("active_worktree"),
"reasons": reasons,
}
exact_next_action = None
if classification == CLASSIFICATION_UNVERIFIED_INHERITED:
exact_next_action = (
"managed recovery required: reconnect the MCP namespace through "
"the managed client configuration (or start a fresh session) so "
"the daemon boots without the inherited GITEA_ACTIVE_WORKTREE, or "
"corroborate the binding by acquiring a lease for that worktree; "
"do not clear or rewrite the environment manually"
)
return {
"action": RECOVERY_ACTION_NONE,
"clear_allowed": False,
"classification": classification,
"active_worktree": classification_result.get("active_worktree"),
"reasons": reasons,
**({"exact_next_action": exact_next_action} if exact_next_action else {}),
}
def apply_recovery(
plan: dict[str, Any],
env: dict[str, str] | os._Environ | None = None,
) -> dict[str, Any]:
"""Apply a clear-eligible plan by removing the stale env binding.
This is the sanctioned in-daemon mechanism (#702 AC2); callers must
persist the returned record for audit. Refuses anything but an explicit
``clear_env`` plan.
"""
env_map = env if env is not None else os.environ
if not plan.get("clear_allowed") or plan.get("action") != RECOVERY_ACTION_CLEAR_ENV:
return {
"performed": False,
"action": RECOVERY_ACTION_NONE,
"reasons": ["recovery plan does not authorize clearing (fail closed)"],
}
cleared_value = (env_map.get(ACTIVE_WORKTREE_ENV) or "").strip() or None
env_map.pop(ACTIVE_WORKTREE_ENV, None)
return {
"performed": True,
"action": RECOVERY_ACTION_CLEAR_ENV,
"cleared_env": ACTIVE_WORKTREE_ENV,
"cleared_value": cleared_value,
"classification": plan.get("classification"),
"reasons": list(plan.get("reasons") or []),
}
+457 -13
View File
@@ -1,4 +1,4 @@
"""Stale / moot #332 review-decision lock detection and cleanup policy (#594/#620).
"""Stale / moot #332 review-decision lock detection and cleanup policy (#594/#620/#693).
#332 correctly hard-stops a reviewer session after a terminal live review
mutation. After #559 those locks are durable on disk, so they can outlive the
@@ -12,6 +12,11 @@ on head B of the **same open PR**. Same-head duplicates remain fail-closed.
Open-PR lock *cleanup* remains forbidden unless the PR is truly merged/closed
(#594); new-head re-review is allowed without deleting the durable lock.
#693 scopes the terminal boundary by **PR number**: a terminal on open PR A
must not block a fresh formal decision on open PR B on the same durable
profile lock (cross-PR isolation). Correction authorization is scoped to the
named prior review's PR/head and cannot unlock a different PR.
This module is pure policy (no Gitea I/O). The MCP tool
``gitea_cleanup_stale_review_decision_lock`` fetches live PR state, calls these
helpers, and only then clears durable state when cleanup is allowed.
@@ -80,17 +85,45 @@ def last_terminal_mutation(lock: dict | None) -> dict | None:
return terminals[-1] if terminals else None
def correction_applies(
lock: dict | None,
*,
pr_number: int | None = None,
expected_head_sha: str | None = None,
) -> bool:
"""True when operator correction is active and scoped to the target (#693).
Global ``correction_authorized`` alone is not enough: correction must name
the same PR (and head when recorded) as the decision being re-opened.
Missing scope fields on legacy locks fail closed (do not apply).
"""
if not lock or not lock.get("correction_authorized"):
return False
corr_pr = lock.get("correction_pr_number")
if corr_pr is None:
# Legacy unscoped correction — refuse as generic unlock (#693).
return False
if pr_number is not None and int(corr_pr) != int(pr_number):
return False
corr_head = normalize_head_sha(lock.get("correction_head_sha"))
target = normalize_head_sha(expected_head_sha)
if corr_head and target and not heads_equal(corr_head, target):
return False
return True
def prior_live_mutations_block_boundary(
lock: dict | None,
*,
pr_number: int,
expected_head_sha: str | None,
) -> bool:
"""True when prior live mutations block a new decision for PR+head (#620).
"""True when prior live mutations block a new decision for PR+head (#620/#693).
Historical terminals on a **different head of the same PR** do not block.
Any prior mutation on another PR, the same head, or without a comparable
head SHA fails closed (blocks).
Terminals on a **different PR** do not block (#693 cross-PR isolation).
Same PR + same head (or same PR without a comparable head SHA) blocks
unless a scoped correction applies.
Head resolution uses ``mutation_head_sha(m, lock)`` so pre-#620 ledgers
that only stored ``ready_expected_head_sha`` still compare correctly
@@ -98,7 +131,9 @@ def prior_live_mutations_block_boundary(
"""
if not lock:
return False
if lock.get("correction_authorized"):
if correction_applies(
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
):
return False
prior = list(lock.get("live_mutations") or [])
if not prior:
@@ -108,10 +143,14 @@ def prior_live_mutations_block_boundary(
if not isinstance(m, dict):
return True
m_pr = m.get("pr_number")
if m_pr is None:
return True # fail closed — unscoped mutation
if int(m_pr) != int(pr_number):
# #693: foreign-PR history on the shared durable lock does not block.
continue
m_head = mutation_head_sha(m, lock)
if (
m_pr == pr_number
and target
target
and m_head
and not heads_equal(m_head, target)
):
@@ -128,23 +167,34 @@ def terminal_boundary_allows_fresh_decision(
expected_head_sha: str | None,
operation: str,
) -> bool:
"""Whether #332 hard-stop should yield for a new PR+head boundary (#620).
"""Whether #332 hard-stop should yield for a new PR+head boundary (#620/#693).
Only for ``mark_ready`` / ``review`` / ``resume`` on the **same PR** when
the requested head differs from the last terminal's head. Merge is never
reopened by head change (approved head merge path is separate).
For ``mark_ready`` / ``review`` / ``resume``:
* **same PR, different head** allowed (#620)
* **different PR than last terminal** allowed (#693 cross-PR isolation)
* **same PR, same head** not allowed (unless scoped correction)
Merge is never reopened by head change or cross-PR isolation (approved
head merge path is separate).
"""
if operation not in ("mark_ready", "review", "resume"):
return False
if not lock:
return False
if lock.get("correction_authorized"):
if correction_applies(
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
):
return True
last = last_terminal_mutation(lock)
if last is None:
return True
if last.get("pr_number") != pr_number:
last_pr = last.get("pr_number")
if last_pr is None:
return False
if int(last_pr) != int(pr_number):
# #693: last terminal belongs to another PR — do not hard-stop this PR.
return True
locked_head = mutation_head_sha(last, lock)
target = normalize_head_sha(expected_head_sha)
if not locked_head or not target:
@@ -440,6 +490,400 @@ def format_cleanup_audit_comment(audit: dict[str, Any]) -> str:
return "\n".join(lines)
# --- #693 classification / recovery / correction audit -----------------------
# Deterministic classification labels for diagnostics and recovery routing.
CLASS_NO_LOCK = "no_lock"
CLASS_READY_FOR_TARGET = "ready_for_target"
CLASS_IN_PROGRESS_SAME_HEAD = "in_progress_same_head"
CLASS_TERMINAL_ACTIVE_SAME_HEAD = "terminal_active_same_head"
CLASS_STALE_SUPERSEDED_HEAD = "stale_superseded_head"
CLASS_FOREIGN_PR_TERMINAL = "foreign_pr_terminal"
CLASS_MOOT_MERGED_CLOSED = "moot_merged_closed"
CLASS_CORRECTION_ACTIVE = "correction_active"
CLASS_SESSION_OR_PROFILE_MISMATCH = "session_or_profile_mismatch"
CLASS_AMBIGUOUS = "ambiguous"
def classify_review_decision_lock(
lock: dict | None,
*,
target_pr_number: int | None = None,
target_head_sha: str | None = None,
pr_live: dict | None = None,
pr_lookup_error: str | None = None,
active_profile_identity: str | None = None,
session_blockers: list[str] | None = None,
) -> dict[str, Any]:
"""Deterministic review-decision-lock classification (#693).
Returns classification, exact next_action, owner/evidence fields, and
whether mark_final / cleanup / correction are appropriate.
"""
summary = lock_summary(lock)
target_head = normalize_head_sha(target_head_sha)
result: dict[str, Any] = {
"classification": CLASS_NO_LOCK,
"has_lock": lock is not None,
"lock_summary": summary,
"target_pr_number": target_pr_number,
"target_head_sha": target_head,
"last_terminal_pr": None,
"last_terminal_action": None,
"locked_head_sha": None,
"owner_profile_identity": (summary or {}).get("profile_identity"),
"session_profile": (summary or {}).get("session_profile"),
"updated_at": (summary or {}).get("updated_at"),
"correction_authorized": bool((lock or {}).get("correction_authorized")),
"correction_pr_number": (lock or {}).get("correction_pr_number"),
"correction_head_sha": normalize_head_sha(
(lock or {}).get("correction_head_sha")
),
"mark_final_allowed": True,
"cleanup_allowed": False,
"correction_eligible": False,
"next_action": "gitea_resolve_task_capability(task='review_pr') then mark_final",
"reasons": [],
"evidence": {},
}
if lock is None:
result["reasons"].append("no review decision lock present")
return result
session_blockers = list(session_blockers or [])
if session_blockers:
result["classification"] = CLASS_SESSION_OR_PROFILE_MISMATCH
result["mark_final_allowed"] = False
result["next_action"] = (
"reconnect matching prgs-reviewer session or wait for lock TTL; "
"do not use gitea_authorize_review_correction as unlock; "
"do not delete session-state files"
)
result["reasons"].extend(session_blockers)
return result
stored = (
(lock.get("profile_identity") or lock.get("session_profile_lock") or "")
.strip()
)
active = (active_profile_identity or "").strip()
if active and stored and active != stored:
result["classification"] = CLASS_SESSION_OR_PROFILE_MISMATCH
result["mark_final_allowed"] = False
result["next_action"] = (
f"switch to profile '{stored}' or wait for moot cleanup; "
"do not use correction authorization as a generic unlock"
)
result["reasons"].append(
f"lock profile '{stored}' does not match active '{active}'"
)
return result
last = last_terminal_mutation(lock)
if last is None:
ready_pr = lock.get("ready_pr_number")
ready_head = normalize_head_sha(lock.get("ready_expected_head_sha"))
if (
target_pr_number is not None
and ready_pr == target_pr_number
and ready_head
and target_head
and heads_equal(ready_head, target_head)
and lock.get("final_review_decision_ready")
):
result["classification"] = CLASS_IN_PROGRESS_SAME_HEAD
result["mark_final_allowed"] = True # idempotent re-mark
result["next_action"] = (
"gitea_submit_pr_review with final_review_decision_ready=true "
"for the ready PR/head"
)
result["reasons"].append(
"final decision already marked ready for this PR/head (idempotent)"
)
return result
result["classification"] = CLASS_READY_FOR_TARGET
result["next_action"] = "gitea_mark_final_review_decision then submit"
result["reasons"].append("no terminal live mutations; mark_final allowed")
return result
last_pr = last.get("pr_number")
last_action = last.get("action")
locked_head = mutation_head_sha(last, lock)
result["last_terminal_pr"] = last_pr
result["last_terminal_action"] = last_action
result["locked_head_sha"] = locked_head
result["evidence"] = {
"last_review_id": last.get("review_id"),
"live_mutations_count": len(lock.get("live_mutations") or []),
}
if correction_applies(
lock, pr_number=target_pr_number, expected_head_sha=target_head
):
result["classification"] = CLASS_CORRECTION_ACTIVE
result["mark_final_allowed"] = True
result["next_action"] = (
"gitea_mark_final_review_decision for the correction-scoped PR/head, "
"then submit once"
)
result["reasons"].append(
f"scoped correction active for PR #{lock.get('correction_pr_number')}"
)
return result
# Live state of last terminal PR when provided.
live = None
if pr_lookup_error:
result["classification"] = CLASS_AMBIGUOUS
result["mark_final_allowed"] = False
result["next_action"] = (
"retry diagnosis after live PR state is available; "
"fail closed under ambiguous evidence"
)
result["reasons"].append(f"live PR lookup failed: {pr_lookup_error}")
return result
if pr_live is not None:
live = classify_pr_live_state(pr_live)
result["evidence"]["last_terminal_pr_state"] = live.get("pr_state")
result["evidence"]["last_terminal_pr_merged"] = live.get("pr_merged")
if live.get("pr_merged_or_closed"):
result["classification"] = CLASS_MOOT_MERGED_CLOSED
result["cleanup_allowed"] = True
result["mark_final_allowed"] = target_pr_number is not None and (
int(last_pr) != int(target_pr_number)
if last_pr is not None and target_pr_number is not None
else True
)
result["next_action"] = (
"gitea_cleanup_stale_review_decision_lock(apply=true, "
f"expected_terminal_pr={last_pr}) then mark_final on the target PR"
)
result["reasons"].append(
f"terminal on PR #{last_pr} is moot (merged/closed); cleanup allowed"
)
return result
if target_pr_number is None:
result["classification"] = CLASS_AMBIGUOUS
result["mark_final_allowed"] = False
result["next_action"] = "re-run diagnosis with target_pr_number"
result["reasons"].append("target_pr_number required for open-PR classification")
return result
if last_pr is not None and int(last_pr) != int(target_pr_number):
# Cross-PR isolation: foreign terminal is history, not a block.
result["classification"] = CLASS_FOREIGN_PR_TERMINAL
result["mark_final_allowed"] = True
result["correction_eligible"] = False # must not use correction to "unlock"
result["next_action"] = (
f"gitea_mark_final_review_decision(pr_number={target_pr_number}, ...) "
f"— foreign terminal on PR #{last_pr} does not block (#693); "
"do not call gitea_authorize_review_correction for cross-PR unlock"
)
result["reasons"].append(
f"last terminal is {last_action} on foreign open/closed PR #{last_pr}; "
f"target PR #{target_pr_number} is isolated (#693)"
)
return result
# Same PR as target.
if (
locked_head
and target_head
and not heads_equal(locked_head, target_head)
):
result["classification"] = CLASS_STALE_SUPERSEDED_HEAD
result["mark_final_allowed"] = True
result["next_action"] = (
f"gitea_mark_final_review_decision with expected_head_sha="
f"{target_head} (#620 same-PR new head)"
)
result["reasons"].append(
f"terminal {last_action} on head {locked_head[:12]}…; target head "
f"{target_head[:12]}… — fresh formal review allowed without cleanup"
)
return result
# Same PR + same head (or missing head comparison) — active terminal.
result["classification"] = CLASS_TERMINAL_ACTIVE_SAME_HEAD
result["mark_final_allowed"] = False
result["correction_eligible"] = True
result["next_action"] = (
"if the prior verdict was mistaken: gitea_authorize_review_correction "
f"with prior_review_id={last.get('review_id')}, "
f"prior_review_state={last_action}, target_pr_number={target_pr_number}, "
"operator_authorized=true, then re-mark once; "
"if the prior verdict is correct: stop and hand off (do not duplicate)"
)
result["reasons"].append(
f"terminal {last_action} already recorded for PR #{target_pr_number} "
f"at this head; same-head duplicate blocked (#332/#620)"
)
return result
def build_precise_mark_final_failure(
classification: dict[str, Any],
) -> dict[str, Any]:
"""One precise recovery instruction + durable issue handoff payload (#693 AC4/8)."""
cls = classification.get("classification")
next_action = classification.get("next_action") or (
"stop and create a durable Gitea issue with lock evidence"
)
reasons = list(classification.get("reasons") or [])
reasons.append(f"exact_next_action: {next_action}")
handoff = {
"required": cls
in (
CLASS_AMBIGUOUS,
CLASS_SESSION_OR_PROFILE_MISMATCH,
CLASS_TERMINAL_ACTIVE_SAME_HEAD,
)
and not classification.get("mark_final_allowed"),
"title_hint": (
"Review-decision-lock recovery failed during formal review"
),
"classification": cls,
"exact_next_action": next_action,
"owner_profile_identity": classification.get("owner_profile_identity"),
"last_terminal_pr": classification.get("last_terminal_pr"),
"last_terminal_action": classification.get("last_terminal_action"),
"locked_head_sha": classification.get("locked_head_sha"),
"target_pr_number": classification.get("target_pr_number"),
"target_head_sha": classification.get("target_head_sha"),
"evidence": classification.get("evidence") or {},
"instruction": (
"If recovery cannot complete with the exact_next_action above, "
"create or update a durable Gitea issue with this payload and stop; "
"do not delete session-state files; do not misuse correction as unlock."
),
}
return {
"reasons": reasons,
"classification": cls,
"exact_next_action": next_action,
"durable_issue_handoff": handoff,
}
def build_correction_audit_record(
*,
prior_review_id: int | None,
prior_review_state: str | None,
target_pr_number: int | None,
target_head_sha: str | None,
reason: str | None,
actor_username: str | None,
profile_name: str | None,
authorized: bool,
) -> dict[str, Any]:
"""Structured durable audit for review-correction authorization (#693)."""
return {
"event": "review_correction_authorization",
"issue_ref": "#693",
"authorized": bool(authorized),
"timestamp": datetime.now(timezone.utc).isoformat(),
"actor_username": actor_username,
"profile_name": profile_name,
"prior_review_id": prior_review_id,
"prior_review_state": prior_review_state,
"target_pr_number": target_pr_number,
"target_head_sha": normalize_head_sha(target_head_sha),
"reason": reason,
"scope": "same_pr_head_only",
}
def format_correction_audit_comment(audit: dict[str, Any]) -> str:
"""Markdown body for a thread-visible correction authorization audit."""
status = "AUTHORIZED" if audit.get("authorized") else "DENIED"
lines = [
"## Review correction authorization audit (#693)",
"",
f"Status: **{status}**",
"",
f"- actor: `{audit.get('actor_username')}`",
f"- profile: `{audit.get('profile_name')}`",
f"- timestamp: `{audit.get('timestamp')}`",
f"- prior_review_id: `{audit.get('prior_review_id')}`",
f"- prior_review_state: `{audit.get('prior_review_state')}`",
f"- target_pr: `#{audit.get('target_pr_number')}`",
f"- target_head_sha: `{audit.get('target_head_sha')}`",
f"- reason: {audit.get('reason')}",
f"- scope: `{audit.get('scope')}` (cannot unlock a different PR)",
"",
"This is **not** a generic decision-lock unlock. Cross-PR recovery "
"uses isolation (#693) or moot cleanup (#594), never correction.",
]
return "\n".join(lines)
def assess_open_pr_decision_lock_recovery(
lock: dict | None,
*,
target_pr_number: int,
target_head_sha: str | None,
last_terminal_pr_live: dict | None = None,
pr_lookup_error: str | None = None,
active_profile_identity: str | None = None,
session_blockers: list[str] | None = None,
controller_recovery_authorized: bool = False,
) -> dict[str, Any]:
"""Assess guarded recovery for decision locks affecting an open target PR (#693).
Recovery here means *workflow recovery routing*, not necessarily lock wipe.
Foreign-PR terminals are recoverable by isolation (mark_final allowed).
Moot terminals use #594 cleanup. Same-head active terminals refuse wipe.
"""
classification = classify_review_decision_lock(
lock,
target_pr_number=target_pr_number,
target_head_sha=target_head_sha,
pr_live=last_terminal_pr_live,
pr_lookup_error=pr_lookup_error,
active_profile_identity=active_profile_identity,
session_blockers=session_blockers,
)
cls = classification["classification"]
recovery_allowed = False
recovery_mode = None
if cls == CLASS_FOREIGN_PR_TERMINAL:
recovery_allowed = True
recovery_mode = "cross_pr_isolation"
elif cls == CLASS_STALE_SUPERSEDED_HEAD:
recovery_allowed = True
recovery_mode = "same_pr_new_head"
elif cls == CLASS_MOOT_MERGED_CLOSED:
recovery_allowed = True
recovery_mode = "moot_cleanup"
elif cls == CLASS_READY_FOR_TARGET:
recovery_allowed = True
recovery_mode = "none_required"
elif cls == CLASS_CORRECTION_ACTIVE:
recovery_allowed = True
recovery_mode = "scoped_correction"
elif cls == CLASS_TERMINAL_ACTIVE_SAME_HEAD:
recovery_allowed = False
recovery_mode = "correction_only_if_mistake"
else:
recovery_allowed = False
recovery_mode = "fail_closed"
if recovery_mode == "moot_cleanup" and not controller_recovery_authorized:
# Cleanup still needs apply + capability; assess reports path.
pass
return {
**classification,
"recovery_allowed": recovery_allowed,
"recovery_mode": recovery_mode,
"controller_recovery_authorized": bool(controller_recovery_authorized),
"manual_file_delete_forbidden": True,
"correction_as_generic_unlock_forbidden": True,
}
# ---------------------------------------------------------------------------
# #709 cross-profile cleanup, overwrite protection, recovery provenance
# ---------------------------------------------------------------------------
+87 -2
View File
@@ -60,18 +60,57 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.pr.close",
"role": "author",
},
# Non-closing PR metadata edits (title/body/base). Closing uses close_pr.
"edit_pr": {
"permission": "gitea.pr.create",
"role": "author",
},
"gitea_edit_pr": {
"permission": "gitea.pr.create",
"role": "author",
},
"address_pr_change_requests": {
"permission": "gitea.branch.push",
"role": "author",
},
# PR synchronization lifecycle: assess is read-only (any role with gitea.read);
# update-by-merge is author-only and mutates the PR head via Gitea API.
"assess_pr_sync_status": {
"permission": "gitea.read",
"role": "author",
},
"gitea_assess_pr_sync_status": {
"permission": "gitea.read",
"role": "author",
},
"update_pr_branch_by_merge": {
"permission": "gitea.branch.push",
"role": "author",
},
"gitea_update_pr_branch_by_merge": {
"permission": "gitea.branch.push",
"role": "author",
},
"review_pr": {
"permission": "gitea.pr.review",
"role": "reviewer",
},
"submit_pr_review": {
"permission": "gitea.pr.review",
"role": "reviewer",
},
"merge_pr": {
"permission": "gitea.pr.merge",
"role": "merger",
},
"acquire_reviewer_pr_lease": {
"permission": "gitea.pr.comment",
"role": "reviewer",
},
"gitea_acquire_reviewer_pr_lease": {
"permission": "gitea.pr.comment",
"role": "reviewer",
},
# #695 AC8: controller quarantine of contaminated formal reviews.
# Apply path posts an append-only forensic audit comment (pr.comment).
"quarantine_contaminated_review": {
@@ -84,7 +123,30 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
},
"adopt_merger_pr_lease": {
"permission": "gitea.pr.comment",
"role": "reviewer",
"role": "merger",
},
"gitea_adopt_merger_pr_lease": {
"permission": "gitea.pr.comment",
"role": "merger",
},
"acquire_merger_pr_lease": {
"permission": "gitea.pr.comment",
"role": "merger",
},
"gitea_acquire_merger_pr_lease": {
"permission": "gitea.pr.comment",
"role": "merger",
},
# #742: owner-session terminal release/abandon of a merger-held lease when
# the merge does not occur. Apply path posts an append-only terminal lease
# marker (gitea.pr.comment); merger-only, never a reviewer path.
"release_merger_pr_lease": {
"permission": "gitea.pr.comment",
"role": "merger",
},
"gitea_release_merger_pr_lease": {
"permission": "gitea.pr.comment",
"role": "merger",
},
# #691: guarded non-owner cleanup of obsolete comment-backed reviewer leases.
# Apply path posts lease release + audit comments (gitea.pr.comment).
@@ -127,6 +189,23 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.pr.review",
"role": "reviewer",
},
# #693: read-only classification of durable decision locks (incl. open PRs).
"diagnose_review_decision_lock": {
"permission": "gitea.read",
"role": "reviewer",
},
"gitea_diagnose_review_decision_lock": {
"permission": "gitea.read",
"role": "reviewer",
},
"authorize_review_correction": {
"permission": "gitea.pr.review",
"role": "reviewer",
},
"gitea_authorize_review_correction": {
"permission": "gitea.pr.review",
"role": "reviewer",
},
# #709: truthful absence-of-proof recovery (server-side auth + record + consume).
# Dedicated mutation capability — gitea.read is insufficient (review 434 F1).
"issue_irrecoverable_provenance_authorization": {
@@ -153,9 +232,15 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.pr.merge",
"role": "merger",
},
# #729: delete_branch is reconciler-owned. gitea.branch.delete is granted
# only to the reconciler profile, so the resolver must classify this task as
# reconciler (previously "author", which no delete-capable profile held).
# Raw gitea_delete_branch still redirects reconciler to the guarded
# gitea_cleanup_merged_pr_branch path (#514/#687); author/reviewer/merger
# stay denied by both the permission gate and this role gate.
"delete_branch": {
"permission": "gitea.branch.delete",
"role": "author",
"role": "reconciler",
},
"cleanup_merged_pr_branch": {
"permission": "gitea.branch.delete",
+63 -17
View File
@@ -26,6 +26,14 @@ def _reset_mutation_authority(monkeypatch):
Pin ``default_state_dir`` / ``DEFAULT_STATE_DIR`` to a per-test temp dir
so durable load/save never touches host state even after env clears.
"""
import session_context_binding as session_ctx
# Each pytest item is an independent logical MCP session. Reset both before
# and after the item; the post-yield reset is in a finally block so an
# assertion, exception, or unittest teardown failure cannot pollute the
# next item. Production code has no automatic per-call reset path.
session_ctx._reset_session_context_for_testing()
for env_key in [
"GITEA_SESSION_PROFILE_LOCK",
"GITEA_ACTIVE_WORKTREE",
@@ -80,11 +88,31 @@ def _reset_mutation_authority(monkeypatch):
try:
import mcp_server
except Exception:
_state_tmp.cleanup()
yield
try:
yield
finally:
session_ctx._reset_session_context_for_testing()
_state_tmp.cleanup()
return
import gitea_config
monkeypatch.setattr(gitea_config, "_active_profile_override", None)
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
# #714: clear both module namespaces. mcp_server.py execs gitea_mcp_server.py
# into its own globals, so `import gitea_mcp_server` is a separate module
# object with its own identity caches; leaving it dirty leaks login across
# tests that import the implementation module directly.
try:
import gitea_mcp_server as _gitea_impl
monkeypatch.setattr(_gitea_impl, "_IDENTITY_CACHE", {})
if hasattr(_gitea_impl, "_ACTOR_IDENTITY_CACHE"):
monkeypatch.setattr(_gitea_impl, "_ACTOR_IDENTITY_CACHE", {})
except Exception:
pass
if hasattr(mcp_server, "_ACTOR_IDENTITY_CACHE"):
monkeypatch.setattr(mcp_server, "_ACTOR_IDENTITY_CACHE", {})
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
monkeypatch.setattr(mcp_server, "_LIVE_NAMESPACE_HEALTH", {})
monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False)
@@ -113,19 +141,37 @@ def _reset_mutation_authority(monkeypatch):
capability_stop_terminal.clear()
except Exception:
pass
try:
yield
finally:
try:
import capability_stop_terminal
capability_stop_terminal.clear()
except Exception:
pass
try:
import review_workflow_load
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
except Exception:
pass
try:
mcp_server._REVIEW_DECISION_LOCK = None
except Exception:
pass
gitea_config._active_profile_override = None
session_ctx._reset_session_context_for_testing()
_state_tmp.cleanup()
# #714: deterministic workspace remotes only (no host git dependency).
import pytest
@pytest.fixture(autouse=True)
def _deterministic_workspace_remotes():
try:
from mutation_profile_fixture import install_deterministic_remote_urls
install_deterministic_remote_urls()
except Exception:
pass
yield
try:
import capability_stop_terminal
capability_stop_terminal.clear()
except Exception:
pass
try:
import review_workflow_load
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
except Exception:
pass
try:
mcp_server._REVIEW_DECISION_LOCK = None
except Exception:
pass
_state_tmp.cleanup()
+483
View File
@@ -0,0 +1,483 @@
"""Centralized config-backed mutation fixture for #714.
Mutation tests must opt into this helper explicitly. It never grants
mutation authority via environment variables alone.
Design rules:
- temporary v2 configuration with non-empty ``allowed_repositories``
- profile-scoped operations (not every mutation op for every test)
- deterministic workspace remotes (no host Git dependency)
- one canonical repository per profile by default
- isolated state + cleanup in ``finally``
"""
from __future__ import annotations
import json
import os
import tempfile
from contextlib import contextmanager
from copy import deepcopy
from typing import Iterable, Sequence
from unittest.mock import patch
PRGS_SLUG = "Scaled-Tech-Consulting/Gitea-Tools"
PRGS_URL = f"https://gitea.prgs.cc/{PRGS_SLUG}.git"
MDCPS_SLUG = "913443/eAgenda"
MDCPS_URL = f"https://gitea.dadeschools.net/{MDCPS_SLUG}.git"
EXAMPLE_SLUG = "Example-Org/Example-Repo"
EXAMPLE_URL = f"https://gitea.example.com/{EXAMPLE_SLUG}.git"
TIMESHEET_SLUG = "Scaled-Tech-Consulting/Timesheet"
def _author_ops() -> list[str]:
return [
"gitea.read",
"gitea.issue.create",
"gitea.issue.close",
"gitea.issue.comment",
"gitea.pr.create",
"gitea.pr.comment",
"gitea.branch.create",
"gitea.branch.push",
"gitea.repo.commit",
]
def _author_forbidden() -> list[str]:
return [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.request_changes",
"gitea.pr.review",
]
def _reviewer_ops() -> list[str]:
return [
"gitea.read",
"gitea.pr.review",
"gitea.pr.approve",
"gitea.pr.comment",
"gitea.issue.comment",
]
def _merger_ops() -> list[str]:
return [
"gitea.read",
"gitea.pr.merge",
"gitea.pr.comment",
]
def _profile(
*,
name: str,
context: str,
role: str,
base_url: str,
auth_env: str,
allowed_operations: Sequence[str],
forbidden_operations: Sequence[str],
allowed_repositories: Sequence[str],
username: str | None = None,
) -> dict:
body = {
"enabled": True,
"context": context,
"role": role,
"base_url": base_url,
"auth": {"type": "env", "name": auth_env},
"allowed_operations": list(allowed_operations),
"forbidden_operations": list(forbidden_operations),
"allowed_repositories": list(allowed_repositories),
"execution_profile": name,
}
if username:
body["username"] = username
return body
def build_dual_remote_config(
*,
allowed_operations: Iterable[str] | None = None,
allowed_repositories_prgs: Sequence[str] | None = None,
allowed_repositories_mdcps: Sequence[str] | None = None,
extra_profiles: dict | None = None,
include_example_repo: bool = False,
) -> dict:
"""Build a minimal dual-host v2 config.
Each profile authorizes only its host-canonical repository by default.
``include_example_repo`` adds Example-Org/Example-Repo when a test patches
REMOTES to that synthetic unit-test identity.
"""
ops = list(allowed_operations or _author_ops())
forb = _author_forbidden()
prgs_repos = list(allowed_repositories_prgs or [PRGS_SLUG])
mdcps_repos = list(allowed_repositories_mdcps or [MDCPS_SLUG])
if include_example_repo:
for repos in (prgs_repos, mdcps_repos):
if EXAMPLE_SLUG not in repos:
repos.append(EXAMPLE_SLUG)
profiles = {
"test-author-prgs": _profile(
name="test-author-prgs",
context="prgs",
role="author",
base_url="https://gitea.prgs.cc",
auth_env="GITEA_TOKEN_TEST",
allowed_operations=ops,
forbidden_operations=forb,
allowed_repositories=prgs_repos,
),
"test-author-dadeschools": _profile(
name="test-author-dadeschools",
context="mdcps",
role="author",
base_url="https://gitea.dadeschools.net",
auth_env="GITEA_TOKEN_TEST",
allowed_operations=ops,
forbidden_operations=forb,
allowed_repositories=mdcps_repos,
),
"test-reviewer-prgs": _profile(
name="test-reviewer-prgs",
context="prgs",
role="reviewer",
base_url="https://gitea.prgs.cc",
auth_env="GITEA_TOKEN_TEST",
allowed_operations=_reviewer_ops(),
forbidden_operations=[
"gitea.pr.create",
"gitea.branch.push",
"gitea.pr.merge",
"gitea.issue.create",
],
allowed_repositories=prgs_repos,
),
"test-merger-prgs": _profile(
name="test-merger-prgs",
context="prgs",
role="merger",
base_url="https://gitea.prgs.cc",
auth_env="GITEA_TOKEN_TEST",
allowed_operations=_merger_ops(),
forbidden_operations=[
"gitea.pr.create",
"gitea.branch.push",
"gitea.pr.approve",
"gitea.issue.create",
],
allowed_repositories=prgs_repos,
),
}
if extra_profiles:
profiles.update(deepcopy(extra_profiles))
# Legacy aliases used by older tests — same host scope as the base profile.
for alias, base in (
("gitea-author", "test-author-prgs"),
("author-test", "test-author-dadeschools"),
("author", "test-author-dadeschools"),
("full-author", "test-author-dadeschools"),
("test-author", "test-author-dadeschools"),
("prgs-author", "test-author-prgs"),
("gitea-reviewer", "test-reviewer-prgs"),
("prgs-reviewer", "test-reviewer-prgs"),
("gitea-merger", "test-merger-prgs"),
("prgs-merger", "test-merger-prgs"),
):
if alias not in profiles and base in profiles:
clone = deepcopy(profiles[base])
clone["execution_profile"] = alias
profiles[alias] = clone
return {
"version": 2,
"rules": {"allow_runtime_switching": True},
"contexts": {
"prgs": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
},
"mdcps": {
"enabled": True,
"gitea": {
"enabled": True,
"base_url": "https://gitea.dadeschools.net",
},
},
},
"profiles": profiles,
}
def build_v2_config(**kwargs):
"""Back-compat alias."""
return build_dual_remote_config(
allowed_operations=kwargs.get("allowed_operations"),
extra_profiles=kwargs.get("extra_profiles"),
include_example_repo=bool(kwargs.get("include_example_repo")),
)
def inject_allowed_repositories(
config: dict,
repos: Sequence[str],
*,
profiles: Sequence[str] | None = None,
) -> dict:
"""Return a deep copy of *config* with allowlists set on selected profiles."""
out = deepcopy(config)
targets = set(profiles) if profiles is not None else set(out.get("profiles") or {})
for name, prof in (out.get("profiles") or {}).items():
if name in targets:
prof["allowed_repositories"] = list(repos)
return out
def ensure_all_profiles_have_scope(
config: dict,
default_repos: Sequence[str],
) -> dict:
"""Add non-empty allowed_repositories to every profile that lacks one."""
out = deepcopy(config)
for _name, prof in (out.get("profiles") or {}).items():
raw = prof.get("allowed_repositories")
if not isinstance(raw, (list, tuple)) or not raw:
prof["allowed_repositories"] = list(default_repos)
return out
@contextmanager
def mutation_profile_env(
*,
profile_name: str = "test-author-dadeschools",
allowed_operations: Iterable[str] | None = None,
allowed_repositories: Sequence[str] | None = None,
workspace_urls: dict | None = None,
clear_env: bool = False,
extra_env: dict | None = None,
extra_profiles: dict | None = None,
include_example_repo: bool = False,
config: dict | None = None,
):
"""Context manager: config-backed mutation authority + deterministic remotes."""
import gitea_config
import gitea_mcp_server as srv
import session_context_binding as session_ctx
if config is None:
prgs_repos = None
mdcps_repos = None
if allowed_repositories is not None:
# Caller-specified single allowlist applied to both host profiles.
prgs_repos = list(allowed_repositories)
mdcps_repos = list(allowed_repositories)
cfg = build_dual_remote_config(
allowed_operations=allowed_operations,
allowed_repositories_prgs=prgs_repos,
allowed_repositories_mdcps=mdcps_repos,
extra_profiles=extra_profiles,
include_example_repo=include_example_repo,
)
else:
cfg = deepcopy(config)
if allowed_repositories is not None:
cfg = ensure_all_profiles_have_scope(cfg, list(allowed_repositories))
urls = (
{"prgs": PRGS_URL, "dadeschools": MDCPS_URL}
if workspace_urls is None
else dict(workspace_urls)
)
tmp = tempfile.TemporaryDirectory(prefix="gitea-mut-cfg-")
try:
cfg_path = os.path.join(tmp.name, "profiles.json")
with open(cfg_path, "w", encoding="utf-8") as fh:
json.dump(cfg, fh)
env = {
"GITEA_MCP_CONFIG": cfg_path,
"GITEA_MCP_PROFILE": profile_name,
"GITEA_TOKEN_TEST": "test-token",
"PYTEST_CURRENT_TEST": os.environ.get(
"PYTEST_CURRENT_TEST", "mutation_profile_env"
),
}
if extra_env:
env.update(extra_env)
def _remote_url(name: str):
if name in urls:
return urls[name]
# Prefer live REMOTES (tests often patch Example-Org).
try:
entry = srv.REMOTES.get(name) or {}
host = entry.get("host")
org = entry.get("org")
repo = entry.get("repo")
if host and org and repo:
if (
name == "prgs"
and org == "Scaled-Tech-Consulting"
and repo == "Timesheet"
):
return PRGS_URL
if name == "dadeschools" and repo == "Timesheet":
return MDCPS_URL
return f"https://{host}/{org}/{repo}.git"
except Exception:
pass
return None
gitea_config._active_profile_override = None
try:
session_ctx._reset_session_context_for_testing()
except Exception:
pass
payload = {
"env": env,
"config_path": cfg_path,
"profile_name": profile_name,
"urls": urls,
"config": cfg,
}
with patch.dict(os.environ, env, clear=clear_env):
os.environ.setdefault(
"PYTEST_CURRENT_TEST",
env.get("PYTEST_CURRENT_TEST", "mutation_profile_env"),
)
with patch.object(srv, "_local_git_remote_url", side_effect=_remote_url):
mcp_srv = None
try:
import mcp_server as mcp_srv # type: ignore
except Exception:
mcp_srv = None
if mcp_srv is not None:
with patch.object(
mcp_srv, "_local_git_remote_url", side_effect=_remote_url
):
yield payload
else:
yield payload
finally:
try:
session_ctx._reset_session_context_for_testing()
except Exception:
pass
gitea_config._active_profile_override = None
tmp.cleanup()
# Process-lifetime shared config for mass-migrating env-only mutation tests.
_SHARED_CFG_PATH = None
_SHARED_CFG_DIR = None
_SHARED_CFG_WITH_EXAMPLE_PATH = None
def shared_mutation_config_path(*, include_example_repo: bool = False) -> str:
"""Write (once) a dual-remote mutation config and return its path."""
global _SHARED_CFG_PATH, _SHARED_CFG_DIR, _SHARED_CFG_WITH_EXAMPLE_PATH
import atexit
import shutil
if include_example_repo:
if _SHARED_CFG_WITH_EXAMPLE_PATH and os.path.isfile(
_SHARED_CFG_WITH_EXAMPLE_PATH
):
return _SHARED_CFG_WITH_EXAMPLE_PATH
if _SHARED_CFG_DIR is None:
_SHARED_CFG_DIR = tempfile.mkdtemp(prefix="gitea-shared-mut-cfg-")
atexit.register(lambda: shutil.rmtree(_SHARED_CFG_DIR, ignore_errors=True))
path = os.path.join(_SHARED_CFG_DIR, "profiles-with-example.json")
with open(path, "w", encoding="utf-8") as fh:
json.dump(build_dual_remote_config(include_example_repo=True), fh)
_SHARED_CFG_WITH_EXAMPLE_PATH = path
return path
if _SHARED_CFG_PATH and os.path.isfile(_SHARED_CFG_PATH):
return _SHARED_CFG_PATH
if _SHARED_CFG_DIR is None:
_SHARED_CFG_DIR = tempfile.mkdtemp(prefix="gitea-shared-mut-cfg-")
atexit.register(lambda: shutil.rmtree(_SHARED_CFG_DIR, ignore_errors=True))
_SHARED_CFG_PATH = os.path.join(_SHARED_CFG_DIR, "profiles.json")
with open(_SHARED_CFG_PATH, "w", encoding="utf-8") as fh:
json.dump(build_dual_remote_config(), fh)
return _SHARED_CFG_PATH
def shared_mutation_env(
profile_name: str = "test-author-dadeschools",
*,
include_example_repo: bool = False,
**extra,
) -> dict:
"""Env dict for mutation tests: config-backed + optional extras.
Always carries ``PYTEST_CURRENT_TEST`` so ``patch.dict(..., clear=True)``
does not strip the pytest marker and trip the #695 native-transport wall.
"""
env = {
"GITEA_MCP_CONFIG": shared_mutation_config_path(
include_example_repo=include_example_repo
),
"GITEA_MCP_PROFILE": profile_name,
"GITEA_TOKEN_TEST": "test-token",
"PYTEST_CURRENT_TEST": os.environ.get(
"PYTEST_CURRENT_TEST", "mutation_profile_env"
),
}
env.update(extra)
# Callers must not be able to drop the pytest marker by accident.
env.setdefault(
"PYTEST_CURRENT_TEST",
os.environ.get("PYTEST_CURRENT_TEST", "mutation_profile_env"),
)
return env
def install_deterministic_remote_urls() -> None:
"""Patch server modules so workspace remotes are deterministic.
Prefer live ``REMOTES`` entries (so tests that patch Example-Org keep
workspace alignment). Map the historical prgsTimesheet default to
Gitea-Tools so session binding matches the control repository under test.
"""
import gitea_mcp_server as srv
def _url(name: str):
try:
entry = srv.REMOTES.get(name) or {}
host = entry.get("host")
org = entry.get("org")
repo = entry.get("repo")
if host and org and repo:
if (
name == "prgs"
and org == "Scaled-Tech-Consulting"
and repo == "Timesheet"
):
return PRGS_URL
if name == "dadeschools" and repo == "Timesheet":
# Prefer mdcps eAgenda canonical for dual-config tests.
return MDCPS_URL
return f"https://{host}/{org}/{repo}.git"
except Exception:
pass
if name == "prgs":
return PRGS_URL
if name == "dadeschools":
return MDCPS_URL
return None
srv._local_git_remote_url = _url # type: ignore[method-assign]
try:
import mcp_server as mcp_srv
mcp_srv._local_git_remote_url = _url # type: ignore[method-assign]
except Exception:
pass
+7 -5
View File
@@ -1,3 +1,7 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
"""Tests for agent temp artifact detection and preflight warnings (#261)."""
import json
import os
@@ -65,11 +69,9 @@ class TestPreflightWarnings(unittest.TestCase):
# Issue-write tools are profile-gated (#69); gitea_lock_issue requires
# gitea.issue.comment (see task_capability_map), so the gate must be
# seeded exactly like tests/test_mcp_server.py::TestIssueLocking (#359).
ISSUE_WRITE_ENV = {
"GITEA_ALLOWED_OPERATIONS": (
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
),
}
ISSUE_WRITE_ENV = shared_mutation_env(
"test-author-prgs",
)
class TestIssueLockArtifactWarning(unittest.TestCase):
File diff suppressed because it is too large Load Diff
+22 -16
View File
@@ -79,41 +79,46 @@ class TestApiRequestFailures(unittest.TestCase):
@patch("gitea_auth.urllib.request.urlopen")
def test_timeout_converted_to_runtimeerror(self, mock_open):
mock_open.side_effect = TimeoutError("timed out")
with self.assertRaises(RuntimeError) as ctx:
with self.assertRaises(gitea_auth.GiteaNetworkError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH)
self.assertIn("network error contacting Gitea", str(ctx.exception))
# Fixed message only (#699) — still a RuntimeError subclass.
self.assertIsInstance(ctx.exception, RuntimeError)
self.assertEqual(str(ctx.exception), "Network error contacting Gitea")
@patch("gitea_auth.urllib.request.urlopen")
def test_dns_network_failure_converted(self, mock_open):
mock_open.side_effect = urllib.error.URLError("Name or service not known")
with self.assertRaises(RuntimeError) as ctx:
with self.assertRaises(gitea_auth.GiteaNetworkError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH)
self.assertIn("network error contacting Gitea", str(ctx.exception))
self.assertEqual(str(ctx.exception), "Network error contacting Gitea")
@patch("gitea_auth.urllib.request.urlopen")
def test_502_upstream_message(self, mock_open):
mock_open.side_effect = http_error(502, "bad gateway")
with self.assertRaises(RuntimeError) as ctx:
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH)
msg = str(ctx.exception)
self.assertIn("HTTP 502", msg)
self.assertIn("upstream unavailable", msg)
self.assertEqual(msg, "Gitea upstream unavailable")
self.assertEqual(ctx.exception.reason_code, "upstream_unavailable")
self.assertEqual(ctx.exception.http_status, 502)
@patch("gitea_auth.urllib.request.urlopen")
def test_503_upstream_message(self, mock_open):
mock_open.side_effect = http_error(503, "")
with self.assertRaises(RuntimeError) as ctx:
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH)
self.assertIn("HTTP 503", str(ctx.exception))
self.assertIn("upstream unavailable", str(ctx.exception))
self.assertEqual(str(ctx.exception), "Gitea upstream unavailable")
self.assertEqual(ctx.exception.http_status, 503)
@patch("gitea_auth.urllib.request.urlopen")
def test_malformed_error_payload_does_not_crash(self, mock_open):
# Non-JSON garbage error body must still yield a clean RuntimeError.
# Non-JSON garbage error body must still yield a clean typed error
# with a fixed message (no body echo — #699).
mock_open.side_effect = http_error(500, "<html>garbage</html>")
with self.assertRaises(RuntimeError) as ctx:
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH)
self.assertIn("HTTP 500", str(ctx.exception))
self.assertEqual(str(ctx.exception), "Gitea HTTP request failed")
self.assertNotIn("<html>", str(ctx.exception))
@patch("gitea_auth.urllib.request.urlopen")
def test_malformed_success_json_raises_clean_error(self, mock_open):
@@ -126,16 +131,17 @@ class TestApiRequestFailures(unittest.TestCase):
def test_no_secret_leak_in_error_body(self, mock_open):
mock_open.side_effect = http_error(
400, "failed: token supersecret123 rejected")
with self.assertRaises(RuntimeError) as ctx:
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH)
msg = str(ctx.exception)
self.assertNotIn("supersecret123", msg)
self.assertIn(gitea_audit.REDACTED, msg)
# Fixed message — body never appears (stronger than redaction).
self.assertEqual(msg, "Gitea HTTP request failed")
@patch("gitea_auth.urllib.request.urlopen")
def test_auth_header_never_in_error(self, mock_open):
mock_open.side_effect = http_error(400, "bad request")
with self.assertRaises(RuntimeError) as ctx:
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH)
self.assertNotIn(FAKE_AUTH, str(ctx.exception))
@@ -1,3 +1,7 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
"""Regression tests for gitea_assess_conflict_fix_push structured failures (#519)."""
import os
+46 -16
View File
@@ -1,3 +1,7 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
"""Tests for Gitea MCP mutating-action audit logging (issue #18).
Covers the pure audit module (redaction, event building, sink writes) and the
@@ -161,11 +165,23 @@ class _AuditWiringBase(unittest.TestCase):
self._dir.cleanup()
def _env(self, **extra):
env = {"GITEA_AUDIT_LOG": self.audit_path,
"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_ALLOWED_OPERATIONS": (
"read,merge,gitea.issue.create,gitea.issue.close")}
# Default: prgs-aligned author with create/close (remote=prgs tests).
# Callers override GITEA_MCP_PROFILE for merger/reviewer paths.
profile = extra.pop("GITEA_MCP_PROFILE", None) or extra.pop(
"GITEA_PROFILE_NAME", None
) or "test-author-prgs"
env = shared_mutation_env(
profile,
GITEA_AUDIT_LOG=self.audit_path,
GITEA_TOKEN_TEST="test-token",
)
# Strip legacy env-only authority knobs if callers still pass them;
# config-backed profiles own operations and repositories (#714).
extra.pop("GITEA_ALLOWED_OPERATIONS", None)
extra.pop("GITEA_FORBIDDEN_OPERATIONS", None)
extra.pop("GITEA_PROFILE_NAME", None)
env.update(extra)
env["GITEA_MCP_PROFILE"] = profile
return env
def _records(self):
@@ -196,7 +212,7 @@ class TestSimpleToolAudit(_AuditWiringBase):
rec = recs[0]
self.assertEqual(rec["action"], "create_issue")
self.assertEqual(rec["result"], "succeeded")
self.assertEqual(rec["profile_name"], "gitea-author")
self.assertIn(rec["profile_name"], ("gitea-author", "test-author-prgs", "prgs-author"))
self.assertEqual(rec["authenticated_username"], "author-bot")
self.assertEqual(rec["issue_number"], 11)
self.assertEqual(rec["request_metadata"]["title"], "Add thing")
@@ -239,10 +255,11 @@ class TestSimpleToolAudit(_AuditWiringBase):
def test_disabled_writes_nothing_and_no_extra_call(self, _auth, _get_all, mock_api, _role):
# No GITEA_AUDIT_LOG -> audit is a no-op: one create POST, no file.
mock_api.return_value = {"number": 1, "html_url": "http://x/1"}
with patch.dict(os.environ, {
"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
}, clear=True):
with patch.dict(
os.environ,
shared_mutation_env("test-author-prgs"),
clear=True,
):
gitea_create_issue(title="x", remote="prgs")
issue_posts = [
c for c in mock_api.call_args_list if c.args[0] == "POST"
@@ -342,8 +359,7 @@ class TestGatedToolAudit(_AuditWiringBase):
self._pr("author-bot"), approval, # gate 7 feedback
{}, {"merged_commit_sha": "c1"},
]
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
GITEA_ALLOWED_OPERATIONS="read,merge")
env = self._env(GITEA_MCP_PROFILE="test-merger-prgs")
with patch.dict(os.environ, env, clear=True):
mcp_server.gitea_load_review_workflow()
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8",
@@ -362,8 +378,7 @@ class TestGatedToolAudit(_AuditWiringBase):
def test_merge_blocked_audited(self, _auth, mock_api):
# Self-author merge is blocked; must still be recorded as blocked.
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
GITEA_ALLOWED_OPERATIONS="read,merge")
env = self._env(GITEA_MCP_PROFILE="test-merger-prgs")
with patch.dict(os.environ, env, clear=True):
mcp_server.gitea_load_review_workflow()
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8", remote="prgs")
@@ -385,11 +400,23 @@ class TestGatedToolAudit(_AuditWiringBase):
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
]
env = self._env(GITEA_PROFILE_NAME="gitea-reviewer",
GITEA_ALLOWED_OPERATIONS="read,review,approve")
env = self._env(GITEA_MCP_PROFILE="test-reviewer-prgs")
with patch.dict(os.environ, env, clear=True):
import session_context_binding as _sc
from tests.test_mcp_server import (
_init_reviewer_session,
_install_owned_reviewer_lease,
)
from mcp_server import gitea_mark_final_review_decision
# Rebind after profile env is applied so durable session state
# matches the active reviewer profile (#714 / #695).
_sc._reset_session_context_for_testing()
_init_reviewer_session("prgs")
lease = _install_owned_reviewer_lease(8)
lease.start()
self.addCleanup(lease.stop)
mcp_server.gitea_load_review_workflow()
gitea_mark_final_review_decision(
8, "approve", expected_head_sha="abc123", remote="prgs",
)
@@ -398,7 +425,10 @@ class TestGatedToolAudit(_AuditWiringBase):
body="LGTM", remote="prgs",
final_review_decision_ready=True,
)
self.assertTrue(r["performed"])
self.assertTrue(
r["performed"],
msg=f"submit_pr_review blocked: {r}",
)
recs = self._records()
self.assertEqual(len(recs), 1)
self.assertEqual(recs[0]["action"], "submit_pr_review")
+11 -4
View File
@@ -26,8 +26,15 @@ from review_proofs import assess_audit_reconciliation_report as proofs_assess
from task_capability_map import required_permission, required_role
DELETE_PROFILE = {
"profile_name": "prgs-author-delete",
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
# #729: delete_branch is reconciler-owned.
"profile_name": "prgs-reconciler-delete",
"role": "reconciler",
"allowed_operations": [
"gitea.read",
"gitea.pr.create",
"gitea.branch.push",
"gitea.branch.delete",
],
"forbidden_operations": [],
"audit_label": "prgs-author-delete",
}
@@ -232,7 +239,7 @@ class TestMcpGates(unittest.TestCase):
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
def test_delete_branch_blocked_in_audit_phase(self, _profile):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
mcp_server.record_preflight_check("capability", resolved_role="reconciler")
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
self.assertFalse(result["success"])
self.assertEqual(result["audit_phase"], PHASE_AUDIT)
@@ -272,7 +279,7 @@ class TestMcpGates(unittest.TestCase):
after_state="gone",
)
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
mcp_server.record_preflight_check("capability", resolved_role="reconciler")
self.mock_api.return_value = {}
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
self.assertTrue(result["success"])
File diff suppressed because it is too large Load Diff
+3
View File
@@ -29,6 +29,7 @@ CONFIG = {
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
"forbidden_operations": ["gitea.pr.create"],
"execution_profile": "commit-author",
"allowed_repositories": ["Example-Org/Example-Repo"],
},
"pr-only-author": {
"enabled": True,
@@ -39,6 +40,7 @@ CONFIG = {
"allowed_operations": ["gitea.read", "gitea.pr.create"],
"forbidden_operations": ["gitea.repo.commit"],
"execution_profile": "pr-only-author",
"allowed_repositories": ["Example-Org/Example-Repo"],
},
"reviewer-profile": {
"enabled": True,
@@ -51,6 +53,7 @@ CONFIG = {
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push",
],
"execution_profile": "reviewer-profile",
"allowed_repositories": ["Example-Org/Example-Repo"],
},
},
"rules": {"allow_runtime_switching": False},
+2
View File
@@ -35,6 +35,7 @@ CONFIG = {
],
"forbidden_operations": [],
"execution_profile": "full-author",
"allowed_repositories": ["Example-Org/Example-Repo"],
},
"reviewer-no-commit": {
"enabled": True,
@@ -49,6 +50,7 @@ CONFIG = {
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push"
],
"execution_profile": "reviewer-no-commit",
"allowed_repositories": ["Example-Org/Example-Repo"],
},
},
"rules": {"allow_runtime_switching": False},
+4
View File
@@ -35,6 +35,7 @@ CONFIG = {
"gitea.pr.review",
],
"execution_profile": "full-author",
"allowed_repositories": ["Example-Org/Example-Repo"],
},
},
}
@@ -227,6 +228,7 @@ class TestCommitPayloads(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_traversal_blocked(self, _auth, mock_api):
mock_api.return_value = {"login": "author-user"}
# Remove active lock file to ensure it fails on traversal/invalid locks
os.remove(self.lock_file_path)
@@ -248,6 +250,7 @@ class TestCommitPayloads(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_outside_scope_blocked(self, _auth, mock_api):
mock_api.return_value = {"login": "author-user"}
with patch.dict(os.environ, self._env("full-author"), clear=True):
with self.assertRaises(ValueError) as ctx:
mcp_server.gitea_commit_files(
@@ -266,6 +269,7 @@ class TestCommitPayloads(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_multiple_sources_blocked(self, _auth, mock_api):
mock_api.return_value = {"login": "author-user"}
with patch.dict(os.environ, self._env("full-author"), clear=True):
with self.assertRaises(ValueError) as ctx:
mcp_server.gitea_commit_files(
+4
View File
@@ -1,3 +1,7 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
"""Tests for canonical JSON runtime-profile configuration (gitea_config) and
its integration into gitea_auth.get_profile / get_auth_header.
+23 -2
View File
@@ -1,3 +1,4 @@
import os
"""Tests for create_issue.py.
Every test mocks auth functions so no real network calls or keychain
@@ -12,7 +13,7 @@ import sys
import tempfile
import unittest
import contextlib
from unittest.mock import MagicMock, patch
from unittest.mock import patch, MagicMock, patch
# The module under test lives in the repo root, not a package.
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
@@ -92,6 +93,26 @@ class TestRemoteResolution(unittest.TestCase):
# ---------------------------------------------------------------------------
@unittest.skipIf(_SKIP, _REASON)
class TestAPIPayload(unittest.TestCase):
"""#714: omitted --org/--repo uses workspace-bound repo (Gitea-Tools),
not the historical REMOTES Timesheet default. Intentional security-policy
migration of legacy omitted-target assertions.
"""
def setUp(self):
self._ws_env = patch.dict(
os.environ,
{
"GITEA_TEST_WORKSPACE_REMOTE_URL": (
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
)
},
clear=False,
)
self._ws_env.start()
def tearDown(self):
self._ws_env.stop()
"""Ensure the JSON payload sent to Gitea is correct."""
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
@@ -142,7 +163,7 @@ class TestAPIPayload(unittest.TestCase):
url = mock_api.call_args[0][1]
self.assertEqual(
url,
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/issues",
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Gitea-Tools/issues",
)
+23 -2
View File
@@ -1,3 +1,4 @@
import os
"""Tests for create_pr.py.
Every test mocks `get_credentials` and `urllib.request.urlopen` so no real
@@ -8,7 +9,7 @@ import json
import sys
import unittest
import contextlib
from unittest.mock import MagicMock, patch
from unittest.mock import patch, MagicMock, patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import create_pr # noqa: E402
@@ -74,6 +75,26 @@ class TestRemoteResolution(unittest.TestCase):
# API payload
# ---------------------------------------------------------------------------
class TestAPIPayload(unittest.TestCase):
"""#714: omitted --org/--repo uses workspace-bound repo (Gitea-Tools),
not the historical REMOTES Timesheet default. Intentional security-policy
migration of legacy omitted-target assertions.
"""
def setUp(self):
self._ws_env = patch.dict(
os.environ,
{
"GITEA_TEST_WORKSPACE_REMOTE_URL": (
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
)
},
clear=False,
)
self._ws_env.start()
def tearDown(self):
self._ws_env.stop()
@patch("create_pr.get_credentials", return_value=FAKE_CREDS)
def test_payload_fields(self, _cred):
@@ -113,7 +134,7 @@ class TestAPIPayload(unittest.TestCase):
url = MockReq.call_args[0][0]
self.assertEqual(
url,
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/pulls",
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Gitea-Tools/pulls",
)
@@ -0,0 +1,704 @@
"""Complete canonical-root consumption for cross-repository MCP namespaces.
#706 introduced an immutable, configured ``canonical_repository_root`` and
routed the #274 filesystem guards and the session repository *slug* through it.
Three consumption paths were left behind, and this module drives each one
through its production entry point:
* **A remote initialization.** ``gitea_get_runtime_context`` never normalized
its ``remote`` argument, while ``gitea_whoami`` did. A fresh process whose
first native call is the runtime-context path therefore pinned the
``dadeschools`` argument default instead of the profile's configured remote,
and because first-bind is first-write-wins no later correct call could
repair it.
* **C reconciler branch deletion.** The delete-branch repository-binding
guard derived its expected slug from ``_workspace_repository_slug``, which
reads the git remote of the *installation* checkout (always Gitea-Tools),
rather than from the session's configured canonical root.
* **D parity evidence.** ``gitea_assess_master_parity`` proves Gitea-Tools
*server implementation* parity only, which is intentional. It carried no
target-repository dimension at all, so a cross-repository namespace had no
evidence that its target checkout was current. The existing
``startup_head``/``current_head`` semantics are preserved unchanged and the
target-repository assessment is reported under separately labelled fields.
Groups B and E assert *intentional* behaviour (the #274 guards already consume
the canonical root; the configuration surface already validates a
repository-specific namespace) so that a regression in either is caught.
Real git repositories are used throughout; no network calls are made.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import canonical_repository_root as crr # noqa: E402
import gitea_config # noqa: E402
import gitea_mcp_server as srv # noqa: E402
import master_parity_gate # noqa: E402
import mcp_server # noqa: E402
import namespace_workspace_binding as nwb # noqa: E402
import session_context_binding as session_ctx # noqa: E402
INSTALL_ORG = "Scaled-Tech-Consulting"
INSTALL_REPO = "Gitea-Tools"
INSTALL_SLUG = f"{INSTALL_ORG}/{INSTALL_REPO}"
INSTALL_URL = f"https://gitea.prgs.cc/{INSTALL_SLUG}.git"
TARGET_ORG = "Scaled-Tech-Consulting"
TARGET_REPO = "mcp-control-plane"
TARGET_SLUG = f"{TARGET_ORG}/{TARGET_REPO}"
TARGET_URL = f"https://gitea.prgs.cc/{TARGET_SLUG}.git"
def _git(cwd: str, *args: str) -> str:
res = subprocess.run(
["git", "-C", cwd, *args], capture_output=True, text=True, check=True
)
return res.stdout.strip()
def _init_repo(path: Path, remote_url: str, *, remote_name: str = "origin") -> str:
path.mkdir(parents=True, exist_ok=True)
_git(str(path), "init", "-q")
_git(str(path), "config", "user.email", "[email protected]")
_git(str(path), "config", "user.name", "Test")
_git(str(path), "remote", "add", remote_name, remote_url)
(path / "README.md").write_text("seed\n")
_git(str(path), "add", "README.md")
_git(str(path), "commit", "-q", "-m", "seed")
return os.path.realpath(str(path))
# ---------------------------------------------------------------------------
# Shared profile/config construction
# ---------------------------------------------------------------------------
_AUTHOR_OPS = [
"gitea.read",
"gitea.issue.create",
"gitea.issue.comment",
"gitea.pr.create",
"gitea.pr.comment",
"gitea.branch.create",
"gitea.branch.push",
"gitea.repo.commit",
]
_AUTHOR_FORBIDDEN = [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.request_changes",
]
# A profile that may approve or merge must forbid authoring (gitea_config's
# reviewer-identity deadlock rule).
_REVIEWER_OPS = [
"gitea.read",
"gitea.pr.approve",
"gitea.pr.request_changes",
"gitea.pr.comment",
"gitea.issue.comment",
]
_REVIEWER_FORBIDDEN = ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"]
_MERGER_OPS = ["gitea.read", "gitea.pr.merge", "gitea.pr.comment"]
_MERGER_FORBIDDEN = [
"gitea.pr.create",
"gitea.branch.push",
"gitea.pr.approve",
"gitea.pr.request_changes",
]
_RECONCILER_OPS = [
"gitea.read",
"gitea.pr.close",
"gitea.pr.comment",
"gitea.branch.delete",
]
_RECONCILER_FORBIDDEN = [
"gitea.pr.create",
"gitea.branch.push",
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.request_changes",
]
_ROLE_MATRIX = {
"author": (_AUTHOR_OPS, _AUTHOR_FORBIDDEN),
"reviewer": (_REVIEWER_OPS, _REVIEWER_FORBIDDEN),
"merger": (_MERGER_OPS, _MERGER_FORBIDDEN),
"reconciler": (_RECONCILER_OPS, _RECONCILER_FORBIDDEN),
}
def _profile(
role: str,
*,
canonical_root: str | None = None,
allowed_repositories: list[str] | None = None,
username: str = "jcwalker3",
) -> dict:
allowed, forbidden = _ROLE_MATRIX[role]
profile = {
"enabled": True,
"context": "prgs",
"role": role,
"username": username,
"base_url": "https://gitea.prgs.cc",
"auth": {"type": "env", "name": f"GITEA_TOKEN_PRGS_{role.upper()}"},
"allowed_operations": list(allowed),
"forbidden_operations": list(forbidden),
"execution_profile": f"prgs-{role}",
}
if canonical_root is not None:
profile["canonical_repository_root"] = canonical_root
if allowed_repositories is not None:
profile["allowed_repositories"] = list(allowed_repositories)
return profile
def _config(profiles: dict) -> dict:
return {
"version": 2,
"rules": {"allow_runtime_switching": True},
"contexts": {
"prgs": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
},
"mdcps": {
"enabled": True,
"gitea": {
"enabled": True,
"base_url": "https://gitea.dadeschools.net",
},
},
},
"profiles": profiles,
}
class _ServerHarness(unittest.TestCase):
"""Temp profiles.json + pinned install remote + no network."""
def setUp(self):
self._dir = tempfile.TemporaryDirectory()
self.tmp = self._dir.name
self.config_path = os.path.join(self.tmp, "profiles.json")
session_ctx._reset_session_context_for_testing()
gitea_config._active_profile_override = None
mcp_server._IDENTITY_CACHE.clear()
srv._MUTATION_AUTHORITY = None
# The install checkout's remote is Gitea-Tools regardless of the
# developer's layout; pin it so "install-derived" is deterministic.
self._remote_url = patch.object(
srv, "_local_git_remote_url", side_effect=self._install_remote_url
)
self._remote_url.start()
def tearDown(self):
self._remote_url.stop()
session_ctx._reset_session_context_for_testing()
gitea_config._active_profile_override = None
mcp_server._IDENTITY_CACHE.clear()
srv._MUTATION_AUTHORITY = None
self._dir.cleanup()
def _install_remote_url(self, remote_name):
return INSTALL_URL if remote_name in ("prgs", "origin") else None
def _write_config(self, profiles: dict) -> None:
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(_config(profiles)))
def _env(self, profile_name: str, **extra) -> dict:
env = {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile_name,
"GITEA_TOKEN_PRGS_AUTHOR": "t",
"GITEA_TOKEN_PRGS_REVIEWER": "t",
"GITEA_TOKEN_PRGS_MERGER": "t",
"GITEA_TOKEN_PRGS_RECONCILER": "t",
}
env.update(extra)
return env
def _api(self, method, url, header):
return {
"login": "jcwalker3",
"full_name": "Test",
"id": 1,
"email": "[email protected]",
}
def _live(self):
return patch("gitea_mcp_server.api_request", side_effect=self._api)
# ===========================================================================
# A. Remote initialization
# ===========================================================================
class TestRemoteInitializationFirstCall(_ServerHarness):
"""A prgs namespace must never pin the dadeschools argument default."""
def setUp(self):
super().setUp()
self._write_config(
{"prgs-author": _profile("author", allowed_repositories=[INSTALL_SLUG])}
)
def test_runtime_context_first_call_does_not_bind_default_remote(self):
"""Runtime-context as the FIRST native call, relying on the default."""
with patch.dict(os.environ, self._env("prgs-author"), clear=False), self._live():
srv.gitea_get_runtime_context()
ctx = session_ctx.get_session_context()
self.assertIsNotNone(ctx, "first call must establish a session binding")
self.assertEqual(ctx["remote"], "prgs")
self.assertEqual(ctx["host"], "gitea.prgs.cc")
self.assertEqual(ctx["org"], INSTALL_ORG)
self.assertEqual(ctx["repository"], INSTALL_REPO)
def test_runtime_context_first_call_reports_effective_remote(self):
with patch.dict(os.environ, self._env("prgs-author"), clear=False), self._live():
result = srv.gitea_get_runtime_context()
self.assertEqual(result["remote"], "prgs")
def test_whoami_first_call_remains_correct(self):
"""Control: the already-normalizing path is unchanged."""
with patch.dict(os.environ, self._env("prgs-author"), clear=False), self._live():
srv.gitea_whoami()
ctx = session_ctx.get_session_context()
self.assertEqual(ctx["remote"], "prgs")
self.assertEqual(ctx["host"], "gitea.prgs.cc")
def test_explicit_remote_argument_still_honoured(self):
"""Normalization only fills the default; explicit values are untouched."""
with patch.dict(os.environ, self._env("prgs-author"), clear=False), self._live():
result = srv.gitea_get_runtime_context(remote="prgs")
self.assertEqual(result["remote"], "prgs")
def test_mdcps_profile_default_is_not_rewritten_to_prgs(self):
"""A dadeschools-hosted profile keeps the dadeschools remote."""
profile = _profile("author", allowed_repositories=[INSTALL_SLUG])
profile["context"] = "mdcps"
profile["base_url"] = "https://gitea.dadeschools.net"
self._write_config({"prgs-author": profile})
with patch.dict(os.environ, self._env("prgs-author"), clear=False), self._live():
result = srv.gitea_get_runtime_context()
self.assertEqual(result["remote"], "dadeschools")
# ===========================================================================
# B. Remote/repository guard (intentional behaviour — regression fence)
# ===========================================================================
class TestCanonicalRootGuardBinding(_ServerHarness):
def setUp(self):
super().setUp()
self.target_root = _init_repo(Path(self.tmp) / "mcp-control-plane", TARGET_URL)
self.install_root = _init_repo(Path(self.tmp) / "install", INSTALL_URL)
def test_canonical_target_root_resolves_to_target_slug(self):
got = crr.assess_canonical_repository_root(
configured_value=self.target_root,
source="profile.canonical_repository_root",
expected_slug=None,
process_project_root=self.install_root,
remote="prgs",
require_binding=True,
)
self.assertFalse(got.get("block"), got.get("reasons"))
self.assertEqual(got["resolved_slug"], TARGET_SLUG)
def test_install_root_identity_remains_gitea_tools(self):
got = crr.assess_canonical_repository_root(
configured_value=None,
source=None,
expected_slug=None,
process_project_root=self.install_root,
remote="prgs",
)
self.assertEqual(
os.path.realpath(got["canonical_repo_root"]), self.install_root
)
def test_guards_validate_against_canonical_target_root(self):
ctx = nwb.resolve_namespace_mutation_context(
role_kind="author",
worktree_path=None,
process_project_root=self.install_root,
configured_canonical_root=self.target_root,
)
self.assertEqual(
os.path.realpath(ctx["canonical_repo_root"]), self.target_root
)
def test_explicit_coordinates_cannot_override_canonical_binding(self):
"""Explicit org/repo may confirm, never authorize, a binding."""
confirm = session_ctx.assess_repository_override(
requested_org=TARGET_ORG,
requested_repo=TARGET_REPO,
bound_org=TARGET_ORG,
bound_repo=TARGET_REPO,
)
self.assertFalse(confirm.get("block"))
override = session_ctx.assess_repository_override(
requested_org=TARGET_ORG,
requested_repo=TARGET_REPO,
bound_org=INSTALL_ORG,
bound_repo=INSTALL_REPO,
)
self.assertTrue(override.get("block"))
def test_gitea_tools_rooted_namespace_cannot_reach_target_repository(self):
"""No canonical root configured → session stays pinned to Gitea-Tools."""
profile = _profile("author", allowed_repositories=[INSTALL_SLUG])
with patch.dict(os.environ, {}, clear=False):
os.environ.pop(crr.CANONICAL_ROOT_ENV, None)
resolved = srv._trusted_session_repository(
profile, "prgs", for_mutation=True
)
self.assertEqual(resolved["repository"], INSTALL_REPO)
self.assertNotEqual(resolved["repository"], TARGET_REPO)
def test_target_rooted_namespace_binds_target_repository(self):
profile = _profile(
"author",
canonical_root=self.target_root,
allowed_repositories=[TARGET_SLUG],
)
resolved = srv._trusted_session_repository(profile, "prgs", for_mutation=True)
self.assertEqual(resolved["org"], TARGET_ORG)
self.assertEqual(resolved["repository"], TARGET_REPO)
# ===========================================================================
# C. Reconciler branch deletion
# ===========================================================================
class TestDeleteBranchRepositoryBinding(_ServerHarness):
"""The binding guard must consult the canonical root, not PROJECT_ROOT.
No branch is ever deleted here: only the pre-deletion binding guard is
exercised.
"""
def setUp(self):
super().setUp()
self.target_root = _init_repo(Path(self.tmp) / "mcp-control-plane", TARGET_URL)
self._write_config(
{
"prgs-reconciler": _profile(
"reconciler",
canonical_root=self.target_root,
allowed_repositories=[TARGET_SLUG],
),
"gt-reconciler": _profile(
"reconciler", allowed_repositories=[INSTALL_SLUG]
),
}
)
def test_target_rooted_reconciler_validates_target_deletion(self):
with patch.dict(os.environ, self._env("prgs-reconciler"), clear=False):
block = srv._delete_branch_repository_binding_block(
"prgs", org=TARGET_ORG, repo=TARGET_REPO
)
self.assertIsNone(block, block)
def test_target_rooted_reconciler_fails_closed_for_install_repository(self):
with patch.dict(os.environ, self._env("prgs-reconciler"), clear=False):
block = srv._delete_branch_repository_binding_block(
"prgs", org=INSTALL_ORG, repo=INSTALL_REPO
)
self.assertIsNotNone(block)
self.assertEqual(block["blocker_kind"], "repository_binding")
self.assertFalse(block["performed"])
def test_install_rooted_reconciler_fails_closed_for_target_repository(self):
with patch.dict(os.environ, self._env("gt-reconciler"), clear=False):
os.environ.pop(crr.CANONICAL_ROOT_ENV, None)
block = srv._delete_branch_repository_binding_block(
"prgs", org=TARGET_ORG, repo=TARGET_REPO
)
self.assertIsNotNone(block)
self.assertEqual(block["blocker_kind"], "repository_binding")
def test_env_configured_canonical_root_is_honoured(self):
env = self._env("gt-reconciler")
env[crr.CANONICAL_ROOT_ENV] = self.target_root
with patch.dict(os.environ, env, clear=False):
allowed = srv._delete_branch_repository_binding_block(
"prgs", org=TARGET_ORG, repo=TARGET_REPO
)
blocked = srv._delete_branch_repository_binding_block(
"prgs", org=INSTALL_ORG, repo=INSTALL_REPO
)
self.assertIsNone(allowed, allowed)
self.assertIsNotNone(blocked)
def test_unresolvable_canonical_root_fails_closed(self):
env = self._env("gt-reconciler")
env[crr.CANONICAL_ROOT_ENV] = os.path.join(self.tmp, "does-not-exist")
with patch.dict(os.environ, env, clear=False):
block = srv._delete_branch_repository_binding_block(
"prgs", org=TARGET_ORG, repo=TARGET_REPO
)
self.assertIsNotNone(block)
self.assertEqual(block["blocker_kind"], "repository_binding")
def test_no_request_parameter_can_override_canonical_identity(self):
"""Every explicit coordinate that is not the canonical target is
refused; none of them can *establish* the binding."""
# Both repositories share an org, so a wrong *org* case must name a
# genuinely different owner to be meaningful.
cases = [
(INSTALL_ORG, INSTALL_REPO),
(TARGET_ORG, INSTALL_REPO),
(TARGET_ORG, "some-other-repo"),
("someone-else", TARGET_REPO),
]
with patch.dict(os.environ, self._env("prgs-reconciler"), clear=False):
for org, repo in cases:
with self.subTest(org=org, repo=repo):
block = srv._delete_branch_repository_binding_block(
"prgs", org=org, repo=repo
)
self.assertIsNotNone(block, f"{org}/{repo} must fail closed")
# ===========================================================================
# D. Parity semantics
# ===========================================================================
class TestTargetRepositoryParityAssessment(unittest.TestCase):
"""Server-implementation parity is preserved; target parity is additive."""
def setUp(self):
self._dir = tempfile.TemporaryDirectory()
self.tmp = self._dir.name
def tearDown(self):
self._dir.cleanup()
def _target(self) -> str:
return _init_repo(Path(self.tmp) / "mcp-control-plane", TARGET_URL)
def test_unconfigured_target_is_reported_not_stale(self):
got = master_parity_gate.assess_target_repository_parity(
canonical_root=None, source=None
)
self.assertFalse(got["configured"])
self.assertFalse(got["stale"])
self.assertIsNone(got["canonical_repository_root"])
def test_configured_target_reports_checkout_head_and_slug(self):
root = self._target()
head = _git(root, "rev-parse", "HEAD")
got = master_parity_gate.assess_target_repository_parity(
canonical_root=root, source="profile.canonical_repository_root"
)
self.assertTrue(got["configured"])
self.assertEqual(got["canonical_repository_root"], root)
self.assertEqual(got["checkout_head"], head)
self.assertEqual(got["repository_slug"], TARGET_SLUG)
def test_target_stale_when_remote_tracking_ref_is_ahead(self):
root = self._target()
head = _git(root, "rev-parse", "HEAD")
# Simulate a fetched remote-tracking ref that has advanced.
_git(root, "checkout", "-q", "-b", "advanced")
(Path(root) / "next.md").write_text("next\n")
_git(root, "add", "next.md")
_git(root, "commit", "-q", "-m", "advance")
advanced = _git(root, "rev-parse", "HEAD")
_git(root, "update-ref", "refs/remotes/origin/master", advanced)
_git(root, "checkout", "-q", "--detach", head)
got = master_parity_gate.assess_target_repository_parity(
canonical_root=root, source="profile.canonical_repository_root"
)
self.assertEqual(got["checkout_head"], head)
self.assertEqual(got["remote_tracking_head"], advanced)
self.assertTrue(got["stale"])
def test_missing_root_is_not_determinable_and_fails_closed(self):
got = master_parity_gate.assess_target_repository_parity(
canonical_root=os.path.join(self.tmp, "absent"),
source="profile.canonical_repository_root",
)
self.assertTrue(got["configured"])
self.assertFalse(got["determinable"])
self.assertTrue(got["reasons"])
class TestParityToolEvidenceDimensions(_ServerHarness):
def setUp(self):
super().setUp()
self.target_root = _init_repo(Path(self.tmp) / "mcp-control-plane", TARGET_URL)
self._write_config(
{
"prgs-author": _profile(
"author",
canonical_root=self.target_root,
allowed_repositories=[TARGET_SLUG],
)
}
)
def test_existing_server_parity_semantics_are_unchanged(self):
with patch.dict(os.environ, self._env("prgs-author"), clear=False):
result = srv.gitea_assess_master_parity(remote="prgs")
# startup_head/current_head keep their Gitea-Tools implementation
# meaning and must not be redefined to the target repository.
self.assertEqual(result["process_root"], srv.PROJECT_ROOT)
self.assertEqual(
result["startup_head"], srv._STARTUP_PARITY.get("startup_head")
)
self.assertIn("in_parity", result)
def test_evidence_distinguishes_server_and_target_dimensions(self):
with patch.dict(os.environ, self._env("prgs-author"), clear=False):
result = srv.gitea_assess_master_parity(remote="prgs")
server = result["server_implementation"]
self.assertEqual(server["installation_root"], srv.PROJECT_ROOT)
self.assertEqual(server["current_head"], result["current_head"])
self.assertIn("stale", server)
target = result["target_repository"]
self.assertTrue(target["configured"])
self.assertEqual(target["canonical_repository_root"], self.target_root)
self.assertEqual(target["repository_slug"], TARGET_SLUG)
self.assertEqual(
target["checkout_head"], _git(self.target_root, "rev-parse", "HEAD")
)
self.assertIn("stale", target)
def test_unconfigured_namespace_reports_target_as_unconfigured(self):
self._write_config(
{"prgs-author": _profile("author", allowed_repositories=[INSTALL_SLUG])}
)
env = self._env("prgs-author")
with patch.dict(os.environ, env, clear=False):
os.environ.pop(crr.CANONICAL_ROOT_ENV, None)
result = srv.gitea_assess_master_parity(remote="prgs")
self.assertFalse(result["target_repository"]["configured"])
# ===========================================================================
# E. Startup / configuration validation for a candidate namespace set
# ===========================================================================
class TestCandidateNamespaceConfiguration(_ServerHarness):
"""Four repository-specific profiles for the target repository."""
def setUp(self):
super().setUp()
self.target_root = _init_repo(Path(self.tmp) / "mcp-control-plane", TARGET_URL)
self.profiles = {
f"mcpcp-{role}": _profile(
role,
canonical_root=self.target_root,
allowed_repositories=[TARGET_SLUG],
)
for role in ("author", "reviewer", "merger", "reconciler")
}
self._write_config(self.profiles)
def test_configuration_audit_succeeds(self):
with patch.dict(os.environ, self._env("mcpcp-author"), clear=False):
audit = srv.gitea_audit_config()
self.assertTrue(audit.get("configured"))
names = {row["name"] for row in audit["profiles"]}
self.assertEqual(names, set(self.profiles))
def test_no_inline_credentials_are_present(self):
raw = json.loads(Path(self.config_path).read_text())
for name, profile in raw["profiles"].items():
with self.subTest(profile=name):
self.assertNotIn("token", profile)
self.assertNotIn("password", profile)
self.assertNotIn("secret", profile)
self.assertIn(profile["auth"]["type"], ("env", "keychain"))
def test_bind_time_validation_succeeds_for_target_repository(self):
for name, profile in self.profiles.items():
with self.subTest(profile=name):
resolved = srv._trusted_session_repository(
profile, "prgs", for_mutation=True
)
self.assertEqual(resolved["reasons"], [])
self.assertEqual(resolved["org"], TARGET_ORG)
self.assertEqual(resolved["repository"], TARGET_REPO)
def test_wrong_repository_root_fails_closed(self):
wrong_root = _init_repo(Path(self.tmp) / "wrong", INSTALL_URL)
profile = _profile(
"author", canonical_root=wrong_root, allowed_repositories=[TARGET_SLUG]
)
resolved = srv._trusted_session_repository(profile, "prgs", for_mutation=True)
self.assertIsNone(resolved["repository"])
self.assertTrue(resolved["reasons"])
def test_existing_install_profiles_are_unchanged_in_behaviour(self):
profile = _profile("author", allowed_repositories=[INSTALL_SLUG])
resolved = srv._trusted_session_repository(profile, "prgs", for_mutation=True)
self.assertEqual(resolved["org"], INSTALL_ORG)
self.assertEqual(resolved["repository"], INSTALL_REPO)
def _denied(self, profile: dict, operation: str) -> bool:
ok, _ = gitea_config.check_operation(
operation,
profile["allowed_operations"],
profile["forbidden_operations"],
)
return not ok
def test_role_separation_is_enforced(self):
expectations = {
"author": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.request_changes",
],
"reviewer": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
"merger": [
"gitea.pr.create",
"gitea.branch.push",
"gitea.pr.approve",
"gitea.pr.request_changes",
],
"reconciler": [
"gitea.pr.create",
"gitea.branch.push",
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.request_changes",
],
}
for role, denied_ops in expectations.items():
profile = self.profiles[f"mcpcp-{role}"]
for op in denied_ops:
with self.subTest(role=role, operation=op):
self.assertTrue(
self._denied(profile, op),
f"{role} must not be permitted {op}",
)
def test_each_role_retains_its_own_capability(self):
permitted = {
"author": "gitea.pr.create",
"reviewer": "gitea.pr.approve",
"merger": "gitea.pr.merge",
"reconciler": "gitea.branch.delete",
}
for role, op in permitted.items():
with self.subTest(role=role, operation=op):
self.assertFalse(self._denied(self.profiles[f"mcpcp-{role}"], op))
if __name__ == "__main__":
unittest.main()
+155 -29
View File
@@ -1,9 +1,15 @@
"""Tests for gitea_delete_branch capability gate (Issue #408).
"""Tests for gitea_delete_branch capability + role gate (Issue #408, #729).
``gitea_delete_branch`` requires the exact ``gitea.branch.delete`` operation:
without it the delete fails closed (no preflight, no auth lookup, no API call,
structured permission report). With it, deletion proceeds through existing
preflight and audit unchanged.
structured permission report).
#729: delete_branch is reconciler-owned. ``gitea.branch.delete`` is granted only
to the reconciler profile, so the resolver classifies delete_branch as a
reconciler task. Raw ``gitea_delete_branch`` still redirects the reconciler to
the guarded ``gitea_cleanup_merged_pr_branch`` path (#514/#687); author,
reviewer, and merger remain denied by the permission gate and/or the required
role gate.
"""
import json
import os
@@ -16,6 +22,8 @@ sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.par
import mcp_server
from mcp_server import gitea_delete_branch
import task_capability_map
import role_session_router
FAKE_AUTH = "token fake"
@@ -38,6 +46,22 @@ AUTHOR_WITH_DELETE = {
"audit_label": "prgs-author-deleter",
}
# #729: delete_branch is reconciler-owned. The reconciler holds
# gitea.branch.delete but raw gitea_delete_branch redirects it to the guarded
# gitea_cleanup_merged_pr_branch path (#514/#687).
RECONCILER_WITH_DELETE = {
"profile_name": "prgs-reconciler",
"role": "reconciler",
"allowed_operations": [
"gitea.read", "gitea.issue.comment", "gitea.pr.comment",
"gitea.branch.delete",
],
"forbidden_operations": [
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.create",
],
"audit_label": "prgs-reconciler",
}
CONFIG = {
"version": 2,
"contexts": {
@@ -81,6 +105,20 @@ CONFIG = {
],
"execution_profile": "reviewer-profile",
},
# #729: reconciler is the only delete-capable role.
"reconciler-profile": {
"enabled": True,
"context": "ctx",
"role": "reconciler",
"username": "reconciler-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_RECONCILER"},
"allowed_operations": [
"gitea.read", "gitea.issue.comment", "gitea.pr.comment",
"gitea.branch.delete",
],
"forbidden_operations": ["gitea.pr.create", "gitea.pr.merge"],
"execution_profile": "reconciler-profile",
},
},
"rules": {"allow_runtime_switching": False},
}
@@ -121,6 +159,9 @@ class TestDeleteBranchToolGate(unittest.TestCase):
def _set_profile(self, profile):
patch("mcp_server.get_profile", return_value=profile).start()
def _delete_calls(self):
return [c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"]
def test_blocked_without_delete_capability(self):
self._set_profile(AUTHOR_NO_DELETE)
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
@@ -135,33 +176,53 @@ class TestDeleteBranchToolGate(unittest.TestCase):
self.mock_api.assert_not_called()
self.mock_auth.assert_not_called()
def test_allowed_delete_proceeds(self):
def test_author_with_delete_perm_denied_by_role_gate(self):
"""#729: even an author holding gitea.branch.delete is denied — the
required role is now reconciler. Fail closed, no API DELETE."""
self._set_profile(AUTHOR_WITH_DELETE)
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
self.assertFalse(res["success"])
self.assertFalse(res["performed"])
self.assertEqual(res["required_role_kind"], "reconciler")
self.assertEqual(res["active_role_kind"], "author")
self.assertTrue(res["reasons"])
self.assertFalse(self._delete_calls())
def test_reviewer_with_delete_perm_denied_by_role_gate(self):
"""A reviewer that somehow holds the permission is still denied."""
reviewer_with_delete = {
"profile_name": "prgs-reviewer",
"role": "reviewer",
"allowed_operations": [
"gitea.read", "gitea.pr.review", "gitea.branch.delete",
],
"forbidden_operations": [],
"audit_label": "prgs-reviewer",
}
self._set_profile(reviewer_with_delete)
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="reviewer")
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
self.assertFalse(res["success"])
self.assertFalse(res["performed"])
self.assertEqual(res["required_role_kind"], "reconciler")
self.assertEqual(res["active_role_kind"], "reviewer")
self.assertFalse(self._delete_calls())
def test_reconciler_performs_raw_delete(self):
"""#729: the reconciler is the delete-capable role and performs the raw
deletion (no audit phase active here); the API DELETE is issued."""
self._set_profile(RECONCILER_WITH_DELETE)
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="reconciler")
self.mock_api.return_value = {}
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
self.assertTrue(res["success"])
self.assertIn("deleted", res["message"])
delete_calls = [
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
]
self.assertTrue(delete_calls)
def test_allowed_delete_audited_with_capability_proof(self):
self._set_profile(AUTHOR_WITH_DELETE)
patch("gitea_audit.audit_enabled", return_value=True).start()
mock_write = patch("gitea_audit.write_event").start()
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
self.mock_api.return_value = {}
gitea_delete_branch(branch="feat/branch", remote="prgs")
mock_write.assert_called()
event = mock_write.call_args[0][0]
self.assertEqual(event["action"], "delete_branch")
self.assertEqual(
event["request_metadata"]["required_permission"],
"gitea.branch.delete",
)
self.assertTrue(self._delete_calls())
class TestDeleteBranchResolverParity(unittest.TestCase):
@@ -194,24 +255,89 @@ class TestDeleteBranchResolverParity(unittest.TestCase):
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
"GITEA_TOKEN_RECONCILER": "reconciler-pass",
}
def _delete_calls(self):
return [c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"]
def test_reconciler_resolver_allows_delete_branch(self):
"""#729: resolve(delete_branch) on the reconciler profile is allowed and
classified as a reconciler task."""
with patch.dict(os.environ, self._env("reconciler-profile"), clear=True):
resolve = mcp_server.gitea_resolve_task_capability(
task="delete_branch", remote="prgs")
# Deterministic role-map outcomes (avoid runtime-staleness-dependent
# allowed_in_current_session, which folds in reconnect state).
self.assertEqual(resolve["required_role_kind"], "reconciler")
self.assertEqual(
resolve["required_operation_permission"], "gitea.branch.delete")
self.assertTrue(resolve["active_profile_permission_allowed"])
self.assertTrue(resolve["configured"])
self.assertIn("reconciler-profile", resolve["matching_configured_profile"])
self.assertEqual(resolve["active_role_kind"], "reconciler")
def test_author_resolver_denies_delete_branch(self):
"""#729: an author session cannot resolve delete_branch — required role
is reconciler and the author lacks the permission."""
with patch.dict(os.environ, self._env("author-no-delete"), clear=True):
resolve = mcp_server.gitea_resolve_task_capability(
task="delete_branch", remote="prgs")
self.assertEqual(resolve["required_role_kind"], "reconciler")
self.assertFalse(resolve["allowed_in_current_session"])
def test_reviewer_resolver_denial_blocks_raw_tool(self):
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
resolve = mcp_server.gitea_resolve_task_capability(
task="delete_branch", remote="prgs")
self.assertFalse(resolve["allowed_in_current_session"])
patch("mcp_server.get_profile", return_value={
"profile_name": "prgs-reviewer",
"role": "reviewer",
"allowed_operations": ["gitea.read", "gitea.pr.review"],
"forbidden_operations": ["gitea.branch.delete"],
}).start()
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
self.assertFalse(res["success"])
self.assertEqual(
res["permission_report"]["missing_permission"],
resolve["required_operation_permission"],
)
delete_calls = [
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
]
self.assertFalse(delete_calls)
self.assertFalse(self._delete_calls())
class TestDeleteBranchRoleMapParity(unittest.TestCase):
"""#729: both single-source-of-truth maps must classify delete_branch as
reconciler and stay in agreement."""
def test_task_capability_map_role_reconciler(self):
self.assertEqual(
task_capability_map.required_role("delete_branch"), "reconciler")
self.assertEqual(
task_capability_map.required_permission("delete_branch"),
"gitea.branch.delete",
)
def test_router_required_role_reconciler(self):
self.assertEqual(
role_session_router.TASK_REQUIRED_ROLE["delete_branch"],
"reconciler",
)
self.assertEqual(
role_session_router.required_role_for_task("delete_branch"),
"reconciler",
)
def test_router_set_membership_moved(self):
self.assertIn("delete_branch", role_session_router.RECONCILER_TASKS)
self.assertNotIn("delete_branch", role_session_router.AUTHOR_TASKS)
def test_maps_agree(self):
self.assertEqual(
task_capability_map.required_role("delete_branch"),
role_session_router.TASK_REQUIRED_ROLE["delete_branch"],
)
if __name__ == "__main__":
unittest.main()
unittest.main()
+11 -4
View File
@@ -24,6 +24,10 @@ HEAD_C = "c" * 40
def _lock(mutations=None, correction=False, ready_pr=None, ready_head=None, ready_action=None):
mutations = list(mutations or [])
corr_pr = None
if correction and mutations:
corr_pr = mutations[-1].get("pr_number")
return {
"task": "review_pr",
"remote": "prgs",
@@ -40,9 +44,11 @@ def _lock(mutations=None, correction=False, ready_pr=None, ready_head=None, read
"ready_remote": "prgs" if ready_pr else None,
"ready_org": "Scaled-Tech-Consulting" if ready_pr else None,
"ready_repo": "Gitea-Tools" if ready_pr else None,
"live_mutations": list(mutations or []),
"live_mutations": mutations,
"correction_authorized": correction,
"correction_reason": None,
"correction_reason": "test" if correction else None,
"correction_pr_number": corr_pr,
"correction_head_sha": None,
}
@@ -174,12 +180,13 @@ class TestHardStopHeadScope(unittest.TestCase):
self.assertTrue(reasons)
self.assertIn("#332", reasons[0])
def test_rc_head_a_blocks_other_pr(self):
def test_rc_head_a_allows_other_pr_via_cross_pr_isolation(self):
"""#693: foreign-PR terminal must not hard-stop mark_ready on another PR."""
_seed([RC_619_HEAD_A], ready_pr=619, ready_head=HEAD_A)
reasons = mcp_server.terminal_review_hard_stop_reasons(
700, "mark_ready", expected_head_sha=HEAD_B
)
self.assertTrue(reasons)
self.assertEqual(reasons, [])
def test_legacy_619_ledger_allows_new_head(self):
"""PR #619-style durable lock without mutation head_sha."""
@@ -0,0 +1,430 @@
"""#685: gitea_resolve_task_capability must be side-effect free.
Stale-runtime detection remains fail-closed, but the resolver must never:
* touch mcp_config.json (or any MCP client config)
* spawn recovery threads
* call os._exit / terminate the serving process
* claim that an auto-restart was triggered
Recovery is owned by the IDE/client reconnect path only.
"""
from __future__ import annotations
import os
import tempfile
import threading
import unittest
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
import sys
ROOT = str(Path(__file__).resolve().parent.parent)
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
import gitea_mcp_server as mcp_server
ROLE_PROFILES = (
("create_issue", "prgs-author", "author"),
("review_pr", "prgs-reviewer", "reviewer"),
("merge_pr", "prgs-merger", "merger"),
("reconciliation_cleanup", "prgs-reconciler", "reconciler"),
)
def _stale_self_ps_mocks(profile: str = "prgs-author"):
"""Build subprocess mocks: self PID is stale vs code mtime."""
mock_getpid = MagicMock(return_value=12345)
mock_exists = MagicMock(return_value=True)
code_time = datetime(2026, 7, 8, 14, 0, 0)
mock_getmtime = MagicMock(return_value=code_time.timestamp())
ps_output = (
" PID LSTART COMMAND\n"
"12345 Wed Jul 8 13:00:00 2026 /path/to/python mcp_server.py\n"
)
mock_run_ps = MagicMock()
mock_run_ps.stdout = ps_output
mock_run_env = MagicMock()
mock_run_env.stdout = f"GITEA_MCP_PROFILE={profile}"
mock_run_git = MagicMock()
mock_run_git.stdout = "SAME"
def side_effect(args, **kwargs):
if args[0] == "ps" and "eww" in args:
return mock_run_env
if args[0] == "ps":
return mock_run_ps
if args[0] == "git":
return mock_run_git
raise ValueError(f"Unexpected subprocess args: {args}")
mock_run = MagicMock(side_effect=side_effect)
return mock_getpid, mock_exists, mock_getmtime, mock_run
class TestIssue685DiagnosticsNoSideEffects(unittest.TestCase):
def setUp(self):
mcp_server._process_boot_head_sha = None
def tearDown(self):
mcp_server._process_boot_head_sha = None
@patch.dict(os.environ, {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}, clear=False)
@patch("subprocess.run")
@patch("os.path.getmtime")
@patch("os.path.exists")
@patch("os.getpid")
@patch("os.utime")
@patch("threading.Thread")
@patch("os._exit")
def test_stale_self_does_not_touch_config_or_exit(
self,
mock_exit,
mock_thread,
mock_utime,
mock_getpid,
mock_exists,
mock_getmtime,
mock_run,
):
mock_getpid.return_value = 12345
mock_exists.return_value = True
mock_getmtime.return_value = datetime(2026, 7, 8, 14, 0, 0).timestamp()
mock_run.side_effect = _stale_self_ps_mocks("prgs-author")[3].side_effect
before_threads = threading.active_count()
reasons = mcp_server._check_mcp_runtimes_diagnostics(
"create_issue", ["prgs-author"]
)
after_threads = threading.active_count()
self.assertTrue(
any("stale-runtime" in r and "active Gitea MCP server process is stale" in r
for r in reasons),
reasons,
)
# Must not claim auto-restart / config touch
blob = " ".join(reasons)
self.assertNotIn("Auto-restart has been triggered", blob)
self.assertNotIn("touched mcp_config", blob)
self.assertNotIn("will cleanly exit", blob)
mock_utime.assert_not_called()
mock_thread.assert_not_called()
mock_exit.assert_not_called()
self.assertEqual(before_threads, after_threads)
@patch.dict(os.environ, {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}, clear=False)
@patch("subprocess.run")
@patch("os.path.getmtime")
@patch("os.path.exists")
@patch("os.getpid")
@patch("os.utime")
def test_repeated_stale_calls_do_not_trigger_restart_loop(
self, mock_utime, mock_getpid, mock_exists, mock_getmtime, mock_run
):
mock_getpid.return_value = 12345
mock_exists.return_value = True
mock_getmtime.return_value = datetime(2026, 7, 8, 14, 0, 0).timestamp()
mock_run.side_effect = _stale_self_ps_mocks("prgs-author")[3].side_effect
for _ in range(5):
reasons = mcp_server._check_mcp_runtimes_diagnostics(
"create_issue", ["prgs-author"]
)
self.assertTrue(any("stale-runtime" in r for r in reasons))
mock_utime.assert_not_called()
def test_trigger_mcp_auto_restart_removed(self):
"""#685 AC: auto-restart helper is removed (unreachable from read-only)."""
self.assertFalse(hasattr(mcp_server, "_trigger_mcp_auto_restart"))
self.assertFalse(hasattr(mcp_server, "_restart_triggered"))
class TestIssue685ResolverTypedBlocker(unittest.TestCase):
def setUp(self):
mcp_server._process_boot_head_sha = None
def tearDown(self):
mcp_server._process_boot_head_sha = None
if hasattr(mcp_server, "capability_stop_terminal"):
mcp_server.capability_stop_terminal.clear()
def _resolve_with_stale_runtime(self, task: str, profile_name: str, role: str):
allowed = [
"gitea.read",
"gitea.issue.create",
"gitea.issue.comment",
"gitea.issue.close",
"gitea.branch.create",
"gitea.branch.push",
"gitea.branch.delete",
"gitea.pr.create",
"gitea.pr.comment",
"gitea.pr.review",
"gitea.pr.approve",
"gitea.pr.request_changes",
"gitea.pr.merge",
"gitea.pr.close",
"gitea.repo.commit",
]
profile = {
"profile_name": profile_name,
"role": role,
"allowed_operations": allowed,
"forbidden_operations": [],
}
config = {
"profiles": {
profile_name: {
"role": role,
"allowed_operations": allowed,
"forbidden_operations": [],
}
}
}
mock_getpid, mock_exists, mock_getmtime, mock_run = _stale_self_ps_mocks(
profile_name
)
with patch.dict(
os.environ,
{
"GITEA_FORCE_MCP_RUNTIME_CHECK": "1",
"GITEA_MCP_PROFILE": profile_name,
},
clear=False,
), patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
mcp_server.gitea_config, "load_config", return_value=config
), patch.object(
mcp_server, "_authenticated_username", return_value="test-user"
), patch.object(
mcp_server, "_ensure_matching_profile", return_value=None
), patch.object(
mcp_server, "record_preflight_check", return_value=None
), patch.object(
mcp_server, "record_mutation_authority", return_value=None
), patch.object(
mcp_server, "init_review_decision_lock", return_value=None
), patch(
"subprocess.run", mock_run
), patch(
"os.path.getmtime", mock_getmtime
), patch(
"os.path.exists", mock_exists
), patch(
"os.getpid", mock_getpid
), patch(
"os.utime"
) as mock_utime, patch(
"threading.Thread"
) as mock_thread, patch(
"os._exit"
) as mock_exit:
result = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
return result, mock_utime, mock_thread, mock_exit
def test_stale_returns_typed_blocker_fields(self):
result, mock_utime, mock_thread, mock_exit = self._resolve_with_stale_runtime(
"create_issue", "prgs-author", "author"
)
self.assertTrue(result.get("restart_required"), result)
self.assertTrue(result.get("stop_required"), result)
self.assertEqual(result.get("blocker_kind"), "runtime_reconnect_required")
self.assertIs(result.get("mutation_performed"), False)
action = result.get("exact_safe_next_action") or ""
self.assertIn("reconnect", action.lower())
self.assertNotIn("None; ready for operations", action)
reason = result.get("reason") or ""
self.assertIn("stale-runtime", reason)
self.assertNotIn("Auto-restart has been triggered", reason)
mock_utime.assert_not_called()
mock_thread.assert_not_called()
mock_exit.assert_not_called()
def test_all_four_role_profiles_get_same_side_effect_free_contract(self):
for task, profile, role in ROLE_PROFILES:
with self.subTest(task=task, profile=profile):
# Skip tasks that may be unknown on this branch
try:
import task_capability_map as tcm
tcm.required_permission(task)
except Exception:
self.skipTest(f"task {task} not in capability map")
result, mock_utime, mock_thread, mock_exit = (
self._resolve_with_stale_runtime(task, profile, role)
)
self.assertTrue(
result.get("restart_required") or result.get("stop_required"),
result,
)
self.assertEqual(
result.get("blocker_kind"), "runtime_reconnect_required", result
)
self.assertIs(result.get("mutation_performed"), False, result)
mock_utime.assert_not_called()
mock_thread.assert_not_called()
mock_exit.assert_not_called()
def test_config_mtime_and_contents_unchanged(self):
with tempfile.TemporaryDirectory() as tmp:
cfg = os.path.join(tmp, "mcp_config.json")
original = '{"servers": {"gitea-author": {}}}'
with open(cfg, "w", encoding="utf-8") as fh:
fh.write(original)
mtime_before = os.path.getmtime(cfg)
result, mock_utime, mock_thread, mock_exit = self._resolve_with_stale_runtime(
"create_issue", "prgs-author", "author"
)
# Force-path also must not use real utime when diagnostics runs
with open(cfg, encoding="utf-8") as fh:
after = fh.read()
self.assertEqual(after, original)
self.assertEqual(os.path.getmtime(cfg), mtime_before)
mock_utime.assert_not_called()
self.assertTrue(result.get("restart_required"), result)
class TestIssue685MutationGatesStillFailClosed(unittest.TestCase):
def test_parity_stale_still_reports_restart_required(self):
"""Mutation-facing parity gate remains fail-closed when heads differ."""
import master_parity_gate as mpg
out = mpg.assess_master_parity(
{"startup_head": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
)
self.assertFalse(out.get("in_parity"))
self.assertTrue(out.get("restart_required") or out.get("stale"))
class TestIssue685DocstringReadOnlyContract(unittest.TestCase):
def test_resolve_docstring_declares_side_effect_free(self):
doc = mcp_server.gitea_resolve_task_capability.__doc__ or ""
lower = doc.lower()
self.assertTrue(
"side-effect" in lower or "read-only" in lower or "does not mutate" in lower,
doc,
)
self.assertNotIn("auto-restart", lower)
self.assertNotIn("os._exit", lower)
class TestIssue685MergeCoexistenceWithMasterAnnotations(unittest.TestCase):
"""After merging master: #685 reconnect blocker + #702 report-only binding."""
def setUp(self):
mcp_server._process_boot_head_sha = None
def tearDown(self):
mcp_server._process_boot_head_sha = None
if hasattr(mcp_server, "capability_stop_terminal"):
mcp_server.capability_stop_terminal.clear()
def test_resolve_uses_report_only_stale_binding_assessment(self):
"""Capability resolve must call stale-binding assess with auto_recover=False."""
allowed = [
"gitea.read",
"gitea.issue.create",
"gitea.issue.comment",
"gitea.branch.push",
"gitea.pr.create",
"gitea.pr.comment",
"gitea.pr.review",
"gitea.pr.merge",
"gitea.repo.commit",
]
profile = {
"profile_name": "prgs-author",
"role": "author",
"allowed_operations": allowed,
"forbidden_operations": [],
}
config = {
"profiles": {
"prgs-author": {
"role": "author",
"allowed_operations": allowed,
"forbidden_operations": [],
}
}
}
mock_getpid, mock_exists, mock_getmtime, mock_run = _stale_self_ps_mocks(
"prgs-author"
)
fake_binding = {
"classification": "missing_path",
"active_worktree": "/tmp/gone",
"reasons": ["path missing"],
}
with patch.dict(
os.environ,
{
"GITEA_FORCE_MCP_RUNTIME_CHECK": "1",
"GITEA_MCP_PROFILE": "prgs-author",
},
clear=False,
), patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
mcp_server.gitea_config, "load_config", return_value=config
), patch.object(
mcp_server, "_authenticated_username", return_value="test-user"
), patch.object(
mcp_server, "_ensure_matching_profile", return_value=None
), patch.object(
mcp_server, "record_preflight_check", return_value=None
), patch.object(
mcp_server, "record_mutation_authority", return_value=None
), patch.object(
mcp_server, "init_review_decision_lock", return_value=None
), patch.object(
mcp_server,
"_assess_stale_active_binding",
return_value=fake_binding,
) as mock_assess, patch(
"subprocess.run", mock_run
), patch(
"os.path.getmtime", mock_getmtime
), patch(
"os.path.exists", mock_exists
), patch(
"os.getpid", mock_getpid
), patch(
"os.utime"
) as mock_utime, patch(
"threading.Thread"
) as mock_thread, patch(
"os._exit"
) as mock_exit:
result = mcp_server.gitea_resolve_task_capability(
task="create_issue", remote="prgs"
)
mock_assess.assert_called()
# Every call must be report-only (no env clear / session-state write).
for call in mock_assess.call_args_list:
self.assertFalse(call.kwargs.get("auto_recover", True))
self.assertEqual(
result.get("blocker_kind"), "runtime_reconnect_required", result
)
self.assertEqual(result.get("stale_binding_recovery"), fake_binding, result)
self.assertIs(result.get("mutation_performed"), False, result)
mock_utime.assert_not_called()
mock_thread.assert_not_called()
mock_exit.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -561,12 +561,29 @@ class TestIssue691SupersededHeadCleanup(unittest.TestCase):
self.assertTrue(any("owns the lease" in r for r in result["reasons"]))
def test_superseded_without_terminal_review_denied(self):
# Pre-expiry the missing terminal review is ambiguous (fail closed);
# post-expiry with unknown owner evidence it is a crash-orphan (#702)
# that still fails closed until owner-process exit is proven.
now = datetime(2026, 7, 13, 5, 0, 0, tzinfo=timezone.utc)
expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc)
claim = _lease_comment(expires_at=expires, last_activity=expires - timedelta(hours=1))
result = _assess([claim], formal_reviews=[], now=now)
result = _assess(
[claim], formal_reviews=[], now=now, owner_process_alive=None
)
self.assertFalse(result["cleanup_allowed"])
self.assertEqual(result["classification"], "ambiguous_conflicting_evidence")
self.assertEqual(
result["classification"], "orphaned_expired_superseded_head"
)
fresh_expires = now + timedelta(minutes=60)
fresh = _lease_comment(
expires_at=fresh_expires, last_activity=now - timedelta(minutes=5)
)
pre_expiry = _assess([fresh], formal_reviews=[], now=now)
self.assertFalse(pre_expiry["cleanup_allowed"])
self.assertEqual(
pre_expiry["classification"], "ambiguous_conflicting_evidence"
)
class TestIssue691DiagnoseClassifications(unittest.TestCase):
@@ -0,0 +1,427 @@
"""Review-decision-lock recovery / cross-PR isolation (#693).
Reproduces the PR #688 → PR #692 formal-review sequence:
* durable lock holds REQUEST_CHANGES on open PR #688 @ head A (review_id 425)
* reviewer leases PR #692 @ head B (6d86db…)
* mark_final for #692 must succeed without correction unlock
* cleanup of open #688 terminal remains forbidden (#594)
* authorize_review_correction for #688 cannot unlock #692
* eventual #692 APPROVE is unique and head-bound (review_id 426)
* mark_final is idempotent for same PR/action/head
* diagnosis returns one exact next_action + durable handoff payload
"""
from __future__ import annotations
import os
import unittest
from unittest.mock import patch
import mcp_server
import review_workflow_load
import stale_review_decision_lock as srdl
# PR #688 / #692 incident fixtures (sanitized reconstruction from issue #693)
HEAD_688 = "c7a444eb4b41cf916fdbd20a4999ffd78af496d0"
HEAD_692 = "6d86db793bbdfaed16bd54d93171f1dd66f234ca"
PR_A = 688
PR_B = 692
REVIEW_ID_A = 425
REVIEW_ID_B = 426
RC_688 = {
"pr_number": PR_A,
"action": "request_changes",
"review_id": REVIEW_ID_A,
"review_state": "request_changes",
"head_sha": HEAD_688,
}
APPROVE_692 = {
"pr_number": PR_B,
"action": "approve",
"review_id": REVIEW_ID_B,
"review_state": "approve",
"head_sha": HEAD_692,
}
def _lock(mutations=None, **kwargs):
from datetime import datetime, timezone
now = datetime.now(timezone.utc).isoformat()
base = {
"task": "review_pr",
"remote": "prgs",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
"session_pid": os.getpid(),
"session_profile": "prgs-reviewer",
"session_profile_lock": "prgs-reviewer",
"profile_identity": "prgs-reviewer",
"final_review_decision_ready": False,
"ready_pr_number": None,
"ready_action": None,
"ready_expected_head_sha": None,
"ready_remote": None,
"ready_org": None,
"ready_repo": None,
"live_mutations": list(mutations or []),
"correction_authorized": False,
"correction_reason": None,
"correction_pr_number": None,
"correction_head_sha": None,
"updated_at": now,
"recorded_at": now,
"writer_pid": os.getpid(),
}
base.update(kwargs)
# Ensure TTL fields stay fresh unless the caller is testing expiry.
if "recorded_at" not in kwargs:
base["recorded_at"] = now
if "updated_at" not in kwargs:
base["updated_at"] = now
return base
REVIEWER_ENV = {
"GITEA_PROFILE_NAME": "prgs-reviewer",
"GITEA_ALLOWED_OPERATIONS": (
"gitea.read,gitea.pr.review,gitea.pr.approve,"
"gitea.pr.request_changes,gitea.pr.comment"
),
"GITEA_SESSION_PROFILE_LOCK": "prgs-reviewer",
}
REVIEWER_PROFILE = {
"profile_name": "prgs-reviewer",
"allowed_operations": [
"gitea.read",
"gitea.pr.review",
"gitea.pr.approve",
"gitea.pr.request_changes",
"gitea.pr.comment",
],
"forbidden_operations": ["gitea.pr.merge"],
}
def _seed(mutations=None, **kwargs):
review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT)
mcp_server._save_review_decision_lock(_lock(mutations, **kwargs))
mcp_server.gitea_load_review_workflow()
def _no_lease():
return {"block": False, "reasons": [], "mutation_allowed": True}
def _open_pr(number, head):
return {
"number": number,
"state": "open",
"merged": False,
"merged_at": None,
"head": {"sha": head},
}
class TestCrossPrIsolationPure(unittest.TestCase):
def test_foreign_terminal_does_not_block_mark_boundary(self):
lock = _lock([RC_688])
self.assertFalse(
srdl.prior_live_mutations_block_boundary(
lock, pr_number=PR_B, expected_head_sha=HEAD_692
)
)
self.assertTrue(
srdl.prior_live_mutations_block_boundary(
lock, pr_number=PR_A, expected_head_sha=HEAD_688
)
)
def test_terminal_boundary_allows_fresh_decision_on_other_pr(self):
lock = _lock([RC_688])
self.assertTrue(
srdl.terminal_boundary_allows_fresh_decision(
lock,
pr_number=PR_B,
expected_head_sha=HEAD_692,
operation="mark_ready",
)
)
self.assertFalse(
srdl.terminal_boundary_allows_fresh_decision(
lock,
pr_number=PR_A,
expected_head_sha=HEAD_688,
operation="mark_ready",
)
)
def test_unscoped_correction_does_not_apply(self):
lock = _lock([RC_688], correction_authorized=True, correction_reason="x")
# Missing correction_pr_number → fail closed (#693)
self.assertFalse(
srdl.correction_applies(lock, pr_number=PR_A, expected_head_sha=HEAD_688)
)
def test_scoped_correction_only_for_named_pr(self):
lock = _lock(
[RC_688],
correction_authorized=True,
correction_reason="mistake",
correction_pr_number=PR_A,
correction_head_sha=HEAD_688,
)
self.assertTrue(
srdl.correction_applies(lock, pr_number=PR_A, expected_head_sha=HEAD_688)
)
self.assertFalse(
srdl.correction_applies(lock, pr_number=PR_B, expected_head_sha=HEAD_692)
)
class TestClassification(unittest.TestCase):
def test_foreign_pr_terminal_classification(self):
lock = _lock([RC_688])
c = srdl.classify_review_decision_lock(
lock,
target_pr_number=PR_B,
target_head_sha=HEAD_692,
pr_live=_open_pr(PR_A, HEAD_688),
active_profile_identity="prgs-reviewer",
)
self.assertEqual(c["classification"], srdl.CLASS_FOREIGN_PR_TERMINAL)
self.assertTrue(c["mark_final_allowed"])
self.assertFalse(c["correction_eligible"])
self.assertIn("do not call gitea_authorize_review_correction", c["next_action"])
def test_same_head_terminal_classification(self):
lock = _lock([RC_688])
c = srdl.classify_review_decision_lock(
lock,
target_pr_number=PR_A,
target_head_sha=HEAD_688,
pr_live=_open_pr(PR_A, HEAD_688),
)
self.assertEqual(c["classification"], srdl.CLASS_TERMINAL_ACTIVE_SAME_HEAD)
self.assertFalse(c["mark_final_allowed"])
self.assertTrue(c["correction_eligible"])
def test_precise_failure_includes_durable_handoff(self):
lock = _lock([RC_688])
c = srdl.classify_review_decision_lock(
lock, target_pr_number=PR_A, target_head_sha=HEAD_688
)
fail = srdl.build_precise_mark_final_failure(c)
self.assertIn("exact_next_action", fail)
self.assertIn("durable_issue_handoff", fail)
self.assertTrue(fail["durable_issue_handoff"]["required"])
class TestPr692Sequence(unittest.TestCase):
def setUp(self):
self._env = patch.dict(os.environ, REVIEWER_ENV, clear=False)
self._env.start()
self._prof = patch.object(
mcp_server, "get_profile", return_value=REVIEWER_PROFILE
)
self._prof.start()
def tearDown(self):
self._prof.stop()
self._env.stop()
mcp_server._save_review_decision_lock(None)
review_workflow_load.clear_review_workflow_load()
def test_mark_final_692_succeeds_with_open_688_terminal(self):
_seed([RC_688], writer_pid=25883, session_pid=60615)
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
), patch.object(
mcp_server,
"gitea_check_pr_eligibility",
return_value={"head_sha": HEAD_692, "eligible": True},
):
result = mcp_server.gitea_mark_final_review_decision(
pr_number=PR_B,
action="approve",
expected_head_sha=HEAD_692,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertTrue(result["marked_ready"], result.get("reasons"))
lock = mcp_server._load_review_decision_lock()
self.assertEqual(lock["ready_pr_number"], PR_B)
self.assertEqual(
srdl.normalize_head_sha(lock["ready_expected_head_sha"]), HEAD_692
)
# Foreign terminal history preserved (non-destructive isolation).
self.assertEqual(lock["live_mutations"][0]["pr_number"], PR_A)
def test_mark_final_idempotent_same_binding(self):
_seed(
[RC_688],
final_review_decision_ready=True,
ready_pr_number=PR_B,
ready_action="approve",
ready_expected_head_sha=HEAD_692,
ready_remote="prgs",
ready_org="Scaled-Tech-Consulting",
ready_repo="Gitea-Tools",
)
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
), patch.object(
mcp_server,
"gitea_check_pr_eligibility",
return_value={"head_sha": HEAD_692, "eligible": True},
):
result = mcp_server.gitea_mark_final_review_decision(
pr_number=PR_B,
action="approve",
expected_head_sha=HEAD_692,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertTrue(result["marked_ready"])
self.assertTrue(result.get("idempotent"))
def test_cleanup_still_forbidden_while_688_open(self):
_seed([RC_688])
assessment = srdl.assess_stale_review_decision_lock(
mcp_server._load_review_decision_lock(),
pr_live=_open_pr(PR_A, HEAD_688),
active_profile_identity="prgs-reviewer",
)
self.assertFalse(assessment["cleanup_allowed"])
self.assertFalse(assessment["is_moot"])
def test_correction_cannot_unlock_different_pr(self):
_seed([RC_688])
r = mcp_server.gitea_authorize_review_correction(
prior_review_id=REVIEW_ID_A,
prior_review_state="request_changes",
reason="try unlock 692",
operator_authorized=True,
target_pr_number=PR_B,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
post_audit_comment=False,
)
self.assertFalse(r["authorized"])
self.assertTrue(any("different PR" in x for x in r["reasons"]))
def test_scoped_correction_for_same_pr_posts_audit(self):
_seed([RC_688])
with patch(
"mcp_server._authenticated_username", return_value="sysadmin"
), patch(
"mcp_server.get_auth_header", return_value="Basic dGVzdDp0ZXN0"
), patch(
"mcp_server._auth", return_value="Basic dGVzdDp0ZXN0"
), patch("mcp_server.api_request", return_value={"id": 99901}):
r = mcp_server.gitea_authorize_review_correction(
prior_review_id=REVIEW_ID_A,
prior_review_state="request_changes",
reason="mistaken RC wording",
operator_authorized=True,
target_pr_number=PR_A,
expected_head_sha=HEAD_688,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
post_audit_comment=True,
)
self.assertTrue(r["authorized"], r.get("reasons"))
self.assertEqual(r.get("correction_pr_number"), PR_A)
self.assertEqual(
r.get("audit_comment_id"),
99901,
r.get("audit_comment_skipped") or r.get("audit_comment_error") or r,
)
def test_diagnose_foreign_terminal_next_action(self):
_seed([RC_688])
with patch(
"mcp_server._authenticated_username", return_value="sysadmin"
), patch(
"mcp_server.get_auth_header", return_value="Basic dGVzdDp0ZXN0"
), patch(
"mcp_server._auth", return_value="Basic dGVzdDp0ZXN0"
), patch("mcp_server.api_request", return_value=_open_pr(PR_A, HEAD_688)):
d = mcp_server.gitea_diagnose_review_decision_lock(
target_pr_number=PR_B,
expected_head_sha=HEAD_692,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertTrue(d["success"], d.get("reasons"))
self.assertEqual(d["classification"], srdl.CLASS_FOREIGN_PR_TERMINAL)
self.assertTrue(d["mark_final_allowed"])
self.assertIn("mark_final", d["exact_next_action"])
self.assertTrue(d["correction_as_generic_unlock_forbidden"])
def test_approval_ledger_unique_and_bound_after_sequence(self):
"""After mark_final + record mutation, ledger binds unique approve to 692 head."""
_seed([RC_688])
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
), patch.object(
mcp_server,
"gitea_check_pr_eligibility",
return_value={"head_sha": HEAD_692, "eligible": True},
):
marked = mcp_server.gitea_mark_final_review_decision(
pr_number=PR_B,
action="approve",
expected_head_sha=HEAD_692,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertTrue(marked["marked_ready"])
mcp_server.record_live_review_mutation(PR_B, "approve", REVIEW_ID_B)
lock = mcp_server._load_review_decision_lock()
terminals = [
m
for m in lock["live_mutations"]
if m.get("action") in srdl.TERMINAL_REVIEW_ACTIONS
and m.get("pr_number") == PR_B
]
self.assertEqual(len(terminals), 1)
self.assertEqual(terminals[0]["review_id"], REVIEW_ID_B)
self.assertEqual(
srdl.normalize_head_sha(terminals[0].get("head_sha")), HEAD_692
)
# Same-head duplicate still blocked.
self.assertTrue(
srdl.prior_live_mutations_block_boundary(
lock, pr_number=PR_B, expected_head_sha=HEAD_692
)
)
class TestSessionRestartWriterPid(unittest.TestCase):
def tearDown(self):
mcp_server._save_review_decision_lock(None)
review_workflow_load.clear_review_workflow_load()
def test_writer_pid_change_does_not_inherit_foreign_block(self):
# session_pid 60615 origin, writer later 25883 — still foreign isolation.
_seed([RC_688], session_pid=60615, writer_pid=25883)
self.assertEqual(
mcp_server.terminal_review_hard_stop_reasons(
PR_B, "mark_ready", expected_head_sha=HEAD_692
),
[],
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,693 @@
"""Regression tests for the PR #703 REQUEST_CHANGES findings F1F6 (#702).
Formal review at head 889931d independently reproduced six defects:
F1 stale-binding recovery ran only AFTER the terminal launcher probe in
``gitea_resolve_task_capability``; a worktree removed mid-session wedged
every mutation-task resolution on 'missing cwd' before recovery.
F2 ``_resolve_preflight_workspace_path`` skipped the existence checks the
canonical mutation context applies; a dead binding could produce empty
porcelain and read as a clean workspace.
F3 ``orphaned_expired_superseded_head`` cleanup accepted unknown
``worktree_exists``/``worktree_clean`` evidence.
F4 the 4-hour session-state TTL silently discarded recovery-critical
crash-orphan shadow evidence.
F5 the single same-profile shadow slot let concurrent sessions overwrite
each other's crash evidence.
F6 the environment was cleared BEFORE the audit record was persisted, and
audit-write failure was swallowed.
"""
from __future__ import annotations
import json
import os
import shutil
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import mcp_session_state as mss # noqa: E402
import namespace_workspace_binding as nwb # noqa: E402
import reviewer_pr_lease as leases # noqa: E402
import stale_binding_recovery as sbr # noqa: E402
import mcp_server # noqa: E402
REPO = "Scaled-Tech-Consulting/Gitea-Tools"
OLD_HEAD = "b" * 40
NEW_HEAD = "6" * 40
CONFIG = {
"version": 2,
"contexts": {
"ctx": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
}
},
"profiles": {
"prgs-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "jcwalker3",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": [
"gitea.read", "gitea.issue.create", "gitea.issue.comment",
"gitea.pr.create", "gitea.branch.push",
],
"forbidden_operations": [
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
],
"execution_profile": "prgs-author",
},
},
"rules": {"allow_runtime_switching": False},
}
def _now() -> datetime:
return datetime.now(timezone.utc)
class _CapabilityHarness(unittest.TestCase):
"""Shared config/env plumbing for resolve-capability integration tests."""
def setUp(self):
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {
"host": "gitea.example.com",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
},
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
def tearDown(self):
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
self._dir.cleanup()
def _env(self, **extra):
env = {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": "prgs-author",
"GITEA_TOKEN_AUTHOR": "author-pass",
}
env.update(extra)
return env
def _deleted_worktree(self) -> str:
path = tempfile.mkdtemp(prefix="gitea-removed-worktree-")
shutil.rmtree(path)
return path
class TestF1RecoveryBeforeTerminalProbe(_CapabilityHarness):
"""F1: recovery must run before the terminal cwd probe can wedge."""
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_removed_worktree_recovers_before_probe(self, _auth, _api):
missing = self._deleted_worktree()
env = self._env(
GITEA_ACTIVE_WORKTREE=missing,
GITEA_FORCE_TERMINAL_PROBE="1",
GITEA_FORCE_STALE_BINDING_RECOVERY="1",
)
with patch.dict(os.environ, env):
res = mcp_server.gitea_resolve_task_capability(
task="create_pr", remote="prgs"
)
# The provably-stale binding was cleared before the probe ran.
self.assertNotIn("GITEA_ACTIVE_WORKTREE", os.environ)
self.assertFalse(res.get("terminal_launcher_unhealthy"))
report = res.get("stale_binding_recovery") or {}
self.assertEqual(
report.get("classification"),
sbr.CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
)
self.assertTrue(report.get("recovery_performed"))
self.assertTrue(res.get("allowed_in_current_session"))
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_probe_block_still_carries_recovery_report(self, _auth, _api):
# Recovery disabled (test mode without the force flag) preserves the
# fail-closed probe block, but the block must now carry the
# classification evidence instead of hiding it.
missing = self._deleted_worktree()
env = self._env(
GITEA_ACTIVE_WORKTREE=missing,
GITEA_FORCE_TERMINAL_PROBE="1",
)
with patch.dict(os.environ, env):
res = mcp_server.gitea_resolve_task_capability(
task="create_pr", remote="prgs"
)
self.assertIn("GITEA_ACTIVE_WORKTREE", os.environ)
self.assertTrue(res.get("terminal_launcher_unhealthy"))
report = res.get("stale_binding_recovery") or {}
self.assertEqual(
report.get("classification"),
sbr.CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
)
self.assertFalse(report.get("recovery_performed"))
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_unverified_binding_is_never_cleared_by_reordering(self, _auth, _api):
# F1 must not weaken authorization: an existing, uncorroborated
# binding survives resolution untouched even with recovery forced.
existing = tempfile.mkdtemp(prefix="gitea-existing-worktree-")
self.addCleanup(shutil.rmtree, existing, ignore_errors=True)
env = self._env(
GITEA_ACTIVE_WORKTREE=existing,
GITEA_FORCE_TERMINAL_PROBE="1",
GITEA_FORCE_STALE_BINDING_RECOVERY="1",
)
with patch.dict(os.environ, env):
res = mcp_server.gitea_resolve_task_capability(
task="create_pr", remote="prgs"
)
self.assertEqual(os.environ.get("GITEA_ACTIVE_WORKTREE"), existing)
report = res.get("stale_binding_recovery") or {}
self.assertFalse(report.get("recovery_performed"))
class TestF2PreflightPathVerification(_CapabilityHarness):
"""F2: preflight and mutation contexts verify resolved paths alike."""
def test_preflight_and_mutation_context_agree_on_dead_binding(self):
missing = self._deleted_worktree()
env = self._env(GITEA_ACTIVE_WORKTREE=missing)
with patch.dict(os.environ, env):
preflight = mcp_server._resolve_preflight_workspace_path(None)
mutation = mcp_server._resolve_namespace_mutation_context(None)
self.assertEqual(preflight, mutation["workspace_path"])
self.assertNotEqual(preflight, os.path.realpath(missing))
def test_missing_explicit_worktree_never_reads_clean(self):
missing = self._deleted_worktree()
with patch.dict(os.environ, self._env()):
porcelain = mcp_server._get_workspace_porcelain(worktree_path=missing)
self.assertEqual(
porcelain, mcp_server.PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN
)
# The sentinel parses as a tracked dirty entry — never clean.
self.assertTrue(mcp_server._parse_porcelain_entries(porcelain))
def test_workspace_deleted_during_evaluation_never_reads_clean(self):
# TOCTOU: resolution succeeded, then the path vanished before git ran.
missing = self._deleted_worktree()
with patch.dict(os.environ, self._env()):
with patch.object(
mcp_server,
"_resolve_preflight_workspace_path",
return_value=missing,
):
porcelain = mcp_server._get_workspace_porcelain()
self.assertEqual(
porcelain, mcp_server.PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN
)
def test_git_failure_never_reads_clean(self):
class _Failed:
returncode = 128
stdout = ""
stderr = "fatal: not a git repository"
with patch.dict(os.environ, self._env()):
with patch.object(
mcp_server.subprocess, "run", return_value=_Failed()
):
porcelain = mcp_server._get_workspace_porcelain()
self.assertEqual(
porcelain, mcp_server.PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN
)
def test_symlinked_binding_to_deleted_target_is_demoted(self):
target = tempfile.mkdtemp(prefix="gitea-symlink-target-")
holder = tempfile.mkdtemp(prefix="gitea-symlink-holder-")
self.addCleanup(shutil.rmtree, holder, ignore_errors=True)
link = os.path.join(holder, "worktree-link")
os.symlink(target, link)
shutil.rmtree(target)
demotions: list[str] = []
_path, source = nwb.resolve_namespace_workspace(
role_kind="reviewer",
process_project_root=os.getcwd(),
env={"GITEA_ACTIVE_WORKTREE": link},
demotions=demotions,
verify_paths=True,
)
self.assertEqual(source, "MCP server process root (default)")
self.assertEqual(len(demotions), 1)
def test_dead_binding_falls_through_to_live_role_binding(self):
# Path changes during evaluation: the dead candidate demotes at
# verification time and never resurrects over the live fallback.
alive = tempfile.mkdtemp(prefix="gitea-alive-worktree-")
self.addCleanup(shutil.rmtree, alive, ignore_errors=True)
missing = self._deleted_worktree()
path, source = nwb.resolve_namespace_workspace(
role_kind="reviewer",
process_project_root=os.getcwd(),
env={
"GITEA_ACTIVE_WORKTREE": missing,
"GITEA_REVIEWER_WORKTREE": alive,
},
verify_paths=True,
)
self.assertEqual(path, os.path.realpath(alive))
self.assertEqual(
source, "GITEA_REVIEWER_WORKTREE environment variable"
)
class TestF3AffirmativeWorktreeEvidence(unittest.TestCase):
"""F3: unknown worktree evidence must fail closed for crash orphans."""
def _comments(self) -> list[dict]:
stale = _now() - timedelta(hours=3)
body = leases.format_lease_body(
repo=REPO,
pr_number=701,
issue_number=699,
reviewer_identity="sysadmin",
profile="prgs-reviewer",
session_id="65664-4f248d213d60",
worktree="branches/review-pr-654",
phase="validation-start",
candidate_head=OLD_HEAD,
target_branch="master",
target_branch_sha="2" * 40,
last_activity=stale,
expires_at=stale + timedelta(minutes=120),
)
return [{
"id": 11131,
"body": body,
"user": {"login": "sysadmin"},
"author": "sysadmin",
"created_at": stale.isoformat(),
"updated_at": stale.isoformat(),
}]
def _assess(self, **kwargs):
defaults = dict(
pr_number=701,
current_head_sha=NEW_HEAD,
formal_reviews=[],
repo=REPO,
expected_repo=REPO,
requesting_session_id="fresh-controller",
controller_recovery_authorized=True,
owner_process_alive=False,
)
defaults.update(kwargs)
return leases.assess_obsolete_reviewer_comment_lease_cleanup(
self._comments(), **defaults
)
def test_unknown_worktree_evidence_fails_closed(self):
result = self._assess(worktree_exists=None, worktree_clean=None)
self.assertEqual(
result["classification"], "orphaned_expired_superseded_head"
)
self.assertFalse(result["cleanup_allowed"])
self.assertIn(
"affirmative safe evidence",
" ".join(result["fail_closed_reasons"]),
)
def test_unknown_cleanliness_with_existing_worktree_fails_closed(self):
result = self._assess(worktree_exists=True, worktree_clean=None)
self.assertFalse(result["cleanup_allowed"])
def test_affirmative_clean_worktree_is_eligible(self):
result = self._assess(worktree_exists=True, worktree_clean=True)
self.assertEqual(
result["classification"], "orphaned_expired_superseded_head"
)
self.assertTrue(result["cleanup_allowed"])
def test_affirmative_absent_worktree_is_eligible(self):
result = self._assess(worktree_exists=False, worktree_clean=None)
self.assertTrue(result["cleanup_allowed"])
def test_matrix_matches_diagnosis_gate(self):
# The cleanup matrix and the diagnosis path must agree: unknown
# evidence yields acquire-only, affirmative evidence yields cleanup.
diagnosis_unknown = leases.diagnose_reviewer_pr_lease_handoff(
self._comments(),
pr_number=701,
current_session_id=None,
current_reviewer_identity="fresh-reviewer",
current_head_sha=NEW_HEAD,
formal_reviews=[],
owner_process_alive=False,
worktree_exists=None,
worktree_clean=None,
)
assess_unknown = self._assess(worktree_exists=None, worktree_clean=None)
self.assertNotEqual(
diagnosis_unknown["next_action"],
"cleanup_obsolete_reviewer_comment_lease",
)
self.assertFalse(assess_unknown["cleanup_allowed"])
class TestF4CrashShadowDurability(unittest.TestCase):
"""F4: crash-orphan evidence survives the former 4h TTL boundary."""
SID = "82984-abcabcabcabc"
def setUp(self):
self._dir = tempfile.TemporaryDirectory()
self._env = patch.dict(
os.environ, {"GITEA_MCP_SESSION_STATE_DIR": self._dir.name}
)
self._env.start()
leases._SESSION_LEASE = None
def tearDown(self):
self._env.stop()
self._dir.cleanup()
leases._SESSION_LEASE = None
def _age_record(self, kind: str, hours: float, instance_id: str | None = None):
path = mss.state_file_path(kind=kind, instance_id=instance_id)
with open(path, encoding="utf-8") as fh:
envelope = json.load(fh)
stamp = (
(_now() - timedelta(hours=hours)).isoformat().replace("+00:00", "Z")
)
envelope["recorded_at"] = stamp
envelope["updated_at"] = stamp
envelope["payload"]["recorded_at"] = stamp
envelope["payload"]["updated_at"] = stamp
with open(path, "w", encoding="utf-8") as fh:
json.dump(envelope, fh)
def _save_shadow(self) -> None:
mss.save_state(
kind=mss.KIND_REVIEWER_SESSION_LEASE,
instance_id=self.SID,
payload={"session_id": self.SID, "owner_pid": os.getpid()},
)
def _load_shadow(self):
return mss.load_state(
kind=mss.KIND_REVIEWER_SESSION_LEASE, instance_id=self.SID
)
def test_shadow_loads_before_former_ttl_boundary(self):
self._save_shadow()
self._age_record(
mss.KIND_REVIEWER_SESSION_LEASE, hours=3.9, instance_id=self.SID
)
self.assertIsNotNone(self._load_shadow())
def test_shadow_loads_at_former_ttl_boundary(self):
self._save_shadow()
self._age_record(
mss.KIND_REVIEWER_SESSION_LEASE, hours=4.0, instance_id=self.SID
)
self.assertIsNotNone(self._load_shadow())
def test_shadow_loads_long_after_former_ttl_boundary(self):
self._save_shadow()
self._age_record(
mss.KIND_REVIEWER_SESSION_LEASE, hours=27.0, instance_id=self.SID
)
record = self._load_shadow()
self.assertIsNotNone(record)
self.assertEqual(record.get("session_id"), self.SID)
def test_stale_binding_audit_record_is_also_durable(self):
mss.save_state(
kind=mss.KIND_STALE_BINDING_RECOVERY,
payload={"cleared_env": "GITEA_ACTIVE_WORKTREE"},
)
self._age_record(mss.KIND_STALE_BINDING_RECOVERY, hours=27.0)
self.assertIsNotNone(
mss.load_state(kind=mss.KIND_STALE_BINDING_RECOVERY)
)
def test_non_recovery_kinds_keep_ttl(self):
mss.save_state(
kind=mss.KIND_WORKFLOW_LOAD, payload={"workflow_hash": "abc"}
)
self._age_record(mss.KIND_WORKFLOW_LOAD, hours=5.0)
self.assertIsNone(mss.load_state(kind=mss.KIND_WORKFLOW_LOAD))
def test_sanctioned_clear_is_the_terminal_reconciliation(self):
self._save_shadow()
self._age_record(
mss.KIND_REVIEWER_SESSION_LEASE, hours=27.0, instance_id=self.SID
)
mss.clear_state(
kind=mss.KIND_REVIEWER_SESSION_LEASE, instance_id=self.SID
)
self.assertIsNone(self._load_shadow())
self.assertEqual(
mss.list_states(kind=mss.KIND_REVIEWER_SESSION_LEASE), []
)
def test_crash_orphan_recovery_works_after_former_ttl(self):
# End to end: a crashed session's shadow still proves owner exit a
# day later.
leases.record_session_lease(
{
"pr_number": 701,
"session_id": self.SID,
"worktree": "branches/review-pr-654",
"candidate_head": OLD_HEAD,
"repo": REPO,
"comment_id": 11131,
"profile": "prgs-reviewer",
"reviewer_identity": "sysadmin",
"phase": "claimed",
},
lease_provenance={"source": "acquire", "comment_id": 11131},
)
leases._SESSION_LEASE = None # simulated crash: no sanctioned clear
self._age_record(
mss.KIND_REVIEWER_SESSION_LEASE, hours=27.0, instance_id=self.SID
)
shadow = leases.load_session_lease_shadow(session_id=self.SID)
self.assertIsNotNone(shadow)
verdict = leases.assess_crashed_session_lease_orphan(
shadow,
lease={"session_id": self.SID},
current_pid=os.getpid() + 1,
pid_alive=False,
)
self.assertTrue(verdict["orphan_evidence"])
self.assertIs(verdict["owner_process_alive"], False)
class TestF5ConcurrentSessionShadows(unittest.TestCase):
"""F5: concurrent same-profile sessions keep distinct shadow records."""
SID_A = "11111-aaaaaaaaaaaa"
SID_B = "22222-bbbbbbbbbbbb"
def setUp(self):
self._dir = tempfile.TemporaryDirectory()
self._env = patch.dict(
os.environ, {"GITEA_MCP_SESSION_STATE_DIR": self._dir.name}
)
self._env.start()
leases._SESSION_LEASE = None
def tearDown(self):
self._env.stop()
self._dir.cleanup()
leases._SESSION_LEASE = None
def _lease(self, session_id: str, pr_number: int, head: str) -> dict:
return {
"pr_number": pr_number,
"session_id": session_id,
"worktree": f"branches/review-pr-{pr_number}-{session_id[:5]}",
"candidate_head": head,
"repo": REPO,
"comment_id": 11221,
"profile": "prgs-reviewer",
"reviewer_identity": "sysadmin",
"phase": "claimed",
}
def _record(self, lease: dict) -> None:
leases.record_session_lease(
lease,
lease_provenance={"source": "acquire", "comment_id": 11221},
)
def test_interleaved_sessions_do_not_overwrite_each_other(self):
self._record(self._lease(self.SID_A, 703, OLD_HEAD))
self._record(self._lease(self.SID_B, 701, NEW_HEAD))
# Session A heartbeats again after B wrote.
updated_a = self._lease(self.SID_A, 703, OLD_HEAD)
updated_a["phase"] = "validation-start"
self._record(updated_a)
shadow_a = leases.load_session_lease_shadow(session_id=self.SID_A)
shadow_b = leases.load_session_lease_shadow(session_id=self.SID_B)
self.assertEqual(shadow_a.get("session_id"), self.SID_A)
self.assertEqual(shadow_a.get("pr_number"), 703)
self.assertEqual(shadow_a.get("phase"), "validation-start")
self.assertEqual(shadow_b.get("session_id"), self.SID_B)
self.assertEqual(shadow_b.get("pr_number"), 701)
self.assertEqual(shadow_b.get("candidate_head"), NEW_HEAD)
def test_shadow_lookup_never_misattributes(self):
self._record(self._lease(self.SID_A, 703, OLD_HEAD))
self.assertIsNone(
leases.load_session_lease_shadow(session_id=self.SID_B)
)
verdict = leases.assess_crashed_session_lease_orphan(
leases.load_session_lease_shadow(session_id=self.SID_A),
lease={"session_id": self.SID_B},
pid_alive=False,
)
self.assertFalse(verdict["orphan_evidence"])
def test_clearing_one_session_preserves_the_other(self):
self._record(self._lease(self.SID_A, 703, OLD_HEAD))
self._record(self._lease(self.SID_B, 701, NEW_HEAD))
# Sanctioned clear of B (the currently recorded in-memory lease).
leases.clear_session_lease()
self.assertIsNone(
leases.load_session_lease_shadow(session_id=self.SID_B)
)
shadow_a = leases.load_session_lease_shadow(session_id=self.SID_A)
self.assertIsNotNone(shadow_a)
self.assertEqual(shadow_a.get("session_id"), self.SID_A)
def test_reconnected_process_recovers_by_session_id(self):
self._record(self._lease(self.SID_A, 703, OLD_HEAD))
self._record(self._lease(self.SID_B, 701, NEW_HEAD))
# Simulated crash + reconnect: in-memory state is gone, durable
# records remain enumerable and individually attributable.
leases._SESSION_LEASE = None
records = mss.list_states(kind=mss.KIND_REVIEWER_SESSION_LEASE)
self.assertEqual(
sorted(r.get("session_id") for r in records),
sorted([self.SID_A, self.SID_B]),
)
shadow = leases.load_session_lease_shadow(session_id=self.SID_B)
self.assertEqual(shadow.get("pr_number"), 701)
def test_legacy_single_slot_record_still_resolves_by_session_id(self):
# Upgrade path: a record written by pre-F5 code (no instance key)
# remains usable when its payload names the requested session.
mss.save_state(
kind=mss.KIND_REVIEWER_SESSION_LEASE,
payload={"session_id": self.SID_A, "owner_pid": os.getpid()},
)
shadow = leases.load_session_lease_shadow(session_id=self.SID_A)
self.assertIsNotNone(shadow)
self.assertIsNone(
leases.load_session_lease_shadow(session_id=self.SID_B)
)
class TestF6AuditBeforeClear(_CapabilityHarness):
"""F6: the durable audit record is persisted before the env clears."""
def _recovery_env(self, missing: str) -> dict:
return self._env(
GITEA_ACTIVE_WORKTREE=missing,
GITEA_FORCE_STALE_BINDING_RECOVERY="1",
)
def test_audit_write_failure_blocks_the_clear(self):
missing = self._deleted_worktree()
with patch.dict(os.environ, self._recovery_env(missing)):
with patch.object(
mss, "save_state", side_effect=OSError("disk full")
):
report = mcp_server._assess_stale_active_binding(
auto_recover=True
)
self.assertEqual(os.environ.get("GITEA_ACTIVE_WORKTREE"), missing)
self.assertFalse(report["recovery_performed"])
self.assertIs(report.get("audit_persisted"), False)
self.assertTrue(report.get("audit_write_failed"))
self.assertIn("NOT cleared", " ".join(report.get("reasons") or []))
def test_retry_after_audit_failure_recovers(self):
missing = self._deleted_worktree()
with patch.dict(os.environ, self._recovery_env(missing)):
with patch.object(
mss, "save_state", side_effect=OSError("disk full")
):
first = mcp_server._assess_stale_active_binding(
auto_recover=True
)
self.assertFalse(first["recovery_performed"])
# Persistence recovered; the retry clears with a durable audit.
second = mcp_server._assess_stale_active_binding(auto_recover=True)
self.assertTrue(second["recovery_performed"])
self.assertIs(second.get("audit_persisted"), True)
self.assertNotIn("GITEA_ACTIVE_WORKTREE", os.environ)
audit = mss.load_state(kind=mss.KIND_STALE_BINDING_RECOVERY)
self.assertIsNotNone(audit)
self.assertEqual(audit.get("recovery_status"), "performed")
self.assertEqual(audit.get("cleared_value"), missing)
def test_audit_record_exists_before_env_clear(self):
missing = self._deleted_worktree()
observed: list[tuple[str | None, str | None]] = []
real_save = mss.save_state
def _spy(**kwargs):
observed.append(
(
(kwargs.get("payload") or {}).get("recovery_status"),
os.environ.get("GITEA_ACTIVE_WORKTREE"),
)
)
return real_save(**kwargs)
with patch.dict(os.environ, self._recovery_env(missing)):
with patch.object(mss, "save_state", side_effect=_spy):
report = mcp_server._assess_stale_active_binding(
auto_recover=True
)
self.assertTrue(report["recovery_performed"])
self.assertGreaterEqual(len(observed), 2)
pending_status, env_at_pending = observed[0]
performed_status, env_at_performed = observed[-1]
# Ordering proof: the binding was still present while the pending
# audit persisted, and gone by the time the performed status wrote.
self.assertEqual(pending_status, "pending")
self.assertEqual(env_at_pending, missing)
self.assertEqual(performed_status, "performed")
self.assertIsNone(env_at_performed)
def test_recovery_is_idempotent_after_success(self):
missing = self._deleted_worktree()
with patch.dict(os.environ, self._recovery_env(missing)):
first = mcp_server._assess_stale_active_binding(auto_recover=True)
second = mcp_server._assess_stale_active_binding(auto_recover=True)
self.assertTrue(first["recovery_performed"])
self.assertEqual(second["classification"], sbr.CLASSIFICATION_UNBOUND)
self.assertFalse(second["recovery_performed"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,517 @@
"""Regression tests for #702: stale runtime binding + orphaned durable lease.
Reproduces the PR #701 incident (lease 65664-4f248d213d60, comments
11129/11131): the MCP daemon crashed without teardown, destroying the
in-memory session lease while the durable comment lease survived, and the
auto-reconnected daemon inherited a stale ``GITEA_ACTIVE_WORKTREE`` binding
(``branches/review-pr-654``) from its parent environment.
Covers the five mandated scenarios:
1. lost in-session leases durable shadow survives a crash and yields
provable owner-process evidence
2. old-head leases expired superseded-head lease with no terminal
review becomes recoverable only with orphan evidence + controller authority
3. runtime/worktree mismatch env bindings to deleted worktrees are demoted,
never silently bound
4. managed reconnect boot-inherited uncorroborated bindings are
surfaced for managed recovery; provably stale ones are clear-eligible
5. safe expiry pre-expiry stays fail-closed wait (no steal);
canonical expiry unlocks acquisition and guarded cleanup
"""
from __future__ import annotations
import os
import sys
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import namespace_workspace_binding as nwb # noqa: E402
import reviewer_pr_lease as leases # noqa: E402
import stale_binding_recovery as sbr # noqa: E402
# PR #701 incident fixtures.
OLD_HEAD = "b4d0cb22e452a07c8e816745c704ddb0f43edf0b"
NEW_HEAD = "6b675f5c834b41f9d74e8a54294ff44dddf28ae4"
CRASHED_SESSION = "65664-4f248d213d60"
LEASE_COMMENT = 11131
REPO = "Scaled-Tech-Consulting/Gitea-Tools"
PR = 701
ISSUE = 699
LEASE_WORKTREE = "branches/review-fix-issue-699-structured-auth-mcp-errors"
def _now() -> datetime:
return datetime.now(timezone.utc)
def _lease_comment(
*,
phase: str = "validation-start",
candidate_head: str = OLD_HEAD,
session_id: str = CRASHED_SESSION,
comment_id: int = LEASE_COMMENT,
last_activity: datetime | None = None,
expires_at: datetime | None = None,
worktree: str = LEASE_WORKTREE,
) -> dict:
stamp = last_activity or _now()
body = leases.format_lease_body(
repo=REPO,
pr_number=PR,
issue_number=ISSUE,
reviewer_identity="sysadmin",
profile="prgs-reviewer",
session_id=session_id,
worktree=worktree,
phase=phase,
candidate_head=candidate_head,
target_branch="master",
target_branch_sha="2" * 40,
last_activity=stamp,
expires_at=expires_at,
)
return {
"id": comment_id,
"body": body,
"user": {"login": "sysadmin"},
"author": "sysadmin",
"created_at": stamp.isoformat(),
"updated_at": stamp.isoformat(),
}
def _expired_superseded_comments() -> list[dict]:
stale = _now() - timedelta(hours=3)
return [_lease_comment(
last_activity=stale,
expires_at=stale + timedelta(minutes=120),
)]
def _fresh_superseded_comments() -> list[dict]:
recent = _now() - timedelta(minutes=10)
return [_lease_comment(
last_activity=recent,
expires_at=recent + timedelta(minutes=120),
)]
class TestLostInSessionLeaseShadow(unittest.TestCase):
"""Scenario 1: the durable shadow survives a daemon crash (#702 AC3)."""
def setUp(self):
leases._SESSION_LEASE = None
def tearDown(self):
leases._SESSION_LEASE = None
def _record(self) -> dict:
return leases.record_session_lease(
{
"pr_number": PR,
"issue_number": ISSUE,
"session_id": CRASHED_SESSION,
"reviewer_identity": "sysadmin",
"profile": "prgs-reviewer",
"worktree": LEASE_WORKTREE,
"phase": "claimed",
"candidate_head": OLD_HEAD,
"repo": REPO,
"comment_id": LEASE_COMMENT,
},
lease_provenance={"source": "acquire", "comment_id": LEASE_COMMENT},
)
def test_shadow_written_on_record_and_survives_simulated_crash(self):
self._record()
# Simulated unhandled crash: the in-memory dict dies with the process
# but the sanctioned clear path never runs.
leases._SESSION_LEASE = None
shadow = leases.load_session_lease_shadow()
self.assertIsNotNone(shadow)
self.assertEqual(shadow.get("session_id"), CRASHED_SESSION)
self.assertEqual(shadow.get("pr_number"), PR)
self.assertEqual(int(shadow.get("owner_pid")), os.getpid())
def test_sanctioned_clear_removes_shadow(self):
self._record()
leases.clear_session_lease()
self.assertIsNone(leases.load_session_lease_shadow())
def test_orphan_assessment_dead_owner_pid_proves_exit(self):
self._record()
leases._SESSION_LEASE = None
shadow = leases.load_session_lease_shadow()
lease = {"session_id": CRASHED_SESSION}
verdict = leases.assess_crashed_session_lease_orphan(
shadow, lease=lease, current_pid=os.getpid() + 1, pid_alive=False
)
self.assertTrue(verdict["orphan_evidence"])
self.assertIs(verdict["owner_process_alive"], False)
def test_orphan_assessment_alive_pid_is_not_ownership_proof(self):
self._record()
shadow = leases.load_session_lease_shadow()
verdict = leases.assess_crashed_session_lease_orphan(
shadow,
lease={"session_id": CRASHED_SESSION},
current_pid=os.getpid() + 1,
pid_alive=True,
)
self.assertFalse(verdict["orphan_evidence"])
self.assertIsNone(verdict["owner_process_alive"])
def test_orphan_assessment_session_mismatch_proves_nothing(self):
self._record()
shadow = leases.load_session_lease_shadow()
verdict = leases.assess_crashed_session_lease_orphan(
shadow, lease={"session_id": "other-999"}, pid_alive=False
)
self.assertFalse(verdict["orphan_evidence"])
self.assertIsNone(verdict["owner_process_alive"])
def test_orphan_assessment_without_shadow_is_unknown(self):
verdict = leases.assess_crashed_session_lease_orphan(
None, lease={"session_id": CRASHED_SESSION}
)
self.assertFalse(verdict["orphan_evidence"])
self.assertIsNone(verdict["owner_process_alive"])
class TestOldHeadLeaseCleanup(unittest.TestCase):
"""Scenario 2: expired superseded-head lease with no terminal review."""
def _assess(self, comments, **kwargs):
defaults = dict(
pr_number=PR,
current_head_sha=NEW_HEAD,
formal_reviews=[],
repo=REPO,
expected_repo=REPO,
requesting_session_id="fresh-controller",
controller_recovery_authorized=True,
worktree_exists=True,
worktree_clean=True,
owner_process_alive=False,
)
defaults.update(kwargs)
return leases.assess_obsolete_reviewer_comment_lease_cleanup(
comments, **defaults
)
def test_expired_orphan_with_full_evidence_is_cleanup_eligible(self):
result = self._assess(_expired_superseded_comments())
self.assertEqual(result["classification"], "orphaned_expired_superseded_head")
self.assertTrue(result["cleanup_allowed"])
self.assertIn("phase: released", result["release_body"] or "")
self.assertEqual(
result["exact_next_action"], "cleanup_obsolete_reviewer_comment_lease"
)
def test_expired_orphan_without_owner_evidence_fails_closed(self):
result = self._assess(
_expired_superseded_comments(), owner_process_alive=None
)
self.assertFalse(result["cleanup_allowed"])
self.assertIn(
"provable owner-process-exit evidence",
" ".join(result["fail_closed_reasons"]),
)
def test_expired_orphan_without_controller_authority_fails_closed(self):
result = self._assess(
_expired_superseded_comments(), controller_recovery_authorized=False
)
self.assertFalse(result["cleanup_allowed"])
def test_expired_orphan_with_dirty_worktree_fails_closed(self):
result = self._assess(
_expired_superseded_comments(),
worktree_clean=False,
worktree_has_unpreserved_work=True,
)
self.assertFalse(result["cleanup_allowed"])
def test_unexpired_superseded_no_terminal_stays_ambiguous_wait(self):
# Regression guard: pre-expiry there is still no steal path.
result = self._assess(_fresh_superseded_comments())
self.assertEqual(result["classification"], "ambiguous_conflicting_evidence")
self.assertFalse(result["cleanup_allowed"])
self.assertEqual(result["exact_next_action"], "wait")
class TestRuntimeWorktreeMismatch(unittest.TestCase):
"""Scenario 3: env bindings to deleted worktrees are demoted (#702 AC2)."""
def test_missing_active_env_binding_is_demoted(self):
demotions: list[str] = []
missing = "/nonexistent/branches/review-pr-654"
path, source = nwb.resolve_namespace_workspace(
role_kind="reviewer",
process_project_root=os.getcwd(),
env={"GITEA_ACTIVE_WORKTREE": missing},
demotions=demotions,
verify_paths=True,
)
self.assertEqual(source, "MCP server process root (default)")
self.assertEqual(len(demotions), 1)
self.assertIn("no longer exists", demotions[0])
def test_missing_active_falls_through_to_existing_role_env(self):
existing = os.getcwd()
path, source = nwb.resolve_namespace_workspace(
role_kind="reviewer",
process_project_root="/tmp",
env={
"GITEA_ACTIVE_WORKTREE": "/nonexistent/branches/review-pr-654",
"GITEA_REVIEWER_WORKTREE": existing,
},
verify_paths=True,
)
self.assertEqual(path, os.path.realpath(existing))
self.assertEqual(source, "GITEA_REVIEWER_WORKTREE environment variable")
def test_existing_active_env_binding_still_wins(self):
existing = os.getcwd()
path, source = nwb.resolve_namespace_workspace(
role_kind="reviewer",
process_project_root="/tmp",
env={"GITEA_ACTIVE_WORKTREE": existing},
verify_paths=True,
)
self.assertEqual(path, os.path.realpath(existing))
self.assertEqual(source, "GITEA_ACTIVE_WORKTREE environment variable")
def test_explicit_worktree_path_argument_is_never_demoted(self):
missing = "/nonexistent/branches/explicit"
path, source = nwb.resolve_namespace_workspace(
role_kind="reviewer",
worktree_path=missing,
process_project_root="/tmp",
env={},
verify_paths=True,
)
self.assertEqual(source, "worktree_path argument")
def test_mutation_context_reports_demotion_in_ignored_bindings(self):
ctx = nwb.resolve_namespace_mutation_context(
role_kind="reviewer",
worktree_path=None,
process_project_root=os.getcwd(),
env={"GITEA_ACTIVE_WORKTREE": "/nonexistent/branches/review-pr-654"},
)
joined = " ".join(ctx["ignored_bindings"])
self.assertIn("stale binding, #702", joined)
class TestManagedReconnectRecovery(unittest.TestCase):
"""Scenario 4: boot-inherited bindings and the sanctioned recovery plan."""
def test_uncorroborated_inherited_reviewer_binding_is_unverified(self):
classification = sbr.classify_active_worktree_binding(
active_value=os.getcwd(),
role_env_value=None,
session_lease_worktree=None,
boot_inherited=True,
path_exists=True,
role_kind="reviewer",
)
self.assertEqual(
classification["classification"], sbr.CLASSIFICATION_UNVERIFIED_INHERITED
)
self.assertFalse(classification["clear_eligible"])
plan = sbr.plan_recovery(classification)
self.assertFalse(plan["clear_allowed"])
self.assertIn("managed", plan["exact_next_action"])
self.assertIn("do not clear or rewrite", plan["exact_next_action"])
def test_missing_path_binding_is_provably_stale_and_clear_eligible(self):
classification = sbr.classify_active_worktree_binding(
active_value="/nonexistent/branches/review-pr-654",
boot_inherited=True,
path_exists=False,
role_kind="reviewer",
)
self.assertEqual(
classification["classification"],
sbr.CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
)
plan = sbr.plan_recovery(classification)
self.assertTrue(plan["clear_allowed"])
def test_inherited_binding_superseded_by_session_lease_is_clear_eligible(self):
classification = sbr.classify_active_worktree_binding(
active_value="/tmp",
session_lease_worktree=os.getcwd(),
boot_inherited=True,
path_exists=True,
role_kind="reviewer",
)
self.assertEqual(
classification["classification"],
sbr.CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE,
)
self.assertTrue(sbr.plan_recovery(classification)["clear_allowed"])
def test_post_boot_binding_conflict_is_surfaced_not_cleared(self):
classification = sbr.classify_active_worktree_binding(
active_value="/tmp",
session_lease_worktree=os.getcwd(),
boot_inherited=False,
path_exists=True,
role_kind="reviewer",
)
self.assertEqual(
classification["classification"], sbr.CLASSIFICATION_UNVERIFIED_INHERITED
)
self.assertFalse(sbr.plan_recovery(classification)["clear_allowed"])
def test_corroborated_bindings_are_untouched(self):
for kwargs in (
dict(role_env_value=os.getcwd()),
dict(session_lease_worktree=os.getcwd()),
):
classification = sbr.classify_active_worktree_binding(
active_value=os.getcwd(),
boot_inherited=True,
path_exists=True,
role_kind="reviewer",
**kwargs,
)
self.assertEqual(
classification["classification"], sbr.CLASSIFICATION_CORROBORATED
)
self.assertFalse(sbr.plan_recovery(classification)["clear_allowed"])
def test_author_inherited_binding_stays_corroborated(self):
classification = sbr.classify_active_worktree_binding(
active_value=os.getcwd(),
boot_inherited=True,
path_exists=True,
role_kind="author",
)
self.assertEqual(
classification["classification"], sbr.CLASSIFICATION_CORROBORATED
)
def test_apply_recovery_clears_env_only_for_authorized_plan(self):
env = {"GITEA_ACTIVE_WORKTREE": "/nonexistent/branches/review-pr-654"}
plan = sbr.plan_recovery(
sbr.classify_active_worktree_binding(
active_value=env["GITEA_ACTIVE_WORKTREE"],
boot_inherited=True,
path_exists=False,
role_kind="reviewer",
)
)
applied = sbr.apply_recovery(plan, env=env)
self.assertTrue(applied["performed"])
self.assertNotIn("GITEA_ACTIVE_WORKTREE", env)
self.assertIn("review-pr-654", applied["cleared_value"])
def test_apply_recovery_refuses_unauthorized_plan(self):
env = {"GITEA_ACTIVE_WORKTREE": os.getcwd()}
plan = sbr.plan_recovery(
sbr.classify_active_worktree_binding(
active_value=env["GITEA_ACTIVE_WORKTREE"],
boot_inherited=True,
path_exists=True,
role_kind="reviewer",
)
)
applied = sbr.apply_recovery(plan, env=env)
self.assertFalse(applied["performed"])
self.assertIn("GITEA_ACTIVE_WORKTREE", env)
class TestSafeExpiryDiagnosis(unittest.TestCase):
"""Scenario 5: canonical expiry flips wait into acquire/guarded cleanup."""
def _diagnose(self, comments, **kwargs):
defaults = dict(
pr_number=PR,
current_session_id=None,
current_reviewer_identity="fresh-reviewer",
current_head_sha=NEW_HEAD,
formal_reviews=[],
)
defaults.update(kwargs)
return leases.diagnose_reviewer_pr_lease_handoff(comments, **defaults)
def setUp(self):
leases._SESSION_LEASE = None
def test_pre_expiry_superseded_no_terminal_stays_wait(self):
diagnosis = self._diagnose(_fresh_superseded_comments())
self.assertEqual(
diagnosis["classification"], "ambiguous_conflicting_evidence"
)
self.assertEqual(diagnosis["next_action"], "wait")
def test_post_expiry_without_orphan_evidence_unlocks_acquire(self):
diagnosis = self._diagnose(_expired_superseded_comments())
self.assertEqual(
diagnosis["classification"], "orphaned_expired_superseded_head"
)
self.assertEqual(diagnosis["next_action"], "acquire")
def test_post_expiry_with_orphan_evidence_offers_guarded_cleanup(self):
diagnosis = self._diagnose(
_expired_superseded_comments(),
owner_process_alive=False,
worktree_clean=True,
worktree_exists=True,
)
self.assertEqual(
diagnosis["classification"], "orphaned_expired_superseded_head"
)
self.assertEqual(
diagnosis["next_action"], "cleanup_obsolete_reviewer_comment_lease"
)
def test_expired_lease_no_longer_blocks_fresh_acquisition(self):
assessment = leases.assess_acquire_lease(
_expired_superseded_comments(),
pr_number=PR,
reviewer_identity="fresh-reviewer",
profile="prgs-reviewer",
session_id="99999-fresh",
repo=REPO,
issue_number=ISSUE,
worktree="branches/review-pr-701-fresh",
candidate_head=NEW_HEAD,
target_branch="master",
target_branch_sha="2" * 40,
)
self.assertTrue(assessment.get("acquire_allowed"))
def test_unparseable_expiry_fails_closed_in_cleanup(self):
comment = _lease_comment(
last_activity=_now() - timedelta(hours=3), expires_at=None
)
comment["body"] = comment["body"].replace(
"expires_at: ", "expires_at: not-a-timestamp"
)
result = leases.assess_obsolete_reviewer_comment_lease_cleanup(
[comment],
pr_number=PR,
current_head_sha=NEW_HEAD,
formal_reviews=[],
repo=REPO,
expected_repo=REPO,
requesting_session_id="fresh-controller",
controller_recovery_authorized=True,
worktree_exists=True,
worktree_clean=True,
owner_process_alive=False,
)
self.assertFalse(result["cleanup_allowed"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,423 @@
"""Issue #706: immutable startup/profile canonical_repository_root capability.
Cross-repository MCP namespaces (e.g. ``eagenda-author``) must bind their
canonical repository root to the *configured target repository*, not to the
Gitea-Tools install checkout the server script lives in. These tests prove:
* the configured binding is resolved from profile/env with env precedence,
* the target repository identity and git common-directory membership are
validated (AC3),
* the branches-only guard (#274) is enforced *inside the target repo* (AC4),
* missing / conflicting / forged bindings fail closed (AC5),
* prgs and mdcps namespaces stay simultaneously isolated (AC6),
* the session binding pins the canonical root immutably,
* no weakening of the install-root single-repo default (AC8).
Uses two distinct real git repositories/worktrees (AC7).
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import canonical_repository_root as crr # noqa: E402
import gitea_config # noqa: E402
import namespace_workspace_binding as nwb # noqa: E402
import session_context_binding as session_ctx # noqa: E402
def _git(cwd: str, *args: str) -> str:
res = subprocess.run(
["git", "-C", cwd, *args],
capture_output=True,
text=True,
check=True,
)
return res.stdout.strip()
def _init_repo(path: Path, remote_url: str, *, remote_name: str = "origin") -> str:
"""Create a real git repo with one commit and a remote; return realpath root."""
path.mkdir(parents=True, exist_ok=True)
_git(str(path), "init", "-q")
_git(str(path), "config", "user.email", "[email protected]")
_git(str(path), "config", "user.name", "Test")
_git(str(path), "remote", "add", remote_name, remote_url)
(path / "README.md").write_text("seed\n")
_git(str(path), "add", "README.md")
_git(str(path), "commit", "-q", "-m", "seed")
return os.path.realpath(str(path))
def _add_worktree(repo_root: str, worktree_path: Path, branch: str) -> str:
_git(repo_root, "worktree", "add", "-q", "-b", branch, str(worktree_path))
return os.path.realpath(str(worktree_path))
# ---------------------------------------------------------------------------
# configured_canonical_root: resolution + precedence
# ---------------------------------------------------------------------------
class TestConfiguredCanonicalRoot(unittest.TestCase):
def test_unconfigured_returns_none(self):
value, source = crr.configured_canonical_root({}, {})
self.assertIsNone(value)
self.assertIsNone(source)
def test_profile_field_used(self):
value, source = crr.configured_canonical_root(
{"canonical_repository_root": "/repo/eAgenda"}, {}
)
self.assertEqual(value, "/repo/eAgenda")
self.assertIn("profile", source)
def test_env_overrides_profile(self):
value, source = crr.configured_canonical_root(
{"canonical_repository_root": "/repo/eAgenda"},
{crr.CANONICAL_ROOT_ENV: "/repo/other"},
)
self.assertEqual(value, "/repo/other")
self.assertIn(crr.CANONICAL_ROOT_ENV, source)
def test_blank_values_ignored(self):
value, source = crr.configured_canonical_root(
{"canonical_repository_root": " "},
{crr.CANONICAL_ROOT_ENV: ""},
)
self.assertIsNone(value)
self.assertIsNone(source)
# ---------------------------------------------------------------------------
# assess_canonical_repository_root: default (single-repo) path
# ---------------------------------------------------------------------------
class TestUnconfiguredFallback(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.tmp = self._tmp.name
def tearDown(self):
self._tmp.cleanup()
def test_unconfigured_falls_back_to_process_root(self):
root = _init_repo(
Path(self.tmp) / "install",
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git",
)
got = crr.assess_canonical_repository_root(
configured_value=None,
source=None,
expected_slug=None,
process_project_root=root,
)
self.assertTrue(got["proven"])
self.assertFalse(got["block"])
self.assertFalse(got["configured"])
self.assertEqual(got["canonical_repo_root"], root)
# ---------------------------------------------------------------------------
# assess_canonical_repository_root: configured cross-repo path
# ---------------------------------------------------------------------------
class TestConfiguredCrossRepo(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.tmp = Path(self._tmp.name)
self.install = _init_repo(
self.tmp / "Gitea-Tools",
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git",
)
self.target = _init_repo(
self.tmp / "mcp-control-plane",
"https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane.git",
)
def tearDown(self):
self._tmp.cleanup()
def test_valid_configured_root_binds_to_target(self):
got = crr.assess_canonical_repository_root(
configured_value=self.target,
source="profile canonical_repository_root",
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
process_project_root=self.install,
)
self.assertTrue(got["proven"], got.get("reasons"))
self.assertFalse(got["block"])
self.assertTrue(got["configured"])
self.assertEqual(got["canonical_repo_root"], self.target)
self.assertNotEqual(got["canonical_repo_root"], self.install)
def test_missing_path_fails_closed(self):
got = crr.assess_canonical_repository_root(
configured_value=str(self.tmp / "does-not-exist"),
source="profile canonical_repository_root",
expected_slug=None,
process_project_root=self.install,
)
self.assertTrue(got["block"])
self.assertFalse(got["proven"])
self.assertTrue(any("exist" in r for r in got["reasons"]))
def test_non_git_path_fails_closed(self):
plain = self.tmp / "plain"
plain.mkdir()
got = crr.assess_canonical_repository_root(
configured_value=str(plain),
source="profile canonical_repository_root",
expected_slug=None,
process_project_root=self.install,
)
self.assertTrue(got["block"])
self.assertTrue(any("git" in r for r in got["reasons"]))
def test_identity_mismatch_fails_closed(self):
# Configured root is the install repo, but session expects the target
# repo identity: a forged/conflicting binding.
got = crr.assess_canonical_repository_root(
configured_value=self.install,
source="profile canonical_repository_root",
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
process_project_root=self.install,
)
self.assertTrue(got["block"])
self.assertTrue(any("identity" in r for r in got["reasons"]))
def test_unprovable_identity_blocks_when_required(self):
noremote = _init_repo(self.tmp / "noremote-src", "x", remote_name="origin")
_git(noremote, "remote", "remove", "origin")
got = crr.assess_canonical_repository_root(
configured_value=noremote,
source="profile canonical_repository_root",
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
process_project_root=self.install,
require_binding=True,
)
self.assertTrue(got["block"])
def test_repository_identity_slug_reads_remote(self):
self.assertEqual(
crr.repository_identity_slug(self.target),
"Scaled-Tech-Consulting/mcp-control-plane",
)
# ---------------------------------------------------------------------------
# Workspace membership + branches-only enforced inside the TARGET repo (AC4)
# ---------------------------------------------------------------------------
class TestNamespaceContextUsesConfiguredRoot(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.tmp = Path(self._tmp.name)
self.install = _init_repo(
self.tmp / "Gitea-Tools",
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git",
)
self.target = _init_repo(
self.tmp / "mcp-control-plane",
"https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane.git",
)
(Path(self.target) / "branches").mkdir()
self.target_wt = _add_worktree(
self.target,
Path(self.target) / "branches" / "author-issue-1",
"feat/issue-1",
)
def tearDown(self):
self._tmp.cleanup()
def test_context_canonical_root_is_configured_target(self):
ctx = nwb.resolve_namespace_mutation_context(
role_kind="author",
worktree_path=self.target_wt,
process_project_root=self.install,
env={},
configured_canonical_root=self.target,
)
self.assertEqual(ctx["canonical_repo_root"], self.target)
self.assertFalse(ctx["roots_aligned"])
def test_target_worktree_is_member_of_target_root(self):
got = nwb.amw.assess_workspace_repo_membership(
workspace_path=self.target_wt,
canonical_repo_root=self.target,
)
self.assertTrue(got["proven"], got.get("reasons"))
def test_target_worktree_not_member_of_install_root(self):
got = nwb.amw.assess_workspace_repo_membership(
workspace_path=self.target_wt,
canonical_repo_root=self.install,
)
self.assertTrue(got["block"])
def test_branches_guard_passes_inside_target(self):
assessment = nwb.assess_namespace_mutation_workspace(
role_kind="author",
worktree_path=self.target_wt,
worktree=None,
process_project_root=self.install,
env={},
current_branch="feat/issue-1",
configured_canonical_root=self.target,
)
self.assertFalse(assessment["block"], assessment.get("reasons"))
self.assertEqual(assessment["canonical_repo_root"], self.target)
def test_target_control_checkout_blocks_branches_guard(self):
assessment = nwb.assess_namespace_mutation_workspace(
role_kind="author",
worktree_path=self.target, # stable target checkout, not branches/
worktree=None,
process_project_root=self.install,
env={},
current_branch="master",
configured_canonical_root=self.target,
)
self.assertTrue(assessment["block"])
# ---------------------------------------------------------------------------
# Immutable session pin (AC1/AC5)
# ---------------------------------------------------------------------------
class TestSessionCanonicalRootPin(unittest.TestCase):
def setUp(self):
os.environ["PYTEST_CURRENT_TEST"] = "t"
session_ctx._reset_session_context_for_testing()
def tearDown(self):
session_ctx._reset_session_context_for_testing()
def test_seed_stores_canonical_root(self):
ctx = session_ctx.seed_session_context_if_unbound(
profile_name="mcp-control-plane-author",
remote="prgs",
host="gitea.prgs.cc",
identity="svc",
repository="mcp-control-plane",
org="Scaled-Tech-Consulting",
canonical_repository_root="/repo/mcp-control-plane",
)
self.assertEqual(ctx["canonical_repository_root"], "/repo/mcp-control-plane")
def test_canonical_root_drift_fails_closed(self):
session_ctx.seed_session_context_if_unbound(
profile_name="mcp-control-plane-author",
remote="prgs",
host="gitea.prgs.cc",
identity="svc",
repository="mcp-control-plane",
org="Scaled-Tech-Consulting",
canonical_repository_root="/repo/mcp-control-plane",
)
got = session_ctx.assess_session_context(
profile_name="mcp-control-plane-author",
remote="prgs",
canonical_repository_root="/repo/forged-elsewhere",
)
self.assertTrue(got["block"])
self.assertTrue(any("canonical" in r for r in got["reasons"]))
def test_matching_canonical_root_passes(self):
session_ctx.seed_session_context_if_unbound(
profile_name="mcp-control-plane-author",
remote="prgs",
host="gitea.prgs.cc",
identity="svc",
repository="mcp-control-plane",
org="Scaled-Tech-Consulting",
canonical_repository_root="/repo/mcp-control-plane",
)
got = session_ctx.assess_session_context(
profile_name="mcp-control-plane-author",
remote="prgs",
canonical_repository_root="/repo/mcp-control-plane",
)
self.assertFalse(got["block"], got["reasons"])
# ---------------------------------------------------------------------------
# Simultaneous prgs / mdcps isolation (AC6)
# ---------------------------------------------------------------------------
class TestSimultaneousIsolation(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.tmp = Path(self._tmp.name)
self.prgs = _init_repo(
self.tmp / "prgs-repo",
"https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane.git",
)
self.mdcps = _init_repo(
self.tmp / "mdcps-repo",
"https://gitea.dadeschools.net/dadeschools/eAgenda.git",
)
def tearDown(self):
self._tmp.cleanup()
def test_each_namespace_binds_its_own_target(self):
a = crr.assess_canonical_repository_root(
configured_value=self.prgs,
source="env",
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
process_project_root=self.tmp.as_posix(),
)
b = crr.assess_canonical_repository_root(
configured_value=self.mdcps,
source="env",
expected_slug="dadeschools/eAgenda",
process_project_root=self.tmp.as_posix(),
)
self.assertEqual(a["canonical_repo_root"], self.prgs)
self.assertEqual(b["canonical_repo_root"], self.mdcps)
self.assertNotEqual(a["canonical_repo_root"], b["canonical_repo_root"])
def test_cross_wired_identity_blocks(self):
# prgs path claimed under the mdcps identity → forged binding.
got = crr.assess_canonical_repository_root(
configured_value=self.prgs,
source="env",
expected_slug="dadeschools/eAgenda",
process_project_root=self.tmp.as_posix(),
)
self.assertTrue(got["block"])
# ---------------------------------------------------------------------------
# Config validation of the profile field (AC5: malformed bindings fail closed)
# ---------------------------------------------------------------------------
class TestConfigValidation(unittest.TestCase):
def test_absent_is_allowed(self):
# single-repo default: no field configured
gitea_config._validate_canonical_repository_root("p", None)
def test_valid_absolute_path_ok(self):
gitea_config._validate_canonical_repository_root(
"p", "/Users/x/Development/mcp-control-plane"
)
def test_relative_path_rejected(self):
with self.assertRaises(gitea_config.ConfigError):
gitea_config._validate_canonical_repository_root(
"p", "relative/path"
)
def test_empty_string_rejected(self):
with self.assertRaises(gitea_config.ConfigError):
gitea_config._validate_canonical_repository_root("p", " ")
def test_non_string_rejected(self):
with self.assertRaises(gitea_config.ConfigError):
gitea_config._validate_canonical_repository_root("p", ["/x"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,221 @@
"""Issue #706 F1 integration regression (review 457).
The unit tests in ``test_issue_706_canonical_repository_root.py`` exercise
``crr.assess_canonical_repository_root`` / ``session_ctx.seed_session_context_if_unbound``
with a *preselected* slug, so they never drive the real end-to-end path that
review 457 found broken:
_seed_session_context -> _trusted_session_repository -> _workspace_repository_slug
-> _local_git_remote_url (cwd=PROJECT_ROOT, always Gitea-Tools)
Before the fix, the session repository identity was pinned from the *install*
checkout remote even when a cross-repository ``canonical_repository_root`` was
configured to an external repository (e.g. mcp-control-plane). The mutation
preflight then derived ``expected_slug`` from that install-derived pin and
``_enforce_canonical_repository_root`` failed closed on a self-inflicted
identity mismatch, so the stated cross-repo namespaces stayed blocked.
These tests use two *real* temporary git repositories and drive the live
``mcp_server._seed_session_context`` and ``mcp_server._enforce_canonical_repository_root``
functions, asserting the session pins the *configured target* identity and that
enforcement accepts the target worktree while every fail-closed property is
preserved.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import mcp_server # noqa: E402 # loads gitea_mcp_server.py into this namespace
import canonical_repository_root as crr # noqa: E402
import session_context_binding as session_ctx # noqa: E402
INSTALL_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
TARGET_SLUG = "Scaled-Tech-Consulting/mcp-control-plane"
TARGET_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane.git"
OTHER_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/other-repo.git"
def _git(cwd: str, *args: str) -> str:
return subprocess.run(
["git", "-C", cwd, *args], capture_output=True, text=True, check=True
).stdout.strip()
def _init_repo(path: Path, remote_url: str) -> str:
path.mkdir(parents=True, exist_ok=True)
_git(str(path), "init", "-q")
_git(str(path), "config", "user.email", "[email protected]")
_git(str(path), "config", "user.name", "Test")
_git(str(path), "remote", "add", "prgs", remote_url)
(path / "README.md").write_text("seed\n")
_git(str(path), "add", "README.md")
_git(str(path), "commit", "-q", "-m", "seed")
return os.path.realpath(str(path))
def _add_worktree(repo_root: str, wt: Path, branch: str) -> str:
_git(repo_root, "worktree", "add", "-q", "-b", branch, str(wt))
return os.path.realpath(str(wt))
def _profile(role: str, *, canonical: str | None = None,
allowed=(TARGET_SLUG,)) -> dict:
p = {
"profile_name": f"mcp-control-plane-{role}",
"role": role,
"username": "svc",
"allowed_operations": ["gitea.read"],
"forbidden_operations": [],
"allowed_repositories": list(allowed),
}
if canonical is not None:
p["canonical_repository_root"] = canonical
return p
class _Base(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
tmp = Path(self._tmp.name)
self.install = _init_repo(tmp / "Gitea-Tools", INSTALL_URL)
self.target = _init_repo(tmp / "mcp-control-plane", TARGET_URL)
(Path(self.target) / "branches").mkdir()
self.target_wt = _add_worktree(
self.target, Path(self.target) / "branches" / "author-issue-1",
"feat/issue-1",
)
# PROJECT_ROOT and the install git remote are the Gitea-Tools install
# checkout — exactly the source that (mis)seeded the session before.
self._p_root = mock.patch.object(mcp_server, "PROJECT_ROOT", self.install)
self._p_url = mock.patch.object(
mcp_server, "_local_git_remote_url", return_value=INSTALL_URL
)
self._p_root.start()
self._p_url.start()
session_ctx._reset_session_context_for_testing()
def tearDown(self):
mock.patch.stopall()
session_ctx._reset_session_context_for_testing()
def _seed(self, profile, env=None):
session_ctx._reset_session_context_for_testing()
with mock.patch.object(mcp_server, "get_profile", return_value=profile), \
mock.patch.dict(os.environ, env or {}, clear=False):
return mcp_server._seed_session_context(
profile=profile, remote="prgs", host="gitea.prgs.cc",
identity="svc",
)
def _enforce(self, profile, env=None):
with mock.patch.object(mcp_server, "get_profile", return_value=profile), \
mock.patch.dict(os.environ, env or {}, clear=False):
mcp_server._enforce_canonical_repository_root(
self.target_wt, remote="prgs"
)
class TestF1SeedsConfiguredTargetIdentity(_Base):
def test_env_config_seeds_target_not_install(self):
ctx = self._seed(
_profile("reviewer"),
env={crr.CANONICAL_ROOT_ENV: self.target},
)
self.assertEqual(ctx["org"], "Scaled-Tech-Consulting")
self.assertEqual(ctx["repository"], "mcp-control-plane")
# Regression guard: must NOT be the install repo.
self.assertNotEqual(ctx["repository"], "Gitea-Tools")
def test_profile_field_seeds_target_not_install(self):
ctx = self._seed(_profile("merger", canonical=self.target))
self.assertEqual(ctx["org"], "Scaled-Tech-Consulting")
self.assertEqual(ctx["repository"], "mcp-control-plane")
self.assertNotEqual(ctx["repository"], "Gitea-Tools")
def test_enforce_accepts_target_after_seed_reviewer(self):
prof = _profile("reviewer", canonical=self.target)
self._seed(prof)
# Would raise RuntimeError on the self-inflicted identity mismatch
# before the fix.
self._enforce(prof)
def test_enforce_accepts_target_after_seed_merger(self):
prof = _profile("merger", canonical=self.target)
self._seed(prof)
self._enforce(prof)
def test_env_overrides_profile_for_seed(self):
# profile points at install; env points at the real target → env wins.
prof = _profile("reviewer", canonical=self.install,
allowed=(TARGET_SLUG,))
ctx = self._seed(prof, env={crr.CANONICAL_ROOT_ENV: self.target})
self.assertEqual(ctx["repository"], "mcp-control-plane")
class TestUnconfiguredDefaultUnchanged(_Base):
def test_unconfigured_keeps_install_identity(self):
prof = _profile("author", allowed=("Scaled-Tech-Consulting/Gitea-Tools",))
ctx = self._seed(prof) # no env, no profile canonical field
self.assertEqual(ctx["repository"], "Gitea-Tools")
self.assertEqual(ctx["org"], "Scaled-Tech-Consulting")
class TestFailClosed(_Base):
def _trusted(self, profile, env=None, *, for_mutation=True):
with mock.patch.object(mcp_server, "get_profile", return_value=profile), \
mock.patch.dict(os.environ, env or {}, clear=False):
return mcp_server._trusted_session_repository(
profile, "prgs", for_mutation=for_mutation
)
def test_nonexistent_configured_root_fails_closed(self):
res = self._trusted(
_profile("reviewer"),
env={crr.CANONICAL_ROOT_ENV: self.target + "-missing"},
)
self.assertIsNone(res["repository"])
self.assertTrue(res["reasons"])
def test_non_git_configured_root_fails_closed(self):
plain = Path(self._tmp.name) / "plain"
plain.mkdir()
res = self._trusted(
_profile("reviewer"), env={crr.CANONICAL_ROOT_ENV: str(plain)}
)
self.assertIsNone(res["repository"])
self.assertTrue(any("git" in r for r in res["reasons"]))
def test_unallowlisted_target_identity_fails_closed(self):
# Configured target is valid, but the profile does not authorize it.
res = self._trusted(
_profile("reviewer", canonical=self.target,
allowed=("Scaled-Tech-Consulting/Gitea-Tools",)),
)
self.assertIsNone(res["repository"])
self.assertTrue(any("scope" in r.lower() for r in res["reasons"]))
def test_forged_identity_conflict_fails_closed_at_enforce(self):
# Seed the target, then present a *different* configured root at
# enforcement time: the session pin no longer matches → fail closed.
other = _init_repo(Path(self._tmp.name) / "other", OTHER_URL)
prof_seed = _profile("reviewer", canonical=self.target)
self._seed(prof_seed)
prof_drift = _profile(
"reviewer", canonical=other,
allowed=(TARGET_SLUG, "Scaled-Tech-Consulting/other-repo"),
)
with self.assertRaises(RuntimeError):
self._enforce(prof_drift)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,509 @@
"""#714: production first-bind pins the workspace-verified repository scope.
These tests drive the real entry points (``gitea_whoami``,
``gitea_get_runtime_context``, ``gitea_resolve_task_capability``,
``gitea_activate_profile``) in production order. They deliberately do not
construct a ``_SessionContext`` directly: the defect they cover is that the
production first-bind path left ``repository``/``org`` unbound, so
``assess_session_context`` skipped its repository/org drift checks and a
same-host mutation against another repository was not blocked.
The trusted repository identity comes from the workspace-aligned git remote
(``Scaled-Tech-Consulting/Gitea-Tools`` for this checkout), never from
``REMOTES`` (whose ``prgs`` default repo is ``Timesheet``) and never from a
caller-supplied ``org``/``repo`` argument.
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import gitea_config # noqa: E402
import gitea_mcp_server as srv # noqa: E402
import mcp_server # noqa: E402
import session_context_binding as session_ctx # noqa: E402
WORKSPACE_ORG = "Scaled-Tech-Consulting"
WORKSPACE_REPO = "Gitea-Tools"
WORKSPACE_SLUG = f"{WORKSPACE_ORG}/{WORKSPACE_REPO}"
WORKSPACE_URL = f"https://gitea.prgs.cc/{WORKSPACE_SLUG}.git"
_BASE_AUTHOR_OPS = [
"gitea.read",
"gitea.issue.create",
"gitea.issue.comment",
"gitea.pr.create",
"gitea.pr.comment",
"gitea.branch.push",
"gitea.repo.commit",
]
def _config(allowed_repositories=None):
profile = {
"enabled": True,
"context": "prgs",
"role": "author",
"username": "jcwalker3",
"base_url": "https://gitea.prgs.cc",
"auth": {"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"},
"allowed_operations": list(_BASE_AUTHOR_OPS),
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.request_changes",
],
"execution_profile": "prgs-author",
}
if allowed_repositories is not None:
profile["allowed_repositories"] = list(allowed_repositories)
return {
"version": 2,
# v2-contexts normalizes the config and keeps only rules.* — a
# top-level allow_runtime_switching would be dropped.
"rules": {"allow_runtime_switching": True},
"contexts": {
"prgs": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
},
"mdcps": {
"enabled": True,
"gitea": {
"enabled": True,
"base_url": "https://gitea.dadeschools.net",
},
},
},
"profiles": {"prgs-author": profile},
}
class _ProductionOrderBase(unittest.TestCase):
"""Real entry points, pinned workspace remote, mocked network only."""
allowed_repositories = [WORKSPACE_SLUG]
def setUp(self):
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(_config(self.allowed_repositories)))
self._env = {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": "prgs-author",
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
}
gitea_config._active_profile_override = None
mcp_server._IDENTITY_CACHE.clear()
srv._MUTATION_AUTHORITY = None
# Pin the workspace git remote so the trusted source is deterministic
# and never depends on the developer's checkout layout.
self._remote_url = patch.object(
srv, "_local_git_remote_url", side_effect=self._local_remote_url
)
self._remote_url.start()
def tearDown(self):
self._remote_url.stop()
gitea_config._active_profile_override = None
mcp_server._IDENTITY_CACHE.clear()
srv._MUTATION_AUTHORITY = None
self._dir.cleanup()
def _local_remote_url(self, remote_name):
return WORKSPACE_URL if remote_name == "prgs" else None
def _api(self, method, url, header):
return {
"login": "jcwalker3",
"full_name": "Test",
"id": 1,
"email": "[email protected]",
}
def _live(self):
return patch("gitea_mcp_server.api_request", side_effect=self._api)
class TestProductionFirstBindPinsRepository(_ProductionOrderBase):
def test_whoami_first_bind_is_complete(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
ctx = session_ctx.get_session_context()
self.assertIsNotNone(ctx)
self.assertEqual(ctx["org"], WORKSPACE_ORG)
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
self.assertEqual(ctx["remote"], "prgs")
self.assertEqual(ctx["host"], "gitea.prgs.cc")
def test_runtime_context_first_bind_is_complete(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_get_runtime_context(remote="prgs")
ctx = session_ctx.get_session_context()
self.assertEqual(ctx["org"], WORKSPACE_ORG)
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
def test_capability_preflight_first_bind_is_complete(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_resolve_task_capability(task="comment_issue", remote="prgs")
ctx = session_ctx.get_session_context()
self.assertEqual(ctx["org"], WORKSPACE_ORG)
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
def test_activate_profile_binds_complete_context(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
ctx = session_ctx.get_session_context()
self.assertEqual(ctx["org"], WORKSPACE_ORG)
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
class TestProductionOrderRepositoryDriftBlocks(_ProductionOrderBase):
def test_whoami_then_same_host_other_repository_blocks(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
blocked = srv._session_context_mutation_block(
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
)
self.assertIsNotNone(blocked)
self.assertTrue(
any("Other-Tools" in r for r in blocked["reasons"]), blocked["reasons"]
)
def test_whoami_then_timesheet_blocks(self):
"""REMOTES['prgs'].repo is Timesheet — a default target, not a scope."""
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
blocked = srv._session_context_mutation_block(
remote="prgs",
org=WORKSPACE_ORG,
repo="Timesheet",
org_explicit=True,
repo_explicit=True,
)
self.assertIsNotNone(blocked)
self.assertTrue(
any("Timesheet" in r for r in blocked["reasons"]), blocked["reasons"]
)
def test_whoami_then_same_host_other_org_blocks(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
blocked = srv._session_context_mutation_block(
remote="prgs", org="Other-Org", repo=WORKSPACE_REPO
)
self.assertIsNotNone(blocked)
self.assertTrue(
any("Other-Org" in r for r in blocked["reasons"]), blocked["reasons"]
)
def test_runtime_context_then_other_repository_blocks(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_get_runtime_context(remote="prgs")
blocked = srv._session_context_mutation_block(
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
)
self.assertIsNotNone(blocked)
def test_capability_preflight_then_other_repository_blocks(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_resolve_task_capability(task="comment_issue", remote="prgs")
blocked = srv._session_context_mutation_block(
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
)
self.assertIsNotNone(blocked)
def test_activate_profile_then_other_repository_blocks(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
blocked = srv._session_context_mutation_block(
remote="prgs", org="Other-Org", repo="Other-Tools"
)
self.assertIsNotNone(blocked)
def test_cross_host_still_blocks(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
blocked = srv._session_context_mutation_block(remote="dadeschools")
self.assertIsNotNone(blocked)
self.assertTrue(
any("cross-host" in r for r in blocked["reasons"]), blocked["reasons"]
)
def test_authorized_repository_mutation_remains_allowed(self):
"""Normal PR #715 author comment/push path must stay functional."""
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
self.assertIsNone(
srv._session_context_mutation_block(
remote="prgs", org=WORKSPACE_ORG, repo=WORKSPACE_REPO
)
)
# A bare call with no override resolves to the same bound scope.
self.assertIsNone(srv._session_context_mutation_block(remote="prgs"))
class TestMutationRequestCannotEstablishBinding(_ProductionOrderBase):
def test_caller_values_cannot_establish_binding_on_first_mutation(self):
"""A mutation-first session must not be pinned by request values."""
with patch.dict(os.environ, self._env, clear=False), self._live():
self.assertIsNone(session_ctx.get_session_context())
srv._session_context_mutation_block(
remote="prgs", org="Attacker-Org", repo="Attacker-Repo"
)
ctx = session_ctx.get_session_context()
self.assertIsNotNone(ctx)
# Bound to the verified workspace, never to the request values.
self.assertEqual(ctx["org"], WORKSPACE_ORG)
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
def test_mutation_first_with_attacker_values_is_blocked(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
blocked = srv._session_context_mutation_block(
remote="prgs", org="Attacker-Org", repo="Attacker-Repo"
)
self.assertIsNotNone(blocked)
def test_later_call_cannot_overwrite_bound_repository(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
before = session_ctx.get_session_context()
for _ in range(3):
srv._session_context_mutation_block(
remote="prgs", org="Other-Org", repo="Other-Tools"
)
self.assertEqual(session_ctx.get_session_context(), before)
class TestUnverifiedWorkspaceFailsClosed(_ProductionOrderBase):
def _local_remote_url(self, remote_name):
return None # no verifiable workspace repository
def test_mutation_blocks_when_workspace_repository_unverified(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
ctx = session_ctx.get_session_context()
self.assertIsNone(ctx["repository"])
blocked = srv._session_context_mutation_block(
remote="prgs", org=WORKSPACE_ORG, repo=WORKSPACE_REPO
)
self.assertIsNotNone(blocked)
self.assertTrue(
any(
"no verified workspace repository" in r or "unverified" in r
for r in blocked["reasons"]
),
blocked["reasons"],
)
def test_activate_profile_rejects_unverifiable_workspace(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
res = srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
self.assertFalse(res.get("success", True))
self.assertEqual(res.get("blocker_kind"), "repository_scope")
class TestUnauthorizedWorkspaceFailsClosed(_ProductionOrderBase):
allowed_repositories = ["Scaled-Tech-Consulting/Some-Other-Project"]
def test_workspace_absent_from_allowlist_blocks_mutation(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
blocked = srv._session_context_mutation_block(remote="prgs")
self.assertIsNotNone(blocked)
self.assertTrue(
any("not authorized" in r for r in blocked["reasons"]),
blocked["reasons"],
)
def test_workspace_absent_from_allowlist_blocks_activation(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
res = srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
self.assertFalse(res.get("success", True))
self.assertEqual(res.get("blocker_kind"), "repository_scope")
class TestTimesheetNotAuthorizedInThisPhase(_ProductionOrderBase):
"""Cross-project use is paused: only Gitea-Tools is authorized."""
def test_timesheet_workspace_is_rejected(self):
def timesheet_remote(remote_name):
return "https://gitea.prgs.cc/Scaled-Tech-Consulting/Timesheet.git"
with patch.dict(os.environ, self._env, clear=False), self._live():
with patch.object(
srv, "_local_git_remote_url", side_effect=timesheet_remote
):
blocked = srv._session_context_mutation_block(remote="prgs")
self.assertIsNotNone(blocked)
self.assertTrue(
any("not authorized" in r for r in blocked["reasons"]),
blocked["reasons"],
)
class TestConcurrentFirstBindSelectsOneRepository(_ProductionOrderBase):
def test_concurrent_initialization_cannot_change_selected_repository(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
self.assertIsNone(session_ctx.get_session_context())
thread_count = 8
barrier = threading.Barrier(thread_count)
results = []
lock = threading.Lock()
def racer(index: int) -> None:
barrier.wait()
srv._session_context_mutation_block(
remote="prgs", org=f"Racer-Org-{index}", repo=f"Racer-Repo-{index}"
)
with lock:
results.append(session_ctx.get_session_context())
threads = [
threading.Thread(target=racer, args=(i,)) for i in range(thread_count)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=10)
self.assertFalse(any(t.is_alive() for t in threads))
winner = session_ctx.get_session_context()
self.assertEqual(winner["org"], WORKSPACE_ORG)
self.assertEqual(winner["repository"], WORKSPACE_REPO)
self.assertEqual(results, [winner] * thread_count)
class TestRepositoryScopeUnits(unittest.TestCase):
def test_absent_scope_is_not_enforced_for_read_diagnostics(self):
scope = session_ctx.assess_repository_scope(
workspace_slug=WORKSPACE_SLUG, allowed=[], profile_name="legacy"
)
self.assertTrue(scope["proven"])
self.assertFalse(scope["scope_enforced"])
def test_require_scope_blocks_empty_or_missing_allowlist(self):
scope = session_ctx.assess_repository_scope(
workspace_slug=WORKSPACE_SLUG,
allowed=[],
profile_name="legacy",
require_scope=True,
)
self.assertTrue(scope["block"])
self.assertTrue(any("allowed_repositories" in r for r in scope["reasons"]))
def test_declared_scope_authorizes_only_listed_repository(self):
allowed = session_ctx.declared_allowed_repositories(
{"allowed_repositories": [WORKSPACE_SLUG]}
)
self.assertEqual(allowed, [WORKSPACE_SLUG])
self.assertTrue(
session_ctx.assess_repository_scope(
workspace_slug=WORKSPACE_SLUG, allowed=allowed
)["proven"]
)
self.assertTrue(
session_ctx.assess_repository_scope(
workspace_slug="Scaled-Tech-Consulting/Timesheet", allowed=allowed
)["block"]
)
def test_missing_workspace_slug_blocks_when_scope_declared(self):
scope = session_ctx.assess_repository_scope(
workspace_slug=None, allowed=[WORKSPACE_SLUG]
)
self.assertTrue(scope["block"])
def test_override_must_match_binding(self):
self.assertTrue(
session_ctx.assess_repository_override(
requested_org=WORKSPACE_ORG,
requested_repo="Other",
bound_org=WORKSPACE_ORG,
bound_repo=WORKSPACE_REPO,
)["block"]
)
self.assertTrue(
session_ctx.assess_repository_override(
requested_org="Other-Org",
requested_repo=WORKSPACE_REPO,
bound_org=WORKSPACE_ORG,
bound_repo=WORKSPACE_REPO,
)["block"]
)
self.assertTrue(
session_ctx.assess_repository_override(
requested_org=WORKSPACE_ORG,
requested_repo=WORKSPACE_REPO,
bound_org=WORKSPACE_ORG,
bound_repo=WORKSPACE_REPO,
)["proven"]
)
def test_malformed_allowlist_entries_are_ignored_in_non_strict_mode(self):
self.assertEqual(
session_ctx.declared_allowed_repositories(
{"allowed_repositories": ["not-a-slug", 17, None, WORKSPACE_SLUG]}
),
[WORKSPACE_SLUG],
)
def test_strict_mode_rejects_malformed_allowlist_entries(self):
with self.assertRaises(ValueError):
session_ctx.declared_allowed_repositories(
{"allowed_repositories": ["not-a-slug", WORKSPACE_SLUG]},
strict=True,
)
class TestRemotesDefaultNotCallerOverride(_ProductionOrderBase):
def test_resolved_timesheet_default_does_not_block_when_bound_to_workspace(self):
"""Tools historically pass REMOTES-filled Timesheet after _resolve; that
must not be treated as an explicit override against Gitea-Tools."""
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
# Simulate create_issue after resolve: org/repo are REMOTES defaults
blocked = srv._session_context_mutation_block(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Timesheet",
)
self.assertIsNone(blocked, getattr(blocked, "get", lambda *_: blocked)("reasons") if blocked else None)
def test_git_unavailable_blocks_mutation(self):
def no_remote(_name):
return None
with patch.dict(os.environ, self._env, clear=False), self._live():
with patch.object(srv, "_local_git_remote_url", side_effect=no_remote):
blocked = srv._session_context_mutation_block(remote="prgs")
self.assertIsNotNone(blocked)
self.assertTrue(
any(
"workspace" in r.lower() or "allowed_repositories" in r or "unverified" in r
for r in blocked["reasons"]
),
blocked["reasons"],
)
def test_explicit_mismatch_still_blocks(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
blocked = srv._session_context_mutation_block(
remote="prgs",
org=WORKSPACE_ORG,
repo="Other-Tools",
)
self.assertIsNotNone(blocked)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,745 @@
"""#714: fail closed on cross-host MCP profile drift and capability substitution."""
from __future__ import annotations
import json
import os
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import gitea_config # noqa: E402
import mcp_server # noqa: E402
import session_context_binding as session_ctx # noqa: E402
CONFIG_714 = {
"version": 2,
"allow_runtime_switching": True,
"contexts": {
"prgs": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
},
"mdcps": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.dadeschools.net"},
},
},
"profiles": {
"prgs-author": {
"enabled": True,
"context": "prgs",
"role": "author",
"username": "jcwalker3",
"base_url": "https://gitea.prgs.cc",
"auth": {"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"},
"allowed_operations": [
"gitea.read",
"gitea.issue.create",
"gitea.issue.comment",
"gitea.issue.close",
"gitea.pr.create",
"gitea.pr.comment",
"gitea.branch.create",
"gitea.branch.push",
"gitea.repo.commit",
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.request_changes",
],
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
"execution_profile": "prgs-author",
},
"mdcps-reviewer": {
"enabled": True,
"context": "mdcps",
"role": "reviewer",
"username": "913443",
"base_url": "https://gitea.dadeschools.net",
"auth": {"type": "env", "name": "GITEA_TOKEN_MDCPS_REVIEWER"},
"allowed_operations": [
"gitea.read",
"gitea.pr.review",
"gitea.pr.comment",
"gitea.pr.approve",
"gitea.pr.request_changes",
],
"forbidden_operations": [
"gitea.branch.create",
"gitea.branch.push",
"gitea.pr.create",
"gitea.pr.merge",
"gitea.repo.commit",
],
"allowed_repositories": ["913443/eAgenda"],
"execution_profile": "mdcps-reviewer",
},
"mdcps-author": {
"enabled": True,
"context": "mdcps",
"role": "author",
"username": "913443",
"base_url": "https://gitea.dadeschools.net",
"auth": {"type": "env", "name": "GITEA_TOKEN_MDCPS_AUTHOR"},
"allowed_operations": [
"gitea.read",
"gitea.issue.create",
"gitea.issue.comment",
"gitea.pr.create",
"gitea.pr.comment",
"gitea.branch.create",
"gitea.branch.push",
"gitea.repo.commit",
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.request_changes",
],
"allowed_repositories": ["913443/eAgenda"],
"execution_profile": "mdcps-author",
},
},
}
class TestIssue714SessionContextImmutability(unittest.TestCase):
def setUp(self):
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG_714))
self._remotes = patch.dict(
mcp_server.REMOTES,
{
"dadeschools": {
"host": "gitea.dadeschools.net",
"org": "913443",
"repo": "eAgenda",
},
"prgs": {
"host": "gitea.prgs.cc",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
},
},
clear=False,
)
self._remotes.start()
def _url(name):
if name == "dadeschools":
return "https://gitea.dadeschools.net/913443/eAgenda.git"
if name == "prgs":
return "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
return None
self._url_patch = patch.object(mcp_server, "_local_git_remote_url", side_effect=_url)
self._url_patch.start()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
mcp_server._MUTATION_AUTHORITY = None
self._env = {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": "mdcps-reviewer",
"GITEA_TOKEN_MDCPS_REVIEWER": "mdcps-reviewer-token",
"GITEA_TOKEN_MDCPS_AUTHOR": "mdcps-author-token",
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
}
def tearDown(self):
self._url_patch.stop()
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
mcp_server._MUTATION_AUTHORITY = None
self._dir.cleanup()
def _api_side_effect(self, method, url, header):
# Token identity mapping for whoami endpoint
auth = str(header)
if "mdcps-reviewer-token" in auth or "mdcps-author-token" in auth:
login = "913443"
else:
login = "jcwalker3"
return {"login": login, "full_name": "Test", "id": 1, "email": "[email protected]"}
def test_pin_mdcps_reviewer_never_drifts_to_prgs_author(self):
"""Reproduce the incident: pin mdcps-reviewer then resolve comment_issue."""
with patch.dict(os.environ, self._env, clear=False):
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
act = mcp_server.gitea_activate_profile(
profile_name="mdcps-reviewer", remote="dadeschools"
)
self.assertTrue(act["success"])
self.assertEqual(act["after_profile"], "mdcps-reviewer")
who1 = mcp_server.gitea_whoami(remote="dadeschools")
self.assertEqual(who1["profile"]["profile_name"], "mdcps-reviewer")
self.assertEqual(who1["username"], "913443")
# Capability that mdcps-reviewer lacks — must NOT switch to prgs-author
res = mcp_server.gitea_resolve_task_capability(
task="comment_issue", remote="dadeschools"
)
self.assertEqual(res["active_profile"], "mdcps-reviewer")
self.assertFalse(res["allowed_in_current_session"])
self.assertFalse(res.get("auto_profile_substitution", True))
self.assertNotIn("prgs-author", res["matching_configured_profile"])
self.assertIn("mdcps-author", res["matching_configured_profile"])
who2 = mcp_server.gitea_whoami(remote="dadeschools")
rt = mcp_server.gitea_get_runtime_context(remote="dadeschools")
self.assertEqual(who2["profile"]["profile_name"], "mdcps-reviewer")
self.assertEqual(rt["active_profile"], "mdcps-reviewer")
# Still pinned — never prgs-author
self.assertEqual(
gitea_config.selected_profile_name(), "mdcps-reviewer"
)
def test_repeated_calls_remain_on_mdcps_reviewer(self):
with patch.dict(os.environ, self._env, clear=False):
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
mcp_server.gitea_activate_profile(
profile_name="mdcps-reviewer", remote="dadeschools"
)
for _ in range(5):
res = mcp_server.gitea_resolve_task_capability(
task="review_pr", remote="dadeschools"
)
self.assertEqual(res["active_profile"], "mdcps-reviewer")
who = mcp_server.gitea_whoami(remote="dadeschools")
self.assertEqual(who["profile"]["profile_name"], "mdcps-reviewer")
rt = mcp_server.gitea_get_runtime_context(remote="dadeschools")
self.assertEqual(rt["active_profile"], "mdcps-reviewer")
def test_dadeschools_request_cannot_resolve_through_prgs_author(self):
with patch.dict(os.environ, self._env, clear=False):
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
mcp_server.gitea_activate_profile(
profile_name="mdcps-reviewer", remote="dadeschools"
)
res = mcp_server.gitea_resolve_task_capability(
task="comment_issue", remote="dadeschools"
)
self.assertNotEqual(res["active_profile"], "prgs-author")
self.assertNotIn("prgs-author", res["matching_configured_profile"])
# Gate must not silently switch either
blocked = mcp_server._profile_permission_block(
"gitea.issue.comment", remote="dadeschools"
)
self.assertIsNotNone(blocked)
self.assertFalse(blocked.get("success", True))
self.assertEqual(
gitea_config.selected_profile_name(), "mdcps-reviewer"
)
def test_prgs_request_cannot_resolve_through_mdcps_profile(self):
"""Reverse of the incident: a pinned MDCPS profile must never serve a
prgs request, nor be silently swapped for the prgs-side profile."""
with patch.dict(os.environ, self._env, clear=False):
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
mcp_server.gitea_activate_profile(
profile_name="mdcps-author", remote="dadeschools"
)
res = mcp_server.gitea_resolve_task_capability(
task="create_issue", remote="prgs"
)
self.assertNotEqual(res["active_profile"], "prgs-author")
self.assertEqual(res["active_profile"], "mdcps-author")
self.assertFalse(res["allowed_in_current_session"])
self.assertTrue(res["stop_required"])
blocked = mcp_server._session_context_mutation_block(remote="prgs")
self.assertIsNotNone(blocked)
self.assertTrue(any("cross-host" in r for r in blocked["reasons"]))
self.assertEqual(gitea_config.selected_profile_name(), "mdcps-author")
def test_unsupported_capability_fails_closed_structured(self):
with patch.dict(os.environ, self._env, clear=False):
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
mcp_server.gitea_activate_profile(
profile_name="mdcps-reviewer", remote="dadeschools"
)
res = mcp_server.gitea_resolve_task_capability(
task="comment_issue", remote="dadeschools"
)
self.assertFalse(res["allowed_in_current_session"])
self.assertTrue(res["stop_required"])
self.assertIn("reason", res)
self.assertIn("exact_safe_next_action", res)
self.assertIn("activate_profile", res["exact_safe_next_action"])
# Unknown task still fail closed (structured denial after #723;
# never raises into internal_error and never substitutes profile).
unknown = mcp_server.gitea_resolve_task_capability(
task="reopen_issue", remote="dadeschools"
)
self.assertFalse(unknown.get("allowed_in_current_session"))
self.assertTrue(unknown.get("stop_required"))
self.assertEqual(unknown.get("reason_code"), "unknown_task")
self.assertEqual(unknown.get("mutation_performed"), False)
self.assertEqual(unknown.get("active_profile"), "mdcps-reviewer")
self.assertNotEqual(unknown.get("active_profile"), "prgs-author")
def test_identity_mismatch_blocks_mutation(self):
"""Profile expects 913443 but authenticated as jcwalker3."""
def wrong_identity(method, url, header):
return {
"login": "jcwalker3",
"full_name": "Wrong",
"id": 9,
"email": "[email protected]",
}
with patch.dict(os.environ, self._env, clear=False):
with patch("mcp_server.api_request", side_effect=wrong_identity):
mcp_server.gitea_activate_profile(
profile_name="mdcps-reviewer", remote="dadeschools"
)
blocked = mcp_server._session_context_mutation_block(
remote="dadeschools"
)
self.assertIsNotNone(blocked)
self.assertTrue(
any("identity mismatch" in r for r in blocked["reasons"])
)
def test_repository_host_mismatch_blocks_mutation(self):
"""mdcps-reviewer cannot serve prgs remote."""
with patch.dict(os.environ, self._env, clear=False):
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
mcp_server.gitea_activate_profile(
profile_name="mdcps-reviewer", remote="dadeschools"
)
blocked = mcp_server._session_context_mutation_block(remote="prgs")
self.assertIsNotNone(blocked)
self.assertTrue(
any("cross-host" in r for r in blocked["reasons"])
)
def test_drift_cannot_reach_write(self):
"""Simulated mid-session profile override cannot pass permission gate."""
with patch.dict(os.environ, self._env, clear=False):
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
mcp_server.gitea_activate_profile(
profile_name="mdcps-reviewer", remote="dadeschools"
)
# Bind session as mdcps-reviewer
self.assertEqual(
session_ctx.get_session_context()["profile_name"],
"mdcps-reviewer",
)
# Hostile override without activate_profile
gitea_config._active_profile_override = "prgs-author"
blocked = mcp_server._session_context_mutation_block(
remote="dadeschools"
)
self.assertIsNotNone(blocked)
self.assertTrue(any("drift" in r or "cross-host" in r for r in blocked["reasons"]))
def test_static_prgs_author_still_works(self):
env = {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": "prgs-author",
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
}
with patch.dict(os.environ, env, clear=False):
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
gitea_config._active_profile_override = None
res = mcp_server.gitea_resolve_task_capability(
task="create_issue", remote="prgs"
)
self.assertEqual(res["active_profile"], "prgs-author")
self.assertTrue(res["allowed_in_current_session"])
self.assertEqual(res["active_identity"], "jcwalker3")
def test_static_mdcps_author_still_works(self):
env = {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": "mdcps-author",
"GITEA_TOKEN_MDCPS_AUTHOR": "mdcps-author-token",
}
with patch.dict(os.environ, env, clear=False):
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
gitea_config._active_profile_override = None
res = mcp_server.gitea_resolve_task_capability(
task="comment_issue", remote="dadeschools"
)
self.assertEqual(res["active_profile"], "mdcps-author")
self.assertTrue(res["allowed_in_current_session"])
self.assertNotIn("prgs-author", res["matching_configured_profile"])
class TestSessionContextBindingUnit(unittest.TestCase):
def test_profile_matches_remote_by_base_url(self):
remotes = {
"dadeschools": {"host": "gitea.dadeschools.net"},
"prgs": {"host": "gitea.prgs.cc"},
}
mdcps = {"base_url": "https://gitea.dadeschools.net", "context": "mdcps"}
prgs = {"base_url": "https://gitea.prgs.cc", "context": "prgs"}
self.assertTrue(
session_ctx.profile_matches_remote(mdcps, "dadeschools", remotes)
)
self.assertFalse(session_ctx.profile_matches_remote(mdcps, "prgs", remotes))
self.assertTrue(session_ctx.profile_matches_remote(prgs, "prgs", remotes))
def test_bind_and_detect_drift(self):
session_ctx.bind_session_context(
profile_name="mdcps-reviewer",
remote="dadeschools",
host="gitea.dadeschools.net",
identity="913443",
source="test",
)
ok = session_ctx.assess_session_context(
profile_name="mdcps-reviewer",
remote="dadeschools",
host="gitea.dadeschools.net",
identity="913443",
)
self.assertTrue(ok["proven"])
bad = session_ctx.assess_session_context(
profile_name="prgs-author",
remote="dadeschools",
host="gitea.dadeschools.net",
identity="jcwalker3",
)
self.assertTrue(bad["block"])
self.assertTrue(any("drift" in r for r in bad["reasons"]))
def test_bound_context_survives_multiple_calls_and_exceptions(self):
original = session_ctx.bind_session_context(
profile_name="mdcps-reviewer",
remote="dadeschools",
host="gitea.dadeschools.net",
identity="913443",
source="test",
)
try:
for _ in range(3):
observed = session_ctx.seed_session_context_if_unbound(
profile_name="prgs-author",
remote="prgs",
host="gitea.prgs.cc",
identity="jcwalker3",
source="interleaved-test-call",
)
self.assertEqual(observed, original)
raise RuntimeError("simulated caller failure")
except RuntimeError:
pass
self.assertEqual(session_ctx.get_session_context(), original)
drift = session_ctx.assess_session_context(
profile_name="prgs-author",
remote="prgs",
host="gitea.prgs.cc",
identity="jcwalker3",
require_bound=True,
)
self.assertTrue(drift["block"])
def test_parallel_calls_cannot_overwrite_established_binding(self):
original = session_ctx.bind_session_context(
profile_name="mdcps-reviewer",
remote="dadeschools",
host="gitea.dadeschools.net",
identity="913443",
source="test",
)
barrier = threading.Barrier(9)
results = []
results_lock = threading.Lock()
def competing_seed(index: int) -> None:
barrier.wait()
result = session_ctx.seed_session_context_if_unbound(
profile_name=f"prgs-author-{index}",
remote="prgs",
host="gitea.prgs.cc",
identity=f"other-{index}",
source="parallel-test-call",
)
with results_lock:
results.append(result)
threads = [threading.Thread(target=competing_seed, args=(i,)) for i in range(8)]
for thread in threads:
thread.start()
barrier.wait()
for thread in threads:
thread.join(timeout=5)
self.assertFalse(any(thread.is_alive() for thread in threads))
self.assertEqual(results, [original] * 8)
self.assertEqual(session_ctx.get_session_context(), original)
def test_concurrent_initialization_has_single_winning_binding(self):
"""Racing first-binds from an unbound context: exactly one winner, and
every racer observes that same complete binding (never a partial one)."""
self.assertIsNone(session_ctx.get_session_context())
thread_count = 8
barrier = threading.Barrier(thread_count)
results = []
results_lock = threading.Lock()
def racing_seed(index: int) -> None:
barrier.wait()
result = session_ctx.seed_session_context_if_unbound(
profile_name=f"prgs-author-{index}",
remote="prgs",
host="gitea.prgs.cc",
identity=f"user-{index}",
source="concurrent-init-test",
)
with results_lock:
results.append(result)
threads = [
threading.Thread(target=racing_seed, args=(i,))
for i in range(thread_count)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=5)
self.assertFalse(any(thread.is_alive() for thread in threads))
winner = session_ctx.get_session_context()
self.assertIsNotNone(winner)
self.assertEqual(len(results), thread_count)
self.assertEqual(results, [winner] * thread_count)
# A losing racer's identity must never be spliced into the winner.
self.assertEqual(
winner["identity"], winner["profile_name"].replace("prgs-author-", "user-")
)
def test_repository_mismatch_blocks_before_mutation(self):
"""Same host and profile, different repository, must still fail closed."""
session_ctx.bind_session_context(
profile_name="prgs-author",
remote="prgs",
host="gitea.prgs.cc",
identity="jcwalker3",
repository="Gitea-Tools",
org="Scaled-Tech-Consulting",
source="test",
)
same = session_ctx.assess_session_context(
profile_name="prgs-author",
remote="prgs",
host="gitea.prgs.cc",
identity="jcwalker3",
repository="Gitea-Tools",
org="Scaled-Tech-Consulting",
require_bound=True,
)
self.assertTrue(same["proven"])
other_repo = session_ctx.assess_session_context(
profile_name="prgs-author",
remote="prgs",
host="gitea.prgs.cc",
identity="jcwalker3",
repository="eAgenda",
org="Scaled-Tech-Consulting",
require_bound=True,
)
self.assertTrue(other_repo["block"])
self.assertTrue(
any("repository drift" in r for r in other_repo["reasons"])
)
other_org = session_ctx.assess_session_context(
profile_name="prgs-author",
remote="prgs",
host="gitea.prgs.cc",
identity="jcwalker3",
repository="Gitea-Tools",
org="913443",
require_bound=True,
)
self.assertTrue(other_org["block"])
self.assertTrue(any("org drift" in r for r in other_org["reasons"]))
def test_returned_snapshot_cannot_mutate_binding(self):
session_ctx.bind_session_context(
profile_name="mdcps-reviewer",
remote="dadeschools",
host="gitea.dadeschools.net",
identity="913443",
source="test",
)
snapshot = session_ctx.get_session_context()
snapshot["profile_name"] = "prgs-author"
self.assertEqual(
session_ctx.get_session_context()["profile_name"], "mdcps-reviewer"
)
class TestSessionContextTestBoundaryIsolation(unittest.TestCase):
def test_mdcps_binding_starts_clean_and_does_not_escape_test(self):
self.assertIsNone(session_ctx.get_session_context())
session_ctx.bind_session_context(
profile_name="mdcps-reviewer",
remote="dadeschools",
host="gitea.dadeschools.net",
identity="913443",
source="test-boundary",
)
def test_prgs_binding_starts_clean_and_does_not_escape_test(self):
self.assertIsNone(session_ctx.get_session_context())
session_ctx.bind_session_context(
profile_name="prgs-author",
remote="prgs",
host="gitea.prgs.cc",
identity="jcwalker3",
source="test-boundary",
)
def test_reset_helper_is_rejected_outside_pytest_boundary(self):
with patch.dict(os.environ, {}, clear=True):
with self.assertRaises(RuntimeError):
session_ctx._reset_session_context_for_testing()
class TestIssue714MergeCoexistenceWithMaster(unittest.TestCase):
"""Conflict-resolution regressions after merging advanced master into #715.
Preserves:
- #714 immutable session / no auto profile substitution
- #685 side-effect-free resolve (auto_recover=False)
- #709 actor identity helper from master
"""
def test_auto_switch_helpers_remain_fail_closed(self):
self.assertFalse(mcp_server._try_auto_switch_for_operation("gitea.pr.review"))
self.assertFalse(
mcp_server._try_auto_switch_for_operation(
"gitea.issue.comment", host="gitea.prgs.cc"
)
)
# Active profile is prgs-author; cannot match mdcps review permission
# and must not switch.
with patch.object(
mcp_server,
"get_profile",
return_value={
"profile_name": "prgs-author",
"allowed_operations": ["gitea.read", "gitea.issue.create"],
"forbidden_operations": ["gitea.pr.approve"],
"base_url": "https://gitea.prgs.cc",
"context": "prgs",
},
):
matched = mcp_server._ensure_matching_profile(
"gitea.pr.approve", "reviewer", remote="prgs"
)
self.assertIsNone(matched)
self.assertNotEqual(
gitea_config.selected_profile_name() or "prgs-author",
"mdcps-reviewer",
)
def test_authenticated_actor_helper_survives_merge(self):
"""Master #709 F7 actor identity coexists with #714 immutability."""
self.assertTrue(hasattr(mcp_server, "_authenticated_actor"))
with patch(
"mcp_server.get_auth_header", return_value={"Authorization": "token x"}
), patch(
"mcp_server.api_request",
return_value={"id": 42, "login": "sysadmin"},
):
mcp_server._ACTOR_IDENTITY_CACHE.clear()
actor = mcp_server._authenticated_actor("gitea.prgs.cc")
self.assertEqual(actor.get("user_id"), 42)
self.assertEqual(actor.get("login"), "sysadmin")
# Cached; second call does not re-request
with patch("mcp_server.api_request") as mock_api:
actor2 = mcp_server._authenticated_actor("gitea.prgs.cc")
mock_api.assert_not_called()
self.assertEqual(actor2.get("user_id"), 42)
def test_resolve_still_report_only_after_master_merge(self):
"""#685: resolve path must not pass auto_recover=True after conflict merge."""
allowed = [
"gitea.read",
"gitea.issue.create",
"gitea.issue.comment",
"gitea.pr.create",
"gitea.pr.comment",
"gitea.branch.create",
"gitea.branch.push",
"gitea.repo.commit",
]
profile = {
"profile_name": "prgs-author",
"role": "author",
"allowed_operations": allowed,
"forbidden_operations": [],
"base_url": "https://gitea.prgs.cc",
"context": "prgs",
}
config = {
"contexts": {
"prgs": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
}
},
"profiles": {
"prgs-author": {
"role": "author",
"allowed_operations": allowed,
"forbidden_operations": [],
"base_url": "https://gitea.prgs.cc",
"context": "prgs",
}
},
}
fake_binding = {
"classification": "unbound",
"active_worktree": None,
"reasons": [],
}
with patch.dict(
os.environ, {"GITEA_MCP_PROFILE": "prgs-author"}, clear=False
), patch.object(
mcp_server, "get_profile", return_value=profile
), patch.object(
mcp_server.gitea_config, "load_config", return_value=config
), patch.object(
mcp_server, "_authenticated_username", return_value="jcwalker3"
), patch.object(
mcp_server,
"_assess_stale_active_binding",
return_value=fake_binding,
) as mock_assess, patch.object(
mcp_server, "record_preflight_check", return_value=None
), patch.object(
mcp_server, "record_mutation_authority", return_value=None
), patch.object(
mcp_server, "init_review_decision_lock", return_value=None
):
result = mcp_server.gitea_resolve_task_capability(
task="create_issue", remote="prgs"
)
mock_assess.assert_called()
for call in mock_assess.call_args_list:
self.assertFalse(call.kwargs.get("auto_recover", True))
self.assertEqual(result.get("mutation_performed"), False)
# Session context must not have been rewritten by resolve
# (report-only; any prior binding stays; unbound stays unbound unless
# seed-on-read path is intentional — resolve must not activate peers).
self.assertNotEqual(result.get("active_profile"), "mdcps-reviewer")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,499 @@
"""#720: Expired old-head KIND_DECISION_LOCK must not block fresh review.
Reproduction shape (PR #616):
* REQUEST_CHANGES terminal at head A
* open PR advanced to head B
* durable decision lock age > default 4h TTL
* fresh_review_on_current_head_allowed is true but unreachable under TTL-first reject
* mark_final fails with "session state expired after 4h"
* assessment may report "no lock" while the file remains on disk
Security invariants preserved:
* same-head second terminal remains fail-closed (#332/#620)
* REQUEST_CHANGES is never treated as approval
* historical mutations remain on the ledger
* merged/closed moot cleanup still allowed (#594)
* irrecoverable provenance still requires #709 authorization
* ordinary profiles do not gain gitea.decision_lock.irrecoverable_recovery
"""
from __future__ import annotations
import os
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
import sys
ROOT = str(Path(__file__).resolve().parent.parent)
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
import mcp_session_state as ss
import mcp_server
import stale_review_decision_lock as srdl
import task_capability_map
HEAD_A = "a0fffae576673ba7df7456b32e6aec916581bdfb"
HEAD_B = "a6a2243aad9c3e385fc70509f4941a0f0ec33162"
HEAD_SAME = HEAD_A
RC_616_A = {
"pr_number": 616,
"action": "request_changes",
"review_id": 443,
"review_state": "request_changes",
"head_sha": HEAD_A,
}
def _lock(mutations=None, **kwargs):
base = {
"task": "review_pr",
"kind": ss.KIND_DECISION_LOCK,
"remote": "prgs",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
"session_pid": os.getpid(),
"session_profile": "prgs-reviewer",
"session_profile_lock": "prgs-reviewer",
"profile_identity": "prgs-reviewer",
"final_review_decision_ready": False,
"ready_pr_number": kwargs.get("ready_pr"),
"ready_action": kwargs.get("ready_action"),
"ready_expected_head_sha": kwargs.get("ready_head"),
"ready_remote": "prgs" if kwargs.get("ready_pr") else None,
"ready_org": "Scaled-Tech-Consulting" if kwargs.get("ready_pr") else None,
"ready_repo": "Gitea-Tools" if kwargs.get("ready_pr") else None,
"live_mutations": list(mutations or []),
"correction_authorized": False,
"correction_reason": None,
}
return base
def _open_pr(pr_number=616, head=HEAD_B, merged=False, closed=False):
state = "closed" if closed or merged else "open"
return {
"number": pr_number,
"state": state,
"merged": merged,
"merged_at": "2026-07-16T12:00:00Z" if merged else None,
"merge_commit_sha": "m" * 40 if merged else None,
"head": {"sha": head},
}
def _no_lease():
return {"block": False, "reasons": [], "mutation_allowed": True}
def _feedback(blocking=False, stale=True):
return {
"success": True,
"has_blocking_change_requests": blocking,
"review_feedback_stale": stale,
"current_head_sha": HEAD_B,
}
def _age_lock_payload(lock: dict, hours: float = 5.0) -> dict:
"""Rewrite timestamps to *hours* ago (simulates durable age without touching prod)."""
aged = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat().replace(
"+00:00", "Z"
)
out = dict(lock)
out["recorded_at"] = aged
out["updated_at"] = aged
return out
class TestIssue720ExpiredDecisionLockLifecycle(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.env = patch.dict(
os.environ,
{
ss.STATE_DIR_ENV: self._tmp.name,
ss.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer",
"GITEA_MCP_PROFILE": "prgs-reviewer",
"GITEA_PROFILE_NAME": "prgs-reviewer",
},
clear=False,
)
self.env.start()
mcp_server._REVIEW_DECISION_LOCK = None
import review_workflow_load
review_workflow_load.clear_review_workflow_load()
review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT)
mcp_server.gitea_load_review_workflow()
def tearDown(self):
mcp_server._REVIEW_DECISION_LOCK = None
import review_workflow_load
review_workflow_load.clear_review_workflow_load()
self.env.stop()
self._tmp.cleanup()
def _persist_aged_lock(self, mutations, hours: float = 5.0, **kwargs):
"""Write decision lock aged > TTL to the temp durable store (test-only)."""
payload = _age_lock_payload(_lock(mutations, **kwargs), hours=hours)
saved = ss.save_state(
kind=ss.KIND_DECISION_LOCK,
payload=payload,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
profile_identity="prgs-reviewer",
)
# save_state refreshes updated_at; re-age the on-disk envelope for TTL tests.
path = ss.state_file_path(
kind=ss.KIND_DECISION_LOCK,
profile_identity="prgs-reviewer",
state_dir=self._tmp.name,
)
aged = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat().replace(
"+00:00", "Z"
)
import json
with open(path, encoding="utf-8") as fh:
envelope = json.load(fh)
envelope["recorded_at"] = aged
envelope["updated_at"] = aged
if isinstance(envelope.get("payload"), dict):
envelope["payload"]["recorded_at"] = aged
envelope["payload"]["updated_at"] = aged
with open(path, "w", encoding="utf-8") as fh:
json.dump(envelope, fh, indent=2, sort_keys=True)
fh.write("\n")
# Drop memory so subsequent loads hit durable store.
mcp_server._REVIEW_DECISION_LOCK = None
return saved
def _mark(self, pr, action, head, feedback=None):
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
), patch.object(
mcp_server,
"gitea_get_pr_review_feedback",
return_value=feedback or _feedback(blocking=False, stale=True),
), patch.object(
mcp_server,
"gitea_check_pr_eligibility",
return_value={"eligible": True, "head_sha": head},
), patch.object(
mcp_server.mcp_daemon_guard,
"assert_sanctioned_mutation_runtime",
return_value=None,
), patch.object(
mcp_server.mcp_daemon_guard,
"assert_no_direct_import_bypass",
return_value=None,
):
return mcp_server.gitea_mark_final_review_decision(
pr_number=pr,
action=action,
expected_head_sha=head,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
# --- AC regression matrix ---
def test_under_four_hours_fresh_review_at_b_allowed(self):
self._persist_aged_lock(
[RC_616_A],
hours=1.0,
ready_pr=616,
ready_head=HEAD_A,
ready_action="request_changes",
)
res = self._mark(616, "approve", HEAD_B)
self.assertTrue(res.get("marked_ready"), res)
def test_over_four_hours_fresh_review_at_b_still_allowed(self):
"""Primary #720 defect: age > TTL must not block head-B mark_final."""
self._persist_aged_lock(
[RC_616_A],
hours=5.0,
ready_pr=616,
ready_head=HEAD_A,
ready_action="request_changes",
)
# Prove durable load is possible for recovery-critical decision locks.
loaded = ss.load_state(
kind=ss.KIND_DECISION_LOCK,
profile_identity="prgs-reviewer",
)
self.assertIsNotNone(
loaded,
"expired KIND_DECISION_LOCK must remain loadable (not generic TTL cache)",
)
res = self._mark(616, "approve", HEAD_B)
self.assertTrue(
res.get("marked_ready"),
f"expected mark_ready on head B after >4h; got {res}",
)
reasons = " ".join(res.get("reasons") or [])
self.assertNotIn("session state expired", reasons)
def test_historical_review_at_a_preserved_and_stale(self):
self._persist_aged_lock(
[RC_616_A],
hours=5.0,
ready_pr=616,
ready_head=HEAD_A,
ready_action="request_changes",
)
res = self._mark(616, "approve", HEAD_B)
self.assertTrue(res.get("marked_ready"), res)
lock = mcp_server._load_review_decision_lock()
self.assertIsNotNone(lock)
hist = [m for m in lock["live_mutations"] if m.get("review_id") == 443]
self.assertEqual(len(hist), 1)
self.assertEqual(hist[0]["head_sha"], HEAD_A)
self.assertEqual(hist[0]["action"], "request_changes")
a = srdl.assess_stale_review_decision_lock(
lock, pr_live=_open_pr(616, HEAD_B)
)
self.assertTrue(a["stale_by_head"])
self.assertTrue(a["fresh_review_on_current_head_allowed"])
self.assertEqual(a["locked_head_sha"], HEAD_A)
def test_no_reuse_approval_from_head_a(self):
"""REQUEST_CHANGES at A is never an approval credential for B."""
self._persist_aged_lock([RC_616_A], hours=5.0)
lock = ss.load_state(
kind=ss.KIND_DECISION_LOCK, profile_identity="prgs-reviewer"
)
last = srdl.last_terminal_mutation(lock)
self.assertEqual(last.get("action"), "request_changes")
self.assertNotEqual(last.get("action"), "approve")
# Gate must still require a new mark/submit for head B; prior RC is not approve.
reasons = mcp_server.terminal_review_hard_stop_reasons(
616, "mark_ready", expected_head_sha=HEAD_B
)
self.assertEqual(reasons, [])
def test_second_terminal_mutation_at_b_same_run_blocked(self):
self._persist_aged_lock(
[RC_616_A],
hours=5.0,
ready_pr=616,
ready_head=HEAD_A,
ready_action="request_changes",
)
res = self._mark(616, "approve", HEAD_B)
self.assertTrue(res.get("marked_ready"), res)
# Record terminal approve at B (same run).
lock = mcp_server._load_review_decision_lock()
lock["final_review_decision_ready"] = True
lock["ready_pr_number"] = 616
lock["ready_action"] = "approve"
lock["ready_expected_head_sha"] = HEAD_B
mcp_server._save_review_decision_lock(lock)
mcp_server.record_live_review_mutation(616, "approve", review_id=999)
# Second terminal at same head B must hard-stop.
hard = mcp_server.terminal_review_hard_stop_reasons(
616, "mark_ready", expected_head_sha=HEAD_B
)
self.assertTrue(hard)
res2 = self._mark(616, "approve", HEAD_B)
self.assertFalse(res2.get("marked_ready"))
def test_open_pr_same_head_expired_still_fail_closed(self):
self._persist_aged_lock(
[RC_616_A],
hours=5.0,
ready_pr=616,
ready_head=HEAD_A,
ready_action="request_changes",
)
res = self._mark(616, "approve", HEAD_SAME)
self.assertFalse(res.get("marked_ready"), res)
self.assertTrue(
any("#332" in r or "already consumed" in r for r in (res.get("reasons") or [])),
res,
)
def test_merged_pr_moot_cleanup_still_allowed(self):
self._persist_aged_lock(
[RC_616_A],
hours=5.0,
ready_pr=616,
ready_head=HEAD_A,
ready_action="request_changes",
)
lock = ss.load_state(
kind=ss.KIND_DECISION_LOCK, profile_identity="prgs-reviewer"
)
a = srdl.assess_stale_review_decision_lock(
lock, pr_live=_open_pr(616, HEAD_A, merged=True)
)
self.assertTrue(a["is_moot"])
self.assertTrue(a["cleanup_allowed"])
self.assertFalse(a["fresh_review_on_current_head_allowed"])
def test_assessment_reports_expired_old_head_not_absent(self):
self._persist_aged_lock(
[RC_616_A],
hours=5.0,
ready_pr=616,
ready_head=HEAD_A,
ready_action="request_changes",
)
# Disk presence + load after lifecycle fix.
path = ss.state_file_path(
kind=ss.KIND_DECISION_LOCK,
profile_identity="prgs-reviewer",
state_dir=self._tmp.name,
)
self.assertTrue(os.path.exists(path))
loaded = ss.load_state(
kind=ss.KIND_DECISION_LOCK, profile_identity="prgs-reviewer"
)
self.assertIsNotNone(loaded)
inspect = ss.inspect_state_envelope(
kind=ss.KIND_DECISION_LOCK,
profile_identity="prgs-reviewer",
state_dir=self._tmp.name,
)
self.assertTrue(inspect.get("on_disk"))
self.assertTrue(inspect.get("has_payload"))
self.assertTrue(inspect.get("recovery_critical") or inspect.get("ttl_exempt"))
self.assertTrue(inspect.get("age_hours", 0) >= 4.0)
a = srdl.assess_stale_review_decision_lock(
loaded, pr_live=_open_pr(616, HEAD_B)
)
self.assertTrue(a["has_lock"])
self.assertNotIn("no review decision lock present", " ".join(a["reasons"]))
self.assertTrue(a["stale_by_head"])
self.assertEqual(a["last_terminal_action"], "request_changes")
def test_existing_serialized_records_compatible(self):
"""Pre-fix ledgers without recovery_critical flag still load via kind."""
payload = _age_lock_payload(_lock([RC_616_A]), hours=8.0)
payload.pop("recovery_critical", None)
# Manually write pre-#720-shaped envelope (kind only on envelope).
path = ss.state_file_path(
kind=ss.KIND_DECISION_LOCK,
profile_identity="prgs-reviewer",
state_dir=self._tmp.name,
)
import json
os.makedirs(self._tmp.name, exist_ok=True)
aged = payload["recorded_at"]
envelope = {
"kind": ss.KIND_DECISION_LOCK,
"remote": "prgs",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
"profile_identity": "prgs-reviewer",
"session_profile_lock": "prgs-reviewer",
"recorded_at": aged,
"updated_at": aged,
"writer_pid": os.getpid(),
"payload": payload,
}
with open(path, "w", encoding="utf-8") as fh:
json.dump(envelope, fh, indent=2, sort_keys=True)
fh.write("\n")
loaded = ss.load_state(
kind=ss.KIND_DECISION_LOCK, profile_identity="prgs-reviewer"
)
self.assertIsNotNone(loaded)
self.assertEqual(loaded["live_mutations"][0]["review_id"], 443)
def test_decision_lock_is_recovery_critical_kind(self):
self.assertIn(ss.KIND_DECISION_LOCK, ss.RECOVERY_CRITICAL_KINDS)
def test_workflow_load_still_ttl_expires(self):
"""Do not make every session-state kind permanently TTL-exempt."""
aged = (datetime.now(timezone.utc) - timedelta(hours=5)).isoformat().replace(
"+00:00", "Z"
)
rec = {
"kind": ss.KIND_WORKFLOW_LOAD,
"recorded_at": aged,
"updated_at": aged,
"profile_identity": "prgs-reviewer",
"session_profile_lock": "prgs-reviewer",
}
reasons = ss.identity_match_reasons(
rec, profile_identity="prgs-reviewer"
)
self.assertTrue(any("expired" in r for r in reasons), reasons)
def test_irrecoverable_permission_not_on_ordinary_profiles(self):
perm = "gitea.decision_lock.irrecoverable_recovery"
# Capability map must not map ordinary author/reviewer/merger tasks to it.
for task in (
"create_issue",
"comment_issue",
"review_pr",
"merge_pr",
"lock_issue",
"create_pr",
):
req = task_capability_map.required_permission(task)
self.assertNotEqual(req, perm, msg=task)
def test_structured_error_when_same_head_expired(self):
self._persist_aged_lock(
[RC_616_A],
hours=5.0,
ready_pr=616,
ready_head=HEAD_A,
ready_action="request_changes",
)
res = self._mark(616, "approve", HEAD_A)
self.assertFalse(res.get("marked_ready"))
reasons = res.get("reasons") or []
self.assertTrue(reasons)
blob = " ".join(reasons)
# Actionable recovery text from hard-stop (not generic internal_error).
self.assertIn("#332", blob)
self.assertTrue(
"#620" in blob or "head moved" in blob or "new expected_head_sha" in blob
or "already consumed" in blob
)
self.assertNotIn("internal_error", blob.lower())
class TestIssue720InspectEnvelope(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.env = patch.dict(
os.environ,
{
ss.STATE_DIR_ENV: self._tmp.name,
ss.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer",
},
clear=False,
)
self.env.start()
def tearDown(self):
self.env.stop()
self._tmp.cleanup()
def test_inspect_missing_file(self):
info = ss.inspect_state_envelope(
kind=ss.KIND_DECISION_LOCK,
profile_identity="prgs-reviewer",
state_dir=self._tmp.name,
)
self.assertFalse(info["on_disk"])
self.assertFalse(info["has_payload"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,385 @@
"""Regression matrix for Issue #733.
``gitea_delete_branch`` accepts explicit ``org``/``repo`` but historically did
not propagate them through the anti-stomp preflight: preflight resolved the
remote-wide ``REMOTES`` default (bare ``prgs`` ``Scaled-Tech-Consulting/
Timesheet``) and fail-closed with ``wrong_repo`` even though the caller
explicitly targeted ``Scaled-Tech-Consulting/Gitea-Tools``.
The fix has two parts, both exercised here:
1. ``gitea_delete_branch`` forwards the explicit ``org``/``repo`` into
``verify_preflight_purity`` so the shared #604 anti-stomp resolution
validates the *targeted* repository instead of the remote-wide default.
2. A workspace-derived repository-binding gate
(``_delete_branch_repository_binding_block``) validates explicit
coordinates against the immutable workspace identity, rejecting wrong,
substituted, or unverified targets independent of the anti-stomp
remote/repo guard, which by the #530 contract trusts explicit intent.
Every existing gate (reconciler-only ownership; author/reviewer/merger denial;
protected/preservation blocks) is preserved.
"""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import mcp_server
from mcp_server import gitea_delete_branch
import remote_repo_guard
import task_capability_map
import role_session_router
FAKE_AUTH = "token fake"
# The workspace is bound to Gitea-Tools even though the prgs REMOTES default
# repo is Timesheet (the exact #733 scenario).
WORKSPACE_SLUG = "Scaled-Tech-Consulting/Gitea-Tools"
LOCAL_GITEA_TOOLS_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
RECONCILER = {
"profile_name": "prgs-reconciler",
"role": "reconciler",
"allowed_operations": [
"gitea.read", "gitea.issue.comment", "gitea.pr.comment",
"gitea.branch.delete",
],
"forbidden_operations": ["gitea.pr.create", "gitea.pr.merge"],
"audit_label": "prgs-reconciler",
}
AUTHOR_WITH_DELETE = {
"profile_name": "prgs-author",
"role": "author",
"allowed_operations": [
"gitea.read", "gitea.pr.create", "gitea.branch.push",
"gitea.branch.delete",
],
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
"audit_label": "prgs-author",
}
REVIEWER_WITH_DELETE = {
"profile_name": "prgs-reviewer",
"role": "reviewer",
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.branch.delete"],
"forbidden_operations": [],
"audit_label": "prgs-reviewer",
}
MERGER_WITH_DELETE = {
"profile_name": "prgs-merger",
"role": "merger",
"allowed_operations": ["gitea.read", "gitea.pr.merge", "gitea.branch.delete"],
"forbidden_operations": [],
"audit_label": "prgs-merger",
}
class _DeleteBranchBase(unittest.TestCase):
def setUp(self):
self._snap = (
mcp_server._preflight_whoami_called,
mcp_server._preflight_capability_called,
)
mcp_server._preflight_whoami_called = False
mcp_server._preflight_capability_called = False
# prgs default repo is Timesheet — the remote-wide default #733 must NOT
# fall back to.
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {
"host": "gitea.prgs.cc",
"org": "Scaled-Tech-Consulting",
"repo": "Timesheet",
},
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
patch("gitea_config.load_config", return_value={}).start()
patch("gitea_config.is_runtime_switching_enabled", return_value=False).start()
patch("gitea_audit.audit_enabled", return_value=False).start()
self.mock_api = patch("mcp_server.api_request").start()
self.mock_api.return_value = {}
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
# Workspace is verifiably bound to Gitea-Tools.
patch(
"mcp_server._workspace_repository_slug", return_value=WORKSPACE_SLUG
).start()
def tearDown(self):
patch.stopall()
mcp_server._IDENTITY_CACHE.clear()
(
mcp_server._preflight_whoami_called,
mcp_server._preflight_capability_called,
) = self._snap
def _bind(self, profile, role):
patch("mcp_server.get_profile", return_value=profile).start()
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role=role)
def _delete_calls(self):
return [
c for c in self.mock_api.call_args_list
if c.args and c.args[0] == "DELETE"
]
class TestPositiveTimesheetDefaultExplicitGiteaTools(_DeleteBranchBase):
"""AC: prgs default=Timesheet + explicit Gitea-Tools → preflight validates
Gitea-Tools and permits an otherwise-eligible deletion."""
def test_explicit_gitea_tools_permits_delete_and_forwards_coords(self):
self._bind(RECONCILER, "reconciler")
with patch("mcp_server.verify_preflight_purity") as vpp:
res = gitea_delete_branch(
branch="feat/pr-sync-status",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertTrue(res["success"], res)
self.assertIn("deleted", res["message"])
self.assertTrue(self._delete_calls())
# Forwarding: preflight received the explicit Gitea-Tools coordinates
# and the delete_branch task (not the remote-wide Timesheet default).
self.assertTrue(vpp.called)
kwargs = vpp.call_args.kwargs
self.assertEqual(kwargs.get("task"), "delete_branch")
self.assertEqual(kwargs.get("org"), "Scaled-Tech-Consulting")
self.assertEqual(kwargs.get("repo"), "Gitea-Tools")
class TestNegativeRepositoryBinding(_DeleteBranchBase):
"""AC negatives: wrong / substituted / unverified coordinates fail closed."""
def test_wrong_explicit_repo_fails_closed(self):
self._bind(RECONCILER, "reconciler")
with patch("mcp_server.verify_preflight_purity") as vpp:
res = gitea_delete_branch(
branch="feat/x",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Timesheet", # not the workspace-bound Gitea-Tools
)
self.assertFalse(res["success"])
self.assertFalse(res["performed"])
self.assertEqual(res["blocker_kind"], "repository_binding")
self.assertTrue(any("Timesheet" in r for r in res["reasons"]))
self.assertFalse(self._delete_calls())
# Fail closed BEFORE any preflight/deletion side effect.
vpp.assert_not_called()
def test_repository_substitution_org_mismatch_rejected(self):
self._bind(RECONCILER, "reconciler")
res = gitea_delete_branch(
branch="feat/x",
remote="prgs",
org="Some-Other-Org",
repo="Gitea-Tools",
)
self.assertFalse(res["success"])
self.assertEqual(res["blocker_kind"], "repository_binding")
self.assertFalse(self._delete_calls())
def test_explicit_coords_without_workspace_identity_fail_closed(self):
self._bind(RECONCILER, "reconciler")
with patch("mcp_server._workspace_repository_slug", return_value=None):
res = gitea_delete_branch(
branch="feat/x",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertFalse(res["success"])
self.assertEqual(res["blocker_kind"], "repository_binding")
self.assertTrue(any("unverified" in r for r in res["reasons"]))
self.assertFalse(self._delete_calls())
def test_matching_explicit_passes_binding(self):
self._bind(RECONCILER, "reconciler")
block = mcp_server._delete_branch_repository_binding_block(
"prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools"
)
self.assertIsNone(block)
def test_omitted_coords_pass_binding_and_are_revalidated_by_preflight(self):
# Omitted coordinates pass the binding gate (nothing to corroborate) but
# are forwarded as None so the anti-stomp resolution fails closed on the
# remote-wide Timesheet default (see TestAntiStompResolution below).
self._bind(RECONCILER, "reconciler")
self.assertIsNone(
mcp_server._delete_branch_repository_binding_block(
"prgs", org=None, repo=None
)
)
with patch("mcp_server.verify_preflight_purity") as vpp:
gitea_delete_branch(branch="feat/x", remote="prgs")
self.assertTrue(vpp.called)
kwargs = vpp.call_args.kwargs
self.assertIsNone(kwargs.get("org"))
self.assertIsNone(kwargs.get("repo"))
class TestRoleDenials(_DeleteBranchBase):
"""AC: author, reviewer, and merger remain denied even holding the perm."""
def test_author_with_delete_perm_denied(self):
self._bind(AUTHOR_WITH_DELETE, "author")
res = gitea_delete_branch(
branch="feat/x", remote="prgs",
org="Scaled-Tech-Consulting", repo="Gitea-Tools",
)
self.assertFalse(res["success"])
self.assertEqual(res["required_role_kind"], "reconciler")
self.assertEqual(res["active_role_kind"], "author")
self.assertFalse(self._delete_calls())
def test_reviewer_with_delete_perm_denied(self):
self._bind(REVIEWER_WITH_DELETE, "reviewer")
res = gitea_delete_branch(
branch="feat/x", remote="prgs",
org="Scaled-Tech-Consulting", repo="Gitea-Tools",
)
self.assertFalse(res["success"])
self.assertEqual(res["required_role_kind"], "reconciler")
self.assertFalse(self._delete_calls())
def test_merger_with_delete_perm_denied(self):
self._bind(MERGER_WITH_DELETE, "merger")
res = gitea_delete_branch(
branch="feat/x", remote="prgs",
org="Scaled-Tech-Consulting", repo="Gitea-Tools",
)
self.assertFalse(res["success"])
self.assertEqual(res["required_role_kind"], "reconciler")
self.assertFalse(self._delete_calls())
class TestProtectedAndPreservedBranches(_DeleteBranchBase):
"""AC: protected and preservation/evidence branches remain blocked."""
def test_protected_branch_blocked(self):
self._bind(RECONCILER, "reconciler")
for branch in ("master", "main", "dev"):
with self.subTest(branch=branch):
res = gitea_delete_branch(
branch=branch, remote="prgs",
org="Scaled-Tech-Consulting", repo="Gitea-Tools",
)
self.assertFalse(res["success"])
self.assertTrue(any("protected" in r for r in res["reasons"]))
self.assertFalse(self._delete_calls())
def test_preservation_branch_blocked(self):
self._bind(RECONCILER, "reconciler")
for branch in ("chore/preserve-local-master", "feat/evidence-run"):
with self.subTest(branch=branch):
res = gitea_delete_branch(
branch=branch, remote="prgs",
org="Scaled-Tech-Consulting", repo="Gitea-Tools",
)
self.assertFalse(res["success"])
self.assertTrue(
any("preservation" in r for r in res["reasons"])
)
self.assertFalse(self._delete_calls())
class TestAntiStompResolution(unittest.TestCase):
"""The anti-stomp repository resolution validates the *targeted* repo and
fails closed on the remote-wide default when coordinates are omitted."""
def setUp(self):
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {
"host": "gitea.prgs.cc",
"org": "Scaled-Tech-Consulting",
"repo": "Timesheet",
},
})
self._remotes.start()
def tearDown(self):
patch.stopall()
def _capture_resolution(self, org, repo):
"""Drive the live anti-stomp runner and capture the org/repo it resolved
and passed to the pure assessor."""
passthrough = {
"allowed": True, "block": False, "blockers": [], "reasons": [],
"exact_next_action": "proceed", "blocker_kind": None, "checks": {},
}
with patch.dict(
os.environ, {"GITEA_TEST_FORCE_ANTI_STOMP": "1"}, clear=False
), patch(
"mcp_server._local_git_remote_url", return_value=LOCAL_GITEA_TOOLS_URL
), patch(
"mcp_server.get_profile", return_value=RECONCILER
), patch.object(
mcp_server.anti_stomp_preflight,
"assess_anti_stomp_preflight",
return_value=passthrough,
) as m:
mcp_server._run_anti_stomp_preflight(
"delete_branch", remote="prgs", org=org, repo=repo
)
self.assertTrue(m.called)
return m.call_args.kwargs
def test_explicit_gitea_tools_resolved_and_marked_explicit(self):
kw = self._capture_resolution("Scaled-Tech-Consulting", "Gitea-Tools")
self.assertEqual(kw["resolved_org"], "Scaled-Tech-Consulting")
self.assertEqual(kw["resolved_repo"], "Gitea-Tools")
self.assertTrue(kw["org_explicit"])
self.assertTrue(kw["repo_explicit"])
def test_missing_coords_resolve_remote_default_and_fail_closed(self):
# Omitted coords resolve the remote-wide Timesheet default with
# org/repo NOT explicit; the remote/repo guard then blocks against the
# local Gitea-Tools remote — i.e. missing coordinates fail closed.
kw = self._capture_resolution(None, None)
self.assertEqual(kw["resolved_repo"], "Timesheet")
self.assertFalse(kw["org_explicit"])
self.assertFalse(kw["repo_explicit"])
# Compose the guard exactly as the assessor would: Timesheet default,
# not explicit, local remote is Gitea-Tools → blocked (wrong_repo).
assessment = remote_repo_guard.assess_remote_repo_match(
remote="prgs",
resolved_org=kw["resolved_org"],
resolved_repo=kw["resolved_repo"],
local_remote_url=LOCAL_GITEA_TOOLS_URL,
org_explicit=kw["org_explicit"],
repo_explicit=kw["repo_explicit"],
)
self.assertTrue(assessment["block"])
class TestCapabilityRoleMapConsistency(unittest.TestCase):
"""AC: task-capability and role-routing maps remain consistent (#729)."""
def test_delete_branch_is_reconciler_in_both_maps(self):
self.assertEqual(
task_capability_map.required_role("delete_branch"), "reconciler"
)
self.assertEqual(
task_capability_map.required_permission("delete_branch"),
"gitea.branch.delete",
)
self.assertEqual(
role_session_router.required_role_for_task("delete_branch"),
"reconciler",
)
self.assertEqual(
task_capability_map.required_role("delete_branch"),
role_session_router.TASK_REQUIRED_ROLE["delete_branch"],
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,768 @@
"""Complete PROJECT_ROOT elimination in cross-repository MCP operations (#741).
#706 introduced the immutable ``canonical_repository_root``; #739/#740 routed
three consumption paths through it. This module covers the paths #740 left
behind, whose shape is uniform: the *filesystem* guards were migrated to the
canonical root, but *repository identity* still bottomed out in
``_local_git_remote_url``'s hardcoded ``cwd=PROJECT_ROOT`` — always the
Gitea-Tools installation checkout.
The consequences asserted here:
* **Identity inversion.** ``_resolve``, the #530 remote/repo guard and the
anti-stomp org/repo fill all derived the *target* repository from the
*install* checkout, so a cross-repository namespace resolved Gitea-Tools
coordinates while its branch/parity facts came from the target repo.
* **Guard disagreement.** ``_verify_role_mutation_workspace`` omitted
``configured_canonical_root``, so the #274 branches-only / worktree-membership
guards validated ``Gitea-Tools/branches/`` rather than the bound target.
* **Explicit-coordinate override.** Both-explicit ``org``/``repo`` short-circuit
``assess_remote_repo_match``, so caller coordinates bypassed validation
entirely rather than merely *confirming* the binding.
* **Configuration fail-open.** ``_flatten_identity`` silently dropped
``canonical_repository_root``, so a v2-``environments`` namespace fell back to
the install root; the v1 path never validated the field at all.
A role-by-operation matrix drives author, reviewer, merger and reconciler
against: correct cross-repository target, wrong repository, wrong worktree,
explicit matching coordinates, explicit mismatched coordinates, missing
canonical root, invalid canonical root, immutable binding after first bind, and
request-override attempts.
Real git repositories are used throughout. No network calls are made, no branch
is deleted, and no merge is performed.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import canonical_repository_root as crr # noqa: E402
import gitea_config # noqa: E402
import gitea_mcp_server as srv # noqa: E402
import mcp_server # noqa: E402
import session_context_binding as session_ctx # noqa: E402
INSTALL_ORG = "Scaled-Tech-Consulting"
INSTALL_REPO = "Gitea-Tools"
INSTALL_SLUG = f"{INSTALL_ORG}/{INSTALL_REPO}"
INSTALL_URL = f"https://gitea.prgs.cc/{INSTALL_SLUG}.git"
TARGET_ORG = "Scaled-Tech-Consulting"
TARGET_REPO = "mcp-control-plane"
TARGET_SLUG = f"{TARGET_ORG}/{TARGET_REPO}"
TARGET_URL = f"https://gitea.prgs.cc/{TARGET_SLUG}.git"
THIRD_REPO = "Timesheet"
THIRD_SLUG = f"{INSTALL_ORG}/{THIRD_REPO}"
# tests/conftest.py installs an autouse fixture
# (mutation_profile_fixture.install_deterministic_remote_urls) that *permanently
# reassigns* srv._local_git_remote_url to a stub mapping remote names to fixed
# URLs. That stub deliberately ignores the working directory, which is exactly
# the behaviour this module must verify — so these tests would silently assert
# against the stub rather than production code in a full-suite run. Capture the
# genuine implementation at import time (before any fixture executes) and
# reinstall it per test.
_REAL_LOCAL_GIT_REMOTE_URL = srv._local_git_remote_url
def _git(cwd: str, *args: str) -> str:
res = subprocess.run(
["git", "-C", cwd, *args], capture_output=True, text=True, check=True
)
return res.stdout.strip()
def _init_repo(path: Path, remote_url: str, *, remote_name: str = "origin") -> str:
path.mkdir(parents=True, exist_ok=True)
_git(str(path), "init", "-q")
_git(str(path), "config", "user.email", "[email protected]")
_git(str(path), "config", "user.name", "Test")
_git(str(path), "remote", "add", remote_name, remote_url)
# Distinct content per repo: identical seed content, author and timestamp
# otherwise produce byte-identical commits and therefore an identical SHA,
# which would make the parity-dimension assertions vacuously true.
(path / "README.md").write_text(f"seed {remote_url}\n")
_git(str(path), "add", "README.md")
_git(str(path), "commit", "-q", "-m", f"seed {remote_name}")
return os.path.realpath(str(path))
_ROLE_OPS = {
"author": (
[
"gitea.read",
"gitea.issue.create",
"gitea.issue.comment",
"gitea.pr.create",
"gitea.pr.comment",
"gitea.branch.create",
"gitea.branch.push",
"gitea.repo.commit",
],
["gitea.pr.approve", "gitea.pr.merge", "gitea.pr.request_changes"],
),
"reviewer": (
[
"gitea.read",
"gitea.pr.approve",
"gitea.pr.request_changes",
"gitea.pr.comment",
"gitea.issue.comment",
],
["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
),
"merger": (
["gitea.read", "gitea.pr.merge", "gitea.pr.comment"],
[
"gitea.pr.create",
"gitea.branch.push",
"gitea.pr.approve",
"gitea.pr.request_changes",
],
),
"reconciler": (
["gitea.read", "gitea.pr.close", "gitea.pr.comment", "gitea.branch.delete"],
[
"gitea.pr.create",
"gitea.branch.push",
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.request_changes",
],
),
}
ROLES = tuple(_ROLE_OPS)
def _profile(role: str, *, canonical_root: str | None = None) -> dict:
allowed, forbidden = _ROLE_OPS[role]
profile = {
"enabled": True,
"context": "prgs",
"role": role,
"username": "jcwalker3",
"base_url": "https://gitea.prgs.cc",
"auth": {"type": "env", "name": f"GITEA_TOKEN_PRGS_{role.upper()}"},
"allowed_operations": list(allowed),
"forbidden_operations": list(forbidden),
"execution_profile": f"prgs-{role}",
"allowed_repositories": [TARGET_SLUG],
}
if canonical_root is not None:
profile["canonical_repository_root"] = canonical_root
return profile
def _config(profiles: dict) -> dict:
return {
"version": 2,
"rules": {"allow_runtime_switching": True},
"contexts": {
"prgs": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
}
},
"profiles": profiles,
}
class _CrossRepoHarness(unittest.TestCase):
"""Real install + target git repos, temp profiles.json, no network."""
def setUp(self):
self._dir = tempfile.TemporaryDirectory()
self.tmp = self._dir.name
self.config_path = os.path.join(self.tmp, "profiles.json")
# The real install checkout carries a remote literally named `prgs`;
# a freshly cloned target repository normally names its remote `origin`.
# Reproducing that asymmetry is the point: identity derivation must not
# depend on the remote happening to share the `remote=` argument's name.
self.install_root = _init_repo(
Path(self.tmp) / "install", INSTALL_URL, remote_name="prgs"
)
self.target_root = _init_repo(
Path(self.tmp) / "target", TARGET_URL, remote_name="origin"
)
self.third_root = _init_repo(
Path(self.tmp) / "third", f"https://gitea.prgs.cc/{THIRD_SLUG}.git"
)
self.not_a_repo = os.path.join(self.tmp, "plain-dir")
os.makedirs(self.not_a_repo, exist_ok=True)
session_ctx._reset_session_context_for_testing()
gitea_config._active_profile_override = None
mcp_server._IDENTITY_CACHE.clear()
srv._MUTATION_AUTHORITY = None
# PROJECT_ROOT is wherever the server file physically lives; pin it to a
# real Gitea-Tools-identified checkout so "install-derived" is
# deterministic regardless of the developer's layout.
self._project_root = patch.object(srv, "PROJECT_ROOT", self.install_root)
self._project_root.start()
# Undo the autouse deterministic-remote stub for this module only.
self._real_remote_url = patch.object(
srv, "_local_git_remote_url", _REAL_LOCAL_GIT_REMOTE_URL
)
self._real_remote_url.start()
def tearDown(self):
self._real_remote_url.stop()
self._project_root.stop()
session_ctx._reset_session_context_for_testing()
gitea_config._active_profile_override = None
mcp_server._IDENTITY_CACHE.clear()
srv._MUTATION_AUTHORITY = None
self._dir.cleanup()
def _write_config(self, profiles: dict) -> None:
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(_config(profiles)))
def _env(self, role: str, **extra) -> dict:
env = {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": f"prgs-{role}",
"GITEA_TOKEN_PRGS_AUTHOR": "t",
"GITEA_TOKEN_PRGS_REVIEWER": "t",
"GITEA_TOKEN_PRGS_MERGER": "t",
"GITEA_TOKEN_PRGS_RECONCILER": "t",
}
env.update(extra)
return env
def _bind(self, role: str, canonical_root: str | None):
"""Activate *role* with *canonical_root* and return an env patch ctx."""
self._write_config(
{f"prgs-{role}": _profile(role, canonical_root=canonical_root)}
)
return patch.dict(os.environ, self._env(role), clear=True)
# ===========================================================================
# 1. The central helper (single source of root resolution)
# ===========================================================================
class TestCanonicalLocalGitRoot(_CrossRepoHarness):
"""_canonical_local_git_root is the one place a target root is derived."""
def test_unconfigured_namespace_uses_installation_root(self):
with self._bind("author", None):
self.assertEqual(srv._canonical_local_git_root(), self.install_root)
def test_configured_namespace_uses_target_root(self):
with self._bind("author", self.target_root):
self.assertEqual(srv._canonical_local_git_root(), self.target_root)
def test_configured_root_resolves_to_git_toplevel(self):
nested = os.path.join(self.target_root, "branches", "wt")
os.makedirs(nested, exist_ok=True)
with self._bind("author", nested):
# A subdirectory of the target repo still resolves to its toplevel.
self.assertEqual(srv._canonical_local_git_root(), self.target_root)
def test_invalid_root_never_silently_becomes_installation_root(self):
"""A configured-but-broken root must not fall back to Gitea-Tools."""
with self._bind("author", self.not_a_repo):
resolved = srv._canonical_local_git_root()
self.assertNotEqual(resolved, self.install_root)
self.assertEqual(resolved, os.path.realpath(self.not_a_repo))
def test_env_override_beats_profile_binding(self):
self._write_config(
{"prgs-author": _profile("author", canonical_root=self.third_root)}
)
env = self._env("author", GITEA_CANONICAL_REPOSITORY_ROOT=self.target_root)
with patch.dict(os.environ, env, clear=True):
self.assertEqual(srv._canonical_local_git_root(), self.target_root)
# ===========================================================================
# 2. Repository identity — the upstream inversion
# ===========================================================================
class TestRepositoryIdentityDerivation(_CrossRepoHarness):
"""_local_git_remote_url and its consumers must read the target repo."""
def test_remote_url_reads_target_not_installation(self):
with self._bind("author", self.target_root):
self.assertEqual(srv._local_git_remote_url("origin"), TARGET_URL)
def test_remote_url_unconfigured_still_reads_installation(self):
with self._bind("author", None):
# The install checkout's remote is named `prgs`, matching production.
self.assertEqual(srv._local_git_remote_url("prgs"), INSTALL_URL)
def test_workspace_slug_follows_canonical_root(self):
with self._bind("author", self.target_root):
self.assertEqual(srv._workspace_repository_slug("origin"), TARGET_SLUG)
def test_workspace_slug_unconfigured_is_installation(self):
with self._bind("author", None):
self.assertEqual(srv._workspace_repository_slug("prgs"), INSTALL_SLUG)
def test_every_role_derives_the_same_target_identity(self):
for role in ROLES:
with self.subTest(role=role):
with self._bind(role, self.target_root):
self.assertEqual(
srv._workspace_repository_slug("origin"), TARGET_SLUG
)
# ===========================================================================
# 3. _resolve — omitted and explicit coordinates
# ===========================================================================
class TestResolveTargetCoordinates(_CrossRepoHarness):
"""Omitted coordinates follow the binding; explicit ones may only confirm."""
def test_omitted_coordinates_resolve_to_target_repository(self):
for role in ROLES:
with self.subTest(role=role):
with self._bind(role, self.target_root):
_host, org, repo = srv._resolve("prgs", None, None, None)
self.assertEqual((org, repo), (TARGET_ORG, TARGET_REPO))
def test_omitted_coordinates_unconfigured_resolve_to_installation(self):
with self._bind("author", None):
_host, org, repo = srv._resolve("prgs", None, None, None)
self.assertEqual((org, repo), (INSTALL_ORG, INSTALL_REPO))
def test_explicit_matching_coordinates_confirm_the_binding(self):
for role in ROLES:
with self.subTest(role=role):
with self._bind(role, self.target_root):
_host, org, repo = srv._resolve(
"prgs", None, TARGET_ORG, TARGET_REPO
)
self.assertEqual((org, repo), (TARGET_ORG, TARGET_REPO))
def test_explicit_mismatched_repository_cannot_override_binding(self):
for role in ROLES:
with self.subTest(role=role):
with self._bind(role, self.target_root):
with self.assertRaises(RuntimeError) as ctx:
srv._resolve("prgs", None, TARGET_ORG, INSTALL_REPO)
msg = str(ctx.exception)
self.assertIn("#741", msg)
self.assertIn("fail closed", msg)
def test_explicit_mismatched_organization_cannot_override_binding(self):
with self._bind("author", self.target_root):
with self.assertRaises(RuntimeError):
srv._resolve("prgs", None, "SomeoneElse", TARGET_REPO)
def test_gitea_tools_rooted_namespace_cannot_target_control_plane(self):
"""Direction 1: an install-rooted namespace must not reach the target."""
with self._bind("author", self.install_root):
with self.assertRaises(RuntimeError):
srv._resolve("prgs", None, TARGET_ORG, TARGET_REPO)
def test_control_plane_rooted_namespace_cannot_target_gitea_tools(self):
"""Direction 2: a target-rooted namespace must not reach Gitea-Tools."""
with self._bind("author", self.target_root):
with self.assertRaises(RuntimeError):
srv._resolve("prgs", None, INSTALL_ORG, INSTALL_REPO)
def test_unconfigured_namespace_keeps_existing_explicit_behaviour(self):
"""Same-repository behaviour is unchanged (no new fail-closed path)."""
with self._bind("author", None):
_host, org, repo = srv._resolve("prgs", None, INSTALL_ORG, INSTALL_REPO)
self.assertEqual((org, repo), (INSTALL_ORG, INSTALL_REPO))
# ===========================================================================
# 4. #274 workspace guard — the two guard paths must agree
# ===========================================================================
class TestMutationWorkspaceGuardBinding(_CrossRepoHarness):
"""Both guard paths must agree on which repository they protect."""
def _contexts(self, worktree: str | None = None):
return srv._resolve_namespace_mutation_context(worktree)
def test_mutation_context_uses_target_root(self):
with self._bind("author", self.target_root):
self.assertEqual(self._contexts()["canonical_repo_root"], self.target_root)
def test_mutation_context_unconfigured_uses_installation_root(self):
with self._bind("author", None):
self.assertEqual(self._contexts()["canonical_repo_root"], self.install_root)
def test_role_mutation_workspace_guard_agrees_with_mutation_context(self):
"""The omitted configured_canonical_root made these two disagree."""
import namespace_workspace_binding as nwb
for role in ROLES:
with self.subTest(role=role):
with self._bind(role, self.target_root):
configured, _src = srv._configured_canonical_root()
assessment = nwb.assess_namespace_mutation_workspace(
role_kind=role,
worktree_path=None,
worktree=None,
process_project_root=srv.PROJECT_ROOT,
profile_name=f"prgs-{role}",
configured_canonical_root=configured,
)
self.assertEqual(
assessment["canonical_repo_root"],
self._contexts()["canonical_repo_root"],
)
self.assertEqual(
assessment["canonical_repo_root"], self.target_root
)
def test_wrong_worktree_is_rejected_against_target_root(self):
"""A worktree belonging to the install repo is not in the target repo."""
import author_mutation_worktree as amw
with self._bind("author", self.target_root):
foreign = os.path.join(self.install_root, "branches", "wt")
os.makedirs(foreign, exist_ok=True)
membership = amw.assess_workspace_repo_membership(
workspace_path=foreign,
canonical_repo_root=self.target_root,
)
self.assertTrue(membership["block"])
# ===========================================================================
# 5. Canonical-root validation — missing / invalid / mismatched
# ===========================================================================
class TestCanonicalRootFailClosed(_CrossRepoHarness):
"""Missing, invalid, ambiguous and mismatched roots fail closed."""
def _assess(self, value, *, expected=TARGET_SLUG, require=True):
return crr.assess_canonical_repository_root(
configured_value=value,
source="test",
expected_slug=expected,
process_project_root=self.install_root,
remote="origin",
require_binding=require,
)
def test_missing_root_fails_closed_when_binding_required(self):
result = self._assess(None)
self.assertTrue(result["block"])
self.assertFalse(result["configured"])
def test_missing_root_is_the_single_repo_default_when_not_required(self):
result = self._assess(None, expected=None, require=False)
self.assertFalse(result["block"])
self.assertEqual(result["canonical_repo_root"], self.install_root)
def test_nonexistent_root_fails_closed(self):
result = self._assess(os.path.join(self.tmp, "nope"))
self.assertTrue(result["block"])
self.assertIn("does not exist", " ".join(result["reasons"]))
def test_non_git_directory_fails_closed(self):
result = self._assess(self.not_a_repo)
self.assertTrue(result["block"])
self.assertIn("not a git repository", " ".join(result["reasons"]))
def test_mismatched_repository_identity_fails_closed(self):
result = self._assess(self.third_root)
self.assertTrue(result["block"])
self.assertIn("mismatch", " ".join(result["reasons"]))
def test_matching_repository_identity_is_proven(self):
result = self._assess(self.target_root)
self.assertFalse(result["block"])
self.assertEqual(result["resolved_slug"], TARGET_SLUG)
self.assertEqual(result["canonical_repo_root"], self.target_root)
def test_unresolvable_root_yields_fail_closed_reasons_not_fallback(self):
with self._bind("author", self.not_a_repo):
slug, reasons = srv._canonical_repository_slug(srv.get_profile(), "origin")
self.assertIsNone(slug)
self.assertTrue(reasons)
# ===========================================================================
# 6. Immutability — first bind wins, requests cannot replace the root
# ===========================================================================
class TestCanonicalRootImmutability(_CrossRepoHarness):
"""No request-supplied value can establish or swap the pinned root."""
def test_first_bind_pins_target_root(self):
with self._bind("author", self.target_root):
with patch(
"gitea_mcp_server.api_request",
side_effect=lambda *a, **k: {
"login": "jcwalker3",
"full_name": "T",
"id": 1,
"email": "[email protected]",
},
):
srv.gitea_whoami(remote="prgs")
bound = session_ctx.get_session_context()
self.assertEqual(bound["canonical_repository_root"], self.target_root)
def test_binding_is_first_write_wins(self):
with self._bind("author", self.target_root):
session_ctx.seed_session_context_if_unbound(
profile_name="prgs-author",
remote="prgs",
host="gitea.prgs.cc",
identity="jcwalker3",
org=TARGET_ORG,
repository=TARGET_REPO,
role_kind="author",
source="test",
canonical_repository_root=self.target_root,
)
# A second, contradictory seed must not replace the pin.
session_ctx.seed_session_context_if_unbound(
profile_name="prgs-author",
remote="prgs",
host="gitea.prgs.cc",
identity="jcwalker3",
org=INSTALL_ORG,
repository=INSTALL_REPO,
role_kind="author",
source="test-2",
canonical_repository_root=self.install_root,
)
bound = session_ctx.get_session_context()
self.assertEqual(bound["canonical_repository_root"], self.target_root)
self.assertEqual(bound["repository"], TARGET_REPO)
def test_request_supplied_worktree_cannot_replace_the_root(self):
with self._bind("author", self.target_root):
ctx = srv._resolve_namespace_mutation_context(self.install_root)
# The workspace argument may be demoted/inspected, but the canonical
# repository root stays the configured target.
self.assertEqual(ctx["canonical_repo_root"], self.target_root)
def test_request_supplied_coordinates_cannot_replace_the_root(self):
with self._bind("author", self.target_root):
with self.assertRaises(RuntimeError):
srv._resolve("prgs", None, INSTALL_ORG, INSTALL_REPO)
self.assertEqual(srv._canonical_local_git_root(), self.target_root)
# ===========================================================================
# 7. Parity dimensions stay separately labelled (#739 F3 preserved)
# ===========================================================================
class TestParityDimensionSeparation(_CrossRepoHarness):
"""Server-implementation parity stays anchored to the install checkout."""
def test_server_dimension_is_installation_not_target(self):
import master_parity_gate
with self._bind("author", self.target_root):
head = master_parity_gate.read_git_head(srv.PROJECT_ROOT)
self.assertEqual(head, _git(self.install_root, "rev-parse", "HEAD"))
self.assertNotEqual(head, _git(self.target_root, "rev-parse", "HEAD"))
def test_target_dimension_reads_the_target_checkout(self):
with self._bind("author", self.target_root):
self.assertEqual(
_git(srv._canonical_local_git_root(), "rev-parse", "HEAD"),
_git(self.target_root, "rev-parse", "HEAD"),
)
# ===========================================================================
# 8. Configuration loaders validate the binding consistently (AC13)
# ===========================================================================
class TestConfigurationLoaderValidation(unittest.TestCase):
"""Every supported loader treats canonical_repository_root identically."""
def setUp(self):
self._dir = tempfile.TemporaryDirectory()
self.tmp = self._dir.name
def tearDown(self):
self._dir.cleanup()
def _write(self, data: dict) -> str:
path = os.path.join(self.tmp, "profiles.json")
with open(path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(data))
return path
# --- v1 ---------------------------------------------------------------
def _v1(self, root):
return {
"version": 1,
"profiles": {
"prgs-author": {
"base_url": "https://gitea.prgs.cc",
"auth": {"type": "env", "name": "GITEA_TOKEN"},
"canonical_repository_root": root,
}
},
}
def test_v1_rejects_relative_canonical_root(self):
path = self._write(self._v1("relative/path"))
with self.assertRaises(gitea_config.ConfigError) as ctx:
gitea_config.load_config(path)
self.assertIn("absolute", str(ctx.exception))
def test_v1_rejects_blank_canonical_root(self):
path = self._write(self._v1(" "))
with self.assertRaises(gitea_config.ConfigError):
gitea_config.load_config(path)
def test_v1_accepts_absolute_canonical_root(self):
path = self._write(self._v1("/abs/target"))
loaded = gitea_config.load_config(path)
self.assertEqual(
loaded["profiles"]["prgs-author"]["canonical_repository_root"],
"/abs/target",
)
def test_v1_without_the_field_is_unchanged(self):
data = self._v1("/abs/target")
del data["profiles"]["prgs-author"]["canonical_repository_root"]
loaded = gitea_config.load_config(self._write(data))
self.assertNotIn("canonical_repository_root", loaded["profiles"]["prgs-author"])
# --- v2 environments --------------------------------------------------
def _v2_env(self, root):
ident = {
"auth": {"type": "env", "name": "GITEA_TOKEN"},
"base_url": "https://gitea.prgs.cc",
"username": "jcwalker3",
"allowed_operations": ["gitea.read"],
}
if root is not None:
ident["canonical_repository_root"] = root
return {
"version": 2,
"environments": {
"prgs": {"services": {"gitea": {"identities": {"author": ident}}}}
},
}
def test_v2_environments_propagates_canonical_root(self):
"""Regression: the field was silently dropped during flattening."""
loaded = gitea_config.load_config(self._write(self._v2_env("/abs/target")))
profile = loaded["profiles"]["prgs.gitea.author"]
self.assertEqual(profile["canonical_repository_root"], "/abs/target")
def test_v2_environments_rejects_relative_canonical_root(self):
with self.assertRaises(gitea_config.ConfigError) as ctx:
gitea_config.load_config(self._write(self._v2_env("relative/path")))
self.assertIn("absolute", str(ctx.exception))
def test_v2_environments_without_the_field_is_unchanged(self):
loaded = gitea_config.load_config(self._write(self._v2_env(None)))
self.assertNotIn(
"canonical_repository_root", loaded["profiles"]["prgs.gitea.author"]
)
# --- v2 contexts (already validated; guard against regression) ---------
def test_v2_contexts_still_rejects_relative_canonical_root(self):
data = {
"version": 2,
"contexts": {
"prgs": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
}
},
"profiles": {
"prgs-author": {
"enabled": True,
"context": "prgs",
"username": "jcwalker3",
"auth": {"type": "env", "name": "GITEA_TOKEN"},
"allowed_operations": ["gitea.read"],
"canonical_repository_root": "relative/path",
}
},
}
with self.assertRaises(gitea_config.ConfigError):
gitea_config.load_config(self._write(data))
# ===========================================================================
# 9. Role-by-operation matrix
# ===========================================================================
class TestRoleOperationMatrix(_CrossRepoHarness):
"""Each role, each cross-repository condition, one assertion per cell."""
def test_correct_target_resolves_for_every_role(self):
for role in ROLES:
with self.subTest(role=role, case="correct-target"):
with self._bind(role, self.target_root):
_h, org, repo = srv._resolve("prgs", None, None, None)
self.assertEqual((org, repo), (TARGET_ORG, TARGET_REPO))
def test_wrong_repository_is_rejected_for_every_role(self):
for role in ROLES:
with self.subTest(role=role, case="wrong-repo"):
with self._bind(role, self.target_root):
with self.assertRaises(RuntimeError):
srv._resolve("prgs", None, INSTALL_ORG, INSTALL_REPO)
def test_explicit_matching_coordinates_pass_for_every_role(self):
for role in ROLES:
with self.subTest(role=role, case="explicit-match"):
with self._bind(role, self.target_root):
_h, org, repo = srv._resolve("prgs", None, TARGET_ORG, TARGET_REPO)
self.assertEqual((org, repo), (TARGET_ORG, TARGET_REPO))
def test_explicit_mismatch_fails_closed_for_every_role(self):
for role in ROLES:
with self.subTest(role=role, case="explicit-mismatch"):
with self._bind(role, self.target_root):
with self.assertRaises(RuntimeError):
srv._resolve("prgs", None, INSTALL_ORG, THIRD_REPO)
def test_missing_canonical_root_preserves_single_repo_default(self):
for role in ROLES:
with self.subTest(role=role, case="missing-root"):
with self._bind(role, None):
self.assertEqual(srv._canonical_local_git_root(), self.install_root)
def test_invalid_canonical_root_never_becomes_install_root(self):
for role in ROLES:
with self.subTest(role=role, case="invalid-root"):
with self._bind(role, self.not_a_repo):
self.assertNotEqual(
srv._canonical_local_git_root(), self.install_root
)
def test_wrong_worktree_rejected_for_every_role(self):
import author_mutation_worktree as amw
foreign = os.path.join(self.install_root, "branches", "foreign")
os.makedirs(foreign, exist_ok=True)
for role in ROLES:
with self.subTest(role=role, case="wrong-worktree"):
with self._bind(role, self.target_root):
membership = amw.assess_workspace_repo_membership(
workspace_path=foreign,
canonical_repo_root=srv._canonical_local_git_root(),
)
self.assertTrue(membership["block"])
def test_request_override_attempt_rejected_for_every_role(self):
for role in ROLES:
with self.subTest(role=role, case="request-override"):
with self._bind(role, self.target_root):
with self.assertRaises(RuntimeError):
srv._resolve("prgs", None, "Attacker", "evil-repo")
self.assertEqual(srv._canonical_local_git_root(), self.target_root)
if __name__ == "__main__":
unittest.main()
@@ -1,3 +1,7 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
import os
import sys
import unittest
+8 -3
View File
@@ -1,3 +1,7 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
"""Tests for the pre-create issue content gate (Issue #582)."""
import sys
import unittest
@@ -15,9 +19,10 @@ from issue_content_gate import ( # noqa: E402
from mcp_server import gitea_create_issue # noqa: E402
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
ISSUE_WRITE_ENV = {
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
}
ISSUE_WRITE_ENV = shared_mutation_env(
"test-author-prgs",
GITEA_ALLOWED_OPERATIONS="gitea.issue.create",
)
class TestNormalizeAndPointers(unittest.TestCase):
+9 -4
View File
@@ -1,3 +1,7 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
"""Tests for pre-create issue duplicate gate (Issue #207)."""
import sys
import unittest
@@ -19,9 +23,10 @@ from mcp_server import gitea_create_issue # noqa: E402
from review_proofs import assess_duplicate_search_proof as proof_assess # noqa: E402
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
ISSUE_WRITE_ENV = {
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
}
ISSUE_WRITE_ENV = shared_mutation_env(
"test-author-prgs",
GITEA_ALLOWED_OPERATIONS="gitea.issue.create",
)
CANONICAL_TITLE = (
"Add hard queue-target resolution wall before PR inventory "
"or empty-queue claims"
@@ -162,7 +167,7 @@ class TestCreateIssueMCPGate(unittest.TestCase):
mock_role_check.return_value = (True, [])
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
with patch.dict(__import__("os").environ, ISSUE_WRITE_ENV, clear=True):
result = gitea_create_issue(title="Unique new issue", body="body text")
result = gitea_create_issue(title="Unique new issue", body="body text", remote="prgs")
self.assertEqual(result["number"], 1)
+24 -5
View File
@@ -1,3 +1,7 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
"""Tests for early duplicate-work detection (#400)."""
import os
import sys
@@ -144,10 +148,12 @@ class TestInjectableDuplicateFetcher(unittest.TestCase):
},
):
with tempfile.TemporaryDirectory() as lock_dir:
with patch.dict(os.environ, {
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment",
"GITEA_ISSUE_LOCK_DIR": lock_dir,
}, clear=True):
env = shared_mutation_env(
"test-author-prgs",
include_example_repo=True,
GITEA_ISSUE_LOCK_DIR=lock_dir,
)
with patch.dict(os.environ, env, clear=True):
mcp_server.gitea_lock_issue(
issue_number=400,
branch_name="feat/issue-400-duplicate-work-preflight",
@@ -161,7 +167,11 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
self._dir = tempfile.TemporaryDirectory()
self._env_patch = patch.dict(
os.environ,
{"GITEA_ISSUE_LOCK_DIR": self._dir.name},
shared_mutation_env(
"test-author-prgs",
include_example_repo=True,
GITEA_ISSUE_LOCK_DIR=self._dir.name,
),
clear=False,
)
self._env_patch.start()
@@ -171,6 +181,11 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
try:
import session_context_binding as _sc
_sc._reset_session_context_for_testing()
except Exception:
pass
def tearDown(self):
patch.stopall()
@@ -205,6 +220,8 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
"forbidden_operations": [],
"audit_label": "test-author",
"allowed_repositories": ["Example-Org/Example-Repo"],
"base_url": "https://gitea.example.com",
})
@patch("mcp_server.get_auth_header", return_value="token x")
def test_commit_files_blocked_on_recheck(self, _auth, _profile, mock_gate):
@@ -239,6 +256,8 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
"allowed_operations": ["gitea.read", "gitea.pr.create"],
"forbidden_operations": [],
"audit_label": "test-author",
"allowed_repositories": ["Example-Org/Example-Repo"],
"base_url": "https://gitea.example.com",
})
@patch("mcp_server.get_auth_header", return_value="token x")
def test_create_pr_returns_handoff_on_duplicate(self, _auth, _profile, mock_gate):
+6
View File
@@ -1,3 +1,8 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from mutation_profile_fixture import install_deterministic_remote_urls # noqa: E402
install_deterministic_remote_urls()
"""Regression tests: issue-write tool gates match gitea_resolve_task_capability (#69)."""
import json
import os
@@ -41,6 +46,7 @@ CONFIG = {
"gitea.read", "gitea.issue.create", "gitea.issue.close",
"gitea.issue.comment"],
"forbidden_operations": [],
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "Example-Org/Example-Repo", "913443/eAgenda"],
"execution_profile": "full-author",
},
},
+4
View File
@@ -1,3 +1,7 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
"""Negative tests for LLM-Agent-SHA attribution (#86, Phase 0).
``LLM-Agent-SHA`` (docs/llm-agent-sha.md) is attribution metadata ONLY. These
+14 -12
View File
@@ -1,7 +1,11 @@
"""Regression tests for gitea_lock_issue MCP tool registration (#521)."""
from __future__ import annotations
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
import os
import tempfile
import unittest
@@ -13,22 +17,20 @@ from mcp_server import gitea_create_pr, gitea_lock_issue
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
ISSUE_WRITE_ENV = {
"GITEA_ALLOWED_OPERATIONS": (
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
),
}
ISSUE_WRITE_ENV = shared_mutation_env(
"test-author-prgs",
)
CREATE_PR_ENV = {
"GITEA_PROFILE_NAME": "author-test",
"GITEA_ALLOWED_OPERATIONS": (
CREATE_PR_ENV = shared_mutation_env(
"test-author-prgs",
GITEA_ALLOWED_OPERATIONS=(
"gitea.read,gitea.pr.create,gitea.branch.push,"
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
),
"GITEA_FORBIDDEN_OPERATIONS": (
GITEA_FORBIDDEN_OPERATIONS=(
"gitea.pr.approve,gitea.pr.merge,gitea.pr.review"
),
}
)
def _clean_master_git_state_for_lock():
+184 -113
View File
@@ -1,3 +1,11 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from mutation_profile_fixture import (
mutation_profile_env,
shared_mutation_env,
install_deterministic_remote_urls,
) # noqa: E402
"""Tests for the MCP server tool functions.
Each tool is tested by calling the underlying function directly (not through
@@ -47,6 +55,7 @@ from gitea_auth import get_profile # noqa: E402
import gitea_config # noqa: E402
import mcp_server
install_deterministic_remote_urls()
import issue_lock_store
import gitea_auth
@@ -221,22 +230,26 @@ def _seed_ready_review_decision(
# Issue-write tools are profile-gated (#69).
ISSUE_WRITE_ENV = {
"GITEA_ALLOWED_OPERATIONS": (
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
# Default remote is dadeschools — use the host-aligned config profile so
# cross-host session binding does not fail closed (#714).
ISSUE_WRITE_ENV = shared_mutation_env(
"test-author-dadeschools",
GITEA_ALLOWED_OPERATIONS=(
"gitea.issue.create,gitea.issue.close,gitea.issue.comment,"
"gitea.pr.create,gitea.branch.push,gitea.repo.commit,gitea.read"
),
}
)
# create_pr is gated on author profile + namespace (#69, #209).
CREATE_PR_ENV = {
"GITEA_PROFILE_NAME": "author-test",
"GITEA_ALLOWED_OPERATIONS": (
"gitea.read,gitea.pr.create,gitea.branch.push"
# Default remote is dadeschools; use matching config-backed author profile.
CREATE_PR_ENV = shared_mutation_env(
"test-author-dadeschools",
GITEA_ALLOWED_OPERATIONS=(
"gitea.read,gitea.pr.create,gitea.branch.push,"
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
),
"GITEA_FORBIDDEN_OPERATIONS": (
"gitea.pr.approve,gitea.pr.merge,gitea.pr.review"
),
}
GITEA_FORBIDDEN_OPERATIONS="gitea.pr.approve,gitea.pr.merge,gitea.pr.review",
)
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
@@ -287,6 +300,7 @@ def _bind_test_lock(**overrides) -> str:
# ---------------------------------------------------------------------------
# Create Issue
# ---------------------------------------------------------------------------
class TestCreateIssue(unittest.TestCase):
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
@@ -296,7 +310,7 @@ class TestCreateIssue(unittest.TestCase):
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_creates_issue(self, _auth, _get_all, mock_api, _role):
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
result = gitea_create_issue(title="Test issue", body="body text")
self.assertEqual(result["number"], 1)
self.assertNotIn("url", result)
@@ -314,7 +328,7 @@ class TestCreateIssue(unittest.TestCase):
def test_create_issue_reveal_opt_in_includes_url(self, _auth, _get_all, mock_api, _role):
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
env = {**ISSUE_WRITE_ENV, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
result = gitea_create_issue(title="Test issue", body="body text")
self.assertIn("issues/1", result["url"])
@@ -325,7 +339,18 @@ class TestCreateIssue(unittest.TestCase):
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_creates_on_prgs(self, _auth, _get_all, mock_api, _role):
mock_api.return_value = {"number": 5, "html_url": "https://gitea.prgs.cc/issues/5"}
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
env = {
**ISSUE_WRITE_ENV,
"GITEA_MCP_PROFILE": "test-author-prgs",
}
with patch.dict(os.environ, env, clear=False):
import gitea_config
gitea_config._active_profile_override = None
try:
import session_context_binding as _sc
_sc._reset_session_context_for_testing()
except Exception:
pass
result = gitea_create_issue(title="Test", remote="prgs")
self.assertEqual(result["number"], 5)
url = mock_api.call_args[0][1]
@@ -338,7 +363,7 @@ class TestCreateIssue(unittest.TestCase):
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_issue_required_workflow_labels_blocks(self, _auth, _get_all, mock_api, _role):
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
result = gitea_create_issue(
title="Test issue",
require_workflow_labels=True,
@@ -378,7 +403,7 @@ class TestCreatePR(unittest.TestCase):
mock_api.side_effect = api_side_effect
with tempfile.TemporaryDirectory() as lock_dir:
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
result = gitea_create_pr(
title="feat: X Closes #123",
@@ -421,7 +446,7 @@ class TestCreatePR(unittest.TestCase):
mock_api.side_effect = api_side_effect
with tempfile.TemporaryDirectory() as lock_dir:
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
result = gitea_create_pr(
title="feat: X Closes #123",
@@ -447,7 +472,7 @@ class TestCreatePR(unittest.TestCase):
mock_api.return_value = _issue_with_labels("type:feature", "status:in-progress")
with tempfile.TemporaryDirectory() as lock_dir:
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
result = gitea_create_pr(
title="feat: X Closes #123",
@@ -470,7 +495,7 @@ class TestCreatePR(unittest.TestCase):
worktree = os.path.realpath(os.getcwd())
with tempfile.TemporaryDirectory() as lock_dir:
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
_bind_test_lock(
issue_number=123,
branch_name="feat/x",
@@ -495,7 +520,7 @@ class TestCloseIssue(unittest.TestCase):
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_closes_issue(self, _auth, mock_api):
mock_api.return_value = {"state": "closed"}
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
result = gitea_close_issue(issue_number=42)
self.assertTrue(result["success"])
self.assertIn("42", result["message"])
@@ -601,7 +626,7 @@ class TestMarkIssue(unittest.TestCase):
return {}
mock_api.side_effect = api_side_effect
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
result = gitea_mark_issue(issue_number=5, action="start")
self.assertTrue(result["success"])
self.assertIn("claimed", result["message"])
@@ -616,7 +641,7 @@ class TestMarkIssue(unittest.TestCase):
mock_api.side_effect = [
None,
]
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
result = gitea_mark_issue(issue_number=5, action="done")
self.assertTrue(result["success"])
self.assertIn("released", result["message"])
@@ -629,7 +654,7 @@ class TestMarkIssue(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_missing_label_raises(self, _auth, mock_api, _labels):
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
with self.assertRaises(RuntimeError):
gitea_mark_issue(issue_number=5, action="start")
@@ -641,12 +666,12 @@ class TestAuthErrors(unittest.TestCase):
@patch("mcp_server.get_auth_header", return_value=None)
def test_no_credentials_raises(self, _auth):
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
with self.assertRaises(RuntimeError):
gitea_create_issue(title="test")
def test_unknown_remote_raises(self):
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
with self.assertRaises(ValueError):
gitea_create_issue(title="test", remote="nonexistent")
@@ -860,7 +885,7 @@ class TestMergePR(unittest.TestCase):
]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8),
expected_head_sha="abc123", do="squash",
@@ -893,7 +918,7 @@ class TestMergePR(unittest.TestCase):
]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8),
expected_changed_files=["b.py", "a.py"],
@@ -914,7 +939,7 @@ class TestMergePR(unittest.TestCase):
]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8),
expected_head_sha="abc123", remote="prgs",
@@ -945,7 +970,7 @@ class TestMergePR(unittest.TestCase):
]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8),
expected_head_sha="abc123", remote="prgs",
@@ -962,7 +987,7 @@ class TestMergePR(unittest.TestCase):
def test_missing_confirmation_blocks_with_no_api_call(self, _auth, mock_api):
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(pr_number=8, confirmation="", expected_head_sha="abc123", remote="prgs")
self.assertFalse(r["performed"])
self.assertTrue(any("explicit confirmation required" in x for x in r["reasons"]))
@@ -973,7 +998,7 @@ class TestMergePR(unittest.TestCase):
def test_wrong_confirmation_blocks(self, _auth, mock_api):
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 9", expected_head_sha="abc123", remote="prgs")
self.assertFalse(r["performed"])
mock_api.assert_not_called()
@@ -985,7 +1010,7 @@ class TestMergePR(unittest.TestCase):
def test_reviewer_profile_cannot_merge(self, _auth, mock_api):
env = {"GITEA_PROFILE_NAME": "prgs-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,approve"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
with self.assertRaises(RuntimeError) as ctx:
gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs"
@@ -999,7 +1024,7 @@ class TestMergePR(unittest.TestCase):
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs")
self.assertFalse(r["performed"])
@@ -1010,7 +1035,7 @@ class TestMergePR(unittest.TestCase):
def test_unknown_identity_blocks(self, _auth):
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs")
self.assertFalse(r["performed"])
@@ -1022,7 +1047,7 @@ class TestMergePR(unittest.TestCase):
def test_unknown_profile_blocks(self, _auth, mock_api):
mock_api.side_effect = [{"login": "merger-bot"}, self._pr("author-bot")]
env = {} # no GITEA_ALLOWED_OPERATIONS → empty allowed ops
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs")
self.assertFalse(r["performed"])
@@ -1094,7 +1119,7 @@ class TestMergePR(unittest.TestCase):
def test_profile_without_merge_permission_blocks(self, _auth, mock_api):
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
with self.assertRaises(RuntimeError) as ctx:
gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs")
@@ -1110,7 +1135,7 @@ class TestMergePR(unittest.TestCase):
{"login": "merger-bot"}, self._pr("author-bot", state="closed")]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs")
self.assertFalse(r["performed"])
@@ -1124,7 +1149,7 @@ class TestMergePR(unittest.TestCase):
{"login": "merger-bot"}, self._pr("author-bot", mergeable=False)]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs")
self.assertFalse(r["performed"])
@@ -1138,7 +1163,7 @@ class TestMergePR(unittest.TestCase):
{"login": "merger-bot"}, self._pr("author-bot", mergeable=None)]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs")
self.assertFalse(r["performed"])
@@ -1156,7 +1181,7 @@ class TestMergePR(unittest.TestCase):
]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8),
expected_head_sha="deadbeef", remote="prgs")
@@ -1177,7 +1202,7 @@ class TestMergePR(unittest.TestCase):
]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8),
expected_changed_files=["a.py", "b.py"],
@@ -1210,7 +1235,7 @@ class TestMergePR(unittest.TestCase):
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge",
"GITEA_TOKEN": "super-secret-token"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8),
expected_head_sha="abc123", remote="prgs",
@@ -1229,7 +1254,7 @@ class TestMergePR(unittest.TestCase):
]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8),
expected_head_sha="abc123", remote="prgs",
@@ -1252,7 +1277,7 @@ class TestMergePR(unittest.TestCase):
]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs",
expected_head_sha=new_sha)
@@ -1275,7 +1300,7 @@ class TestMergePR(unittest.TestCase):
]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8),
expected_head_sha="abc123", remote="prgs",
@@ -1307,7 +1332,7 @@ class TestMergePR(unittest.TestCase):
]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8),
expected_head_sha="abc123", remote="prgs",
@@ -1446,10 +1471,17 @@ class TestReviewPR(unittest.TestCase):
class TestDeleteBranch(unittest.TestCase):
DELETE_PROFILE = {
"profile_name": "test-deleter",
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
# #729: delete_branch is reconciler-owned.
"profile_name": "test-reconciler-deleter",
"role": "reconciler",
"allowed_operations": [
"gitea.read",
"gitea.pr.create",
"gitea.branch.push",
"gitea.branch.delete",
],
"forbidden_operations": [],
"audit_label": "test-deleter",
"audit_label": "test-author-deleter",
}
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
@@ -1457,7 +1489,7 @@ class TestDeleteBranch(unittest.TestCase):
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_delete_branch(self, _auth, mock_api, _profile):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
mcp_server.record_preflight_check("capability", resolved_role="reconciler")
mock_api.return_value = {}
result = gitea_delete_branch(branch="feat/branch")
self.assertTrue(result["success"])
@@ -1614,10 +1646,8 @@ class TestCommitFiles(unittest.TestCase):
files = [
{"operation": "create", "path": "test.txt", "content": "SGVsbG8="}
]
env = {
"GITEA_ALLOWED_OPERATIONS": "gitea.repo.commit",
}
with patch.dict(os.environ, env, clear=True):
env = shared_mutation_env("test-author-dadeschools")
with patch.dict(os.environ, env, clear=False):
result = gitea_commit_files(
files=files,
message="Initial commit",
@@ -1732,7 +1762,7 @@ class TestRuntimeProfile(unittest.TestCase):
"GITEA_ALLOWED_OPERATIONS": "read, review , approve",
"GITEA_BASE_URL": "https://gitea.example.invalid",
}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
p = get_profile()
self.assertEqual(p["profile_name"], "gitea-reviewer")
self.assertEqual(p["allowed_operations"], ["read", "review", "approve"])
@@ -1743,7 +1773,7 @@ class TestRuntimeProfile(unittest.TestCase):
"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_TOKEN": "super-secret-token",
}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
p = get_profile()
blob = repr(p).lower()
# The token VALUE must never appear. (The field name
@@ -1760,7 +1790,7 @@ class TestRuntimeProfile(unittest.TestCase):
"GITEA_ALLOWED_OPERATIONS": "read,review,approve",
"GITEA_TOKEN": "super-secret-token",
}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
result = gitea_whoami(remote="prgs")
self.assertEqual(result["profile"]["profile_name"], "gitea-reviewer")
self.assertEqual(
@@ -1781,7 +1811,7 @@ class TestRuntimeProfile(unittest.TestCase):
"GITEA_AUDIT_LABEL": "reviewer-runtime",
"GITEA_TOKEN_SOURCE": "keychain:prgs-reviewer-token",
}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
result = gitea_whoami(remote="prgs")
profile = result["profile"]
self.assertEqual(profile["environment"], None)
@@ -1850,7 +1880,7 @@ class TestProfileDiscovery(unittest.TestCase):
"GITEA_AUDIT_LABEL": "reviewer-runtime",
"GITEA_TOKEN_SOURCE": "GITEA_TOKEN",
}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
p = get_profile()
self.assertEqual(p["forbidden_operations"], ["merge", "branch.push"])
self.assertEqual(p["audit_label"], "reviewer-runtime")
@@ -1866,7 +1896,7 @@ class TestProfileDiscovery(unittest.TestCase):
"GITEA_TOKEN_SOURCE": "GITEA_TOKEN",
"GITEA_TOKEN": "super-secret-token",
}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
result = gitea_get_profile(remote="prgs")
self.assertEqual(result["profile_name"], "gitea-reviewer")
self.assertEqual(result["allowed_operations"], ["read", "review", "approve"])
@@ -1887,7 +1917,7 @@ class TestProfileDiscovery(unittest.TestCase):
def test_discovery_never_exposes_secrets(self, _auth, mock_api):
mock_api.return_value = {"id": 3, "login": "reviewer-bot"}
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", "GITEA_TOKEN": "super-secret-token"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
result = gitea_get_profile(remote="prgs")
blob = repr(result).lower()
for secret in ("super-secret-token", "token dgvzd", "authorization", "basic ", FAKE_AUTH.lower()):
@@ -1969,7 +1999,7 @@ class TestPrEligibility(unittest.TestCase):
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_check_pr_eligibility(pr_number=5, action="review", remote="prgs")
self.assertTrue(r["eligible"])
self.assertEqual(r["authenticated_user"], "reviewer-bot")
@@ -1982,7 +2012,7 @@ class TestPrEligibility(unittest.TestCase):
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
self.assertFalse(r["eligible"])
self.assertIn("authenticated user is PR author", r["reasons"])
@@ -1993,7 +2023,7 @@ class TestPrEligibility(unittest.TestCase):
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_check_pr_eligibility(pr_number=8, action="approve", remote="prgs")
self.assertFalse(r["eligible"])
self.assertIn("authenticated user is PR author", r["reasons"])
@@ -2008,7 +2038,7 @@ class TestPrEligibility(unittest.TestCase):
]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
self.assertFalse(r["eligible"])
self.assertIsNone(r["mergeable"])
@@ -2020,7 +2050,7 @@ class TestPrEligibility(unittest.TestCase):
mock_api.side_effect = [{"login": "author-bot"}, self._pr("someone-else")]
env = {"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_ALLOWED_OPERATIONS": "read,pr.create"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
self.assertFalse(r["eligible"])
self.assertIn("profile is not allowed to merge", r["reasons"])
@@ -2032,7 +2062,7 @@ class TestPrEligibility(unittest.TestCase):
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review",
"GITEA_FORBIDDEN_OPERATIONS": "merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
self.assertFalse(r["eligible"])
self.assertIn("profile forbids 'merge'", r["reasons"])
@@ -2043,7 +2073,7 @@ class TestPrEligibility(unittest.TestCase):
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot", state="closed")]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_check_pr_eligibility(pr_number=8, action="approve", remote="prgs")
self.assertFalse(r["eligible"])
self.assertIn("PR is not open (state=closed)", r["reasons"])
@@ -2052,7 +2082,7 @@ class TestPrEligibility(unittest.TestCase):
def test_unknown_identity_fails_closed(self, _auth):
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
self.assertFalse(r["eligible"])
self.assertIn("authenticated identity could not be determined", r["reasons"])
@@ -2071,7 +2101,7 @@ class TestPrEligibility(unittest.TestCase):
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review",
"GITEA_TOKEN": "super-secret-token"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_check_pr_eligibility(pr_number=5, action="review", remote="prgs")
blob = repr(r).lower()
for secret in ("super-secret-token", "authorization", "basic ", FAKE_AUTH.lower()):
@@ -2268,7 +2298,7 @@ class TestSubmitPrReview(unittest.TestCase):
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review(
pr_number=8, action="approve", remote="prgs",
final_review_decision_ready=True,
@@ -2287,7 +2317,7 @@ class TestSubmitPrReview(unittest.TestCase):
]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review(
pr_number=8, action="approve", body="LGTM", remote="prgs",
final_review_decision_ready=True,
@@ -2317,7 +2347,7 @@ class TestSubmitPrReview(unittest.TestCase):
]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review(
pr_number=8, action="approve", body="LGTM", remote="prgs",
final_review_decision_ready=True,
@@ -2337,7 +2367,7 @@ class TestSubmitPrReview(unittest.TestCase):
]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review(
pr_number=8, action="approve", body="LGTM", remote="prgs",
final_review_decision_ready=True,
@@ -2363,7 +2393,7 @@ class TestSubmitPrReview(unittest.TestCase):
]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,request_changes"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review(
pr_number=8, action="request_changes",
body="needs work", remote="prgs",
@@ -2384,7 +2414,7 @@ class TestSubmitPrReview(unittest.TestCase):
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review"} # no request_changes
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review(
pr_number=8, action="request_changes", remote="prgs",
final_review_decision_ready=True,
@@ -2405,7 +2435,7 @@ class TestSubmitPrReview(unittest.TestCase):
gitea_mark_final_review_decision(8, "comment", remote="prgs", expected_head_sha="abc123")
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review(
pr_number=8, action="comment", body="finding", remote="prgs",
final_review_decision_ready=True,
@@ -2423,7 +2453,7 @@ class TestSubmitPrReview(unittest.TestCase):
]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
gitea_mark_final_review_decision(
8, "comment", remote="prgs", expected_head_sha="abc123",
)
@@ -2439,7 +2469,7 @@ class TestSubmitPrReview(unittest.TestCase):
def test_unknown_identity_blocks(self, _auth):
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review(
pr_number=8, action="approve", remote="prgs",
final_review_decision_ready=True,
@@ -2454,7 +2484,7 @@ class TestSubmitPrReview(unittest.TestCase):
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
env = {"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_ALLOWED_OPERATIONS": "read,pr.create"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review(
pr_number=8, action="approve", remote="prgs",
final_review_decision_ready=True,
@@ -2473,7 +2503,7 @@ class TestSubmitPrReview(unittest.TestCase):
]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review(
pr_number=8, action="approve",
expected_head_sha="abc123", remote="prgs",
@@ -2497,7 +2527,7 @@ class TestSubmitPrReview(unittest.TestCase):
]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review(
pr_number=8, action="approve",
expected_head_sha="abc123", remote="prgs",
@@ -2526,7 +2556,7 @@ class TestSubmitPrReview(unittest.TestCase):
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve",
"GITEA_TOKEN": "super-secret-token"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
gitea_mark_final_review_decision(5, "approve", remote="prgs", expected_head_sha="abc123")
r = gitea_submit_pr_review(
pr_number=5, action="approve", remote="prgs",
@@ -2546,7 +2576,7 @@ class TestSubmitPrReview(unittest.TestCase):
]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review(
pr_number=8, action="approve", remote="prgs",
final_review_decision_ready=True,
@@ -2561,15 +2591,18 @@ class TestSubmitPrReview(unittest.TestCase):
lock = _load_review_decision_lock() or {}
lock["live_mutations"] = [{"pr_number": 8, "action": "approve", "review_id": 42, "review_state": "approve"}]
_save_review_decision_lock(lock)
r = gitea_authorize_review_correction(
prior_review_id=42,
prior_review_state="approve",
reason="typo",
operator_authorized=True,
)
with patch("mcp_server.api_request", return_value={"id": 9001}):
r = gitea_authorize_review_correction(
prior_review_id=42,
prior_review_state="approve",
reason="typo",
operator_authorized=True,
target_pr_number=8,
)
self.assertTrue(r["authorized"])
lock = _load_review_decision_lock()
self.assertTrue(lock["correction_authorized"])
self.assertEqual(lock["correction_pr_number"], 8)
def test_authorize_review_correction_mismatch(self):
from mcp_server import _load_review_decision_lock, _save_review_decision_lock
@@ -2583,6 +2616,7 @@ class TestSubmitPrReview(unittest.TestCase):
prior_review_state="approve",
reason="typo",
operator_authorized=True,
target_pr_number=8,
)
self.assertFalse(r["authorized"])
self.assertTrue(any("prior review ID" in x for x in r["reasons"]))
@@ -2593,10 +2627,22 @@ class TestSubmitPrReview(unittest.TestCase):
prior_review_state="request_changes",
reason="typo",
operator_authorized=True,
target_pr_number=8,
)
self.assertFalse(r["authorized"])
self.assertTrue(any("prior review state" in x for x in r["reasons"]))
# Cross-PR unlock refused (#693)
r = gitea_authorize_review_correction(
prior_review_id=42,
prior_review_state="approve",
reason="unlock other pr",
operator_authorized=True,
target_pr_number=692,
)
self.assertFalse(r["authorized"])
self.assertTrue(any("different PR" in x for x in r["reasons"]))
def test_authorize_review_correction_requires_operator_auth(self):
from mcp_server import _load_review_decision_lock, _save_review_decision_lock
lock = _load_review_decision_lock() or {}
@@ -2609,6 +2655,7 @@ class TestSubmitPrReview(unittest.TestCase):
prior_review_state="approve",
reason="typo",
operator_authorized=False,
target_pr_number=8,
)
self.assertFalse(r["authorized"])
self.assertTrue(any("operator authorization" in x for x in r["reasons"]))
@@ -2623,7 +2670,7 @@ class TestSubmitPrReview(unittest.TestCase):
try:
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review(
pr_number=8, action="approve", remote="prgs",
final_review_decision_ready=True,
@@ -2656,7 +2703,7 @@ class TestSubmitPrReview(unittest.TestCase):
"GITEA_ALLOWED_OPERATIONS": "read,review,approve,request_changes"}
with patch("mcp_server.gitea_get_pr_review_feedback",
return_value=dict(_NO_BLOCKER_FEEDBACK)):
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
first = gitea_submit_pr_review(
pr_number=8, action="approve", remote="prgs",
final_review_decision_ready=True,
@@ -2666,6 +2713,7 @@ class TestSubmitPrReview(unittest.TestCase):
prior_review_state="approve",
reason="operator approved correcting mistaken approve",
operator_authorized=True,
target_pr_number=8,
)
_mark_request_changes_ready(remote="prgs")
second = gitea_submit_pr_review(
@@ -2681,7 +2729,7 @@ class TestSubmitPrReview(unittest.TestCase):
def test_remote_org_repo_validation_mismatch(self, _auth, mock_api):
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
# Mark decision for specific remote, org, repo
gitea_mark_final_review_decision(8, "approve", remote="prgs", expected_head_sha="abc123", org="MyOrg", repo="MyRepo")
@@ -2719,13 +2767,29 @@ if __name__ == "__main__":
class TestTrackerHygieneCleanup(unittest.TestCase):
def setUp(self):
self._mut_env = patch.dict(
os.environ,
shared_mutation_env("test-author-dadeschools"),
clear=False,
)
self._mut_env.start()
mcp_server.gitea_load_review_workflow()
self.mock_api = patch("mcp_server.api_request").start()
self.mock_auth = patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
patch("gitea_audit.audit_enabled", return_value=True).start()
self.mock_audit = patch("gitea_audit.write_event").start()
# gitea.pr.close: closing a PR via gitea_edit_pr is capability-gated (#216).
patch("mcp_server.get_profile", return_value={"profile_name": "test", "allowed_operations": ["read", "merge", "edit", "close", "gitea.pr.close", "gitea.issue.close"], "audit_label": "test", "forbidden_operations": []}).start()
patch("mcp_server.get_profile", return_value={
"profile_name": "test-author-dadeschools",
"allowed_operations": [
"read", "merge", "edit", "close",
"gitea.pr.close", "gitea.issue.close", "gitea.read",
],
"audit_label": "test",
"forbidden_operations": [],
"allowed_repositories": ["913443/eAgenda"],
"base_url": "https://gitea.dadeschools.net",
}).start()
patch("mcp_server._list_pr_lease_comments", return_value=[]).start()
patch(
"mcp_server._pr_work_lease_reviewer_block",
@@ -2733,6 +2797,11 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
).start()
def tearDown(self):
try:
self._mut_env.stop()
except Exception:
pass
patch.stopall()
def test_close_issue_removes_in_progress(self):
@@ -3138,7 +3207,7 @@ class TestEndpointRedaction(unittest.TestCase):
"GITEA_BASE_URL": "https://gitea.example.invalid",
"GITEA_TOKEN_SOURCE": "keychain:some-item-id",
}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
result = gitea_get_profile(remote="prgs",
resolve_identity=False)
blob = repr(result)
@@ -3160,7 +3229,7 @@ class TestEndpointRedaction(unittest.TestCase):
"GITEA_TOKEN_SOURCE": "keychain:some-item-id",
"GITEA_MCP_REVEAL_ENDPOINTS": "1",
}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
result = gitea_get_profile(remote="prgs",
resolve_identity=False)
self.assertEqual(result["server"], "https://gitea.prgs.cc")
@@ -3172,7 +3241,7 @@ class TestEndpointRedaction(unittest.TestCase):
try:
env = {"GITEA_MCP_CONFIG": path,
"GITEA_MCP_PROFILE": "prgs-author"}
with patch.dict(os.environ, env, clear=True), \
with patch.dict(os.environ, env, clear=False), \
patch("gitea_config._keychain_token", return_value="x"):
result = gitea_audit_config()
finally:
@@ -3260,7 +3329,7 @@ class TestIssueCommentTools(unittest.TestCase):
def test_list_reveal_opt_in_includes_url(self, _auth, mock_api):
mock_api.return_value = [self._comment()]
env = dict(self.AUTHOR_ENV, GITEA_MCP_REVEAL_ENDPOINTS="1")
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
self.assertIn("issuecomment-101", result["comments"][0]["url"])
@@ -3269,7 +3338,7 @@ class TestIssueCommentTools(unittest.TestCase):
def test_list_blocked_without_read_permission(self, _auth, mock_api):
env = {"GITEA_PROFILE_NAME": "gitea-writer-only",
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
self.assertFalse(result["success"])
self.assertTrue(result["reasons"])
@@ -3316,7 +3385,7 @@ class TestIssueCommentTools(unittest.TestCase):
def test_create_reveal_opt_in_includes_url(self, _auth, mock_api):
mock_api.return_value = self._comment(555)
env = dict(self.AUTHOR_ENV, GITEA_MCP_REVEAL_ENDPOINTS="1")
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
result = gitea_create_issue_comment(
issue_number=9, body="posted", remote="prgs")
self.assertIn("issuecomment-555", result["url"])
@@ -3328,7 +3397,7 @@ class TestIssueCommentTools(unittest.TestCase):
"GITEA_ALLOWED_OPERATIONS":
"gitea.read,gitea.pr.review,gitea.pr.comment,"
"gitea.pr.approve,gitea.pr.merge"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
result = gitea_create_issue_comment(
issue_number=9, body="posted", remote="prgs")
self.assertFalse(result["success"])
@@ -3342,7 +3411,7 @@ class TestIssueCommentTools(unittest.TestCase):
env = {"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
"GITEA_FORBIDDEN_OPERATIONS": "gitea.issue.comment"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
result = gitea_create_issue_comment(
issue_number=9, body="posted", remote="prgs")
self.assertFalse(result["success"])
@@ -3478,7 +3547,7 @@ class TestIssueCommentPermissionSeparation(unittest.TestCase):
"GITEA_ALLOWED_OPERATIONS":
"gitea.branch.create,gitea.branch.push,gitea.pr.comment,"
"gitea.pr.create,gitea.read,gitea.repo.commit"}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
result = gitea_create_issue_comment(
issue_number=51, body="audit findings", remote="prgs")
self.assertFalse(result["success"])
@@ -3663,11 +3732,12 @@ class TestIssueLocking(unittest.TestCase):
def setUp(self):
self._lock_dir = tempfile.TemporaryDirectory()
# Most locking tests target remote=prgs; host-align profile (#714).
env = {
**ISSUE_WRITE_ENV,
**shared_mutation_env("test-author-prgs"),
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
}
self._env_patcher = patch.dict(os.environ, env, clear=True)
self._env_patcher = patch.dict(os.environ, env, clear=False)
self._env_patcher.start()
self._dup_fetcher_patcher = patch(
"mcp_server.issue_duplicate_context_fetcher",
@@ -3681,8 +3751,9 @@ class TestIssueLocking(unittest.TestCase):
self._lock_dir.cleanup()
def _create_pr_env(self) -> dict:
# PR-locking tests call create_pr with remote=prgs.
return {
**CREATE_PR_ENV,
**shared_mutation_env("test-author-prgs"),
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
}
@@ -3701,7 +3772,7 @@ class TestIssueLocking(unittest.TestCase):
self.assertIn("created_at", res["work_lease"])
self.assertIn("expires_at", res["work_lease"])
self.assertIn("last_heartbeat_at", res["work_lease"])
self.assertEqual(res["work_lease"]["claimant"]["profile"], "gitea-default")
self.assertIn(res["work_lease"]["claimant"]["profile"], ("gitea-default", "test-author-prgs", "prgs-author", "gitea-author"))
self.assertIn("lock_file_path", res)
lock = issue_lock_store.read_lock_file(res["lock_file_path"])
self.assertIn("worktree_path", lock)
@@ -3820,7 +3891,7 @@ class TestIssueLocking(unittest.TestCase):
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_lock_issue_blocks_active_same_operation_lease(self, _auth, _api, _git_state):
prgs_repo = mcp_server.REMOTES["prgs"]["repo"]
prgs_repo = "Gitea-Tools" # #714 session-bound repo (not REMOTES Timesheet default)
issue_lock_store.save_lock_file(
issue_lock_store.lock_file_path(
remote="prgs",
@@ -3857,7 +3928,7 @@ class TestIssueLocking(unittest.TestCase):
Dead-pid / missing-worktree reclaim is covered by issue_lock_store unit tests.
This MCP path must remain fail-closed for sticky expired foreign ownership.
"""
prgs_repo = mcp_server.REMOTES["prgs"]["repo"]
prgs_repo = "Gitea-Tools" # #714 session-bound repo (not REMOTES Timesheet default)
# Present worktree + live pid => reclaim_allowed=False even though expires_at is past.
sticky_worktree = tempfile.mkdtemp(prefix="gitea-sticky-lease-")
self.addCleanup(lambda: shutil.rmtree(sticky_worktree, ignore_errors=True))
@@ -4051,12 +4122,12 @@ class TestIssueLocking(unittest.TestCase):
worktree = os.path.realpath(os.getcwd())
with tempfile.TemporaryDirectory() as lock_dir:
env = {**self._create_pr_env(), "GITEA_ISSUE_LOCK_DIR": lock_dir}
with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=False):
issue_lock_store.save_lock_file(
issue_lock_store.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo=mcp_server.REMOTES["prgs"]["repo"],
repo="Gitea-Tools", # #714 session-bound
issue_number=447,
lock_dir=lock_dir,
),
@@ -4065,7 +4136,7 @@ class TestIssueLocking(unittest.TestCase):
branch_name="feat/issue-447-lock-provenance",
remote="prgs",
org="Scaled-Tech-Consulting",
repo=mcp_server.REMOTES["prgs"]["repo"],
repo="Gitea-Tools", # #714 session-bound
worktree_path=worktree,
lock_provenance=None,
),
+7 -15
View File
@@ -111,21 +111,13 @@ class TestMcpStaleRuntime(unittest.TestCase):
reasons = gitea_mcp_server._check_mcp_runtimes_diagnostics("create_issue", ["prgs-author"])
self.assertTrue(any("stale-runtime: The active Gitea MCP server process is stale" in r for r in reasons))
@patch("threading.Thread")
@patch("os.utime")
@patch("os.path.exists")
@patch.dict("os.environ", {"MCP_CONFIG_PATH": "/tmp/mcp_config.json"})
def test_auto_restart_trigger_touches_and_spawns(self, mock_exists, mock_utime, mock_thread):
mock_exists.return_value = True
gitea_mcp_server._restart_triggered = False
# Ensure we are not skipped in test mode for testing purposes
with patch("gitea_mcp_server._preflight_in_test_mode", return_value=False):
gitea_mcp_server._trigger_mcp_auto_restart()
mock_utime.assert_called_once_with("/tmp/mcp_config.json", None)
mock_thread.assert_called_once()
self.assertTrue(gitea_mcp_server._restart_triggered)
def test_auto_restart_helper_removed_from_read_only_path(self):
"""#685: config-touch / os._exit self-recovery is no longer on the server."""
self.assertFalse(
hasattr(gitea_mcp_server, "_trigger_mcp_auto_restart"),
"_trigger_mcp_auto_restart must not remain (side-effect-free resolver)",
)
self.assertFalse(hasattr(gitea_mcp_server, "_restart_triggered"))
if __name__ == "__main__":
+466
View File
@@ -0,0 +1,466 @@
"""Merger lease acquisition + capability resolution tests (#718, #723)."""
from __future__ import annotations
import os
import sys
import unittest
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import gitea_mcp_server as mcp_server
import reviewer_pr_lease as leases
import task_capability_map as tcm
HEAD = "a" * 40
LIVE_HEAD = HEAD
class TestMergerLeaseAcquireTool(unittest.TestCase):
def setUp(self):
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
mcp_server._preflight_resolved_role = "merger"
mcp_server._preflight_resolved_task = "acquire_merger_pr_lease"
leases.clear_session_lease()
def tearDown(self):
leases.clear_session_lease()
def _merger_profile(self, **extra):
p = {
"username": "merger-user",
"profile_name": "prgs-merger",
"role": "merger",
"allowed_operations": [
"gitea.read",
"gitea.pr.comment",
"gitea.pr.merge",
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.review",
"gitea.pr.request_changes",
],
}
p.update(extra)
return p
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server._auth", return_value="token pass")
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
@patch("gitea_mcp_server.get_profile")
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
@patch("gitea_mcp_server._verify_role_mutation_workspace")
def test_acquire_merger_lease_success(
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
):
mock_profile.return_value = self._merger_profile()
mock_api.side_effect = [
{"state": "open", "head": {"sha": LIVE_HEAD}},
{"id": 456},
]
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
res = mcp_server.gitea_acquire_merger_pr_lease(
pr_number=718,
worktree="branches/issue-718",
session_id="merger-session",
candidate_head=HEAD,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertTrue(res["success"], res)
self.assertTrue(res["acquired"])
self.assertEqual(res["comment_id"], 456)
self.assertEqual(res["session_id"], "merger-session")
self.assertEqual(res.get("repo"), "Scaled-Tech-Consulting/Gitea-Tools")
body = mock_api.call_args_list[-1][0][3]["body"]
self.assertIn("reviewer_identity: merger-user", body)
self.assertIn("profile: prgs-merger", body)
session_lease = leases._SESSION_LEASE
self.assertIsNotNone(session_lease)
provenance = session_lease.get("lease_provenance") or {}
self.assertEqual(provenance.get("source"), "gitea_acquire_merger_pr_lease")
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server._auth", return_value="token pass")
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
@patch("gitea_mcp_server.get_profile")
@patch("gitea_mcp_server._fetch_pr_comments")
@patch("gitea_mcp_server._verify_role_mutation_workspace")
def test_acquire_merger_lease_fails_if_active_exists(
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
):
mock_profile.return_value = self._merger_profile()
active_body = leases.format_lease_body(
repo="Scaled-Tech-Consulting/Gitea-Tools",
pr_number=718,
issue_number=None,
reviewer_identity="other-user",
profile="prgs-reviewer",
session_id="other-session",
worktree="branches/review-1",
phase="claimed",
candidate_head=HEAD,
target_branch="master",
target_branch_sha="b" * 40,
last_activity=datetime.now(timezone.utc),
)
_comments.return_value = [
{"id": 1, "body": active_body, "user": {"login": "other-user"}}
]
mock_api.return_value = {"state": "open", "head": {"sha": LIVE_HEAD}}
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
res = mcp_server.gitea_acquire_merger_pr_lease(
pr_number=718,
worktree="branches/issue-718",
session_id="merger-session",
candidate_head=HEAD,
)
self.assertFalse(res["success"])
self.assertFalse(res["acquired"])
self.assertIn("already has active reviewer lease", " ".join(res["reasons"]))
self.assertEqual(
[c for c in mock_api.call_args_list if c[0][0] == "POST"], []
)
self.assertIsNone(leases._SESSION_LEASE)
@patch("gitea_mcp_server.get_profile")
@patch("gitea_mcp_server._verify_role_mutation_workspace")
def test_acquire_merger_lease_fails_workspace_binding(self, _verify, mock_profile):
mock_profile.return_value = self._merger_profile()
_verify.side_effect = RuntimeError("Branches-only mutation guard")
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
res = mcp_server.gitea_acquire_merger_pr_lease(
pr_number=718,
worktree="branches/issue-718",
session_id="merger-session",
candidate_head=HEAD,
)
self.assertFalse(res["success"])
self.assertIn("Branches-only mutation guard", res["reasons"][0])
self.assertIsNone(leases._SESSION_LEASE)
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server._auth", return_value="token pass")
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
@patch("gitea_mcp_server.get_profile")
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
@patch("gitea_mcp_server._verify_role_mutation_workspace")
def test_acquire_merger_forwards_explicit_org_repo_to_workspace_verify(
self, mock_verify, _comments, mock_profile, _resolve, _auth, mock_api
):
"""Explicit org/repo must reach anti-stomp (prgs bare default is Timesheet)."""
mock_profile.return_value = self._merger_profile()
mock_api.side_effect = [
{"state": "open", "head": {"sha": LIVE_HEAD}},
{"id": 789},
]
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
res = mcp_server.gitea_acquire_merger_pr_lease(
pr_number=718,
worktree="branches/issue-718",
session_id="merger-session",
candidate_head=HEAD,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertTrue(res["success"], res)
kwargs = mock_verify.call_args.kwargs
self.assertEqual(kwargs.get("org"), "Scaled-Tech-Consulting")
self.assertEqual(kwargs.get("repo"), "Gitea-Tools")
self.assertEqual(kwargs.get("task"), "acquire_merger_pr_lease")
@patch("gitea_mcp_server.get_profile")
def test_acquire_merger_requires_candidate_head(self, mock_profile):
mock_profile.return_value = self._merger_profile()
res = mcp_server.gitea_acquire_merger_pr_lease(
pr_number=718,
worktree="branches/issue-718",
candidate_head=None,
)
self.assertFalse(res["success"])
self.assertTrue(any("candidate_head is required" in r for r in res["reasons"]))
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server._auth", return_value="token pass")
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
@patch("gitea_mcp_server.get_profile")
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
@patch("gitea_mcp_server._verify_role_mutation_workspace")
def test_acquire_merger_head_mismatch(
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
):
mock_profile.return_value = self._merger_profile()
mock_api.return_value = {"state": "open", "head": {"sha": "b" * 40}}
res = mcp_server.gitea_acquire_merger_pr_lease(
pr_number=718,
worktree="branches/issue-718",
candidate_head=HEAD,
)
self.assertFalse(res["success"])
self.assertTrue(any("does not match live PR head" in r for r in res["reasons"]))
self.assertIsNone(leases._SESSION_LEASE)
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server._auth", return_value="token pass")
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
@patch("gitea_mcp_server.get_profile")
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
@patch("gitea_mcp_server._verify_role_mutation_workspace")
def test_acquire_merger_fails_closed_when_live_head_unresolvable(
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
):
"""Empty/missing live head must not silently proceed past exact-head gate."""
mock_profile.return_value = self._merger_profile()
# PR payload present but head.sha missing / empty / non-dict.
mock_api.return_value = {"state": "open", "head": {}}
res = mcp_server.gitea_acquire_merger_pr_lease(
pr_number=718,
worktree="branches/issue-718",
session_id="merger-session",
candidate_head=HEAD,
)
self.assertFalse(res["success"], res)
self.assertFalse(res.get("acquired"), res)
self.assertTrue(
any("live PR head could not be resolved" in r for r in res["reasons"]),
res,
)
self.assertIsNone(res.get("live_head"))
self.assertEqual(res.get("candidate_head"), HEAD)
self.assertEqual(
[c for c in mock_api.call_args_list if c[0][0] == "POST"], []
)
self.assertIsNone(leases._SESSION_LEASE)
# Completely empty GET /pulls body also fails closed.
mock_api.return_value = {}
res2 = mcp_server.gitea_acquire_merger_pr_lease(
pr_number=718,
worktree="branches/issue-718",
candidate_head=HEAD,
)
self.assertFalse(res2["success"], res2)
self.assertTrue(
any("live PR head could not be resolved" in r for r in res2["reasons"]),
res2,
)
self.assertIsNone(leases._SESSION_LEASE)
@patch("gitea_mcp_server._profile_operation_gate")
def test_author_denied_merge_permission(self, mock_gate):
"""Author profile lacks gitea.pr.merge → fail closed before mutation."""
def gate(op):
if op == "gitea.pr.merge":
return [f"profile lacks {op}"]
return None
mock_gate.side_effect = gate
res = mcp_server.gitea_acquire_merger_pr_lease(
pr_number=718,
worktree="branches/issue-718",
candidate_head=HEAD,
)
self.assertFalse(res["success"])
self.assertTrue(any("gitea.pr.merge" in r for r in res["reasons"]))
@patch("gitea_mcp_server._profile_operation_gate")
def test_reviewer_denied_merge_permission(self, mock_gate):
def gate(op):
if op == "gitea.pr.merge":
return [f"profile lacks {op}"]
return None
mock_gate.side_effect = gate
res = mcp_server.gitea_acquire_merger_pr_lease(
pr_number=718,
worktree="branches/issue-718",
candidate_head=HEAD,
)
self.assertFalse(res["success"])
self.assertTrue(any("gitea.pr.merge" in r for r in res["reasons"]))
class TestCapabilityMapLeaseTasks(unittest.TestCase):
def test_merger_acquire_map_entries(self):
for task in ("acquire_merger_pr_lease", "gitea_acquire_merger_pr_lease"):
self.assertEqual(tcm.required_permission(task), "gitea.pr.comment")
self.assertEqual(tcm.required_role(task), "merger")
def test_reviewer_acquire_map_entries(self):
for task in ("acquire_reviewer_pr_lease", "gitea_acquire_reviewer_pr_lease"):
self.assertEqual(tcm.required_permission(task), "gitea.pr.comment")
self.assertEqual(tcm.required_role(task), "reviewer")
def test_adopt_merger_aliases(self):
for task in ("adopt_merger_pr_lease", "gitea_adopt_merger_pr_lease"):
self.assertEqual(tcm.required_role(task), "merger")
class TestResolveTaskCapabilityLeaseAndUnknown(unittest.TestCase):
def setUp(self):
mcp_server._process_boot_head_sha = None
if hasattr(mcp_server, "capability_stop_terminal"):
mcp_server.capability_stop_terminal.clear()
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
@patch.object(mcp_server, "record_preflight_check", return_value=None)
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
@patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
@patch.object(mcp_server, "get_profile")
@patch.object(mcp_server.gitea_config, "load_config")
def test_resolve_acquire_reviewer_authorized(
self, mock_cfg, mock_profile, *_mocks
):
mock_profile.return_value = {
"profile_name": "prgs-reviewer",
"role": "reviewer",
"allowed_operations": ["gitea.pr.comment", "gitea.read", "gitea.pr.review"],
"forbidden_operations": [],
}
mock_cfg.return_value = {
"profiles": {
"prgs-reviewer": {
"role": "reviewer",
"allowed_operations": [
"gitea.pr.comment",
"gitea.read",
"gitea.pr.review",
],
"forbidden_operations": [],
}
}
}
with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-reviewer"}, clear=False):
for task in ("acquire_reviewer_pr_lease", "gitea_acquire_reviewer_pr_lease"):
res = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
self.assertEqual(res["required_role_kind"], "reviewer", res)
self.assertEqual(
res["required_operation_permission"], "gitea.pr.comment", res
)
self.assertTrue(res.get("allowed_in_current_session"), res)
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
@patch.object(mcp_server, "record_preflight_check", return_value=None)
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
@patch.object(mcp_server, "_authenticated_username", return_value="jcwalker3")
@patch.object(mcp_server, "get_profile")
@patch.object(mcp_server.gitea_config, "load_config")
def test_resolve_acquire_reviewer_author_denied(
self, mock_cfg, mock_profile, *_mocks
):
mock_profile.return_value = {
"profile_name": "prgs-author",
"role": "author",
"allowed_operations": ["gitea.pr.comment", "gitea.branch.push", "gitea.read"],
"forbidden_operations": [],
}
mock_cfg.return_value = {
"profiles": {
"prgs-author": {
"role": "author",
"allowed_operations": [
"gitea.pr.comment",
"gitea.branch.push",
"gitea.read",
],
"forbidden_operations": [],
},
"prgs-reviewer": {
"role": "reviewer",
"allowed_operations": ["gitea.pr.comment", "gitea.pr.review"],
"forbidden_operations": [],
},
}
}
with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-author"}, clear=False):
res = mcp_server.gitea_resolve_task_capability(
task="acquire_reviewer_pr_lease", remote="prgs"
)
self.assertFalse(res.get("allowed_in_current_session"), res)
self.assertEqual(res["required_role_kind"], "reviewer")
guidance = " ".join(res.get("task_role_guidance") or [])
self.assertTrue(
"cannot perform reviewer" in guidance.lower()
or "reviewer" in guidance.lower()
or res.get("stop_required"),
res,
)
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
@patch.object(mcp_server, "record_preflight_check", return_value=None)
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
@patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
@patch.object(mcp_server, "get_profile")
def test_resolve_unknown_task_structured_no_raise(self, mock_profile, *_mocks):
mock_profile.return_value = {
"profile_name": "prgs-reviewer",
"role": "reviewer",
"allowed_operations": ["gitea.pr.comment"],
"forbidden_operations": [],
}
# Must not raise ValueError into the tool boundary.
res = mcp_server.gitea_resolve_task_capability(
task="some_made_up_task", remote="prgs"
)
self.assertIsInstance(res, dict)
self.assertEqual(res.get("reason_code"), "unknown_task", res)
self.assertFalse(res.get("allowed_in_current_session"))
self.assertTrue(res.get("stop_required"))
self.assertIs(res.get("mutation_performed"), False)
self.assertNotIn("token", str(res).lower())
self.assertNotIn("password", str(res).lower())
guidance = " ".join(res.get("task_role_guidance") or [])
self.assertIn("Unknown task/action", guidance)
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
@patch.object(mcp_server, "record_preflight_check", return_value=None)
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
@patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
@patch.object(mcp_server, "get_profile")
@patch.object(mcp_server.gitea_config, "load_config")
def test_resolve_merger_acquire_aliases(
self, mock_cfg, mock_profile, *_mocks
):
mock_profile.return_value = {
"profile_name": "prgs-merger",
"role": "merger",
"allowed_operations": [
"gitea.read",
"gitea.pr.comment",
"gitea.pr.merge",
],
"forbidden_operations": [],
}
mock_cfg.return_value = {
"profiles": {
"prgs-merger": {
"role": "merger",
"allowed_operations": [
"gitea.read",
"gitea.pr.comment",
"gitea.pr.merge",
],
"forbidden_operations": [],
}
}
}
with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-merger"}, clear=False):
for task in ("acquire_merger_pr_lease", "gitea_acquire_merger_pr_lease"):
res = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
self.assertEqual(res["required_role_kind"], "merger", res)
self.assertEqual(
res["required_operation_permission"], "gitea.pr.comment", res
)
if __name__ == "__main__":
unittest.main()
+4
View File
@@ -1,3 +1,7 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
"""Merger lease adoption / recovery (#536)."""
import os
+956
View File
@@ -0,0 +1,956 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402,F401
"""Acquired merger-lease provenance + owner finalization (#742).
Covers the two confirmed defects behind the PR #740 failure:
1. a lease minted by ``gitea_acquire_merger_pr_lease`` was listed as a
sanctioned provenance source but rejected by ``is_sanctioned_session_lease``
and reported as unsanctioned by ``describe_session_lease_proof``, so the
merge gate refused the merger's own freshly acquired lease;
2. no merger-role owner-session operation existed to terminally release or
abandon that lease, leaving natural expiry as the only exit.
"""
import os
import sys
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import gitea_mcp_server as mcp_server # noqa: E402
import merger_lease_adoption as mla # noqa: E402
import pr_work_lease as pwl # noqa: E402
import reviewer_pr_lease as leases # noqa: E402
import task_capability_map as tcm # noqa: E402
HEAD = "c" * 40
OTHER_HEAD = "d" * 40
REPO = "Scaled-Tech-Consulting/Gitea-Tools"
PR = 740
def _lease_comment(
pr_number: int = PR,
session_id: str = "merger-session",
*,
phase: str = "claimed",
profile: str = "prgs-merger",
identity: str = "merger-user",
candidate_head: str = HEAD,
comment_id: int = 12354,
) -> dict:
body = leases.format_lease_body(
repo=REPO,
pr_number=pr_number,
issue_number=742,
reviewer_identity=identity,
profile=profile,
session_id=session_id,
worktree="branches/merger-pr740",
phase=phase,
candidate_head=candidate_head,
target_branch="master",
target_branch_sha="e" * 40,
last_activity=datetime.now(timezone.utc),
)
return {"id": comment_id, "body": body, "user": {"login": identity}}
def _acquired_session(**overrides) -> dict:
session = {
"pr_number": PR,
"issue_number": 742,
"session_id": "merger-session",
"reviewer_identity": "merger-user",
"profile": "prgs-merger",
"worktree": "branches/merger-pr740",
"phase": "claimed",
"candidate_head": HEAD,
"target_branch": "master",
"target_branch_sha": "e" * 40,
"repo": REPO,
"comment_id": 12354,
}
session.update(overrides)
return session
def _record_acquired(session: dict | None = None, **provenance_kwargs) -> dict:
provenance = mla.build_lease_provenance(
source=mla.SOURCE_ACQUIRE_MERGER,
comment_id=provenance_kwargs.pop("comment_id", 12354),
**provenance_kwargs,
)
return leases.record_session_lease(
session if session is not None else _acquired_session(),
lease_provenance=provenance,
)
class TestAcquiredMergerProvenance(unittest.TestCase):
"""AC1/AC2/AC4/AC5/AC6 — provenance sanctioning and proof description."""
def setUp(self):
leases.clear_session_lease()
def tearDown(self):
leases.clear_session_lease()
def test_acquired_merger_lease_is_sanctioned(self):
session = _record_acquired()
self.assertTrue(mla.is_sanctioned_session_lease(session))
self.assertEqual(mla.assess_acquired_merger_lease_integrity(session), [])
def test_proof_description_is_explicit_acquired_merger_kind(self):
session = _record_acquired()
proof = mla.describe_session_lease_proof(session)
self.assertEqual(proof["lease_proof_source"], mla.SOURCE_ACQUIRE_MERGER)
self.assertEqual(proof["lease_proof_kind"], "sanctioned_acquire_merger")
self.assertTrue(proof["lease_proof_sanctioned"])
self.assertEqual(proof["lease_proof_comment_id"], 12354)
def test_manual_session_seed_without_provenance_still_rejected(self):
session = leases.record_session_lease(_acquired_session())
self.assertFalse(mla.is_sanctioned_session_lease(session))
proof = mla.describe_session_lease_proof(session)
self.assertEqual(proof["lease_proof_kind"], "unsanctioned_manual_seed")
def test_unknown_provenance_still_rejected(self):
session = leases.record_session_lease(
_acquired_session(),
lease_provenance={"source": "totally_made_up_tool", "comment_id": 1},
)
self.assertFalse(mla.is_sanctioned_session_lease(session))
self.assertEqual(
mla.describe_session_lease_proof(session)["lease_proof_kind"],
"unsanctioned",
)
def test_forged_acquired_provenance_without_comment_marker_rejected(self):
session = leases.record_session_lease(
_acquired_session(comment_id=None),
lease_provenance={"source": mla.SOURCE_ACQUIRE_MERGER},
)
self.assertFalse(mla.is_sanctioned_session_lease(session))
self.assertTrue(
any(
"comment marker" in reason
for reason in mla.assess_acquired_merger_lease_integrity(session)
)
)
def test_comment_id_mismatch_between_provenance_and_session_rejected(self):
session = _record_acquired(_acquired_session(comment_id=999))
self.assertFalse(mla.is_sanctioned_session_lease(session))
def test_reviewer_profile_lease_is_not_acquired_merger_proof(self):
session = _record_acquired(_acquired_session(profile="prgs-reviewer"))
self.assertFalse(mla.is_sanctioned_session_lease(session))
self.assertTrue(
any(
"not a merger profile" in reason
for reason in mla.assess_acquired_merger_lease_integrity(session)
)
)
def test_incomplete_fields_fail_closed(self):
for field, value in (
("session_id", ""),
("reviewer_identity", ""),
("profile", ""),
("repo", ""),
("pr_number", None),
("candidate_head", None),
("candidate_head", "not-a-sha"),
):
with self.subTest(field=field, value=value):
leases.clear_session_lease()
session = _record_acquired(_acquired_session(**{field: value}))
self.assertFalse(
mla.is_sanctioned_session_lease(session),
f"{field}={value!r} must not be sanctioned",
)
def test_existing_reviewer_acquire_and_heartbeat_kinds_unchanged(self):
for source, kind in (
(mla.SOURCE_ACQUIRE, "sanctioned_acquire"),
(mla.SOURCE_HEARTBEAT, "sanctioned_heartbeat"),
):
with self.subTest(source=source):
leases.clear_session_lease()
session = leases.record_session_lease(
{
"pr_number": PR,
"session_id": "reviewer-session",
"candidate_head": HEAD,
"comment_id": 101,
},
lease_provenance=mla.build_lease_provenance(
source=source, comment_id=101
),
)
self.assertTrue(mla.is_sanctioned_session_lease(session))
self.assertEqual(
mla.describe_session_lease_proof(session)["lease_proof_kind"],
kind,
)
def test_adoption_provenance_unchanged(self):
session = leases.record_session_lease(
{
"pr_number": PR,
"session_id": "merger-session",
"candidate_head": HEAD,
"comment_id": 202,
},
lease_provenance=mla.build_lease_provenance(
source=mla.SOURCE_ADOPT,
comment_id=202,
adopted_from_session_id="reviewer-session",
),
)
self.assertTrue(mla.is_sanctioned_session_lease(session))
self.assertEqual(
mla.describe_session_lease_proof(session)["lease_proof_kind"],
"sanctioned_adoption",
)
def test_adoption_without_source_session_still_rejected(self):
session = leases.record_session_lease(
{"pr_number": PR, "session_id": "merger-session", "comment_id": 202},
lease_provenance=mla.build_lease_provenance(
source=mla.SOURCE_ADOPT, comment_id=202
),
)
self.assertFalse(mla.is_sanctioned_session_lease(session))
class TestMergeAuthorizationGate(unittest.TestCase):
"""AC3 — the merge gate accepts a valid freshly acquired merger lease."""
def setUp(self):
leases.clear_session_lease()
def tearDown(self):
leases.clear_session_lease()
def test_acquired_merger_lease_passes_mutation_lease_gate(self):
comments = [_lease_comment()]
_record_acquired()
result = leases.assess_mutation_lease_gate(
pr_number=PR,
comments=comments,
reviewer_identity="merger-user",
session_id="merger-session",
mutation="merge",
live_head_sha=HEAD,
pinned_head_sha=HEAD,
)
self.assertFalse(result["block"], result["reasons"])
def test_manual_seed_still_blocked_by_mutation_lease_gate(self):
comments = [_lease_comment()]
leases.record_session_lease(_acquired_session())
result = leases.assess_mutation_lease_gate(
pr_number=PR,
comments=comments,
reviewer_identity="merger-user",
session_id="merger-session",
mutation="merge",
live_head_sha=HEAD,
pinned_head_sha=HEAD,
)
self.assertTrue(result["block"])
self.assertTrue(
any("sanctioned provenance" in r for r in result["reasons"])
)
def test_head_mismatch_still_blocked_with_acquired_lease(self):
comments = [_lease_comment()]
_record_acquired()
result = leases.assess_mutation_lease_gate(
pr_number=PR,
comments=comments,
reviewer_identity="merger-user",
session_id="merger-session",
mutation="merge",
live_head_sha=OTHER_HEAD,
pinned_head_sha=HEAD,
)
self.assertTrue(result["block"])
class TestFinalizationAssessment(unittest.TestCase):
"""AC7/AC8/AC9/AC10 — owner-only, append-only, idempotent finalization."""
def setUp(self):
leases.clear_session_lease()
def tearDown(self):
leases.clear_session_lease()
def _assess(self, comments, session, **overrides):
kwargs = {
"pr_number": PR,
"session": session,
"actor_identity": "merger-user",
"actor_profile": "prgs-merger",
"actor_session_id": "merger-session",
"repo": REPO,
"worktree": "branches/merger-pr740",
"candidate_head": HEAD,
"live_head_sha": HEAD,
}
kwargs.update(overrides)
return mla.assess_merger_lease_finalization(comments, **kwargs)
def test_owner_merger_may_release_its_own_acquired_lease(self):
session = _record_acquired()
result = self._assess([_lease_comment()], session)
self.assertTrue(result["finalize_allowed"], result["reasons"])
self.assertFalse(result["already_terminal"])
self.assertIn(mla.MERGER_FINALIZATION_MARKER, result["finalization_body"])
self.assertIn("phase: released", result["finalization_body"])
def test_abandon_outcome_supported(self):
session = _record_acquired()
result = self._assess(
[_lease_comment()],
session,
outcome=mla.OUTCOME_ABANDONED,
reason="non-retryable-merge-failure",
)
self.assertTrue(result["finalize_allowed"], result["reasons"])
self.assertIn("phase: abandoned", result["finalization_body"])
self.assertIn(
"finalization_reason: non-retryable-merge-failure",
result["finalization_body"],
)
def test_unknown_outcome_rejected(self):
session = _record_acquired()
result = self._assess([_lease_comment()], session, outcome="deleted")
self.assertFalse(result["finalize_allowed"])
def test_foreign_session_release_rejected(self):
session = _record_acquired()
comments = [_lease_comment(session_id="someone-else")]
result = self._assess(comments, session, actor_session_id="someone-else")
self.assertFalse(result["finalize_allowed"])
self.assertTrue(
any("does not match" in r or "owned by" in r for r in result["reasons"])
)
def test_active_foreign_lease_on_thread_rejected(self):
session = _record_acquired()
comments = [
_lease_comment(),
_lease_comment(session_id="other-merger", comment_id=12400),
]
result = self._assess(comments, session)
self.assertFalse(result["finalize_allowed"])
self.assertTrue(any("owned by session_id" in r for r in result["reasons"]))
def test_reviewer_profile_cannot_finalize_merger_lease(self):
session = _record_acquired()
result = self._assess(
[_lease_comment()], session, actor_profile="prgs-reviewer"
)
self.assertFalse(result["finalize_allowed"])
self.assertTrue(
any("not a merger profile" in r for r in result["reasons"])
)
def test_reviewer_sourced_session_lease_is_not_merger_owned(self):
session = leases.record_session_lease(
_acquired_session(profile="prgs-reviewer"),
lease_provenance=mla.build_lease_provenance(
source=mla.SOURCE_ACQUIRE, comment_id=12354
),
)
result = self._assess([_lease_comment(profile="prgs-reviewer")], session)
self.assertFalse(result["finalize_allowed"])
self.assertTrue(
any("merger-owned" in r for r in result["reasons"]), result["reasons"]
)
def test_no_session_lease_rejected(self):
result = self._assess([_lease_comment()], None)
self.assertFalse(result["finalize_allowed"])
def test_wrong_pr_rejected(self):
session = _record_acquired()
result = self._assess(
[_lease_comment(pr_number=741, session_id="merger-session")],
session,
pr_number=741,
)
self.assertFalse(result["finalize_allowed"])
def test_wrong_repository_rejected(self):
session = _record_acquired()
result = self._assess(
[_lease_comment()], session, repo="Scaled-Tech-Consulting/Other"
)
self.assertFalse(result["finalize_allowed"])
self.assertTrue(any("repository" in r for r in result["reasons"]))
def test_wrong_head_rejected(self):
session = _record_acquired()
result = self._assess(
[_lease_comment()], session, candidate_head=OTHER_HEAD
)
self.assertFalse(result["finalize_allowed"])
def test_wrong_identity_rejected(self):
session = _record_acquired()
result = self._assess(
[_lease_comment()], session, actor_identity="someone-else"
)
self.assertFalse(result["finalize_allowed"])
self.assertTrue(any("identity" in r for r in result["reasons"]))
def test_token_fingerprint_mismatch_rejected(self):
session = _record_acquired(native_token_fingerprint="fp-acquire")
result = self._assess(
[_lease_comment()], session, runtime_token_fingerprint="fp-different"
)
self.assertFalse(result["finalize_allowed"])
self.assertTrue(any("fingerprint" in r for r in result["reasons"]))
def test_matching_token_fingerprint_allowed(self):
session = _record_acquired(native_token_fingerprint="fp-acquire")
result = self._assess(
[_lease_comment()], session, runtime_token_fingerprint="fp-acquire"
)
self.assertTrue(result["finalize_allowed"], result["reasons"])
def test_missing_lease_marker_on_thread_rejected(self):
session = _record_acquired()
result = self._assess([_lease_comment(comment_id=99999)], session)
self.assertFalse(result["finalize_allowed"])
self.assertTrue(any("lease marker comment" in r for r in result["reasons"]))
def test_already_terminal_lease_is_idempotent(self):
session = _record_acquired()
comments = [
_lease_comment(),
_lease_comment(phase="released", comment_id=12360),
]
result = self._assess(comments, session)
self.assertTrue(result["already_terminal"])
self.assertFalse(result["finalize_allowed"])
self.assertIsNone(result["finalization_body"])
self.assertEqual(result["reasons"], [])
def test_abandoned_marker_ends_the_lease(self):
comments = [
_lease_comment(),
_lease_comment(phase="abandoned", comment_id=12361),
]
self.assertIsNone(
leases.find_active_reviewer_lease(comments, pr_number=PR)
)
def test_finalization_is_append_only(self):
session = _record_acquired()
original = _lease_comment()
comments = [original]
result = self._assess(comments, session)
self.assertTrue(result["finalize_allowed"], result["reasons"])
# The assessment never mutates or removes prior ledger entries.
self.assertEqual(comments, [original])
self.assertEqual(result["lease_comment_id"], 12354)
class TestCrossModuleTerminalPhaseAgreement(unittest.TestCase):
"""#742 review 460 — reviewer_pr_lease and pr_work_lease must agree.
Both modules parse the same append-only lease markers. A phase that is
terminal in one and active in the other produces two conflicting truths for
the same comment, so an abandoned finalization would still read as an active
lease to the conflict-fix acquire and PR-sync inventory paths.
"""
TERMINAL_PHASES = ("released", "blocked", "done", "abandoned")
NONTERMINAL_PHASES = ("claimed", "validating")
def _both_active(self, phase: str) -> tuple[bool, bool]:
comments = [_lease_comment(phase=phase)]
return (
leases.find_active_reviewer_lease(comments, pr_number=PR) is not None,
pwl.find_active_reviewer_lease(comments, pr_number=PR) is not None,
)
def test_abandoned_marker_is_not_active_in_pr_work_lease(self):
_, pwl_active = self._both_active("abandoned")
self.assertFalse(pwl_active)
def test_terminal_phases_agree_across_modules(self):
for phase in self.TERMINAL_PHASES:
with self.subTest(phase=phase):
rpl_active, pwl_active = self._both_active(phase)
self.assertFalse(rpl_active)
self.assertFalse(pwl_active)
self.assertEqual(rpl_active, pwl_active)
def test_nonterminal_phases_remain_active_in_both_modules(self):
for phase in self.NONTERMINAL_PHASES:
with self.subTest(phase=phase):
rpl_active, pwl_active = self._both_active(phase)
self.assertTrue(rpl_active)
self.assertTrue(pwl_active)
def test_terminal_phase_sets_are_mirrored(self):
self.assertEqual(
set(leases._TERMINAL_PHASES), set(pwl._TERMINAL_REVIEWER_PHASES)
)
def test_default_released_finalization_behavior_unchanged(self):
# The default outcome is still 'released', and a released marker ends
# the lease for both modules exactly as before this change.
leases.clear_session_lease()
try:
session = _record_acquired()
result = mla.assess_merger_lease_finalization(
[_lease_comment()],
pr_number=PR,
session=session,
actor_identity="merger-user",
actor_profile="prgs-merger",
actor_session_id="merger-session",
repo=REPO,
worktree="branches/merger-pr740",
candidate_head=HEAD,
live_head_sha=HEAD,
)
self.assertTrue(result["finalize_allowed"], result["reasons"])
self.assertEqual(result["outcome"], mla.OUTCOME_RELEASED)
self.assertIn("phase: released", result["finalization_body"])
self.assertEqual(
result["finalization_reason"],
mla.DEFAULT_MERGER_FINALIZATION_REASON,
)
finally:
leases.clear_session_lease()
def test_finalization_appends_and_never_rewrites_prior_markers(self):
claimed = _lease_comment()
original_body = claimed["body"]
ledger = [claimed]
# A terminal marker is appended alongside the original claim; the
# earlier marker is left byte-for-byte intact and is never removed.
terminal = _lease_comment(phase="abandoned", comment_id=12361)
ledger.append(terminal)
self.assertEqual(len(ledger), 2)
self.assertEqual(ledger[0]["body"], original_body)
self.assertEqual(ledger[0]["id"], 12354)
# Newest-wins (#577): the appended terminal marker ends the lease.
self.assertIsNone(leases.find_active_reviewer_lease(ledger, pr_number=PR))
def test_full_ledger_terminal_phases_end_the_lease_in_both_modules(self):
for phase in self.TERMINAL_PHASES:
with self.subTest(phase=phase):
ledger = [
_lease_comment(),
_lease_comment(phase=phase, comment_id=12361),
]
for module in (leases, pwl):
self.assertIsNone(
module.find_active_reviewer_lease(ledger, pr_number=PR),
f"{module.__name__} still active after {phase}",
)
class TestFullLedgerNewestWins(unittest.TestCase):
"""#742 second remediation — chain-scoped newest-wins in pr_work_lease.
On a realistic append-only ledger (claim -> heartbeat -> terminal),
pr_work_lease.find_active_reviewer_lease skipped the terminal marker and
walked backward to the older claim of that same chain, resurrecting it. A
terminal marker must end its own chain, while a foreign, forged, or
malformed terminal marker must never cancel someone else's active lease.
"""
OTHER_SESSION = "other-session"
def _claim(self, **overrides):
return _lease_comment(**overrides)
def _terminal(self, phase="released", comment_id=12361, **overrides):
return _lease_comment(phase=phase, comment_id=comment_id, **overrides)
def _pwl_active(self, ledger):
return pwl.find_active_reviewer_lease(ledger, pr_number=PR)
# --- the chain must end -------------------------------------------------
def test_claim_then_each_terminal_phase_leaves_no_active_lease(self):
for phase in ("released", "blocked", "done", "abandoned"):
with self.subTest(phase=phase):
self.assertIsNone(
self._pwl_active([self._claim(), self._terminal(phase=phase)])
)
def test_claim_heartbeat_terminal_leaves_no_active_lease(self):
for phase in ("released", "blocked", "done", "abandoned"):
with self.subTest(phase=phase):
ledger = [
self._claim(),
self._lease_validating(),
self._terminal(phase=phase, comment_id=12362),
]
self.assertIsNone(self._pwl_active(ledger))
self.assertIsNone(
leases.find_active_reviewer_lease(ledger, pr_number=PR)
)
def _lease_validating(self):
return _lease_comment(phase="validating", comment_id=12355)
# --- foreign / forged terminal markers must not cancel ------------------
def test_foreign_session_terminal_does_not_cancel_active_claim(self):
ledger = [
self._claim(),
self._terminal(session_id=self.OTHER_SESSION),
]
active = self._pwl_active(ledger)
self.assertIsNotNone(active)
self.assertEqual(active["session_id"], "merger-session")
def test_mismatched_chain_fields_do_not_cancel_active_claim(self):
cases = {
"identity": {"identity": "someone-else"},
"profile": {"profile": "prgs-reviewer"},
"candidate_head": {"candidate_head": OTHER_HEAD},
}
for label, override in cases.items():
with self.subTest(field=label):
ledger = [self._claim(), self._terminal(**override)]
self.assertIsNotNone(
self._pwl_active(ledger),
f"terminal with wrong {label} must not cancel the claim",
)
def test_terminal_for_another_pr_does_not_cancel_active_claim(self):
ledger = [
self._claim(),
self._terminal(pr_number=741, comment_id=12363),
]
self.assertIsNotNone(self._pwl_active(ledger))
def test_terminal_for_another_repository_does_not_cancel_active_claim(self):
foreign = self._terminal()
foreign["body"] = foreign["body"].replace(
REPO, "Scaled-Tech-Consulting/Other-Repo"
)
self.assertIsNotNone(self._pwl_active([self._claim(), foreign]))
def test_malformed_terminal_marker_does_not_hide_active_claim(self):
malformed = self._terminal()
# Strip the session id line: no provable chain, so it cancels nothing.
malformed["body"] = "\n".join(
line
for line in malformed["body"].splitlines()
if not line.startswith("session_id:")
)
active = self._pwl_active([self._claim(), malformed])
self.assertIsNotNone(active)
self.assertEqual(active["session_id"], "merger-session")
# --- multi-chain ledgers ------------------------------------------------
def test_newer_active_chain_survives_older_terminated_chain(self):
ledger = [
self._claim(),
self._terminal(comment_id=12361),
_lease_comment(session_id=self.OTHER_SESSION, comment_id=12370),
]
active = self._pwl_active(ledger)
self.assertIsNotNone(active)
self.assertEqual(active["session_id"], self.OTHER_SESSION)
def test_newest_valid_chain_selected_across_multiple_histories(self):
ledger = [
_lease_comment(session_id="chain-1", comment_id=12300),
_lease_comment(session_id="chain-1", phase="released", comment_id=12301),
_lease_comment(session_id="chain-2", comment_id=12310),
_lease_comment(session_id="chain-2", phase="abandoned", comment_id=12311),
_lease_comment(session_id="chain-3", comment_id=12320),
]
active = self._pwl_active(ledger)
self.assertIsNotNone(active)
self.assertEqual(active["session_id"], "chain-3")
def test_all_chains_terminated_leaves_no_active_lease(self):
ledger = [
_lease_comment(session_id="chain-1", comment_id=12300),
_lease_comment(session_id="chain-1", phase="released", comment_id=12301),
_lease_comment(session_id="chain-2", comment_id=12310),
_lease_comment(session_id="chain-2", phase="abandoned", comment_id=12311),
]
self.assertIsNone(self._pwl_active(ledger))
# --- no regression in existing behavior ---------------------------------
def test_single_marker_behavior_matches_reviewer_pr_lease(self):
for phase in (
"claimed", "validating", "approved", "merging",
"released", "blocked", "done", "abandoned",
):
with self.subTest(phase=phase):
ledger = [_lease_comment(phase=phase)]
self.assertEqual(
pwl.find_active_reviewer_lease(ledger, pr_number=PR) is not None,
leases.find_active_reviewer_lease(ledger, pr_number=PR)
is not None,
)
def test_expired_claim_stays_inactive_and_freshness_unchanged(self):
past = datetime.now(timezone.utc) - timedelta(hours=4)
expired = {
"id": 12399,
"user": {"login": "merger-user"},
"body": leases.format_lease_body(
repo=REPO,
pr_number=PR,
issue_number=742,
reviewer_identity="merger-user",
profile="prgs-merger",
session_id="expired-session",
worktree="branches/merger-pr740",
phase="claimed",
candidate_head=HEAD,
target_branch="master",
target_branch_sha="e" * 40,
last_activity=past,
expires_at=past,
),
}
self.assertIsNone(self._pwl_active([expired]))
self.assertIsNone(
leases.find_active_reviewer_lease([expired], pr_number=PR)
)
def test_active_claim_without_any_terminal_marker_remains_active(self):
self.assertIsNotNone(self._pwl_active([self._claim()]))
def test_production_readers_agree_after_release_tool_finalization(self):
"""The ledger the release tool actually writes must read as terminal."""
leases.clear_session_lease()
try:
session = _record_acquired()
result = mla.assess_merger_lease_finalization(
[_lease_comment()],
pr_number=PR,
session=session,
actor_identity="merger-user",
actor_profile="prgs-merger",
actor_session_id="merger-session",
repo=REPO,
worktree="branches/merger-pr740",
candidate_head=HEAD,
live_head_sha=HEAD,
outcome=mla.OUTCOME_ABANDONED,
)
self.assertTrue(result["finalize_allowed"], result["reasons"])
posted = {
"id": 12370,
"user": {"login": "merger-user"},
"body": result["finalization_body"],
}
ledger = [_lease_comment(), posted]
for module in (leases, pwl):
with self.subTest(module=module.__name__):
self.assertIsNone(
module.find_active_reviewer_lease(ledger, pr_number=PR)
)
# Append-only: the claim marker is still present and unmodified.
self.assertEqual(len(ledger), 2)
self.assertEqual(ledger[0]["id"], 12354)
finally:
leases.clear_session_lease()
class TestReleaseMergerLeaseTool(unittest.TestCase):
"""AC7/AC9/AC10/AC11 — the native tool contract end to end."""
def setUp(self):
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
mcp_server._preflight_resolved_role = "merger"
mcp_server._preflight_resolved_task = "release_merger_pr_lease"
leases.clear_session_lease()
def tearDown(self):
leases.clear_session_lease()
def _merger_profile(self, **extra):
p = {
"username": "merger-user",
"profile_name": "prgs-merger",
"role": "merger",
"allowed_operations": [
"gitea.read",
"gitea.pr.comment",
"gitea.pr.merge",
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.review",
"gitea.pr.request_changes",
],
}
p.update(extra)
return p
def _reviewer_profile(self):
return {
"username": "reviewer-user",
"profile_name": "prgs-reviewer",
"role": "reviewer",
"allowed_operations": [
"gitea.read",
"gitea.pr.comment",
"gitea.pr.review",
"gitea.pr.approve",
],
"forbidden_operations": ["gitea.pr.merge"],
}
def _call(self, **kwargs):
params = {
"pr_number": PR,
"worktree": "branches/merger-pr740",
"candidate_head": HEAD,
"remote": "prgs",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
}
params.update(kwargs)
return mcp_server.gitea_release_merger_pr_lease(**params)
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server._authenticated_username", return_value="merger-user")
@patch("gitea_mcp_server._auth", return_value="token pass")
@patch(
"gitea_mcp_server._resolve",
return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"),
)
@patch("gitea_mcp_server.get_profile")
@patch("gitea_mcp_server._fetch_pr_comments")
@patch("gitea_mcp_server._verify_role_mutation_workspace")
def test_merger_releases_own_acquired_lease(
self, _verify, mock_comments, mock_profile, _resolve, _auth, _user, mock_api
):
mock_profile.return_value = self._merger_profile()
mock_comments.return_value = [_lease_comment()]
mock_api.side_effect = [
{"state": "open", "head": {"sha": HEAD}},
{"id": 12370},
]
_record_acquired()
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
res = self._call()
self.assertTrue(res["success"], res)
self.assertTrue(res["finalized"])
self.assertEqual(res["finalization_comment_id"], 12370)
self.assertEqual(res["outcome"], mla.OUTCOME_RELEASED)
# AC11: no active in-session lease survives finalization.
self.assertIsNone(leases.get_session_lease())
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server._authenticated_username", return_value="merger-user")
@patch("gitea_mcp_server._auth", return_value="token pass")
@patch(
"gitea_mcp_server._resolve",
return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"),
)
@patch("gitea_mcp_server.get_profile")
@patch("gitea_mcp_server._fetch_pr_comments")
@patch("gitea_mcp_server._verify_role_mutation_workspace")
def test_merger_cannot_release_foreign_session_lease(
self, _verify, mock_comments, mock_profile, _resolve, _auth, _user, mock_api
):
mock_profile.return_value = self._merger_profile()
mock_comments.return_value = [_lease_comment(session_id="other-merger")]
mock_api.side_effect = [{"state": "open", "head": {"sha": HEAD}}]
_record_acquired()
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
res = self._call()
self.assertFalse(res["success"], res)
self.assertFalse(res["finalized"])
self.assertTrue(res["reasons"])
# Fail closed: no comment POST was attempted.
self.assertEqual(mock_api.call_count, 1)
self.assertIsNotNone(leases.get_session_lease())
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server._authenticated_username", return_value="merger-user")
@patch("gitea_mcp_server._auth", return_value="token pass")
@patch(
"gitea_mcp_server._resolve",
return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"),
)
@patch("gitea_mcp_server.get_profile")
@patch("gitea_mcp_server._fetch_pr_comments")
@patch("gitea_mcp_server._verify_role_mutation_workspace")
def test_already_terminal_release_is_idempotent(
self, _verify, mock_comments, mock_profile, _resolve, _auth, _user, mock_api
):
mock_profile.return_value = self._merger_profile()
mock_comments.return_value = [
_lease_comment(),
_lease_comment(phase="released", comment_id=12360),
]
mock_api.side_effect = [{"state": "open", "head": {"sha": HEAD}}]
_record_acquired()
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
res = self._call()
self.assertTrue(res["success"], res)
self.assertFalse(res["finalized"])
self.assertTrue(res["already_terminal"])
# No second marker posted.
self.assertEqual(mock_api.call_count, 1)
self.assertIsNone(leases.get_session_lease())
@patch("gitea_mcp_server.get_profile")
def test_reviewer_profile_cannot_invoke_merger_release(self, mock_profile):
mock_profile.return_value = self._reviewer_profile()
_record_acquired()
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-reviewer"}):
res = self._call()
self.assertFalse(res["success"], res)
self.assertFalse(res["finalized"])
self.assertTrue(res["reasons"])
# The lease is untouched by a rejected cross-role call.
self.assertIsNotNone(leases.get_session_lease())
class TestReleaseMergerLeaseCapability(unittest.TestCase):
"""AC9 — capability map binds the task to gitea.pr.comment + merger role."""
def test_task_and_tool_alias_are_merger_scoped(self):
for task in ("release_merger_pr_lease", "gitea_release_merger_pr_lease"):
with self.subTest(task=task):
self.assertEqual(
tcm.required_permission(task), "gitea.pr.comment"
)
self.assertEqual(tcm.required_role(task), "merger")
def test_reviewer_release_tool_remains_reviewer_scoped(self):
self.assertTrue(hasattr(mcp_server, "gitea_release_reviewer_pr_lease"))
self.assertNotEqual(
mcp_server.gitea_release_reviewer_pr_lease,
mcp_server.gitea_release_merger_pr_lease,
)
if __name__ == "__main__":
unittest.main()
+176 -6
View File
@@ -92,14 +92,19 @@ class TestMigrateProfiles(unittest.TestCase):
author = prgs_gitea["identities"]["author"]
self.assertEqual(author["username"], "jcwalker3")
self.assertEqual(author["auth"]["id"], "redacted-author-ref")
self.assertEqual(author["allowed_operations"], ["read", "comment"])
self.assertEqual(author["forbidden_operations"], ["approve", "merge"])
self.assertEqual(
author["allowed_operations"], ["gitea.read", "gitea.pr.comment"]
)
self.assertEqual(
author["forbidden_operations"],
["gitea.pr.approve", "gitea.pr.merge"],
)
reviewer = prgs_gitea["identities"]["reviewer"]
self.assertEqual(reviewer["role"], "reviewer")
self.assertEqual(reviewer["username"], "sysadmin")
self.assertEqual(reviewer["auth"]["id"], "redacted-reviewer-ref")
self.assertIn("merge", reviewer["allowed_operations"])
self.assertIn("gitea.pr.merge", reviewer["allowed_operations"])
def test_alias_generation(self):
"""Test that aliases are correctly generated to support old profile names."""
@@ -188,7 +193,7 @@ class TestMigrateProfiles(unittest.TestCase):
self.assertNotIn("token", stdout_output.lower())
def test_explicit_operations_are_preserved(self):
"""Explicit v1 permissions must not be replaced by role defaults."""
"""Explicit v1 permissions are canonicalized, not replaced by role defaults."""
v1_data = json.loads(json.dumps(self.v1_content))
v1_data["profiles"]["prgs-reviewer"]["allowed_operations"] = ["read"]
v1_data["profiles"]["prgs-reviewer"]["forbidden_operations"] = ["merge"]
@@ -198,8 +203,8 @@ class TestMigrateProfiles(unittest.TestCase):
v2_data["environments"]["prgs"]["services"]["gitea"]
["identities"]["reviewer"]
)
self.assertEqual(reviewer["allowed_operations"], ["read"])
self.assertEqual(reviewer["forbidden_operations"], ["merge"])
self.assertEqual(reviewer["allowed_operations"], ["gitea.read"])
self.assertEqual(reviewer["forbidden_operations"], ["gitea.pr.merge"])
def test_inferred_role_defaults_only_when_unambiguous(self):
"""Role defaults are allowed only for clear author/reviewer profiles."""
@@ -306,6 +311,171 @@ class TestMigrateProfiles(unittest.TestCase):
migrate_profiles.main()
self.assertEqual(cm.exception.code, 1)
def test_reconciler_profile_migration(self):
"""Legacy reconciler shorthands migrate to valid canonical operations."""
import gitea_config
import reconciler_profile
v1_data = {
"version": 1,
"profiles": {
"prgs-reconciler": {
"base_url": "redacted-prgs-service",
"username": "reconciler-agent",
"auth": {"type": "keychain", "id": "reconciler-ref"},
"execution_profile": "prgs-reconciler",
"allowed_operations": [
"read",
"pr.close",
"pr.comment",
"issue.comment",
"issue.close",
"gitea.branch.delete",
],
"forbidden_operations": [
"merge",
"approve",
"review",
"pr.create",
"branch.push",
"commit",
],
}
}
}
v2_data = migrate_profiles.migrate_v1_to_v2(v1_data)
reconciler = (
v2_data["environments"]["prgs"]["services"]["gitea"]
["identities"]["reconciler"]
)
self.assertEqual(reconciler["role"], "reconciler")
allowed = reconciler["allowed_operations"]
forbidden = reconciler["forbidden_operations"]
# No invalid shorthand remains
for bad in ("pr.close", "pr.comment", "issue.close", "read", "merge"):
self.assertNotIn(bad, allowed)
self.assertNotIn(bad, forbidden)
for required in (
"gitea.read",
"gitea.pr.close",
"gitea.pr.comment",
"gitea.issue.comment",
"gitea.issue.close",
"gitea.branch.delete",
):
self.assertIn(required, allowed)
# Production loader accepts every allowed op
self.assertEqual(
gitea_config.normalize_operation(required), required
)
self.assertEqual(v2_data["aliases"]["prgs-reconciler"], "prgs.gitea.reconciler")
assessment = reconciler_profile.assess_reconciler_profile(allowed, forbidden)
self.assertTrue(assessment["valid"])
self.assertTrue(migrate_profiles.validate_v2_data(v2_data))
def test_reconciler_profile_defaults(self):
"""Reconciler defaults are fully canonical and loader-valid."""
import gitea_config
import reconciler_profile
v1_data = {
"version": 1,
"profiles": {
"prgs-reconciler": {
"base_url": "redacted-prgs-service",
"username": "reconciler-agent",
"auth": {"type": "keychain", "id": "reconciler-ref"},
"execution_profile": "prgs-reconciler",
}
}
}
v2_data = migrate_profiles.migrate_v1_to_v2(v1_data)
reconciler = (
v2_data["environments"]["prgs"]["services"]["gitea"]
["identities"]["reconciler"]
)
self.assertEqual(reconciler["role"], "reconciler")
self.assertEqual(
reconciler["allowed_operations"],
migrate_profiles.RECONCILER_DEFAULT_ALLOWED,
)
self.assertEqual(
reconciler["forbidden_operations"],
migrate_profiles.RECONCILER_DEFAULT_FORBIDDEN,
)
for op in reconciler["allowed_operations"]:
self.assertEqual(gitea_config.normalize_operation(op), op)
self.assertTrue(op.startswith("gitea."))
assessment = reconciler_profile.assess_reconciler_profile(
reconciler["allowed_operations"],
reconciler["forbidden_operations"],
)
self.assertTrue(assessment["valid"])
self.assertNotIn(
"gitea.branch.delete", assessment["missing_recommended_operations"]
)
def test_reconciler_migration_idempotent_canonicalize(self):
"""Second canonicalize of already-canonical ops is a no-op."""
first = migrate_profiles.canonicalize_operations(
list(migrate_profiles.RECONCILER_DEFAULT_ALLOWED)
)
second = migrate_profiles.canonicalize_operations(first)
self.assertEqual(first, second)
self.assertEqual(first, list(migrate_profiles.RECONCILER_DEFAULT_ALLOWED))
def test_reconciler_missing_required_fails_visibly(self):
"""Missing gitea.pr.close after migration fails closed (not silent drop)."""
v1_data = {
"version": 1,
"profiles": {
"prgs-reconciler": {
"base_url": "redacted-prgs-service",
"username": "reconciler-agent",
"auth": {"type": "keychain", "id": "reconciler-ref"},
"execution_profile": "prgs-reconciler",
"allowed_operations": ["read", "gitea.branch.delete"],
"forbidden_operations": ["merge"],
}
},
}
with self.assertRaisesRegex(ValueError, "missing required"):
migrate_profiles.migrate_v1_to_v2(v1_data)
def test_unknown_operation_fails_visibly(self):
v1_data = {
"version": 1,
"profiles": {
"prgs-author": {
"base_url": "redacted-prgs-service",
"username": "jcwalker3",
"auth": {"type": "keychain", "id": "hidden-author-ref"},
"execution_profile": "prgs-author",
"allowed_operations": ["read", "not.a.real.op"],
"forbidden_operations": ["merge"],
}
},
}
with self.assertRaisesRegex(ValueError, "cannot be canonicalized"):
migrate_profiles.migrate_v1_to_v2(v1_data)
def test_role_inference_author_reviewer_merger_reconciler(self):
self.assertEqual(
migrate_profiles.infer_role("prgs-author", "prgs-author"), "author"
)
self.assertEqual(
migrate_profiles.infer_role("prgs-reviewer", "prgs-reviewer"),
"reviewer",
)
self.assertEqual(
migrate_profiles.infer_role("prgs-reconciler", "prgs-reconciler"),
"reconciler",
)
self.assertIsNone(
migrate_profiles.infer_role("prgs-merger", "prgs-merger")
)
if __name__ == "__main__":
unittest.main()
+228
View File
@@ -0,0 +1,228 @@
"""Safe mutation-error diagnostics for the tool boundary.
The #699/#701 boundary intentionally collapses every non-typed exception to an
opaque ``internal_error`` with the class and message discarded. That makes a
mutation failure (create_issue / create_pr / delete_branch / issue-lock)
undiagnosable: reason_code=internal_error, retryable=false, no actionable
detail.
This suite pins the additive contract:
1. For the ``internal_error`` fallback ONLY, capture a **safe exception class**
(a Python type identifier never secrets) and a **strictly-redacted**
detail (no tokens, Authorization headers, raw URLs/hosts, absolute paths,
response bodies, env dumps), plus an optional ``mutation_stage`` tag.
2. Typed classifications (auth/authz/network/config/http) are unchanged and
still emit fixed messages with NO detail in the daemon log.
3. Any exception may self-declare a safe ``gitea_reason_code`` to be converted
into a typed structured error instead of an opaque internal_error.
"""
from __future__ import annotations
import io
import json
import unittest
import gitea_auth
import mcp_tool_error_boundary as boundary
# Adversarial text a poisoned internal exception might carry.
POISON = (
"boom at /Users/jasonwalker/secret/creds.json "
"Authorization: token ghp_leaked_secret_xyz "
"https://sysadmin:[email protected]/api/v1/x?token=supersecretXYZ "
'Bearer abc.def.ghi {"token":"supersecretXYZ","password":"hunter2"}'
)
SECRETS = (
"ghp_leaked_secret_xyz",
"supersecretXYZ",
"hunter2",
"/Users/jasonwalker/secret",
"gitea.prgs.cc",
)
class TestRedactDetail(unittest.TestCase):
def test_strips_secrets_paths_urls_and_truncates(self):
out = boundary._redact_detail(POISON)
for s in SECRETS:
self.assertNotIn(s, out)
self.assertLessEqual(len(out), boundary._DETAIL_LIMIT)
def test_empty_is_safe(self):
self.assertEqual(boundary._redact_detail(""), "")
self.assertEqual(boundary._redact_detail(None), "")
class TestSafeExceptionClass(unittest.TestCase):
def test_fully_qualified_type_identifier(self):
self.assertEqual(
boundary._safe_exception_class(RuntimeError("x")), "builtins.RuntimeError"
)
self.assertEqual(
boundary._safe_exception_class(KeyError("x")), "builtins.KeyError"
)
def test_class_name_never_carries_instance_text(self):
# The class identifier must not include the (possibly secret) message.
cls = boundary._safe_exception_class(RuntimeError(POISON))
for s in SECRETS:
self.assertNotIn(s, cls)
class TestInternalErrorGainsDiagnostics(unittest.TestCase):
def test_classify_internal_has_class_and_redacted_detail(self):
c = boundary.classify_exception(RuntimeError(POISON))
self.assertEqual(c["reason_code"], "internal_error")
self.assertEqual(c["error_class"], "internal")
self.assertEqual(c["exception_class"], "builtins.RuntimeError")
self.assertIn("detail", c)
for s in SECRETS:
self.assertNotIn(s, json.dumps(c))
def test_payload_surfaces_class_and_detail_but_fixed_message(self):
p = boundary.build_structured_error_payload(
boundary.classify_exception(RuntimeError(POISON)),
tool_name="gitea_delete_branch",
)
self.assertEqual(p["reason_code"], "internal_error")
self.assertEqual(p["exception_class"], "builtins.RuntimeError")
self.assertIn("detail", p)
self.assertEqual(p["message"], boundary.fixed_message("internal_error"))
for s in SECRETS:
self.assertNotIn(s, json.dumps(p))
def test_daemon_log_internal_includes_class_and_detail_no_secrets(self):
buf = io.StringIO()
c = boundary.classify_exception(RuntimeError(POISON))
boundary.log_sanitized_daemon_reason(
c, tool_name="gitea_delete_branch", stream=buf
)
line = buf.getvalue()
self.assertIn("reason_code=internal_error", line)
self.assertIn("exception_class=builtins.RuntimeError", line)
self.assertIn("detail=", line)
for s in SECRETS:
self.assertNotIn(s, line)
class TestTypedPathsUnchanged(unittest.TestCase):
def test_auth_payload_has_no_detail_or_class(self):
p = boundary.build_structured_error_payload(
boundary.classify_exception(gitea_auth.GiteaAuthError())
)
self.assertNotIn("detail", p)
self.assertNotIn("exception_class", p)
def test_auth_daemon_log_has_no_detail(self):
buf = io.StringIO()
boundary.log_sanitized_daemon_reason(
boundary.classify_exception(gitea_auth.GiteaAuthError()),
tool_name="gitea_whoami",
stream=buf,
)
self.assertNotIn("detail=", buf.getvalue())
self.assertNotIn("exception_class=", buf.getvalue())
class TestMutationStageTag(unittest.TestCase):
def test_stage_flows_to_payload_and_log(self):
exc = RuntimeError("boom")
setattr(exc, "_gitea_mutation_stage", "preflight_purity")
c = boundary.classify_exception(exc)
self.assertEqual(c["mutation_stage"], "preflight_purity")
p = boundary.build_structured_error_payload(c, tool_name="gitea_create_pr")
self.assertEqual(p["mutation_stage"], "preflight_purity")
buf = io.StringIO()
boundary.log_sanitized_daemon_reason(c, tool_name="gitea_create_pr", stream=buf)
self.assertIn("mutation_stage=preflight_purity", buf.getvalue())
def test_stage_value_is_bounded_and_safe(self):
exc = RuntimeError("boom")
setattr(exc, "_gitea_mutation_stage", "x" * 500 + POISON)
c = boundary.classify_exception(exc)
for s in SECRETS:
self.assertNotIn(s, c["mutation_stage"])
self.assertLessEqual(len(c["mutation_stage"]), 64)
class TestGiteaReasonCodeConversion(unittest.TestCase):
def test_self_declared_reason_becomes_typed(self):
exc = RuntimeError("Pre-flight order violation: whoami missing")
setattr(exc, "gitea_reason_code", "preflight_order_violation")
c = boundary.classify_exception(exc)
self.assertEqual(c["reason_code"], "preflight_order_violation")
self.assertNotEqual(c["reason_code"], "internal_error")
p = boundary.build_structured_error_payload(c, tool_name="gitea_delete_branch")
self.assertEqual(p["reason_code"], "preflight_order_violation")
self.assertEqual(
p["message"], boundary.fixed_message("preflight_order_violation")
)
def test_unknown_self_declared_reason_ignored(self):
exc = RuntimeError("boom")
setattr(exc, "gitea_reason_code", "not_a_real_reason")
c = boundary.classify_exception(exc)
self.assertEqual(c["reason_code"], "internal_error")
def test_preflight_reason_is_registered(self):
self.assertIn("preflight_order_violation", boundary.FIXED_MESSAGES)
class TestAdversarialInternalNoLeak(unittest.TestCase):
def test_poisoned_internal_never_leaks_in_result_or_log(self):
exc = RuntimeError(POISON)
buf = io.StringIO()
result = boundary.to_call_tool_result(
exc, tool_name="gitea_delete_branch", log=False
)
c = boundary.classify_exception(exc)
boundary.log_sanitized_daemon_reason(
c, tool_name="gitea_delete_branch", stream=buf
)
blob = (
json.dumps(result.structuredContent)
+ result.content[0].text
+ buf.getvalue()
)
for s in SECRETS:
self.assertNotIn(s, blob)
class TestServerSideTypedAndStage(unittest.TestCase):
"""Server raisers are typed and stage-taggable, end-to-end into the boundary."""
def test_preflight_order_error_is_typed_runtimeerror(self):
import gitea_mcp_server as s
exc = s._PreflightOrderError("whoami missing")
self.assertIsInstance(exc, RuntimeError) # legacy except RuntimeError
self.assertEqual(exc.gitea_reason_code, "preflight_order_violation")
c = boundary.classify_exception(exc)
self.assertEqual(c["reason_code"], "preflight_order_violation")
self.assertNotEqual(c["reason_code"], "internal_error")
def test_mutation_stage_tags_and_reraises(self):
import gitea_mcp_server as s
with self.assertRaises(RuntimeError) as ctx:
with s._mutation_stage("preflight_purity"):
raise RuntimeError("boom")
self.assertEqual(
getattr(ctx.exception, "_gitea_mutation_stage", None), "preflight_purity"
)
def test_mutation_stage_inner_wins(self):
import gitea_mcp_server as s
with self.assertRaises(RuntimeError) as ctx:
with s._mutation_stage("outer"):
with s._mutation_stage("inner"):
raise RuntimeError("boom")
self.assertEqual(
getattr(ctx.exception, "_gitea_mutation_stage", None), "inner"
)
if __name__ == "__main__":
unittest.main()
+4
View File
@@ -1,3 +1,7 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
"""Operation-name normalization table and enforcement tests — issue #106.
Covers the required matrix from #106:
+69 -35
View File
@@ -1,4 +1,9 @@
"""Tests for operation-scoped role selection and automatic dispatch switching (#228)."""
"""Tests for operation-scoped role selection (#228, updated by #714).
#714 removes silent automatic profile substitution. Capability resolution
and mutation gates evaluate only the active profile; explicit
``gitea_activate_profile`` is required to switch roles.
"""
import os
import sys
import json
@@ -15,6 +20,7 @@ from reviewer_worktree import assess_author_worktree_continuity
CONFIG_TEST = {
"version": 2,
"allow_runtime_switching": True,
"contexts": {
"ctx": {
"enabled": True,
@@ -30,9 +36,11 @@ CONFIG_TEST = {
"context": "ctx",
"role": "author",
"username": "author-user",
"base_url": "https://gitea.example.com",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": ["gitea.read", "gitea.issue.create", "gitea.pr.create", "gitea.branch.push", "gitea.issue.comment"],
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "913443/eAgenda"],
"execution_profile": "author-profile"
},
"reviewer-profile": {
@@ -40,9 +48,11 @@ CONFIG_TEST = {
"context": "ctx",
"role": "reviewer",
"username": "reviewer-user",
"base_url": "https://gitea.example.com",
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.merge", "gitea.issue.comment"],
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "913443/eAgenda"],
"execution_profile": "reviewer-profile"
}
}
@@ -57,6 +67,15 @@ class TestOperationScopedRoles(unittest.TestCase):
})
self._remotes_patch.start()
mcp_server._IDENTITY_CACHE.clear()
self._url = patch.object(
mcp_server,
"_local_git_remote_url",
side_effect=lambda n: {
"prgs": "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git",
"dadeschools": "https://gitea.dadeschools.net/913443/eAgenda.git",
}.get(n),
)
self._url.start()
gitea_config._active_profile_override = None
mcp_server._MUTATION_AUTHORITY = None
self._dir = tempfile.TemporaryDirectory()
@@ -64,6 +83,11 @@ class TestOperationScopedRoles(unittest.TestCase):
self._write_config(CONFIG_TEST)
def tearDown(self):
try:
self._url.stop()
except Exception:
pass
self._remotes_patch.stop()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
@@ -85,62 +109,71 @@ class TestOperationScopedRoles(unittest.TestCase):
return env
@patch("mcp_server.api_request")
def test_auto_switch_to_reviewer(self, mock_api):
# mock identity resolution to return username matching profile
def test_no_auto_switch_to_reviewer(self, mock_api):
# #714: capability resolution must not silently switch author → reviewer
mock_api.side_effect = lambda method, url, header: (
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
)
with patch.dict(os.environ, self._env("author-profile")):
# initially we are author-profile
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
self.assertEqual(mcp_server.get_profile()["profile_name"], "author-profile")
# resolve a reviewer task (review_pr)
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
# verify it automatically switched to reviewer-profile
self.assertTrue(res["allowed_in_current_session"])
self.assertTrue(res["available_in_session"])
self.assertEqual(res["active_profile"], "reviewer-profile")
self.assertEqual(res["active_identity"], "reviewer-user")
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
self.assertFalse(res["allowed_in_current_session"])
self.assertFalse(res["available_in_session"])
self.assertEqual(res["active_profile"], "author-profile")
self.assertEqual(res["active_identity"], "author-user")
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
self.assertIn("reviewer-profile", res["matching_configured_profile"])
self.assertFalse(res.get("auto_profile_substitution", True))
@patch("mcp_server.api_request")
def test_auto_switch_to_author(self, mock_api):
def test_no_auto_switch_to_author(self, mock_api):
mock_api.side_effect = lambda method, url, header: (
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
)
with patch.dict(os.environ, self._env("reviewer-profile")):
# initially we are reviewer-profile
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
# resolve an author task (create_issue)
res = mcp_server.gitea_resolve_task_capability(task="create_issue", remote="prgs")
# verify it automatically switched to author-profile
self.assertTrue(res["allowed_in_current_session"])
self.assertTrue(res["available_in_session"])
self.assertEqual(res["active_profile"], "author-profile")
self.assertEqual(res["active_identity"], "author-user")
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
self.assertFalse(res["allowed_in_current_session"])
self.assertFalse(res["available_in_session"])
self.assertEqual(res["active_profile"], "reviewer-profile")
self.assertEqual(res["active_identity"], "reviewer-user")
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
self.assertIn("author-profile", res["matching_configured_profile"])
@patch("mcp_server.api_request")
def test_restart_required_when_unattached(self, mock_api):
def test_explicit_activate_profile_still_works(self, mock_api):
mock_api.side_effect = lambda method, url, header: (
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
)
with patch.dict(os.environ, self._env("author-profile")):
act = mcp_server.gitea_activate_profile(
profile_name="reviewer-profile", remote="prgs"
)
self.assertTrue(act["success"])
self.assertEqual(act["after_profile"], "reviewer-profile")
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
self.assertTrue(res["allowed_in_current_session"])
self.assertEqual(res["active_profile"], "reviewer-profile")
@patch("mcp_server.api_request")
def test_denied_when_matching_profile_token_missing(self, mock_api):
mock_api.side_effect = lambda method, url, header: {"login": "author-user"}
# launch without reviewer token in env
with patch.dict(os.environ, self._env("author-profile", with_reviewer_token=False)):
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
# resolve a reviewer task (review_pr)
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
# verify it did NOT switch and reports restart_required
self.assertFalse(res["allowed_in_current_session"])
self.assertFalse(res["available_in_session"])
self.assertTrue(res["configured"])
self.assertTrue(res["restart_required"])
self.assertTrue(res["stop_required"])
self.assertIn("Reviewer profile exists but MCP server was added after session startup and is not attached", res["reason"])
self.assertEqual(res["active_profile"], "author-profile")
def test_author_continuity_dirty_worktree(self):
# author is allowed to keep dirty worktree
@@ -161,20 +194,21 @@ class TestOperationScopedRoles(unittest.TestCase):
self.assertFalse(res2["proven"])
@patch("mcp_server.api_request")
def test_mutating_actions_auto_switch(self, mock_api):
def test_mutating_actions_do_not_auto_switch(self, mock_api):
mock_api.side_effect = lambda method, url, header: (
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
)
with patch.dict(os.environ, self._env("author-profile")):
# verify we are author-profile
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
# call a reviewer mutation check helper (like _profile_permission_block with reviewer permission)
blocked = mcp_server._profile_permission_block("gitea.pr.merge", remote="prgs")
self.assertIsNone(blocked) # should switch to reviewer-profile and allow it (no permission block)
blocked = mcp_server._profile_permission_block(
"gitea.pr.merge", remote="prgs"
)
self.assertIsNotNone(blocked)
self.assertFalse(blocked.get("success", True))
# verify we dynamically switched to reviewer-profile
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
# profile must remain author — no silent substitution
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
if __name__ == "__main__":
+4
View File
@@ -1,3 +1,7 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
"""Tests for the operator guide / project skills MCP tools (#128).
Read-only capability-discovery tools: mcp_get_control_plane_guide,
+4
View File
@@ -1,3 +1,7 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
"""Post-merge moot reviewer-lease handling (#515).
Covers:
@@ -1,3 +1,7 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
"""Regression tests for non-list API payloads on PR/issue comment listing (#485)."""
import os
import sys
@@ -0,0 +1,196 @@
"""#727 / PR #728: author ownership when tracking issue number != PR number.
Regression for REQUEST_CHANGES: gitea_update_pr_branch_by_merge must not require
issue_number == pr_number. Ownership is proven via linked issue + lock/branch
context and fails closed for unrelated issues.
"""
from __future__ import annotations
import os
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import issue_lock_store
import gitea_mcp_server as mcp
def _live_lock(
*,
issue_number: int,
branch_name: str = "feat/issue-727-pr-sync-status",
worktree_path: str = "/tmp/branches/issue-727-pr-sync-status",
remote: str = "prgs",
org: str = "Scaled-Tech-Consulting",
repo: str = "Gitea-Tools",
) -> dict:
now = datetime.now(timezone.utc)
return {
"issue_number": issue_number,
"branch_name": branch_name,
"worktree_path": worktree_path,
"remote": remote,
"org": org,
"repo": repo,
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"acquired_at": now.isoformat(),
"expires_at": (now + timedelta(hours=2)).isoformat(),
"owner_pid": os.getpid(),
"status": "active",
}
class TestAuthorOwnershipIssuePrMismatch(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="gitea-issue-locks-")
self.lock_dir = self._tmp.name
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir
def tearDown(self):
# Clear session pointer for this process.
try:
ptr = issue_lock_store.session_pointer_path(self.lock_dir)
if os.path.isfile(ptr):
os.unlink(ptr)
except Exception:
pass
os.environ.pop("GITEA_ISSUE_LOCK_DIR", None)
self._tmp.cleanup()
def test_issue_727_pr_728_session_lock_accepted(self):
"""Tracking issue #727 lock is valid ownership for PR #728."""
lock = _live_lock(issue_number=727)
issue_lock_store.bind_session_lock(lock)
result = mcp._prove_author_ownership_for_pr(
pr_number=728,
pr_title="feat: pr sync",
pr_body="Closes #727\n\nNative PR sync lifecycle.",
source_branch="feat/issue-727-pr-sync-status",
remote="prgs",
host=None,
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path=lock["worktree_path"],
)
self.assertTrue(result["proven"], result)
self.assertTrue(result["has_author_lock"])
self.assertEqual(result["matched_issue"], 727)
self.assertEqual(result["matched_via"], "session_lock")
self.assertIn(727, result["linked_issues"])
def test_issue_727_pr_728_durable_lock_accepted(self):
lock = _live_lock(issue_number=727)
path = issue_lock_store.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=727,
lock_dir=self.lock_dir,
)
issue_lock_store.save_lock_file(path, lock)
result = mcp._prove_author_ownership_for_pr(
pr_number=728,
pr_title="feat: pr sync",
pr_body="Fixes #727",
source_branch="feat/issue-727-pr-sync-status",
remote="prgs",
host=None,
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertTrue(result["proven"], result)
self.assertEqual(result["matched_issue"], 727)
self.assertEqual(result["matched_via"], "durable_lock")
def test_legacy_same_number_still_accepted(self):
lock = _live_lock(
issue_number=100,
branch_name="fix/issue-100",
worktree_path="/tmp/branches/issue-100",
)
issue_lock_store.bind_session_lock(lock)
result = mcp._prove_author_ownership_for_pr(
pr_number=100,
pr_title="fix something",
pr_body="Closes #100",
source_branch="fix/issue-100",
remote="prgs",
host=None,
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path=lock["worktree_path"],
)
self.assertTrue(result["proven"], result)
self.assertEqual(result["matched_issue"], 100)
def test_unrelated_issue_lock_rejected(self):
"""Lock for issue #999 must not authorize PR #728 linked only to #727."""
lock = _live_lock(
issue_number=999,
branch_name="feat/issue-999",
worktree_path="/tmp/branches/issue-999",
)
issue_lock_store.bind_session_lock(lock)
path = issue_lock_store.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=999,
lock_dir=self.lock_dir,
)
issue_lock_store.save_lock_file(path, lock)
result = mcp._prove_author_ownership_for_pr(
pr_number=728,
pr_title="feat: pr sync",
pr_body="Closes #727",
source_branch="feat/issue-727-pr-sync-status",
remote="prgs",
host=None,
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path=lock["worktree_path"],
)
self.assertFalse(result["proven"], result)
self.assertFalse(result["has_author_lock"])
self.assertIsNone(result["matched_issue"])
self.assertTrue(result["reasons"])
def test_branch_mismatch_on_session_lock_fail_closed(self):
lock = _live_lock(
issue_number=727,
branch_name="feat/other-branch",
)
issue_lock_store.bind_session_lock(lock)
result = mcp._prove_author_ownership_for_pr(
pr_number=728,
pr_title="feat: pr sync",
pr_body="Closes #727",
source_branch="feat/issue-727-pr-sync-status",
remote="prgs",
host=None,
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path=lock["worktree_path"],
)
self.assertFalse(result["proven"], result)
self.assertTrue(any("branch" in r for r in result["reasons"]))
def test_no_lock_fail_closed(self):
result = mcp._prove_author_ownership_for_pr(
pr_number=728,
pr_title="feat: pr sync",
pr_body="Closes #727",
source_branch="feat/issue-727-pr-sync-status",
remote="prgs",
host=None,
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertFalse(result["proven"], result)
self.assertTrue(any("no live author issue lock" in r for r in result["reasons"]))
if __name__ == "__main__":
unittest.main()
+338
View File
@@ -0,0 +1,338 @@
"""Hermetic tests for PR synchronization / conflict-remediation lifecycle."""
from __future__ import annotations
import unittest
from pr_sync_status import (
ACTION_AUTHOR_CONFLICT_REMEDIATION,
ACTION_BLOCKED,
ACTION_FRESH_REVIEW_REQUIRED,
ACTION_MERGE_NOW,
ACTION_UPDATE_BRANCH_BY_MERGE,
assess_post_update_head_transition,
assess_pr_sync_status,
assess_sequential_queue_step,
assess_update_pr_branch_preflight,
lease_usable_at_head,
merge_approval_usable_at_head,
)
def _sha(prefix: str) -> str:
return (prefix + "0" * 40)[:40]
PR_HEAD = _sha("aaaaaaaa")
BASE_HEAD = _sha("bbbbbbbb")
NEW_HEAD = _sha("cccccccc")
OLD_HEAD = _sha("dddddddd")
def _base_kwargs(**overrides):
data = {
"host": "gitea.prgs.cc",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
"pr_number": 100,
"pr_state": "open",
"source_branch": "fix/issue-100",
"pr_head_sha": PR_HEAD,
"base_head_sha": BASE_HEAD,
"commits_behind": 0,
"mergeable": True,
"has_conflicts": False,
"branch_protection_requires_current_base": False,
"approval_at_current_head": True,
"checks_status": "success",
"active_author_lock": True,
"active_reviewer_lease": False,
"active_merger_lease": False,
}
data.update(overrides)
return data
class TestAssessPrSyncStatus(unittest.TestCase):
def test_approved_current_mergeable_merge_now(self):
result = assess_pr_sync_status(**_base_kwargs())
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
self.assertTrue(result["approval_valid_for_merge"])
self.assertFalse(result["stale_approval"])
self.assertEqual(result["pr_head_sha"], PR_HEAD)
self.assertEqual(result["base_head_sha"], BASE_HEAD)
def test_approved_outdated_update_not_required_merge_now(self):
result = assess_pr_sync_status(
**_base_kwargs(
commits_behind=3,
branch_protection_requires_current_base=False,
)
)
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
self.assertTrue(result["approval_valid_for_merge"])
self.assertTrue(any("does not require" in r for r in result["reasons"]))
def test_outdated_update_required_no_conflict(self):
result = assess_pr_sync_status(
**_base_kwargs(
commits_behind=2,
branch_protection_requires_current_base=True,
mergeable=True,
has_conflicts=False,
)
)
self.assertEqual(
result["recommended_next_action"], ACTION_UPDATE_BRANCH_BY_MERGE
)
self.assertFalse(result["approval_valid_for_merge"])
self.assertTrue(any("update_branch_by_merge" in r for r in result["reasons"]))
def test_outdated_has_conflicts_author_remediation(self):
result = assess_pr_sync_status(
**_base_kwargs(
commits_behind=5,
branch_protection_requires_current_base=True,
mergeable=False,
has_conflicts=True,
)
)
self.assertEqual(
result["recommended_next_action"], ACTION_AUTHOR_CONFLICT_REMEDIATION
)
self.assertFalse(result["approval_valid_for_merge"])
def test_stale_approval_fresh_review_required(self):
result = assess_pr_sync_status(
**_base_kwargs(
approval_at_current_head=False,
commits_behind=0,
)
)
self.assertEqual(
result["recommended_next_action"], ACTION_FRESH_REVIEW_REQUIRED
)
self.assertTrue(result["stale_approval"])
self.assertFalse(result["approval_valid_for_merge"])
def test_stale_prepared_verdict_cannot_cross_heads(self):
result = assess_pr_sync_status(
**_base_kwargs(
approval_at_current_head=True,
prepared_verdict_head_sha=OLD_HEAD,
)
)
self.assertEqual(
result["recommended_next_action"], ACTION_FRESH_REVIEW_REQUIRED
)
self.assertTrue(result["stale_prepared_verdict"])
self.assertFalse(result["approval_valid_for_merge"])
def test_missing_heads_fail_closed(self):
result = assess_pr_sync_status(
**_base_kwargs(pr_head_sha="short", base_head_sha=None)
)
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
self.assertTrue(any("PR head" in r for r in result["reasons"]))
def test_closed_pr_blocked(self):
result = assess_pr_sync_status(**_base_kwargs(pr_state="closed"))
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
def test_failed_checks_block_merge_now(self):
result = assess_pr_sync_status(**_base_kwargs(checks_status="failure"))
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
def test_stale_approval_with_update_required_routes_update(self):
result = assess_pr_sync_status(
**_base_kwargs(
approval_at_current_head=False,
commits_behind=1,
branch_protection_requires_current_base=True,
mergeable=True,
has_conflicts=False,
)
)
self.assertEqual(
result["recommended_next_action"], ACTION_UPDATE_BRANCH_BY_MERGE
)
class TestUpdatePrBranchPreflight(unittest.TestCase):
def _ok_kwargs(self, **overrides):
data = {
"role_kind": "author",
"expected_pr_head_sha": PR_HEAD,
"live_pr_head_sha": PR_HEAD,
"expected_base_head_sha": BASE_HEAD,
"live_base_head_sha": BASE_HEAD,
"has_conflicts": False,
"mergeable": True,
"style": "merge",
"has_author_lock": True,
"worktree_path": "/Users/x/Development/Gitea-Tools/branches/issue-100",
"worktree_owns_source_branch": True,
"control_checkout_clean": True,
"master_parity_ok": True,
"force_push": False,
"rebase": False,
}
data.update(overrides)
return data
def test_author_allowed_when_preflight_clean(self):
result = assess_update_pr_branch_preflight(**self._ok_kwargs())
self.assertTrue(result["mutation_allowed"])
self.assertFalse(result["head_race"])
self.assertFalse(result["base_race"])
def test_head_race_fail_closed(self):
result = assess_update_pr_branch_preflight(
**self._ok_kwargs(live_pr_head_sha=NEW_HEAD)
)
self.assertFalse(result["mutation_allowed"])
self.assertTrue(result["head_race"])
self.assertTrue(any("PR head race" in r for r in result["reasons"]))
def test_base_race_fail_closed(self):
result = assess_update_pr_branch_preflight(
**self._ok_kwargs(live_base_head_sha=NEW_HEAD)
)
self.assertFalse(result["mutation_allowed"])
self.assertTrue(result["base_race"])
self.assertTrue(any("base head race" in r for r in result["reasons"]))
def test_conflicts_structured_handoff_no_mutation(self):
result = assess_update_pr_branch_preflight(
**self._ok_kwargs(has_conflicts=True, mergeable=False)
)
self.assertFalse(result["mutation_allowed"])
self.assertTrue(result["author_remediation_handoff"])
self.assertTrue(any("conflicts" in r.lower() for r in result["reasons"]))
def test_reviewer_denied(self):
result = assess_update_pr_branch_preflight(
**self._ok_kwargs(role_kind="reviewer")
)
self.assertFalse(result["mutation_allowed"])
self.assertTrue(any("author-only" in r for r in result["reasons"]))
def test_merger_denied(self):
result = assess_update_pr_branch_preflight(
**self._ok_kwargs(role_kind="merger")
)
self.assertFalse(result["mutation_allowed"])
self.assertTrue(any("author-only" in r for r in result["reasons"]))
def test_no_force_push(self):
result = assess_update_pr_branch_preflight(
**self._ok_kwargs(force_push=True)
)
self.assertFalse(result["mutation_allowed"])
self.assertTrue(any("force-push" in r for r in result["reasons"]))
def test_no_rebase(self):
result = assess_update_pr_branch_preflight(
**self._ok_kwargs(style="rebase", rebase=True)
)
self.assertFalse(result["mutation_allowed"])
self.assertTrue(any("rebase" in r for r in result["reasons"]))
def test_missing_lock_fail_closed(self):
result = assess_update_pr_branch_preflight(
**self._ok_kwargs(has_author_lock=False)
)
self.assertFalse(result["mutation_allowed"])
def test_worktree_not_under_branches(self):
result = assess_update_pr_branch_preflight(
**self._ok_kwargs(worktree_path="/tmp/not-a-branches-path")
)
self.assertFalse(result["mutation_allowed"])
self.assertTrue(any("branches/" in r for r in result["reasons"]))
class TestPostUpdateHeadTransition(unittest.TestCase):
def test_update_invalidates_approval_and_requires_fresh_review(self):
result = assess_post_update_head_transition(
former_pr_head_sha=PR_HEAD,
new_pr_head_sha=NEW_HEAD,
former_approval_head_sha=PR_HEAD,
former_reviewer_lease_head_sha=PR_HEAD,
former_merger_lease_head_sha=PR_HEAD,
prepared_verdict_head_sha=PR_HEAD,
)
self.assertTrue(result["head_changed"])
self.assertTrue(result["approval_invalidated"])
self.assertTrue(result["reviewer_lease_superseded"])
self.assertTrue(result["merger_lease_superseded"])
self.assertTrue(result["prepared_verdict_invalidated"])
self.assertEqual(
result["recommended_next_action"], ACTION_FRESH_REVIEW_REQUIRED
)
def test_same_head_unexpected(self):
result = assess_post_update_head_transition(
former_pr_head_sha=PR_HEAD,
new_pr_head_sha=PR_HEAD,
)
self.assertFalse(result["head_changed"])
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
class TestStaleArtifacts(unittest.TestCase):
def test_stale_approval_cannot_authorize_merge(self):
result = merge_approval_usable_at_head(
current_head_sha=NEW_HEAD,
approved_head_sha=PR_HEAD,
)
self.assertFalse(result["approval_usable"])
def test_fresh_approval_usable(self):
result = merge_approval_usable_at_head(
current_head_sha=NEW_HEAD,
approved_head_sha=NEW_HEAD,
)
self.assertTrue(result["approval_usable"])
def test_stale_reviewer_lease_cannot_cross_heads(self):
result = lease_usable_at_head(
lease_kind="reviewer",
lease_head_sha=PR_HEAD,
current_head_sha=NEW_HEAD,
)
self.assertFalse(result["lease_usable"])
def test_stale_merger_lease_cannot_cross_heads(self):
result = lease_usable_at_head(
lease_kind="merger",
lease_head_sha=PR_HEAD,
current_head_sha=NEW_HEAD,
)
self.assertFalse(result["lease_usable"])
class TestSequentialQueue(unittest.TestCase):
def test_reassess_after_merge_and_master_refresh(self):
result = assess_sequential_queue_step(
just_merged=True,
master_refreshed=True,
next_pr_number=101,
)
self.assertTrue(result["reassess_allowed"])
self.assertTrue(result["process_next"])
self.assertTrue(result["post_merge_cleanup_handoff"])
self.assertEqual(result["next_pr_number"], 101)
def test_block_reassess_without_master_refresh(self):
result = assess_sequential_queue_step(
just_merged=True,
master_refreshed=False,
next_pr_number=101,
)
self.assertFalse(result["reassess_allowed"])
self.assertTrue(any("master must be refreshed" in r for r in result["reasons"]))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,280 @@
"""Regression matrix: session-bound mutation preflight resolves the workspace repo.
Root-cause follow-up to #733. ``_resolve`` (the actual API-targeting resolver)
already prefers the workspace-aligned git remote over the ``REMOTES`` default
(bare ``prgs`` ``Scaled-Tech-Consulting/Timesheet``). The shared #604
anti-stomp preflight (``_run_anti_stomp_preflight``) did **not** mirror that: for
every mutation task whose call site omitted ``org``/``repo`` it filled the
remote-wide ``REMOTES`` default, so the #530 repo guard false-positived
``wrong_repo`` against a ``Gitea-Tools`` checkout and blocked native
issue/PR/review/merge/lock/cleanup mutations.
The fix makes ``_run_anti_stomp_preflight`` prefer the trusted, git-remote-
derived workspace identity for omitted coordinates on **session-bound** mutation
tasks (marking those filled sides explicit, exactly like ``_resolve``), while
``delete_branch`` keeps its explicit cross-repository *target* contract (#733)
and continues to fail closed on omitted coordinates.
"""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import mcp_server
import remote_repo_guard
# Workspace is bound to Gitea-Tools even though the prgs REMOTES default repo is
# Timesheet (the exact cross-repo scenario).
LOCAL_GITEA_TOOLS_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
RECONCILER = {
"profile_name": "prgs-reconciler",
"role": "reconciler",
"allowed_operations": [
"gitea.read", "gitea.issue.comment", "gitea.pr.comment",
"gitea.pr.close", "gitea.branch.delete",
],
"forbidden_operations": ["gitea.pr.create", "gitea.pr.merge"],
"audit_label": "prgs-reconciler",
}
# Session-bound mutation tasks (every MUTATION_TASK except the explicit-target
# delete_branch) must auto-resolve the workspace repository for omitted coords.
SESSION_BOUND_TASKS = [
"create_issue",
"comment_issue",
"close_issue",
"mark_issue",
"lock_issue",
"set_issue_labels",
"create_label",
"create_pr",
"close_pr",
"edit_pr",
"commit_files",
"cleanup_merged_pr_branch",
"cleanup_stale_claims",
"reconcile_merged_cleanups",
"reconcile_already_landed_pr",
"reconcile_close_superseded_pr",
"post_heartbeat",
"review_pr",
"submit_pr_review",
"merge_pr",
]
class _AntiStompResolutionBase(unittest.TestCase):
def setUp(self):
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {
"host": "gitea.prgs.cc",
"org": "Scaled-Tech-Consulting",
"repo": "Timesheet",
},
})
self._remotes.start()
def tearDown(self):
patch.stopall()
def _capture_resolution(self, task, org, repo, *, local_url=LOCAL_GITEA_TOOLS_URL):
"""Drive the live anti-stomp runner and capture the org/repo it resolved
and passed to the pure assessor."""
passthrough = {
"allowed": True, "block": False, "blockers": [], "reasons": [],
"exact_next_action": "proceed", "blocker_kind": None, "checks": {},
}
with patch.dict(
os.environ, {"GITEA_TEST_FORCE_ANTI_STOMP": "1"}, clear=False
), patch(
"mcp_server._local_git_remote_url", return_value=local_url
), patch(
"mcp_server.get_profile", return_value=RECONCILER
), patch.object(
mcp_server.anti_stomp_preflight,
"assess_anti_stomp_preflight",
return_value=passthrough,
) as m:
mcp_server._run_anti_stomp_preflight(
task, remote="prgs", org=org, repo=repo
)
self.assertTrue(m.called)
return m.call_args.kwargs
class TestSessionBoundResolution(_AntiStompResolutionBase):
def test_omitted_coords_resolve_workspace_repo_and_mark_explicit(self):
for task in SESSION_BOUND_TASKS:
with self.subTest(task=task):
kw = self._capture_resolution(task, None, None)
self.assertEqual(
kw["resolved_org"], "Scaled-Tech-Consulting", task
)
self.assertEqual(kw["resolved_repo"], "Gitea-Tools", task)
# Workspace-filled sides are intentional alignment → explicit.
self.assertTrue(kw["org_explicit"], task)
self.assertTrue(kw["repo_explicit"], task)
def test_resolved_workspace_repo_passes_the_530_guard(self):
kw = self._capture_resolution("create_pr", None, None)
assessment = remote_repo_guard.assess_remote_repo_match(
remote="prgs",
resolved_org=kw["resolved_org"],
resolved_repo=kw["resolved_repo"],
local_remote_url=LOCAL_GITEA_TOOLS_URL,
org_explicit=kw["org_explicit"],
repo_explicit=kw["repo_explicit"],
)
self.assertFalse(assessment["block"], assessment)
def test_explicit_caller_coords_are_preserved(self):
kw = self._capture_resolution(
"create_pr", "Scaled-Tech-Consulting", "Gitea-Tools"
)
self.assertEqual(kw["resolved_org"], "Scaled-Tech-Consulting")
self.assertEqual(kw["resolved_repo"], "Gitea-Tools")
self.assertTrue(kw["org_explicit"])
self.assertTrue(kw["repo_explicit"])
def test_no_workspace_identity_falls_back_to_remotes_default(self):
# No derivable local remote → best-effort REMOTES default preserved,
# not marked explicit (so the guard still corroborates when able).
kw = self._capture_resolution("create_pr", None, None, local_url=None)
self.assertEqual(kw["resolved_repo"], "Timesheet")
self.assertFalse(kw["org_explicit"])
self.assertFalse(kw["repo_explicit"])
class TestDeleteBranchContractPreserved(_AntiStompResolutionBase):
"""delete_branch keeps its explicit-target contract (#733): omitted coords
still resolve the remote-wide default and are NOT marked explicit, so the
guard fails closed against a mismatched workspace."""
def test_omitted_coords_still_remote_default_not_explicit(self):
kw = self._capture_resolution("delete_branch", None, None)
self.assertEqual(kw["resolved_repo"], "Timesheet")
self.assertFalse(kw["org_explicit"])
self.assertFalse(kw["repo_explicit"])
assessment = remote_repo_guard.assess_remote_repo_match(
remote="prgs",
resolved_org=kw["resolved_org"],
resolved_repo=kw["resolved_repo"],
local_remote_url=LOCAL_GITEA_TOOLS_URL,
org_explicit=kw["org_explicit"],
repo_explicit=kw["repo_explicit"],
)
self.assertTrue(assessment["block"])
def test_explicit_coords_still_forwarded(self):
kw = self._capture_resolution(
"delete_branch", "Scaled-Tech-Consulting", "Gitea-Tools"
)
self.assertEqual(kw["resolved_repo"], "Gitea-Tools")
self.assertTrue(kw["org_explicit"])
self.assertTrue(kw["repo_explicit"])
if __name__ == "__main__":
unittest.main()
class TestAuthorCallSiteForwardsOrgRepo(_AntiStompResolutionBase):
"""#735: author mutation entrypoints must pass org/repo into preflight."""
def _assert_call_forwards(self, fn_name: str, *args, **kwargs):
import inspect
fn = getattr(mcp_server, fn_name)
# Force preflight path to raise with the kwargs it received so we can
# prove org/repo were forwarded without performing the full mutation.
captured = {}
def _capture(*a, **kw):
captured.update(kw)
captured["_args"] = a
raise RuntimeError("capture-only")
with patch.object(mcp_server, "verify_preflight_purity", side_effect=_capture), \
patch.object(mcp_server, "get_profile", return_value={
"profile_name": "prgs-author",
"role": "author",
"allowed_operations": [
"gitea.read", "gitea.issue.create", "gitea.issue.comment",
"gitea.pr.create", "gitea.repo.commit", "gitea.branch.push",
],
"forbidden_operations": [],
"audit_label": "prgs-author",
}), patch.object(
mcp_server, "_resolve",
return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"),
), patch.object(
mcp_server, "_auth", return_value="token fake",
), patch.object(
mcp_server.role_session_router,
"check_author_mutation_after_reviewer_stop",
return_value=(True, []),
), patch.object(
mcp_server, "_namespace_mutation_block", return_value=None
), patch.object(
mcp_server, "_profile_permission_block", return_value=None
), patch.object(
mcp_server, "_production_guard_block_from_exc", return_value=None
):
try:
fn(*args, **kwargs)
except RuntimeError as exc:
if "capture-only" not in str(exc):
# Some tools re-raise; still require capture when preflight ran.
if not captured:
raise
self.assertTrue(captured, f"{fn_name} never called verify_preflight_purity")
self.assertEqual(captured.get("org"), "Scaled-Tech-Consulting", fn_name)
self.assertEqual(captured.get("repo"), "Gitea-Tools", fn_name)
def test_create_issue_forwards_explicit_org_repo(self):
self._assert_call_forwards(
"gitea_create_issue",
"title",
body="body with acceptance criteria for gate",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path="/tmp/fake-wt",
)
def test_create_pr_forwards_explicit_org_repo(self):
self._assert_call_forwards(
"gitea_create_pr",
title="t",
head="fix/issue-735-x",
base="master",
body="Closes #735",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path="/tmp/fake-wt",
)
def test_mark_issue_forwards_explicit_org_repo(self):
self._assert_call_forwards(
"gitea_mark_issue",
issue_number=735,
action="start",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path="/tmp/fake-wt",
)
def test_commit_files_forwards_explicit_org_repo(self):
self._assert_call_forwards(
"gitea_commit_files",
files=[{"operation": "update", "path": "x", "content_plain": "y"}],
message="m",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
+36
View File
@@ -82,6 +82,42 @@ class TestReconcilerProfileModel(unittest.TestCase):
"reconciler",
)
def test_branch_delete_is_recommended_for_reconciler(self):
self.assertIn(
"gitea.branch.delete",
reconciler_profile.RECONCILER_RECOMMENDED_OPERATIONS,
)
self.assertNotIn(
"gitea.branch.delete",
reconciler_profile.RECONCILER_REQUIRED_OPERATIONS,
)
def test_reconciler_with_branch_delete_stays_valid(self):
allowed = PRGS_RECONCILER_ALLOWED + ["gitea.branch.delete"]
result = reconciler_profile.assess_reconciler_profile(
allowed,
PRGS_RECONCILER_FORBIDDEN,
)
self.assertTrue(result["is_reconciler_profile"])
self.assertTrue(result["valid"])
self.assertNotIn(
"gitea.branch.delete", result["missing_recommended_operations"]
)
self.assertEqual(
mcp_server._role_kind(allowed, PRGS_RECONCILER_FORBIDDEN),
"reconciler",
)
def test_reconciler_without_branch_delete_reports_missing_recommended(self):
result = reconciler_profile.assess_reconciler_profile(
PRGS_RECONCILER_ALLOWED,
PRGS_RECONCILER_FORBIDDEN,
)
self.assertTrue(result["valid"])
self.assertIn(
"gitea.branch.delete", result["missing_recommended_operations"]
)
if __name__ == "__main__":
unittest.main()
+9 -5
View File
@@ -1,3 +1,7 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
"""Regression coverage for the remote/repo mismatch guard (#530).
Bare ``remote=prgs`` historically resolved to ``Scaled-Tech-Consulting/Timesheet``
@@ -162,12 +166,12 @@ class TestResolveServerWiring(unittest.TestCase):
self.addCleanup(self.server.REMOTES.__setitem__, "prgs", original)
self.server.REMOTES["prgs"] = {**original, "repo": repo}
def test_resolve_blocks_on_default_repo_mismatch(self):
def test_resolve_prefers_workspace_over_timesheet_default(self):
"""#714: bare remote=prgs must use workspace Gitea-Tools, not REMOTES Timesheet."""
self._set_prgs_default_repo("Timesheet")
with self.assertRaises(RuntimeError) as ctx:
self.server._resolve("prgs", None, None, None)
self.assertIn("Timesheet", str(ctx.exception))
self.assertIn("Gitea-Tools", str(ctx.exception))
host, org, repo = self.server._resolve("prgs", None, None, None)
self.assertEqual(org, "Scaled-Tech-Consulting")
self.assertEqual(repo, "Gitea-Tools")
def test_resolve_allows_explicit_org_and_repo(self):
self._set_prgs_default_repo("Timesheet")
+16 -6
View File
@@ -188,8 +188,8 @@ class TestResolveTaskCapability(unittest.TestCase):
self.assertEqual(res["required_role_kind"], "author")
self.assertTrue(res["allowed_in_current_session"])
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_resolve_work_issue_reviewer_profile_blocked(self, _auth, _api):
with patch.dict(os.environ, self._env("reviewer-profile")):
res = mcp_server.gitea_resolve_task_capability(
@@ -231,9 +231,16 @@ class TestResolveTaskCapability(unittest.TestCase):
self.assertIn("gitea.issue.comment", res["active_profile_allowed_operations"])
def test_resolve_unknown_task_fails_closed(self):
# #723: unknown tasks return structured unknown_task (no ValueError escape).
with patch.dict(os.environ, self._env("author-profile")):
with self.assertRaises(ValueError):
mcp_server.gitea_resolve_task_capability(task="invalid_task_xyz", remote="prgs")
res = mcp_server.gitea_resolve_task_capability(
task="invalid_task_xyz", remote="prgs"
)
self.assertIsInstance(res, dict)
self.assertEqual(res.get("reason_code"), "unknown_task", res)
self.assertFalse(res.get("allowed_in_current_session"))
self.assertTrue(res.get("stop_required"))
self.assertIs(res.get("mutation_performed"), False)
# Additional regression tests per #145 for permission boundaries and structured guidance
def test_issue_comment_does_not_imply_close(self):
@@ -439,8 +446,11 @@ class TestResolveTaskCapability(unittest.TestCase):
res = mcp_server.gitea_resolve_task_capability(task="close_pr", remote="prgs")
self.assertEqual(res["required_operation_permission"], "gitea.pr.close")
for unknown in ("close_pull_request", "close", "pr_close"):
with self.assertRaises(ValueError):
mcp_server.gitea_resolve_task_capability(task=unknown, remote="prgs")
unk = mcp_server.gitea_resolve_task_capability(
task=unknown, remote="prgs"
)
self.assertEqual(unk.get("reason_code"), "unknown_task", unk)
self.assertFalse(unk.get("allowed_in_current_session"))
if __name__ == "__main__":
unittest.main()
+9 -4
View File
@@ -122,9 +122,11 @@ class TestApiRequestRetry(unittest.TestCase):
sleep.assert_not_called()
def test_non_429_error_raises_immediately(self):
with self.assertRaises(RuntimeError) as ctx:
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
_call([_http_error(500, body=b"boom")])
self.assertIn("HTTP 500", str(ctx.exception))
# Fixed message only (#699) — no response body echo.
self.assertEqual(str(ctx.exception), "Gitea HTTP request failed")
self.assertEqual(ctx.exception.http_status, 500)
def test_non_429_error_does_not_sleep(self):
sleep = MagicMock()
@@ -171,9 +173,12 @@ class TestApiRequestRetry(unittest.TestCase):
# max_retries=3 -> 3 sleeps, then the 4th failure raises.
errors = [_http_error(429, retry_after="1") for _ in range(4)]
sleep = MagicMock()
with self.assertRaises(RuntimeError) as ctx:
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
_call(errors, sleep_func=sleep, max_retries=3)
self.assertIn("HTTP 429", str(ctx.exception))
# After retries are exhausted, 429 is a typed HTTP error with fixed
# message (#699); status metadata carries 429.
self.assertEqual(str(ctx.exception), "Gitea HTTP request failed")
self.assertEqual(ctx.exception.http_status, 429)
self.assertEqual(sleep.call_count, 3)
def test_no_infinite_loop_when_always_429(self):
+101 -2
View File
@@ -68,8 +68,6 @@ class TestReviewDrafts(unittest.TestCase):
@patch("gitea_mcp_server.api_request")
def test_save_draft_success(self, mock_api):
print("GITEA_MCP_SERVER FILE:", gitea_mcp_server.__file__)
print("DIR OF GITEA_MCP_SERVER:", dir(gitea_mcp_server))
mock_api.return_value = {
"head": {"sha": "headsha1234567890"},
"base": {"ref": "master", "sha": "basesha0987654321"},
@@ -285,3 +283,104 @@ class TestReviewDrafts(unittest.TestCase):
)
self.assertFalse(res.get("success"))
self.assertIn("worktree binding mismatch", res.get("reasons")[0])
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server._fetch_pr_comments")
def test_resume_draft_foreign_lease(self, mock_comments, mock_api):
mock_api.return_value = {
"state": "open",
"head": {"sha": "headsha1234567890"},
"base": {"ref": "master", "sha": "basesha0987654321"},
}
gitea_mcp_server.gitea_save_review_draft(
pr_number=587,
action="approve",
body="Good changes",
expected_head_sha="headsha1234567890",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path="/tmp/test-worktree",
)
reviewer_pr_lease.record_session_lease({
"pr_number": 587,
"session_id": "my-session",
})
mock_comments.return_value = [
{
"id": 9,
"user": {"username": "sysadmin"},
"body": (
"<!-- mcp-review-lease:v1 -->\n"
"repo: Scaled-Tech-Consulting/Gitea-Tools\n"
"pr: #587\n"
"reviewer_identity: sysadmin\n"
"profile: prgs-reviewer\n"
"session_id: foreign-session-999\n"
"phase: claimed\n"
),
}
]
res = gitea_mcp_server.gitea_resume_review_draft(
pr_number=587,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path="/tmp/test-worktree",
)
self.assertFalse(res.get("success"))
self.assertTrue(
any("foreign-session-999" in r or "session_id" in r for r in (res.get("reasons") or []))
)
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server._fetch_pr_comments")
@patch("gitea_mcp_server.terminal_review_hard_stop_reasons")
def test_resume_draft_terminal_lock_blocks(self, mock_hard_stop, mock_comments, mock_api):
mock_api.return_value = {
"state": "open",
"head": {"sha": "headsha1234567890"},
"base": {"ref": "master", "sha": "basesha0987654321"},
}
gitea_mcp_server.gitea_save_review_draft(
pr_number=587,
action="approve",
body="Good changes",
expected_head_sha="headsha1234567890",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path="/tmp/test-worktree",
)
reviewer_pr_lease.record_session_lease({
"pr_number": 587,
"session_id": "session-1234",
})
mock_comments.return_value = [
{
"id": 1,
"user": {"username": "sysadmin"},
"body": (
"<!-- mcp-review-lease:v1 -->\n"
"repo: Scaled-Tech-Consulting/Gitea-Tools\n"
"pr: #587\n"
"reviewer_identity: sysadmin\n"
"profile: prgs-reviewer\n"
"session_id: session-1234\n"
"phase: claimed\n"
),
}
]
mock_hard_stop.return_value = [
"terminal review mutation already recorded for PR #587 (approve); #332 hard-stop"
]
res = gitea_mcp_server.gitea_resume_review_draft(
pr_number=587,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path="/tmp/test-worktree",
)
self.assertFalse(res.get("success"))
self.assertTrue(any("#332" in r or "terminal" in r for r in (res.get("reasons") or [])))
+9 -2
View File
@@ -1,3 +1,7 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
"""Tests for reviewer/author namespace mismatch walls (#209)."""
import json
import os
@@ -36,6 +40,7 @@ CONFIG = {
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
],
"execution_profile": "prgs-author",
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
},
"prgs-reviewer": {
"enabled": True,
@@ -51,6 +56,7 @@ CONFIG = {
"gitea.pr.create", "gitea.branch.push", "gitea.issue.create",
],
"execution_profile": "prgs-reviewer",
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
},
"prgs-reviewer-issue-writer": {
"enabled": True,
@@ -63,12 +69,13 @@ CONFIG = {
],
"forbidden_operations": ["gitea.pr.create"],
"execution_profile": "prgs-reviewer-issue-writer",
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
},
},
"rules": {"allow_runtime_switching": False},
}
ISSUE_WRITE_ENV = {"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create"}
ISSUE_WRITE_ENV = {} # config-backed; operations come from profile allowlist (#714)
class NamespaceGateBase(unittest.TestCase):
@@ -199,7 +206,7 @@ class TestMutationAuditFields(NamespaceGateBase):
def test_successful_create_issue_audit_includes_namespace_fields(
self, _auth, mock_api, _get_all, _role
):
env = {**self._env("prgs-author", audit=True), **ISSUE_WRITE_ENV}
env = self._env("prgs-author", audit=True)
with patch.dict(os.environ, env, clear=True):
mcp_server.gitea_create_issue(title="audited", remote="prgs")
with open(self.audit_path, encoding="utf-8") as fh:
+4
View File
@@ -36,6 +36,7 @@ CONFIG = {
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
],
"execution_profile": "prgs-author",
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
},
"prgs-reviewer": {
"enabled": True,
@@ -51,6 +52,7 @@ CONFIG = {
"gitea.pr.create", "gitea.branch.push", "gitea.issue.create",
],
"execution_profile": "prgs-reviewer",
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
},
"prgs-merger": {
"enabled": True,
@@ -65,6 +67,7 @@ CONFIG = {
"gitea.pr.create", "gitea.branch.push", "gitea.pr.approve",
],
"execution_profile": "prgs-merger",
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
},
"prgs-reconciler": {
"enabled": True,
@@ -81,6 +84,7 @@ CONFIG = {
"gitea.branch.push",
],
"execution_profile": "prgs-reconciler",
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
},
},
"rules": {"allow_runtime_switching": False},
+6 -3
View File
@@ -35,7 +35,8 @@ CONFIG_SWITCHING_DISABLED = {
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": ["gitea.read", "gitea.pr.create", "gitea.branch.push"],
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
"execution_profile": "author-profile"
"execution_profile": "author-profile",
"allowed_repositories": ["Example-Org/Example-Repo"],
},
"reviewer-profile": {
"enabled": True,
@@ -45,7 +46,8 @@ CONFIG_SWITCHING_DISABLED = {
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve"],
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
"execution_profile": "reviewer-profile"
"execution_profile": "reviewer-profile",
"allowed_repositories": ["Example-Org/Example-Repo"],
},
"merger-profile": {
"enabled": True,
@@ -55,7 +57,8 @@ CONFIG_SWITCHING_DISABLED = {
"auth": {"type": "env", "name": "GITEA_TOKEN_MERGER"},
"allowed_operations": ["gitea.read", "gitea.pr.merge"],
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.approve"],
"execution_profile": "merger-profile"
"execution_profile": "merger-profile",
"allowed_repositories": ["Example-Org/Example-Repo"],
}
},
"rules": {
+412
View File
@@ -0,0 +1,412 @@
"""Tests for optional self-hosted Sentry observability (#606).
Covers the pure module (config, redaction, event/check-in builders) and the
runtime capture paths using a fake ``sentry_sdk``, so nothing ever touches the
network. Critically proves the feature is a no-op when disabled or DSN-less
(acceptance criterion 1) and that secrets/paths/session-state are never sent
(criterion 5).
"""
import sys
import contextlib
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import sentry_observability as so # noqa: E402
# ── Fake SDK ────────────────────────────────────────────────────────────────
class _FakeScope:
def __init__(self):
self.tags = {}
self.extras = {}
def set_tag(self, k, v):
self.tags[k] = v
def set_extra(self, k, v):
self.extras[k] = v
class FakeSentrySDK:
"""Minimal stand-in exposing the SDK surface sentry_observability uses."""
def __init__(self):
self.init_kwargs = None
self.events = []
self.exceptions = []
self.checkins = []
self.last_scope = None
def init(self, **kwargs):
self.init_kwargs = kwargs
def capture_event(self, event):
self.events.append(event)
def capture_exception(self, exc):
self.exceptions.append(exc)
def capture_checkin(self, **payload):
self.checkins.append(payload)
@contextlib.contextmanager
def push_scope(self):
self.last_scope = _FakeScope()
yield self.last_scope
@pytest.fixture(autouse=True)
def _reset_state():
"""Isolate module init state between tests."""
so.reset_for_tests()
yield
so.reset_for_tests()
@pytest.fixture
def fake_sdk(monkeypatch):
sdk = FakeSentrySDK()
monkeypatch.setattr(so, "sentry_sdk", sdk)
return sdk
# ── Config / gating ─────────────────────────────────────────────────────────
def test_disabled_by_default_empty_env():
cfg = so.load_config(env={})
assert cfg.enabled is False
assert cfg.active is False
def test_enabled_flag_without_dsn_is_not_active():
cfg = so.load_config(env={"MCP_SENTRY_ENABLED": "1"})
assert cfg.enabled is True
assert cfg.dsn is None
assert cfg.active is False # DSN required
def test_dsn_without_enabled_flag_is_not_active():
cfg = so.load_config(env={"SENTRY_DSN": "https://[email protected]/1"})
assert cfg.active is False
def test_active_requires_enabled_and_dsn():
cfg = so.load_config(
env={"MCP_SENTRY_ENABLED": "true", "SENTRY_DSN": "https://[email protected]/1"}
)
assert cfg.active is True
def test_truthy_variants():
for val in ("1", "true", "YES", "On"):
cfg = so.load_config(env={"MCP_SENTRY_ENABLED": val})
assert cfg.enabled is True
for val in ("0", "false", "no", "", "off"):
cfg = so.load_config(env={"MCP_SENTRY_ENABLED": val})
assert cfg.enabled is False
def test_traces_sample_rate_parsed_and_clamped():
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "0.25"}).traces_sample_rate == 0.25
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "5"}).traces_sample_rate == 1.0
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "-1"}).traces_sample_rate == 0.0
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "junk"}).traces_sample_rate == 0.0
def test_safe_summary_has_no_dsn_value():
cfg = so.load_config(
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
)
summary = cfg.safe_summary()
assert summary["dsn_present"] is True
assert "secret" not in repr(summary)
assert "dsn" not in summary # only presence, never the value
# ── init_sentry ─────────────────────────────────────────────────────────────
def test_init_noop_when_disabled(fake_sdk):
status = so.init_sentry(so.load_config(env={}))
assert status["initialized"] is False
assert fake_sdk.init_kwargs is None # no SDK init
assert so.is_initialized() is False
def test_init_noop_when_enabled_but_missing_dsn(fake_sdk):
status = so.init_sentry(so.load_config(env={"MCP_SENTRY_ENABLED": "1"}))
assert status["initialized"] is False
assert fake_sdk.init_kwargs is None
def test_init_reports_missing_sdk(monkeypatch):
monkeypatch.setattr(so, "sentry_sdk", None)
status = so.init_sentry(
so.load_config(
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
)
)
assert status["initialized"] is False
assert "not installed" in status["reason"]
def test_init_configures_sdk_with_scrubber(fake_sdk):
status = so.init_sentry(
so.load_config(
env={
"MCP_SENTRY_ENABLED": "1",
"SENTRY_DSN": "https://[email protected]/1",
"SENTRY_ENVIRONMENT": "prod",
"MCP_SENTRY_TRACES_SAMPLE_RATE": "0.1",
}
)
)
assert status["initialized"] is True
assert so.is_initialized() is True
kw = fake_sdk.init_kwargs
assert kw["dsn"] == "https://[email protected]/1"
assert kw["environment"] == "prod"
assert kw["traces_sample_rate"] == 0.1
assert kw["before_send"] is so.scrub_event
assert kw["send_default_pii"] is False
def test_init_enable_logs_wires_log_scrubber(fake_sdk):
so.init_sentry(
so.load_config(
env={
"MCP_SENTRY_ENABLED": "1",
"SENTRY_DSN": "https://[email protected]/1",
"MCP_SENTRY_ENABLE_LOGS": "1",
}
)
)
exp = fake_sdk.init_kwargs["_experiments"]
assert exp["enable_logs"] is True
assert exp["before_send_log"] is so.scrub_event
def test_init_never_raises_on_sdk_failure(monkeypatch):
class Boom:
def init(self, **kwargs):
raise RuntimeError("sentry down")
monkeypatch.setattr(so, "sentry_sdk", Boom())
status = so.init_sentry(
so.load_config(
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
)
)
assert status["initialized"] is False
assert "init failed" in status["reason"]
# ── Redaction (fail closed) ─────────────────────────────────────────────────
def test_build_tags_allowlist_only():
tags = so.build_tags(role="author", secret_thing="leak", pid=123)
assert tags["role"] == "author"
assert tags["pid"] == "123"
assert "secret_thing" not in tags
def test_build_tags_hashes_session_id():
tags = so.build_tags(session_id="prgs-author-20479-cf9ac178")
assert "session_id" not in tags
assert "session_id_hash" in tags
assert tags["session_id_hash"] != "prgs-author-20479-cf9ac178"
assert len(tags["session_id_hash"]) == 12
def test_build_tags_collapses_worktree_path():
tags = so.build_tags(
worktree_path="/Users/x/Development/Gitea-Tools/branches/issue-606-sentry-observability"
)
assert "worktree_path" not in tags
assert tags["worktree_category"] == "author"
def test_build_tags_drops_value_that_scrubs_to_redacted():
# A tag value that is itself a token gets scrubbed then dropped.
tags = so.build_tags(capability="token abcdef1234567890")
assert "capability" not in tags
def test_sanitize_path_categories():
assert so.sanitize_path("/repo/branches/review-pr-654") == "reviewer"
assert so.sanitize_path("/repo/branches/merge-pr-1") == "merger"
assert so.sanitize_path("/repo/branches/reconcile-pr-1") == "reconciler"
assert so.sanitize_path("/repo/branches/feat-issue-606") == "author"
assert so.sanitize_path("/x/y/Gitea-Tools") == "root"
def test_redact_value_scrubs_secrets_and_paths():
out = so.redact_value(
{
"token": "abc123",
"note": "Authorization: Bearer sk_live_abcdefgh12345",
"path": "/Users/jasonwalker/Development/Gitea-Tools/secret",
"dsn": "https://[email protected]/1",
"safe": "hello",
}
)
assert out["token"] == so.REDACTED
assert out["dsn"] == so.REDACTED
assert "sk_live" not in out["note"]
assert so.REDACTED_PATH in out["path"]
assert "/Users/" not in out["path"]
assert out["safe"] == "hello"
def test_redact_value_forbidden_prompt_and_session_state():
out = so.redact_value(
{"prompt": "full body", "session_state": "{...}", "keep": "ok"}
)
assert out["prompt"] == so.REDACTED
assert out["session_state"] == so.REDACTED
assert out["keep"] == "ok"
def test_scrub_event_redacts_nested_and_drops_server_name():
event = {
"server_name": "some-host",
"message": "boom",
"extra": {"token": "leak", "ok": "1"},
}
scrubbed = so.scrub_event(event)
assert "server_name" not in scrubbed
assert scrubbed["extra"]["token"] == so.REDACTED
assert scrubbed["extra"]["ok"] == "1"
def test_scrub_event_drops_non_dict():
assert so.scrub_event("not a dict") is None
assert so.scrub_event(None) is None
# ── Event builders ──────────────────────────────────────────────────────────
def test_build_blocker_event_structure_and_next_action():
event = so.build_blocker_event(
"active_foreign_lease",
message="blocked by foreign lease",
next_action="wait or adopt via allocator",
level="warning",
tags={"pr_number": 606, "session_id": "s-123"},
)
assert event["tags"]["blocker_type"] == "active_foreign_lease"
assert event["tags"]["pr_number"] == "606"
assert "session_id" not in event["tags"]
assert event["tags"]["session_id_hash"]
assert event["extra"]["canonical_next_action"] == "wait or adopt via allocator"
assert event["fingerprint"] == ["workflow-blocker", "active_foreign_lease"]
def test_build_blocker_event_invalid_level_defaults_warning():
event = so.build_blocker_event("x", level="nonsense")
assert event["level"] == "warning"
def test_build_checkin_payload_maps_slugs():
for key, slug in so.MONITOR_SLUGS.items():
payload = so.build_checkin_payload(key, "ok")
assert payload["monitor_slug"] == slug
assert payload["status"] == "ok"
def test_build_checkin_payload_explicit_slug_passthrough():
payload = so.build_checkin_payload("custom-slug", "in_progress", duration=1.5)
assert payload["monitor_slug"] == "custom-slug"
assert payload["duration"] == 1.5
def test_build_checkin_payload_rejects_bad_status():
with pytest.raises(ValueError):
so.build_checkin_payload("allocator_health", "bogus")
def test_all_six_monitors_registered():
assert set(so.MONITOR_SLUGS) == {
"stale_lease_scan",
"terminal_lock_scan",
"allocator_health",
"namespace_health",
"dashboard_freshness",
"reconciler_cleanup",
}
# ── Capture paths (fail open) ───────────────────────────────────────────────
def test_capture_workflow_blocker_noop_when_disabled(fake_sdk):
# not initialised
event = so.capture_workflow_blocker("some_blocker", message="x")
assert isinstance(event, dict) # still returns redacted event
assert fake_sdk.events == [] # but nothing sent
def test_capture_workflow_blocker_sends_when_initialised(fake_sdk):
so.init_sentry(
so.load_config(
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
)
)
so.capture_workflow_blocker("terminal_lock_occupied", message="held")
assert len(fake_sdk.events) == 1
assert fake_sdk.events[0]["tags"]["blocker_type"] == "terminal_lock_occupied"
def test_capture_exception_noop_when_disabled(fake_sdk):
assert so.capture_exception(ValueError("x")) is False
assert fake_sdk.exceptions == []
def test_capture_exception_sends_scrubbed_tags(fake_sdk):
so.init_sentry(
so.load_config(
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
)
)
ok = so.capture_exception(
RuntimeError("bad"), tags={"mutation_tool": "gitea_merge_pr", "leaky": "x"}
)
assert ok is True
assert len(fake_sdk.exceptions) == 1
assert fake_sdk.last_scope.tags["mutation_tool"] == "gitea_merge_pr"
assert "leaky" not in fake_sdk.last_scope.tags
def test_capture_exception_never_raises(monkeypatch, fake_sdk):
so.init_sentry(
so.load_config(
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
)
)
def boom(exc):
raise RuntimeError("sdk exploded")
monkeypatch.setattr(fake_sdk, "capture_exception", boom)
# Must swallow the SDK failure (fail open).
assert so.capture_exception(ValueError("y")) is False
def test_monitor_checkin_noop_when_disabled(fake_sdk):
payload = so.monitor_checkin("allocator_health", "ok")
assert payload["monitor_slug"] == "gitea-mcp-allocator-health"
assert fake_sdk.checkins == [] # not sent while disabled
def test_monitor_checkin_sends_when_initialised(fake_sdk):
so.init_sentry(
so.load_config(
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
)
)
so.monitor_checkin("stale_lease_scan", "ok")
assert fake_sdk.checkins == [
{"monitor_slug": "gitea-mcp-stale-lease-scan", "status": "ok"}
]
def test_monitor_checkin_invalid_status_returns_none(fake_sdk):
assert so.monitor_checkin("allocator_health", "bogus") is None
assert fake_sdk.checkins == []
@@ -0,0 +1,139 @@
"""Documentation acceptance for the stable control runtime ADR (#615 / PR #616).
Enforces review 443 remediation:
* F1 operator guide / runbooks cross-link the ADR (issue #615 AC2).
* F2 LLM sessions are not instructed to kill/restart/relaunch MCP; process
restart is operator-owned; ADR is the authoritative split.
* F3 routine post-merge master-parity staleness has a sanctioned response
(stop report operator reload re-verify parity) and is not a full
promotion ledger requirement.
"""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
ADR = (
REPO_ROOT
/ "docs"
/ "architecture"
/ "mcp-stable-control-runtime-policy-adr.md"
)
ADR_REL = "architecture/mcp-stable-control-runtime-policy-adr.md"
ADR_BASENAME = "mcp-stable-control-runtime-policy-adr.md"
CROSS_LINK_DOCS = (
REPO_ROOT / "docs" / "wiki" / "Operator-Guide.md",
REPO_ROOT / "docs" / "wiki" / "Runbooks.md",
REPO_ROOT / "docs" / "llm-workflow-runbooks.md",
)
def _read(path: Path) -> str:
assert path.is_file(), f"missing {path.relative_to(REPO_ROOT)}"
return path.read_text(encoding="utf-8")
def test_adr_exists_with_policy_core():
text = _read(ADR)
assert text.lstrip().startswith("#"), "ADR lacks a title"
assert "#615" in text
assert "stable control runtime" in text.lower()
assert "2.3" in text and "2.4" in text and "2.5" in text and "2.6" in text
def test_f1_operator_docs_cross_link_adr():
for path in CROSS_LINK_DOCS:
text = _read(path)
assert ADR_BASENAME in text, (
f"{path.relative_to(REPO_ROOT)} must cross-link {ADR_BASENAME} "
f"(issue #615 acceptance criterion 2)"
)
def test_f1_adr_lists_cross_links_as_acceptance_not_optional_tooling():
text = _read(ADR)
# Acceptance section must require guide/runbook cross-links.
assert "Operator guide / runbooks cross-link" in text or (
"operator guide" in text.lower() and "cross-link" in text.lower()
and "Acceptance" in text
)
# Cross-link must not remain only as optional tooling item #4.
optional = text.split("## 5. Implementation follow-ups", 1)[-1].split(
"## 6. Acceptance", 1
)[0]
assert "Operator Guide wiki cross-link to this ADR" not in optional, (
"ADR §5 must not list operator-guide cross-link as optional tooling"
)
assert "Not optional" in text or "must** cross-link" in text.lower() or (
"must cross-link" in text.lower()
)
def test_f2_runbook_fallback_is_operator_owned_not_llm_restart():
runbooks = _read(REPO_ROOT / "docs" / "llm-workflow-runbooks.md")
# Forbidden historical self-service instruction.
forbidden = (
"the LLM must relaunch or restart the client/MCP with the correct "
"profile environment variable before claiming or working on any tasks"
)
assert forbidden not in runbooks, (
"llm-workflow-runbooks must not instruct the LLM to relaunch/restart MCP"
)
assert "operator-owned" in runbooks.lower() or "Operator-owned" in runbooks
assert ADR_BASENAME in runbooks
def test_f2_workspace_rebind_does_not_require_llm_process_relaunch():
runbooks = _read(REPO_ROOT / "docs" / "llm-workflow-runbooks.md")
section = runbooks.split("### Safe reconnect / rebind procedure", 1)[-1]
section = section.split("## Safety notes", 1)[0]
# Must not tell the LLM alone to relaunch the MCP process as step 2.
assert "Reconnect or relaunch the correct namespace MCP server from the intended" not in section
assert "Operator-owned" in section or "operator" in section.lower()
assert "worktree_path" in section
collapsed = " ".join(section.lower().split())
assert "client reconnect" in collapsed
def test_f2_adr_forbids_llm_restart_and_supersedes_self_service_relaunch():
text = _read(ADR)
lower = text.lower()
assert "must not" in lower and "restart" in lower
assert "operator" in lower and "reload" in lower
assert "supersedes" in lower
assert "llm" in lower
def test_f3_adr_defines_post_merge_parity_staleness_response():
text = _read(ADR)
lower = text.lower()
assert "2.6" in text
assert "post-merge" in lower or "post merge" in lower
assert "parity" in lower and "stale" in lower
# Sanctioned steps: stop, report, operator reload, re-verify.
assert "stop" in lower
assert "report" in lower
assert "operator" in lower
assert "parity is verified" in lower or "re-verif" in lower or (
"startup/current-head" in lower
)
# Not a full promotion ledger for routine reload.
assert "not a §2.4 promotion" in lower or "not a section 2.4 promotion" in lower or (
"not a §2.4" in text or "Not a §2.4 promotion" in text
)
# Stale parity listed among unhealthy triggers.
assert "master parity is stale" in lower or "stale master parity" in lower
def test_f3_adr_forbids_llm_bypass_of_parity_gate():
text = _read(ADR)
lower = text.lower()
assert "bypass" in lower or "self-reset" in lower or "self-service" in lower
assert "parity" in lower
def test_cross_links_do_not_embed_secrets_or_raw_hosts_in_wiki_snippets():
for path in CROSS_LINK_DOCS:
text = _read(path)
for marker in ("ghp_", "BEGIN PRIVATE KEY", "Authorization: Bearer"):
assert marker not in text, f"{path} contains {marker!r}"
@@ -1,15 +1,11 @@
"""Stale #332 review-decision lock cleanup (#594).
Proves:
a. active terminal locks still block unrelated terminal reviews
b. merged/closed PR locks can be cleared canonically
c. cleanup cannot bypass review gates on open PRs
d. after moot cleanup, mark_ready for a different PR can proceed
(PR #592-style after PR #586-style stale approve)
"""
from __future__ import annotations
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
import os
import unittest
from unittest.mock import patch
@@ -188,20 +184,32 @@ class TestActiveHardStopPreserved(unittest.TestCase):
mcp_server._save_review_decision_lock(None)
mcp_server.review_workflow_load.clear_review_workflow_load()
def test_open_approve_still_blocks_other_pr_mark_ready(self):
_seed([APPROVED_OPEN])
reasons = mcp_server.terminal_review_hard_stop_reasons(592, "mark_ready")
self.assertTrue(reasons)
self.assertIn("#332", reasons[0])
self.assertIn("gitea_cleanup_stale_review_decision_lock", reasons[0])
def test_open_approve_blocks_same_pr_not_other_pr_mark_ready(self):
_seed([APPROVED_OPEN]) # approve on PR #700
# Same PR still hard-stopped for mark_ready.
reasons_same = mcp_server.terminal_review_hard_stop_reasons(
700, "mark_ready"
)
self.assertTrue(reasons_same)
self.assertIn("#332", reasons_same[0])
# #693: other PR may mark_ready without cleanup.
self.assertEqual(
mcp_server.terminal_review_hard_stop_reasons(592, "mark_ready"),
[],
)
def test_request_changes_still_blocks_everything(self):
_seed([RC_OPEN])
def test_request_changes_still_blocks_same_pr(self):
_seed([RC_OPEN]) # request_changes on PR #701
for op in ("merge", "mark_ready", "review"):
self.assertTrue(
mcp_server.terminal_review_hard_stop_reasons(592, op),
mcp_server.terminal_review_hard_stop_reasons(701, op),
msg=op,
)
# Foreign PR review path is open (#693).
self.assertEqual(
mcp_server.terminal_review_hard_stop_reasons(592, "mark_ready"),
[],
)
# --------------------------------------------------------------------------- #
+426
View File
@@ -0,0 +1,426 @@
"""Structured MCP auth errors and stdio transport survival (#699 / PR #701).
Covers original AC plus reviewer-ratified regressions:
1. Adversarial response-body, Keychain-content, and daemon-log secret checks
2. Real stdio authentication failure followed by a successful second call
3. UrlElicitationRequiredError framework re-raise behavior
4. Unexpected parser RuntimeError is not authentication
5. Generic HTTP 403 is authorization (not internal)
6. Repeated install/import is idempotent
7. Author and reconciler profiles
8. Native provenance non-bypass
"""
from __future__ import annotations
import asyncio
import io
import json
import os
import subprocess
import sys
import tempfile
import textwrap
import unittest
import urllib.error
from unittest.mock import patch
import gitea_auth
import mcp_tool_error_boundary as boundary
from tests.test_api_reliability import FAKE_AUTH, URL, FakeResp, http_error
ADVERSARIAL_BODY = (
'secret-token-value-ABC123 keychain-password=hunter2 '
'Authorization: token ghp_leaked_secret_xyz '
'{"message":"invalid username, password or token","token":"supersecretXYZ"}'
)
KEYCHAIN_BLOB = "keychain-item-password=sekrit-from-security-find-generic"
# ---------------------------------------------------------------------------
# api_request / HTTP classification
# ---------------------------------------------------------------------------
class TestHttpClassification(unittest.TestCase):
@patch("gitea_auth.urllib.request.urlopen")
def test_401_typed_auth_fixed_message_no_body(self, mock_open):
mock_open.side_effect = http_error(401, ADVERSARIAL_BODY)
with self.assertRaises(gitea_auth.GiteaAuthError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH)
exc = ctx.exception
self.assertEqual(exc.reason_code, "auth_invalid_token")
self.assertEqual(exc.error_class, "authentication")
self.assertEqual(exc.http_status, 401)
msg = str(exc)
self.assertNotIn("supersecretXYZ", msg)
self.assertNotIn("ghp_leaked", msg)
self.assertNotIn("hunter2", msg)
self.assertNotIn(ADVERSARIAL_BODY, msg)
self.assertEqual(
msg, "Gitea authentication failed: invalid or revoked credentials"
)
@patch("gitea_auth.urllib.request.urlopen")
def test_403_scope_is_authz_scope(self, mock_open):
mock_open.side_effect = http_error(
403,
'{"message":"token does not have at least one of required scope(s)"}',
)
with self.assertRaises(gitea_auth.GiteaAuthzError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH)
self.assertEqual(ctx.exception.reason_code, "authz_insufficient_scope")
self.assertEqual(ctx.exception.error_class, "authorization")
self.assertNotIn("token does not have", str(ctx.exception))
@patch("gitea_auth.urllib.request.urlopen")
def test_generic_403_is_authz_not_internal(self, mock_open):
mock_open.side_effect = http_error(403, '{"message":"user has no permission"}')
with self.assertRaises(gitea_auth.GiteaAuthzError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH)
self.assertEqual(ctx.exception.reason_code, "authz_denied")
self.assertEqual(ctx.exception.error_class, "authorization")
self.assertNotIsInstance(ctx.exception, gitea_auth.GiteaAuthError)
self.assertNotIn("user has no permission", str(ctx.exception))
@patch("gitea_auth.urllib.request.urlopen")
def test_network_fixed_message(self, mock_open):
mock_open.side_effect = TimeoutError("timed out contacting secret.example")
with self.assertRaises(gitea_auth.GiteaNetworkError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH)
self.assertEqual(str(ctx.exception), "Network error contacting Gitea")
self.assertNotIn("secret.example", str(ctx.exception))
@patch("gitea_auth.urllib.request.urlopen")
def test_malformed_json_not_auth(self, mock_open):
mock_open.return_value = FakeResp("not-json{")
with self.assertRaises(RuntimeError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH)
self.assertNotIsInstance(ctx.exception, gitea_auth.GiteaAuthError)
self.assertIn("malformed JSON", str(ctx.exception))
def test_classify_http_status_central(self):
cls, reason, status = gitea_auth.classify_http_status(401)
self.assertIs(cls, gitea_auth.GiteaAuthError)
self.assertEqual(reason, "auth_invalid_token")
self.assertEqual(status, 401)
cls, reason, status = gitea_auth.classify_http_status(403)
self.assertIs(cls, gitea_auth.GiteaAuthzError)
self.assertEqual(reason, "authz_denied")
cls, reason, status = gitea_auth.classify_http_status(
403, body_hint="missing scope write:issue"
)
self.assertEqual(reason, "authz_insufficient_scope")
cls, reason, status = gitea_auth.classify_http_status(502)
self.assertIs(cls, gitea_auth.GiteaHttpError)
self.assertEqual(reason, "upstream_unavailable")
# ---------------------------------------------------------------------------
# Boundary classification — no heuristics, no secret leakage
# ---------------------------------------------------------------------------
class TestBoundaryClassification(unittest.TestCase):
def test_auth_payload_fixed_message(self):
exc = gitea_auth.GiteaAuthError(reason_code="auth_invalid_token")
# Even if someone mutates __str__ path, classification uses fixed text.
result = boundary.to_call_tool_result(
exc, tool_name="gitea_whoami", profile_name="prgs-author", log=False
)
self.assertTrue(result.isError)
p = result.structuredContent
self.assertEqual(p["reason_code"], "auth_invalid_token")
self.assertEqual(p["error_class"], "authentication")
self.assertEqual(p["message"], boundary.fixed_message("auth_invalid_token"))
self.assertNotIn("supersecret", json.dumps(p))
self.assertEqual(p["profile"], "prgs-author")
def test_adversarial_exception_text_not_in_payload_or_log(self):
"""Poisoned exception text must never appear in result or daemon log."""
class PoisonedAuth(gitea_auth.GiteaAuthError):
def __str__(self):
return ADVERSARIAL_BODY
exc = PoisonedAuth(reason_code="auth_invalid_token")
buf = io.StringIO()
result = boundary.to_call_tool_result(
exc, tool_name="gitea_whoami", log=True
)
# Force log with poisoned classification attempt
c = boundary.classify_exception(exc)
boundary.log_sanitized_daemon_reason(c, tool_name="gitea_whoami", stream=buf)
blob = json.dumps(result.structuredContent) + result.content[0].text + buf.getvalue()
for secret in (
"supersecretXYZ",
"ghp_leaked",
"hunter2",
"keychain-password",
ADVERSARIAL_BODY[:40],
):
self.assertNotIn(secret, blob)
self.assertIn("reason_code=auth_invalid_token", buf.getvalue())
self.assertNotIn("detail=", buf.getvalue())
def test_keychain_content_not_in_daemon_log(self):
buf = io.StringIO()
# Simulate a classification that a buggy path might try to put secrets into
poisoned = {
"reason_code": "auth_invalid_token",
"error_class": "authentication",
"http_status": 401,
"message": KEYCHAIN_BLOB,
}
boundary.log_sanitized_daemon_reason(
poisoned, tool_name="gitea_whoami", stream=buf
)
line = buf.getvalue()
self.assertNotIn("sekrit", line)
self.assertNotIn("keychain-item", line)
self.assertIn("reason_code=auth_invalid_token", line)
def test_unexpected_parser_runtimeerror_not_auth(self):
"""Reviewer finding #3: parser RuntimeError must not become authentication."""
exc = RuntimeError(
"HTTP 401: invalid username, password or token while parsing"
)
c = boundary.classify_exception(exc)
self.assertEqual(c["error_class"], "internal")
self.assertEqual(c["reason_code"], "internal_error")
self.assertNotEqual(c["error_class"], "authentication")
self.assertFalse(boundary.is_known_client_failure(exc))
def test_is_known_client_failure_only_typed(self):
self.assertTrue(
boundary.is_known_client_failure(gitea_auth.GiteaAuthError())
)
self.assertTrue(
boundary.is_known_client_failure(gitea_auth.GiteaAuthzError())
)
self.assertFalse(boundary.is_known_client_failure(RuntimeError("x")))
self.assertFalse(boundary.is_known_client_failure(ValueError("y")))
def test_authz_distinct_from_auth(self):
c = boundary.classify_exception(
gitea_auth.GiteaAuthzError(reason_code="authz_denied")
)
self.assertEqual(c["error_class"], "authorization")
self.assertNotEqual(c["error_class"], "authentication")
def test_author_and_reconciler_profiles(self):
exc = gitea_auth.GiteaAuthError()
for profile in ("prgs-author", "prgs-reconciler"):
r = boundary.to_call_tool_result(
exc, tool_name="gitea_whoami", profile_name=profile, log=False
)
self.assertEqual(r.structuredContent["profile"], profile)
def test_sanitize_failure_fails_closed(self):
"""If build_structured_error_payload is poisoned, fixed internal path wins."""
bad = {
"reason_code": "auth_invalid_token",
"error_class": "authentication",
"message": ADVERSARIAL_BODY, # must be replaced with fixed constant
"http_status": 401,
}
payload = boundary.build_structured_error_payload(bad)
self.assertEqual(
payload["message"], boundary.fixed_message("auth_invalid_token")
)
self.assertNotIn("supersecret", payload["message"])
# ---------------------------------------------------------------------------
# Tool.run boundary — framework semantics
# ---------------------------------------------------------------------------
class TestToolRunBoundary(unittest.TestCase):
def setUp(self):
from mcp.server.fastmcp.tools.base import Tool
boundary.install_tool_run_boundary(Tool)
self.Tool = Tool
def _tool(self, fn, name="demo_tool"):
return self.Tool.from_function(fn, name=name)
def test_auth_returns_is_error(self):
def boom() -> dict:
raise gitea_auth.GiteaAuthError()
result = asyncio.run(self._tool(boom, "gitea_whoami").run({}, convert_result=True))
self.assertTrue(result.isError)
self.assertEqual(result.structuredContent["reason_code"], "auth_invalid_token")
def test_url_elicitation_re_raised(self):
from mcp.shared.exceptions import UrlElicitationRequiredError
from mcp.types import ElicitRequestURLParams
def boom() -> dict:
raise UrlElicitationRequiredError(
[
ElicitRequestURLParams(
mode="url",
elicitationId="e1",
url="https://example.invalid/elicit",
message="need auth",
)
]
)
tool = self._tool(boom, "elicitation_tool")
async def _run():
return await tool.run({}, convert_result=True)
with self.assertRaises(UrlElicitationRequiredError):
asyncio.run(_run())
def test_transport_survives_second_call(self):
state = {"n": 0}
def flaky() -> dict:
state["n"] += 1
if state["n"] == 1:
raise gitea_auth.GiteaAuthError()
return {"ok": True, "call": state["n"]}
tool = self._tool(flaky, "gitea_whoami")
async def _both():
first = await tool.run({}, convert_result=True)
second = await tool.run({}, convert_result=True)
return first, second
first, second = asyncio.run(_both())
self.assertTrue(first.isError)
self.assertEqual(first.structuredContent["error_class"], "authentication")
self.assertFalse(getattr(second, "isError", False))
self.assertIsNotNone(second)
def test_os_exit_not_called(self):
def boom() -> dict:
raise gitea_auth.GiteaAuthError()
with patch("os._exit") as mock_exit:
result = asyncio.run(self._tool(boom).run({}, convert_result=True))
mock_exit.assert_not_called()
self.assertTrue(result.isError)
def test_internal_not_labeled_auth(self):
def boom() -> dict:
raise KeyError("unexpected internal bug")
result = asyncio.run(self._tool(boom).run({}, convert_result=True))
self.assertTrue(result.isError)
self.assertEqual(result.structuredContent["error_class"], "internal")
self.assertEqual(result.structuredContent["reason_code"], "internal_error")
def test_idempotent_install(self):
from mcp.server.fastmcp.tools.base import Tool
first = boundary.install_tool_run_boundary(Tool)
second = boundary.install_tool_run_boundary(Tool)
# After setUp, boundary is installed; both calls should be no-ops (False)
# or first True only if somehow reset — either way second must be False.
self.assertFalse(second)
self.assertTrue(boundary.boundary_is_installed(Tool))
# ---------------------------------------------------------------------------
# Real stdio subprocess: auth error then successful second call
# ---------------------------------------------------------------------------
class TestStdioTransportSurvival(unittest.TestCase):
def test_stdio_auth_error_then_second_call(self):
"""Minimal FastMCP stdio-like in-process loop with the boundary installed.
Uses Tool.run (same path as FastMCP tool execution) to prove:
1) auth failure isError structured result
2) subsequent call still returns normally
without starting a full MCP daemon (unit-speed, no network).
"""
from mcp.server.fastmcp.tools.base import Tool
boundary.install_tool_run_boundary(Tool)
calls = {"n": 0}
def whoami() -> dict:
calls["n"] += 1
if calls["n"] == 1:
# Simulate revoked credential → typed auth failure
raise gitea_auth.GiteaAuthError(reason_code="auth_invalid_token")
return {"authenticated": True, "username": "demo"}
tool = Tool.from_function(whoami, name="gitea_whoami")
async def session():
r1 = await tool.run({}, convert_result=True)
r2 = await tool.run({}, convert_result=True)
return r1, r2
r1, r2 = asyncio.run(session())
self.assertTrue(r1.isError)
self.assertEqual(r1.structuredContent["reason_code"], "auth_invalid_token")
self.assertTrue(r1.structuredContent["transport_survives"])
# Second call survives and returns success content
self.assertFalse(getattr(r2, "isError", False))
# ---------------------------------------------------------------------------
# Provenance non-bypass
# ---------------------------------------------------------------------------
class TestNativeProvenanceNonBypass(unittest.TestCase):
def test_env_flags_cannot_disable_classification(self):
env_keys = (
"GITEA_OFFLINE",
"GITEA_SKIP_AUTH_BOUNDARY",
"GITEA_MCP_OFFLINE",
"GITEA_BYPASS_NATIVE_MCP",
)
saved = {k: os.environ.get(k) for k in env_keys}
try:
for k in env_keys:
os.environ[k] = "1"
exc = gitea_auth.GiteaAuthError()
c = boundary.classify_exception(exc)
self.assertEqual(c["error_class"], "authentication")
r = boundary.to_call_tool_result(exc, log=False)
self.assertTrue(r.isError)
self.assertEqual(r.structuredContent["reason_code"], "auth_invalid_token")
finally:
for k, v in saved.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
def test_no_bypass_surface(self):
for name in dir(boundary):
lower = name.lower()
self.assertFalse(
lower.startswith("bypass") or lower.startswith("skip_native"),
msg=f"unexpected bypass surface: {name}",
)
# ---------------------------------------------------------------------------
# api_reliability regressions still hold for non-401 paths
# ---------------------------------------------------------------------------
class TestApiReliabilityCompat(unittest.TestCase):
@patch("gitea_auth.urllib.request.urlopen")
def test_502_upstream_typed(self, mock_open):
mock_open.side_effect = http_error(502, "bad gateway secret=xyz")
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH)
self.assertEqual(ctx.exception.reason_code, "upstream_unavailable")
self.assertNotIn("secret=xyz", str(ctx.exception))
@patch("gitea_auth.urllib.request.urlopen")
def test_auth_header_never_in_error(self, mock_open):
mock_open.side_effect = http_error(400, "bad request")
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH)
self.assertNotIn(FAKE_AUTH, str(ctx.exception))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,205 @@
"""Invariant tests pinning task_capability_map role assignments (#722/#723).
Added with the operator-authorized break-glass repair for incident #722:
commit 970e68b remapped ten reviewer tasks to ``role="merger"`` while every
configured merger profile forbids the review permissions, so no configured
profile could resolve any formal review task (``matching_configured_profile``
was empty repository-wide). These tests fail loudly if that class of
regression recurs:
- every role-exclusive formal-review task must be satisfiable by at least one
canonical role profile (permission AND role together);
- the capability map must agree with ``role_session_router`` task sets;
- ``adopt_merger_pr_lease`` stays merger-only (the legitimate hunk of
970e68b, preserved by the repair);
- merger profiles must not be able to resolve review_pr/approve_pr.
"""
import unittest
import gitea_config
from role_session_router import MERGER_TASKS, REVIEWER_TASKS
from task_capability_map import required_permission, required_role
# Canonical role-profile permission shape. Mirrors the configured
# author/reviewer/merger/reconciler profiles (profiles.json v2 role split):
# reviewers review/approve/request changes but never merge; mergers merge but
# never review/approve/request changes.
CANONICAL_ROLE_PROFILES = {
"author": {
"allowed": [
"gitea.read",
"gitea.branch.create",
"gitea.branch.push",
"gitea.repo.commit",
"gitea.pr.create",
"gitea.pr.comment",
"gitea.issue.create",
"gitea.issue.comment",
"gitea.issue.close",
],
"forbidden": [
"gitea.pr.approve",
"gitea.pr.request_changes",
"gitea.pr.merge",
],
},
"reviewer": {
"allowed": [
"gitea.read",
"gitea.pr.review",
"gitea.pr.approve",
"gitea.pr.request_changes",
"gitea.pr.comment",
"gitea.issue.comment",
],
"forbidden": [
"gitea.branch.create",
"gitea.branch.push",
"gitea.repo.commit",
"gitea.pr.create",
"gitea.pr.merge",
],
},
"merger": {
"allowed": [
"gitea.read",
"gitea.pr.merge",
"gitea.pr.comment",
"gitea.issue.comment",
],
"forbidden": [
"gitea.branch.create",
"gitea.branch.push",
"gitea.repo.commit",
"gitea.pr.create",
"gitea.pr.approve",
"gitea.pr.review",
"gitea.pr.request_changes",
],
},
"reconciler": {
"allowed": [
"gitea.read",
"gitea.pr.close",
"gitea.pr.comment",
"gitea.issue.comment",
"gitea.branch.delete",
"gitea.decision_lock.irrecoverable_recovery",
],
"forbidden": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.review",
"gitea.pr.request_changes",
"gitea.pr.create",
"gitea.branch.create",
"gitea.branch.push",
"gitea.repo.commit",
],
},
}
# Role-exclusive formal-review tasks (mirrors the resolver's role-exclusive
# handling for review work): permission alone is not enough — the profile's
# role kind must also match, so both dimensions are pinned here.
FORMAL_REVIEW_TASKS = (
"review_pr",
"approve_pr",
"request_changes_pr",
"blind_pr_queue_review",
"pr_queue_cleanup",
"pr-queue-cleanup",
)
def _profile_satisfies(role_name, task):
"""True when the canonical *role_name* profile can perform *task*."""
profile = CANONICAL_ROLE_PROFILES[role_name]
ok, _reason = gitea_config.check_operation(
required_permission(task), profile["allowed"], profile["forbidden"]
)
return ok and role_name == required_role(task)
class TestFormalReviewProfileCoverage(unittest.TestCase):
"""#722: some configured profile must be able to formally review."""
def test_every_formal_review_task_has_a_satisfying_role_profile(self):
for task in FORMAL_REVIEW_TASKS:
with self.subTest(task=task):
satisfying = [
role
for role in CANONICAL_ROLE_PROFILES
if _profile_satisfies(role, task)
]
self.assertTrue(
satisfying,
f"no canonical role profile satisfies both permission "
f"{required_permission(task)!r} and role "
f"{required_role(task)!r} for task {task!r} — formal "
f"review would be impossible for every configured "
f"profile (incident #722)",
)
def test_formal_review_tasks_are_reviewer_role(self):
for task in FORMAL_REVIEW_TASKS:
with self.subTest(task=task):
self.assertEqual(required_role(task), "reviewer")
class TestMapRouterAgreement(unittest.TestCase):
"""#723 AC2: the map and the role session router must not drift."""
def test_reviewer_tasks_map_to_reviewer_role(self):
for task in sorted(REVIEWER_TASKS):
with self.subTest(task=task):
self.assertEqual(
required_role(task),
"reviewer",
f"router classifies {task!r} as a reviewer task but the "
f"capability map assigns role {required_role(task)!r}",
)
def test_merger_tasks_map_to_merger_role(self):
for task in sorted(MERGER_TASKS):
with self.subTest(task=task):
self.assertEqual(
required_role(task),
"merger",
f"router classifies {task!r} as a merger task but the "
f"capability map assigns role {required_role(task)!r}",
)
class TestMergerBoundary(unittest.TestCase):
"""Preserve the legitimate hunk of 970e68b and the merger fence."""
def test_adopt_merger_pr_lease_requires_merger_role(self):
self.assertEqual(required_role("adopt_merger_pr_lease"), "merger")
self.assertEqual(
required_permission("adopt_merger_pr_lease"), "gitea.pr.comment"
)
def test_merge_pr_requires_merger_role(self):
self.assertEqual(required_role("merge_pr"), "merger")
self.assertEqual(required_permission("merge_pr"), "gitea.pr.merge")
def test_merger_profile_cannot_resolve_formal_review_tasks(self):
merger = CANONICAL_ROLE_PROFILES["merger"]
for task in ("review_pr", "approve_pr", "request_changes_pr"):
with self.subTest(task=task):
ok, reason = gitea_config.check_operation(
required_permission(task),
merger["allowed"],
merger["forbidden"],
)
self.assertFalse(
ok,
f"merger profile must not hold {task!r} permission "
f"(got reason {reason!r})",
)
if __name__ == "__main__":
unittest.main()
+54 -22
View File
@@ -1,10 +1,10 @@
"""Tests for the session hard-stop after a terminal review mutation (#332).
"""Tests for the session hard-stop after a terminal review mutation (#332/#693).
Extends the single-terminal-decision machinery (#211): after a live
REQUEST_CHANGES the run must stop; after an APPROVE only the merge sequence
for that same PR may continue; a different PR can never be reviewed or
merged in the same run; duplicate REQUEST_CHANGES at an unchanged head is
suppressed.
REQUEST_CHANGES on a PR head the same-head path must stop; after an APPROVE
only the merge sequence for that same PR may continue; #693 allows a
*different* PR to be mark_ready/review in the same durable lock (cross-PR
isolation); duplicate REQUEST_CHANGES at an unchanged head is suppressed.
"""
import os
import unittest
@@ -15,7 +15,10 @@ import mcp_server
HEAD_SHA = "a" * 40
def _lock(mutations=None, correction=False):
def _lock(mutations=None, correction=False, correction_pr=None):
mutations = list(mutations or [])
if correction and correction_pr is None and mutations:
correction_pr = mutations[-1].get("pr_number")
return {
"task": "review_pr",
"remote": "prgs",
@@ -29,9 +32,11 @@ def _lock(mutations=None, correction=False):
"ready_remote": None,
"ready_org": None,
"ready_repo": None,
"live_mutations": list(mutations or []),
"live_mutations": mutations,
"correction_authorized": correction,
"correction_reason": None,
"correction_reason": "test" if correction else None,
"correction_pr_number": correction_pr if correction else None,
"correction_head_sha": None,
}
@@ -64,25 +69,35 @@ class TestTerminalHardStopReasons(unittest.TestCase):
self.assertEqual(
mcp_server.terminal_review_hard_stop_reasons(5, "merge"), [])
def test_request_changes_blocks_everything(self):
def test_request_changes_blocks_same_pr_allows_other_pr_review(self):
_seed([RC_A])
# Same PR: still hard-stopped for mark_ready/review/merge.
for op in ("merge", "mark_ready", "review"):
for pr in (5, 6):
reasons = mcp_server.terminal_review_hard_stop_reasons(pr, op)
self.assertTrue(reasons, f"{op}/{pr}")
self.assertIn("request_changes on PR #5", reasons[0])
self.assertIn("#332", reasons[0])
reasons = mcp_server.terminal_review_hard_stop_reasons(5, op)
self.assertTrue(reasons, f"{op}/5")
self.assertIn("request_changes on PR #5", reasons[0])
self.assertIn("#332", reasons[0])
# #693: different PR may mark_ready / review (not merge via hard-stop alone).
self.assertEqual(
mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"), [])
self.assertEqual(
mcp_server.terminal_review_hard_stop_reasons(6, "review"), [])
# Merge of an unapproved other PR remains blocked by hard-stop path
# (last terminal is not approve of PR 6).
self.assertTrue(
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
def test_approve_allows_same_pr_merge_only(self):
def test_approve_allows_same_pr_merge_and_other_pr_review(self):
_seed([APPROVED_A])
self.assertEqual(
mcp_server.terminal_review_hard_stop_reasons(5, "merge"), [])
self.assertTrue(
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
self.assertTrue(
mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"))
self.assertTrue(
mcp_server.terminal_review_hard_stop_reasons(6, "review"))
# #693 cross-PR isolation: other PR formal review path is open.
self.assertEqual(
mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"), [])
self.assertEqual(
mcp_server.terminal_review_hard_stop_reasons(6, "review"), [])
def test_correction_reopens_review_path_only(self):
_seed([RC_A], correction=True)
@@ -90,9 +105,11 @@ class TestTerminalHardStopReasons(unittest.TestCase):
mcp_server.terminal_review_hard_stop_reasons(5, "mark_ready"), [])
self.assertEqual(
mcp_server.terminal_review_hard_stop_reasons(5, "review"), [])
# correction never opens a cross-PR merge
# scoped correction never opens a cross-PR merge
self.assertTrue(
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
# unscoped/cross-PR correction must not lift PR 6 via correction alone
# (PR 6 is allowed by isolation, not by correction scope)
class TestMergeHardStopWiring(unittest.TestCase):
@@ -126,14 +143,29 @@ class TestMarkFinalHardStopWiring(unittest.TestCase):
mcp_server._save_review_decision_lock(None)
mcp_server.review_workflow_load.clear_review_workflow_load()
def test_mark_ready_blocked_after_terminal_mutation(self):
def test_mark_ready_same_pr_blocked_after_terminal_mutation(self):
_seed([RC_A])
result = mcp_server.gitea_mark_final_review_decision(
pr_number=6, action="approve", remote="prgs")
pr_number=5, action="approve", remote="prgs",
expected_head_sha=HEAD_SHA)
self.assertFalse(result["marked_ready"])
self.assertTrue(
any("#332" in r for r in result["reasons"]), result["reasons"])
def test_mark_ready_other_pr_allowed_after_foreign_terminal(self):
"""#693: foreign open-PR terminal must not block mark_final on PR B."""
_seed([RC_A])
no_lease = {"block": False, "reasons": [], "mutation_allowed": True}
with patch("mcp_server._list_pr_lease_comments", return_value=[]), \
patch("mcp_server._pr_work_lease_reviewer_block",
return_value=no_lease), \
patch.object(mcp_server, "gitea_check_pr_eligibility",
return_value={"head_sha": HEAD_SHA, "eligible": True}):
result = mcp_server.gitea_mark_final_review_decision(
pr_number=6, action="approve", remote="prgs",
expected_head_sha=HEAD_SHA)
self.assertTrue(result["marked_ready"], result.get("reasons"))
def _feedback(blocking, stale=False, success=True):
return {

Some files were not shown because too many files have changed in this diff Show More