Compare commits

..
Author SHA1 Message Date
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
sysadminandClaude Opus 4.8 573e721437 fix(workflow): fail-closed key version, strict incident evidence, archive-gated clear (#709)
Address formal review 438 REQUEST_CHANGES on PR #710 (F6/F7/F8).

F6 — HMAC key-version validation fails closed:
- verify_authorization_artifact validates key_version BEFORE any MAC work, so an
  attacker-chosen version can never select the signing key.
- Require exactly one nonempty, well-formed key-version field; missing, empty,
  unknown, malformed, duplicated (including identical-valued and nested
  aliases), and mismatched versions all fail. No versionless legacy fallback.
- Artifact version must equal the configured active version; production now
  requires GITEA_IRRECOVERABLE_AUTH_HMAC_KEY_VERSION explicitly (an implicit
  default made rotation ambiguous). Version stays inside the signed material.

F7 — strictly canonical incident evidence:
- Replace substring/first-match parsing with an exact schema: marker on line 1,
  every field once, fixed order, no duplicate/unknown/empty/conflicting fields
  in or outside the signed block. The parsed body is re-rendered and compared
  for exact equality before acceptance.
- content_digest now binds the full recovery scope: repository identity, PR,
  decision-lock identity, destroyed subject, recovery action, recorded and
  expected head, incident issue, evidence author, minting actor, key version,
  nonce and issued_at.
- Actor identity is the immutable user id with login consistency; conflicting
  ids/logins and display-name-only identities fail closed. Edited comments are
  rejected. The independent-author rule is preserved and enforced by stable id.
- build_canonical_incident_body is the single source of the accepted format and
  refuses to emit ambiguous evidence.

F8 — archival is a prerequisite for clearing terminal evidence:
- _clear_decision_lock_for_profile no longer swallows archive failures. It
  requires a successful write plus a durable read-back matching the PR/head,
  and otherwise returns a structured, retry-safe failure that retains the lock
  and records actionable recovery evidence.
- Fixes a latent bug the read-back exposed: the archive payload inherited the
  source lock's session_profile_lock, so save_state keyed the archive under the
  reviewer profile instead of the archive identity and it never read back.

Adversarial regressions added for every listed case: key-version missing/empty/
unknown/malformed/duplicate/rotation/wrong-key-after-restart, reordered fields,
duplicate identical and conflicting fields, conflicting actor ids/names,
digest-preserving substitution, decision-lock and recovery-action substitution,
cross-PR/repo/org/remote/head replay, archive exception/timeout/false/empty/
partial-readback with proof the lock survives, exactly-one permitted clear, and
retry after archive failure. No existing assertion was weakened.

Validation: focused 150 passed in three module orders; full tests/ 2809 passed,
6 skipped, 1 warning (pre-existing StarletteDeprecationWarning in
tests/test_webui_audit.py:8), 161 subtests passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-15 13:55:06 -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
sysadminandGrok 4.5 2b359e0c26 fix(workflow): durable HMAC, dedicated mint capability, head-exact approve match (#709)
Address formal review 435 REQUEST_CHANGES on PR #710:
- F4: require durable GITEA_IRRECOVERABLE_AUTH_HMAC_KEY (fail closed; no ephemeral
  per-process secret); bind key_version into HMAC; cross-process verify works
- F5: dedicated gitea.decision_lock.irrecoverable_recovery only; reject reconciler
  equivalence; authoritative incident body + author + content_digest; reject
  self-authored incident evidence
- F3 residual: lock_targets_merged_pr_approval requires recorded-head match when
  expected_head_sha is provided (legacy no-head approve no longer primary-clears)

Co-Authored-By: Grok 4.5 (xAI) <[email protected]>
2026-07-14 02:13:47 -04:00
sysadminandGrok 4.5 9cb12ee0f4 fix(workflow): non-forgeable irrecoverable auth, merger consumer, exact-scope cleanup (#709)
Address formal review 434 REQUEST_CHANGES on PR #710:
- F1: replace caller operator_authorized with server-side HMAC auth artifacts
- F2: implement fail-closed merger consumption for prior-provenance only
- F3: enforce remote/org/repo/head on cross-profile load and clear

Co-Authored-By: Grok 4.5 (xAI) <[email protected]>
2026-07-14 01:36:39 -04:00
sysadmin ec5cf67771 fix(workflow): cross-profile decision-lock cleanup and irrecoverable provenance (#709)
Prevent merger-local empty decision locks from standing in for reviewer
terminal cleanup, refuse silent re-init overwrite of unresolved terminal
evidence, record post-merge recovery-required state when audit fails, and
add a truthful irrecoverable-provenance path that never claims applied=true.

Closes #709
2026-07-14 00:54:33 -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
97 changed files with 27490 additions and 816 deletions
+30
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
@@ -55,3 +76,12 @@ GITEA_MCP_PROFILE=prgs
# GITEA_MERGER_WORKTREE=/path/to/repo/branches/merge-pr456
# GITEA_RECONCILER_WORKTREE=/path/to/repo/branches/reconcile-pr456
# GITEA_ACTIVE_WORKTREE=/path/to/repo/branches/session-override
# Durable HMAC key for irrecoverable decision-lock provenance artifacts (#709 F4).
# REQUIRED in production native MCP processes that mint or verify recovery
# authorization (reconciler + merger must share the same key). Hex (preferred),
# base64, or utf-8 literal (>=16 bytes). Never generate an ephemeral per-process
# key — cross-process / post-restart verification would fail closed.
# GITEA_IRRECOVERABLE_AUTH_HMAC_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
# Optional key version string bound into the signature (for rotation).
# GITEA_IRRECOVERABLE_AUTH_HMAC_KEY_VERSION=v1
+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 |
+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,
+55
View File
@@ -475,6 +475,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 +600,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.
+5259 -352
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+408 -8
View File
@@ -36,7 +36,43 @@ 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.
KIND_POST_MERGE_DECISION_RECOVERY = "post_merge_decision_recovery"
# #709: truthful record when historical terminal evidence is irrecoverably gone.
KIND_IRRECOVERABLE_DECISION_PROVENANCE = "irrecoverable_decision_provenance"
# #709 F1: server-side non-forgeable authorization artifact for recovery.
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,
}
)
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
@@ -114,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.
@@ -121,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(
@@ -142,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(
@@ -150,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")
@@ -228,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,
*,
@@ -270,7 +322,9 @@ def identity_match_reasons(
reasons.append("session state missing recorded_at timestamp (fail closed)")
else:
age = _now_utc() - recorded_at
if age > timedelta(hours=ttl_hours()):
kind = (record.get("kind") or "").strip()
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)"
)
@@ -279,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,
@@ -287,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)
@@ -297,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):
@@ -342,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(
@@ -354,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(
@@ -363,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):
@@ -392,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
@@ -425,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)
@@ -437,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,
@@ -446,4 +621,229 @@ 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]:
"""Return profile identities that have a durable review_decision_lock file (#709).
Filename form: ``review_decision_lock-<profile>.json`` (see ``state_key``).
Does not validate TTL or identity — callers must load via ``load_state``.
"""
root = (state_dir or default_state_dir()).strip()
if not root or not os.path.isdir(root):
return []
prefix = f"{_sanitize_segment(KIND_DECISION_LOCK)}-"
suffix = ".json"
found: list[str] = []
try:
names = os.listdir(root)
except OSError:
return []
for name in names:
if not name.startswith(prefix) or not name.endswith(suffix):
continue
if name.endswith(".lock"):
continue
mid = name[len(prefix) : -len(suffix)]
if mid:
found.append(mid)
return sorted(set(found))
def load_state_for_profile(
*,
kind: str,
profile_identity: str,
remote: str | None = None,
org: str | None = None,
repo: str | None = None,
state_dir: str | None = None,
skip_identity_match: bool = False,
enforce_repo_scope: bool = True,
) -> dict[str, Any] | None:
"""Load durable state for an explicit profile identity (#709 cross-profile).
When *skip_identity_match* is True, still requires the file's recorded
profile_identity to equal the requested profile (anti-stomp), but does not
require the *active* session identity to match — needed so a merger can
inspect a reviewer lock after merge.
#709 F3: remote/org/repo filter reasons are **enforced** (not merely
computed). When *enforce_repo_scope* is True (default) and the caller
supplies remote/org/repo, mismatches fail closed with ``None``.
"""
# #709 F3: refuse traversal / malformed profile identities.
try:
from irrecoverable_provenance import assess_profile_path_identity
path_gate = assess_profile_path_identity(profile_identity)
if not path_gate.get("valid"):
return None
except Exception:
# Module may be mid-import in edge bootstraps; fall through to
# conservative checks below.
raw = str(profile_identity or "")
if ".." in raw or "/" in raw or "\\" in raw or "\x00" in raw:
return None
profile = current_profile_identity(profile_identity=profile_identity)
root = _ensure_state_dir(state_dir)
# Refuse symlink escape of the state root (#709 F3).
try:
real_root = os.path.realpath(root)
path = state_file_path(
kind=kind,
remote=remote,
org=org,
repo=repo,
profile_identity=profile,
state_dir=root,
)
real_path = os.path.realpath(path) if os.path.exists(path) else path
if os.path.exists(path) and not str(real_path).startswith(
str(real_root) + os.sep
) and str(real_path) != str(real_root):
return None
except OSError:
return None
path = state_file_path(
kind=kind,
remote=remote,
org=org,
repo=repo,
profile_identity=profile,
state_dir=root,
)
lock_path = f"{path}.lock"
with _exclusive_file_lock(lock_path):
envelope = _read_json(path)
if not envelope:
return None
payload = envelope.get("payload")
if not isinstance(payload, dict):
return None
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]
stored = (merged.get("profile_identity") or "").strip()
if stored and stored != profile:
return None
if skip_identity_match:
# Enforce remote/org/repo when caller provides them (#709 F3).
# Drop only *active session* profile-identity mismatches; keep
# expiry, spoof, and repository-scope reasons.
scope_remote = remote if enforce_repo_scope else None
scope_org = org if enforce_repo_scope else None
scope_repo = repo if enforce_repo_scope else None
reasons = identity_match_reasons(
merged,
remote=scope_remote,
org=scope_org,
repo=scope_repo,
profile_identity=stored or profile,
)
filtered = [
r
for r in reasons
if "profile identity mismatch" not in r
or (stored and stored != profile)
]
# When caller requested a specific remote/org/repo, also fail if the
# stored record lacks those identity fields entirely (legacy incomplete).
if enforce_repo_scope:
for field, want in (
("remote", remote),
("org", org),
("repo", repo),
):
want_s = (want or "").strip()
if not want_s:
continue
have = (str(merged.get(field) or "")).strip()
if not have:
filtered.append(
f"session state {field} missing on durable record "
f"(expected={want_s!r}; fail closed, #709 F3)"
)
elif have != want_s:
# identity_match_reasons already adds mismatch; ensure kept
pass
if filtered:
return None
return merged
reasons = identity_match_reasons(
merged,
remote=remote,
org=org,
repo=repo,
profile_identity=profile,
)
if reasons:
return None
return merged
+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))
+4
View File
@@ -18,11 +18,13 @@ 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"
SANCTIONED_PROVENANCE_SOURCES = frozenset({
SOURCE_ADOPT,
SOURCE_ACQUIRE,
SOURCE_ACQUIRE_MERGER,
SOURCE_HEARTBEAT,
})
@@ -209,8 +211,10 @@ _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|"
r"adoption_comment_id\s*[:=]|"
+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,
}
+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
+235
View File
@@ -407,18 +407,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 +733,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 +1027,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 +1100,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 +1119,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 +1392,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 []),
}
+706 -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:
@@ -438,3 +488,646 @@ def format_cleanup_audit_comment(audit: dict[str, Any]) -> str:
"This path only clears a lock when the referenced PR is merged/closed.",
]
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
# ---------------------------------------------------------------------------
def has_unresolved_terminal_evidence(lock: dict | None) -> bool:
"""True when *lock* still carries a terminal live mutation ledger."""
return last_terminal_mutation(lock) is not None
def assess_init_overwrite(
existing_lock: dict | None,
*,
force: bool = False,
) -> dict[str, Any]:
"""Whether init_review_decision_lock may replace an existing durable lock (#709 AC2).
Unresolved terminal evidence must never be silently replaced by an empty
initialized lock. *force* does not authorize destruction of terminal
ledgers — only explicit cleanup / archive transitions may remove them.
"""
result: dict[str, Any] = {
"overwrite_allowed": True,
"has_existing": existing_lock is not None,
"has_unresolved_terminal": False,
"reasons": [],
"last_terminal_pr": None,
"last_terminal_action": None,
"existing_profile_identity": None,
}
if existing_lock is None:
result["reasons"].append("no existing lock; empty init allowed")
return result
result["existing_profile_identity"] = (
existing_lock.get("profile_identity")
or existing_lock.get("session_profile_lock")
or existing_lock.get("session_profile")
)
last = last_terminal_mutation(existing_lock)
if last is None:
result["reasons"].append(
"existing lock has no terminal mutations; re-init allowed"
)
return result
result["has_unresolved_terminal"] = True
result["overwrite_allowed"] = False
result["last_terminal_pr"] = last.get("pr_number")
result["last_terminal_action"] = last.get("action")
result["reasons"].append(
"refuse empty re-init: unresolved terminal decision-lock evidence "
f"present ({last.get('action')} on PR #{last.get('pr_number')}); "
"use gitea_cleanup_stale_review_decision_lock when moot, or archive "
f"via sanctioned recovery — force={force!r} does not authorize overwrite "
"(#709 AC2)"
)
return result
def lock_targets_merged_pr_approval(
lock: dict | None,
*,
pr_number: int,
expected_head_sha: str | None = None,
) -> bool:
"""True when *lock*'s last terminal mutation is approve of *pr_number*.
When *expected_head_sha* is provided, a **recorded** terminal head must
match. Legacy same-repo approve locks with no recorded head do **not**
match (fail closed, #709 F3 residual / review 435) — callers must use the
strict secondary path that refuses no-head clears.
"""
last = last_terminal_mutation(lock)
if last is None:
return False
if last.get("action") != "approve":
return False
if last.get("pr_number") != pr_number:
return False
if expected_head_sha:
want = normalize_head_sha(expected_head_sha)
if not want:
return False
locked = mutation_head_sha(last, lock)
# Require recorded-head match; unrecorded head is not a match (#709 F3).
if not locked or not heads_equal(locked, want):
return False
return True
def build_post_merge_recovery_record(
*,
pr_number: int,
head_sha: str | None,
merge_commit_sha: str | None,
target_profile_identity: str | None,
failed_step: str,
error: str | None,
remote: str | None,
org: str | None,
repo: str | None,
actor_username: str | None,
profile_name: str | None,
) -> dict[str, Any]:
"""Durable recovery-required payload after irreversible merge (#709 AC3)."""
return {
"event": "post_merge_decision_lock_recovery_required",
"status": "recovery_required",
"issue_ref": "#709",
"recovery_critical": True,
"applied": False, # never claim historical cleanup succeeded
"timestamp": datetime.now(timezone.utc).isoformat(),
"pr_number": pr_number,
"head_sha": normalize_head_sha(head_sha),
"merge_commit_sha": merge_commit_sha,
"target_profile_identity": target_profile_identity,
"failed_step": failed_step,
"error": error,
"remote": remote,
"org": org,
"repo": repo,
"actor_username": actor_username,
"profile_name": profile_name,
"required_recovery_action": (
"retry cross-profile decision-lock cleanup and audit publication "
"for the merged PR; if terminal evidence is gone, use "
"gitea_record_irrecoverable_decision_lock_provenance"
),
}
def build_irrecoverable_provenance_record(
*,
pr_number: int,
head_sha: str | None,
remote: str | None,
org: str | None,
repo: str | None,
actor_username: str | None,
profile_name: str | None,
reason: str,
incident_ref: str | None = None,
# Deprecated kwargs retained only so stale call sites fail closed:
operator_authorized: bool | None = None,
# Required for merger-acceptable records (#709 F1):
authorization: dict[str, Any] | None = None,
incident_issue: int | None = None,
incident_comment_id: int | None = None,
destroyed_subject: str | None = None,
historical_provenance_subject: str | None = None,
) -> dict[str, Any]:
"""Truthful absence-of-proof record (#709 AC5). Never sets applied=True.
Caller-supplied ``operator_authorized`` is **ignored** as authorization
evidence (review 434 F1). Prefer
:func:`irrecoverable_provenance.build_irrecoverable_provenance_record`
with a verified server-side authorization artifact.
"""
# Explicitly ignore deprecated self-assertable Boolean.
_ = operator_authorized
if authorization is not None and incident_issue is not None and incident_comment_id is not None:
from irrecoverable_provenance import (
build_irrecoverable_provenance_record as _build,
)
return _build(
pr_number=int(pr_number),
head_sha=str(head_sha or ""),
remote=str(remote or ""),
org=str(org or ""),
repo=str(repo or ""),
actor_username=actor_username,
profile_name=profile_name,
reason=reason,
incident_issue=int(incident_issue),
incident_comment_id=int(incident_comment_id),
authorization=authorization,
destroyed_subject=destroyed_subject,
historical_provenance_subject=historical_provenance_subject
or destroyed_subject,
)
# Fail-closed skeleton when no server authorization is supplied: never
# sets merger_may_accept True (even if operator_authorized was True).
return {
"event": "irrecoverable_decision_lock_provenance",
"status": "provenance_irrecoverable",
"record_type": "irrecoverable_decision_provenance",
"operator_recovery_required": True,
"issue_ref": "#709",
"recovery_critical": True,
"applied": False,
"historical_cleanup_proven": False,
"timestamp": datetime.now(timezone.utc).isoformat(),
"pr_number": pr_number,
"head_sha": normalize_head_sha(head_sha),
"remote": remote,
"org": org,
"repo": repo,
"actor_username": actor_username,
"profile_name": profile_name,
"reason": reason,
"incident_ref": incident_ref,
"incident_issue": incident_issue,
"incident_comment_id": incident_comment_id,
"authorization_verified": False,
"merger_may_accept": False,
"acceptance_rule": (
"Merger may accept this record only when a server-side "
"authorization artifact verifies for remote/org/repo/PR/exact "
"head/incident, the record is durable and read back, and normal "
"merge gates still pass. Caller Booleans never authorize. This "
"does not prove historical cleanup."
),
}
def format_irrecoverable_audit_comment(record: dict[str, Any]) -> str:
"""Markdown body for irrecoverable provenance audit (no applied=true claim)."""
from irrecoverable_provenance import (
format_irrecoverable_audit_comment as _fmt,
)
return _fmt(record)
def format_post_merge_recovery_comment(record: dict[str, Any]) -> str:
"""Markdown body for post-merge recovery-required audit."""
lines = [
"## Post-merge decision-lock recovery required (#709)",
"",
"Status: **RECOVERY_REQUIRED** (merge is irreversible; cleanup/audit incomplete)",
"",
f"- actor: `{record.get('actor_username')}`",
f"- profile: `{record.get('profile_name')}`",
f"- timestamp: `{record.get('timestamp')}`",
f"- PR: `#{record.get('pr_number')}`",
f"- head_sha: `{record.get('head_sha')}`",
f"- merge_commit_sha: `{record.get('merge_commit_sha')}`",
f"- target_profile_identity: `{record.get('target_profile_identity')}`",
f"- failed_step: `{record.get('failed_step')}`",
f"- error: `{record.get('error')}`",
"",
f"Required action: {record.get('required_recovery_action')}",
"",
"applied=false — do not treat this as successful cleanup evidence.",
]
return "\n".join(lines)
+102 -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,19 @@ 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",
},
# #691: guarded non-owner cleanup of obsolete comment-backed reviewer leases.
# Apply path posts lease release + audit comments (gitea.pr.comment).
@@ -127,9 +178,58 @@ 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": {
"permission": "gitea.decision_lock.irrecoverable_recovery",
"role": "reconciler",
},
"gitea_issue_irrecoverable_provenance_authorization": {
"permission": "gitea.decision_lock.irrecoverable_recovery",
"role": "reconciler",
},
"record_irrecoverable_decision_lock_provenance": {
"permission": "gitea.decision_lock.irrecoverable_recovery",
"role": "reconciler",
},
"gitea_record_irrecoverable_decision_lock_provenance": {
"permission": "gitea.decision_lock.irrecoverable_recovery",
"role": "reconciler",
},
"consume_irrecoverable_decision_lock_provenance": {
"permission": "gitea.pr.merge",
"role": "merger",
},
"gitea_consume_irrecoverable_decision_lock_provenance": {
"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 prgs→Timesheet 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",
)
+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()
File diff suppressed because it is too large Load Diff
@@ -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()
@@ -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
+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 {
+6 -2
View File
@@ -1,7 +1,11 @@
"""Tests for canonical workflow skill naming and preflight (#551)."""
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