Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4920076309 | ||
|
|
32ab839289 | ||
|
|
03b434a0b6 | ||
|
|
2d5d5c9d17 | ||
|
|
108cbfa173 | ||
|
|
0dbff6dcd5 | ||
|
|
4a1cc63e94 | ||
|
|
6596b259fc | ||
|
|
b0868be6b3 | ||
|
|
1a38ef95e3 | ||
|
|
324a0c8a8d | ||
|
|
34968475c7 | ||
|
|
a6c7d1491e | ||
|
|
9d96cf4cfa | ||
|
|
7978008709 | ||
|
|
6a53308473 | ||
|
|
626be8b178 | ||
|
|
c763161702 | ||
|
|
3f584352df | ||
|
|
956fa15fe3 | ||
|
|
fa510dd28d | ||
|
|
1dd30ecb15 | ||
|
|
8eada1fbe4 | ||
|
|
58bd852188 | ||
|
|
ca5f078d8a | ||
|
|
126d76ad28 | ||
|
|
e3fa3b263d | ||
|
|
4533e86bcd | ||
|
|
17ba1ff035 | ||
|
|
0104a76eea | ||
|
|
a143cd065b | ||
|
|
2fb835a1aa | ||
|
|
c162608175 | ||
|
|
9b80e75ca3 | ||
|
|
b3de9c941c | ||
|
|
aad5c8b423 | ||
|
|
b4c9f55890 | ||
|
|
1aa351718a | ||
|
|
55d66c57e4 | ||
|
|
cdf0daefa9 | ||
|
|
08d9cf4cbd |
@@ -0,0 +1,23 @@
|
|||||||
|
# Agent Rules
|
||||||
|
|
||||||
|
## Project charter (authoritative)
|
||||||
|
|
||||||
|
Before inspecting work, selecting a task, claiming work, implementing, reviewing,
|
||||||
|
approving, or merging, read the full project charter:
|
||||||
|
|
||||||
|
**`PROJECT_CHARTER.md`**
|
||||||
|
|
||||||
|
That file is the ultimate source of truth for purpose, direction, principles,
|
||||||
|
non-goals, success criteria, and change-control. This file only points to it;
|
||||||
|
do not treat this file, session memory, or prompts as a substitute for the charter.
|
||||||
|
|
||||||
|
If proposed work would change the project’s fundamental direction or any
|
||||||
|
non-negotiable principle in the charter, stop and present the change to the
|
||||||
|
human maintainer for an explicit decision recorded in Gitea.
|
||||||
|
|
||||||
|
## Issue-first workflow
|
||||||
|
|
||||||
|
- Never fix code directly. A Gitea issue must be created first, and all fix work happens under that issue (branch, PR, review, merge) per the canonical workflow.
|
||||||
|
- No implementation, remediation, refactor, operational change, or charter amendment may begin without an open Gitea issue authorizing that work.
|
||||||
|
- Investigation may prepare or validate an issue; it must not silently become implementation.
|
||||||
|
- Every implementation PR must reference its governing issue.
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# MCP Control Plane — Project Charter
|
||||||
|
|
||||||
|
```
|
||||||
|
Charter-ID: mcp-control-plane
|
||||||
|
Charter-Version: 1.0
|
||||||
|
Status: approved
|
||||||
|
Project: Scaled-Tech-Consulting/Gitea-Tools
|
||||||
|
Repository-Binding: Scaled-Tech-Consulting/Gitea-Tools
|
||||||
|
Approved-By: human-operator
|
||||||
|
Approved-At: 2026-07-31
|
||||||
|
Governing-Issue: #987
|
||||||
|
Charter-Revision: (set to merge commit SHA)
|
||||||
|
Content-Hash: (set by enforcement tooling when enabled)
|
||||||
|
```
|
||||||
|
|
||||||
|
This file is the authoritative project charter for Gitea-Tools / MCP Control Plane.
|
||||||
|
It is the ultimate source of truth for purpose, operating model, non-negotiable
|
||||||
|
principles, authority boundaries, non-goals, definition of success, and human
|
||||||
|
change-control. Issues authorize units of work; pull requests implement issues.
|
||||||
|
Session memory and prompts are not authoritative governance.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
The purpose of this project is to allow one or more general-purpose LLMs to work autonomously and safely on a software project.
|
||||||
|
|
||||||
|
An LLM is not permanently assigned to be an author, reviewer, merger, controller, or reconciler. It begins each work cycle without a predetermined role.
|
||||||
|
|
||||||
|
The LLM examines the live project state, decides for itself what work is most valuable, and only then adopts the role required to perform that task.
|
||||||
|
|
||||||
|
## Fundamental workflow
|
||||||
|
|
||||||
|
Each LLM follows this cycle:
|
||||||
|
|
||||||
|
1. Start as a general, uncommitted worker.
|
||||||
|
|
||||||
|
2. Inspect the complete authoritative project state in Gitea.
|
||||||
|
|
||||||
|
3. Identify the work that is currently available, including:
|
||||||
|
|
||||||
|
* Issues ready for implementation
|
||||||
|
|
||||||
|
* Pull requests awaiting review
|
||||||
|
|
||||||
|
* Approved pull requests ready to merge
|
||||||
|
|
||||||
|
* Change-requested work needing remediation
|
||||||
|
|
||||||
|
* Prerequisite work blocking more important work
|
||||||
|
|
||||||
|
* Abandoned or orphaned work requiring reconciliation (expired leases, stale claims, half-finished PRs)
|
||||||
|
|
||||||
|
4. Compare those tasks using:
|
||||||
|
|
||||||
|
* Priority
|
||||||
|
|
||||||
|
* Dependencies
|
||||||
|
|
||||||
|
* Urgency
|
||||||
|
|
||||||
|
* Project impact
|
||||||
|
|
||||||
|
* Readiness
|
||||||
|
|
||||||
|
* Active claims (work under a live lock or lease is not available)
|
||||||
|
|
||||||
|
5. Independently choose the task it believes is most valuable.
|
||||||
|
|
||||||
|
6. Derive the required role from the chosen task.
|
||||||
|
|
||||||
|
7. Ask the MCP to verify that the task and role are currently safe and permitted.
|
||||||
|
|
||||||
|
8. Claim the work using the appropriate lock or lease.
|
||||||
|
|
||||||
|
9. Complete one bounded work cycle.
|
||||||
|
|
||||||
|
10. Release ownership and finish the required handoff.
|
||||||
|
|
||||||
|
11. Return to the uncommitted state.
|
||||||
|
|
||||||
|
12. Inspect the project again and make a new independent decision.
|
||||||
|
|
||||||
|
The governing order is:
|
||||||
|
**Inspect project → choose task → derive role → validate → claim → work → release → reassess**
|
||||||
|
|
||||||
|
## Bounded work cycle
|
||||||
|
|
||||||
|
A bounded work cycle is the smallest unit of work that leaves the project in a coherent, handoff-ready state, completed within the duration of a single lease. Examples: implementing one issue as one PR, reviewing one PR, merging one approved PR, remediating one round of change requests, or reconciling one abandoned artifact.
|
||||||
|
|
||||||
|
A work cycle never spans multiple leases. If the work cannot be completed within the lease, the LLM must bring the artifact to a coherent stopping point, record its state in Gitea (not in session memory), and release the claim. Continuation is a new task, available to any worker.
|
||||||
|
|
||||||
|
## Contention is normal
|
||||||
|
|
||||||
|
Multiple LLMs inspecting the same state with the same criteria will often converge on the same task. A failed claim is therefore an expected, routine outcome — not an error.
|
||||||
|
|
||||||
|
On claim failure, the LLM does not retry the same claim. It re-evaluates from fresh state and selects the next most valuable eligible task. Active claims must be visible in the project state so that workers can route around in-progress work before attempting a claim.
|
||||||
|
|
||||||
|
## Abandoned work and reconciliation
|
||||||
|
|
||||||
|
Leases expire. Workers fail mid-cycle. The resulting orphaned branches, stale claims, and half-finished PRs are first-class work items, discoverable in Gitea like any other task.
|
||||||
|
|
||||||
|
Reconciling an abandoned artifact is a task like any other: an LLM may choose it, derive the reconciler role for that one cycle, and release the role when done. No LLM is ever permanently a reconciler.
|
||||||
|
|
||||||
|
Expired leases become discoverable abandoned-work tasks. Detection may be passive (workers observing expired claims during ordinary inspection) or assisted by control-plane signals; either approach is acceptable provided reconciliation remains ordinary task selection under the fundamental workflow.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
| Component | Responsibility |
|
||||||
|
|--------------------|----------------|
|
||||||
|
| LLM | Understand the project, compare available work, and choose what it wants to do |
|
||||||
|
| Gitea | Store the authoritative issues, PRs, priorities, dependencies, decisions, claims, and history |
|
||||||
|
| Gitea MCP | Expose live facts from Gitea and provide sanctioned workflow operations |
|
||||||
|
| Control plane | Enforce identity, capability, ownership, and safety boundaries (including claim validation and rejection of unsafe or stale choices) |
|
||||||
|
| Locks and leases | Prevent conflicting LLMs from performing the same exclusive work |
|
||||||
|
| Human maintainer | Set priorities, approve charter changes, and resolve escalations |
|
||||||
|
|
||||||
|
The MCP may reject an unsafe or stale choice. It must not decide which task the LLM wants or permanently assign the LLM a role.
|
||||||
|
|
||||||
|
**Guardrail:** Rejection policy is validation, not steering. If a pattern of rejections effectively routes workers toward particular tasks, the MCP has become a dispatcher and the design has been violated. Rejection rules must themselves be durable, reviewable artifacts in Gitea so that patterns of rejection can be audited against this test.
|
||||||
|
|
||||||
|
## Identity and independence
|
||||||
|
|
||||||
|
Every worker session operates under a distinct identity issued by the control plane. Authorship, review, approval, and merge actions are attributed to that identity in Gitea.
|
||||||
|
|
||||||
|
Independence is defined at the identity level: the identity that authored (or last pushed to) a PR cannot review, approve, or merge it. A different identity — even one backed by the same underlying model — satisfies independence.
|
||||||
|
|
||||||
|
This is a deliberate, accepted limitation: same-model reviewers are epistemically correlated and may share blind spots. Identity-level independence is the enforced floor; stronger diversity (different models, human review) may be layered on for designated-critical changes but is not required by this charter.
|
||||||
|
|
||||||
|
## Approval, parity, and remediation
|
||||||
|
|
||||||
|
* A PR may be merged only after valid independent approval at its exact current head commit (head-SHA parity between the approved commit and the merged commit).
|
||||||
|
|
||||||
|
* Any new commit to the PR branch — including rebases and conflict resolutions — invalidates all prior approvals. Re-approval at the new head is required before merge.
|
||||||
|
|
||||||
|
* After changes are requested, remediation is a new task. Any identity may claim it, but the identity that pushes remediation commits becomes an author of the PR and loses review/approval/merge eligibility for it.
|
||||||
|
|
||||||
|
* Only the identity holding the active claim on a PR may push to its branch.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
Roles are temporary and derived strictly from the chosen task. The common roles are:
|
||||||
|
|
||||||
|
* **Author / implementer** — implements an issue as a pull request
|
||||||
|
* **Reviewer** — reviews a pull request
|
||||||
|
* **Merger** — merges an independently approved pull request
|
||||||
|
* **Remediator** — addresses change requests on a pull request
|
||||||
|
* **Reconciler** — cleans up abandoned or orphaned work (expired leases, stale claims, half-finished PRs)
|
||||||
|
|
||||||
|
No other permanent or standing roles exist. An LLM never begins a cycle already holding one of these roles.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
* Hard dependencies (task B cannot proceed until task A is complete) are distinct from priority (task B matters more than task A). A hard dependency is a gate; priority is a comparison.
|
||||||
|
|
||||||
|
* Dependencies are represented in Gitea as structured project state — never in prompts, session memory, or external documents. If Gitea cannot express a dependency, that is a gap in the project state model to be fixed, not worked around.
|
||||||
|
|
||||||
|
## Project state model requirements
|
||||||
|
|
||||||
|
The following must be expressible as structured, machine-discoverable state in Gitea:
|
||||||
|
|
||||||
|
* **Active claims** (locks and leases) — so workers can observe and route around in-progress work
|
||||||
|
* **Priorities** — comparable values or ordered labels that allow ranking of available work
|
||||||
|
* **Hard dependencies** — explicit blocker relationships between issues or PRs
|
||||||
|
* **Blocked-pending-clarification** — a distinct, machine-discoverable marker (label, status, or equivalent) that surfaces items requiring human maintainer attention
|
||||||
|
|
||||||
|
If the current Gitea configuration cannot express any of the above, that is a defect in the project state model and must be fixed before relying on workarounds.
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
Workers must not create new issues as a response to confusion. The sanctioned alternative:
|
||||||
|
|
||||||
|
* If requirements are ambiguous, principles conflict, or the correct action cannot be determined from project state, the LLM records the question on the existing issue or PR, marks it blocked-pending-clarification (using the machine-discoverable marker), releases its claim, and moves to other work.
|
||||||
|
|
||||||
|
* Blocked-pending-clarification items are surfaced to the human maintainer. Resolving them is a maintainer responsibility, and the resolution is recorded in Gitea so the answer becomes durable project state.
|
||||||
|
|
||||||
|
## Non-negotiable principles
|
||||||
|
|
||||||
|
1. Task first, role second.
|
||||||
|
2. The LLM chooses its own task.
|
||||||
|
3. Roles are temporary and last only for the current task.
|
||||||
|
4. Gitea is the authoritative shared project state.
|
||||||
|
5. Priority and dependencies affect what work matters most.
|
||||||
|
6. Hard dependencies are gates; priority is a comparison. They are not interchangeable.
|
||||||
|
7. Multiple LLMs may make independent choices concurrently, and contention is a normal outcome.
|
||||||
|
8. Locks and leases prevent conflicting claims after a choice is made; a failed claim triggers reassessment, not retry.
|
||||||
|
9. An identity cannot independently review or approve work it authored.
|
||||||
|
10. A PR may be merged only after valid independent approval at its exact current head.
|
||||||
|
11. Identity, capability, parity, and ownership failures stop mutations safely.
|
||||||
|
12. After every completed task, the LLM reassesses from fresh state.
|
||||||
|
13. Durable project state — not session memory or manually written prompts — coordinates the LLMs.
|
||||||
|
14. Abandoned work is discoverable and reconcilable through the same task-selection workflow as all other work.
|
||||||
|
|
||||||
|
## What this project is not
|
||||||
|
|
||||||
|
This project is not intended to:
|
||||||
|
|
||||||
|
* Permanently launch an LLM as only an author, reviewer, merger, or reconciler.
|
||||||
|
* Require a human to select every task or role.
|
||||||
|
* Turn the MCP into a dispatcher that assigns work — including de facto dispatch through rejection policy.
|
||||||
|
* Make a queue allocator authoritative over the LLM's decision.
|
||||||
|
* Choose a role first and then search for work that fits it.
|
||||||
|
* Depend on LLMs communicating directly with one another.
|
||||||
|
* Create new issues whenever an LLM encounters a confusing workflow.
|
||||||
|
* Allow safety infrastructure to become the project's purpose.
|
||||||
|
* Accumulate mechanisms that do not directly support the fundamental workflow.
|
||||||
|
|
||||||
|
## Definition of success
|
||||||
|
|
||||||
|
The project succeeds when:
|
||||||
|
|
||||||
|
* A general LLM can start without being told what role to perform.
|
||||||
|
* It can understand the complete live project state.
|
||||||
|
* It can identify and compare meaningful work.
|
||||||
|
* It can independently choose the most valuable eligible task.
|
||||||
|
* Its required role is derived from that task.
|
||||||
|
* The MCP safely validates and protects the chosen action.
|
||||||
|
* Multiple LLMs can operate without claiming or corrupting the same work.
|
||||||
|
* Each LLM completes a task, relinquishes its temporary role, and reassesses.
|
||||||
|
* Routine operation no longer requires a person to repeatedly write author, reviewer, or merger prompts.
|
||||||
|
|
||||||
|
**Measurable criteria:**
|
||||||
|
|
||||||
|
* N consecutive issue-to-merge cycles (implementation → independent review → merge) complete without a human writing an author, reviewer, or merger prompt, where N is set by the maintainer (initial target: 10).
|
||||||
|
* No merge ever occurs without head-SHA-parity approval, verified against Gitea history.
|
||||||
|
* Every expired lease is reconciled through the normal workflow within a maintainer-defined window, with zero permanently orphaned artifacts.
|
||||||
|
* Claim contention resolves without duplicate completed work (no two merged PRs implementing the same issue).
|
||||||
|
|
||||||
|
## Change-control rule
|
||||||
|
|
||||||
|
This overview governs the roadmap.
|
||||||
|
|
||||||
|
Every existing or proposed issue must identify which part of this fundamental workflow it supports. Before creating another issue, the open issue and PR inventory must be checked for existing coverage.
|
||||||
|
|
||||||
|
A proposed change that alters any non-negotiable principle — especially task-first selection, autonomous task choice, or temporary role derivation — is a change to the project's fundamental design. It must be explicitly discussed with, and approved by, the human maintainer before implementation, and the approval must be recorded in Gitea.
|
||||||
@@ -355,6 +355,10 @@ def assess_anti_stomp_preflight(
|
|||||||
root_head_sha: str | None = None,
|
root_head_sha: str | None = None,
|
||||||
root_porcelain: str | None = None,
|
root_porcelain: str | None = None,
|
||||||
remote_master_sha: str | None = None,
|
remote_master_sha: str | None = None,
|
||||||
|
# #983: the tracking integration ref the SHA above came from, so root-checkout
|
||||||
|
# contamination names the ref actually compared instead of a hardcoded
|
||||||
|
# 'prgs/master'. None preserves the previous generic wording.
|
||||||
|
remote_master_ref: str | None = None,
|
||||||
check_root_checkout: bool = True,
|
check_root_checkout: bool = True,
|
||||||
check_worktree: bool = True,
|
check_worktree: bool = True,
|
||||||
create_issue_bootstrap_assessment: dict[str, Any] | None = None,
|
create_issue_bootstrap_assessment: dict[str, Any] | None = None,
|
||||||
@@ -553,6 +557,7 @@ def assess_anti_stomp_preflight(
|
|||||||
remote_master_sha=remote_master_sha,
|
remote_master_sha=remote_master_sha,
|
||||||
resolved_role=req_role or role,
|
resolved_role=req_role or role,
|
||||||
actual_role=role,
|
actual_role=role,
|
||||||
|
remote_master_ref=remote_master_ref,
|
||||||
)
|
)
|
||||||
checks["root_checkout"] = {
|
checks["root_checkout"] = {
|
||||||
"block": bool(root_assessment.get("block")),
|
"block": bool(root_assessment.get("block")),
|
||||||
|
|||||||
+134
-24
@@ -23,6 +23,7 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
import author_lock_contract
|
||||||
import author_mutation_worktree
|
import author_mutation_worktree
|
||||||
import control_plane_db
|
import control_plane_db
|
||||||
import issue_lock_store
|
import issue_lock_store
|
||||||
@@ -278,6 +279,36 @@ def _verify_assignment_and_lease_ids(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _branch_exists(canonical_repo_root: str, branch_name: str) -> bool:
|
||||||
|
"""Whether *branch_name* still resolves in the canonical checkout (#953 F2).
|
||||||
|
|
||||||
|
Used after compensating recovery to observe what survived rather than infer
|
||||||
|
it from the journal. Fails closed to ``True``: an unobservable branch is
|
||||||
|
reported as present, so the recommendation stays conservative rather than
|
||||||
|
telling an author to re-bootstrap over something that may still be there.
|
||||||
|
"""
|
||||||
|
if not branch_name:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
canonical_repo_root,
|
||||||
|
"rev-parse",
|
||||||
|
"--verify",
|
||||||
|
"--quiet",
|
||||||
|
f"refs/heads/{branch_name}",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
return res.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
def run_compensating_recovery(
|
def run_compensating_recovery(
|
||||||
journal: dict[str, Any],
|
journal: dict[str, Any],
|
||||||
canonical_repo_root: str,
|
canonical_repo_root: str,
|
||||||
@@ -314,10 +345,21 @@ def run_compensating_recovery(
|
|||||||
issue_number=issue_num,
|
issue_number=issue_num,
|
||||||
session=session_id,
|
session=session_id,
|
||||||
lock_dir=journal_dir,
|
lock_dir=journal_dir,
|
||||||
|
remote=journal.get("remote"),
|
||||||
|
# The same defaults the lock was written under, so the
|
||||||
|
# rollback targets the exact file bind_session_lock keyed.
|
||||||
|
org=journal.get("org") or "Scaled-Tech-Consulting",
|
||||||
|
repo=journal.get("repo") or "Gitea-Tools",
|
||||||
)
|
)
|
||||||
rolled_back.append(f"lock:issue-{issue_num}")
|
rolled_back.append(f"lock:issue-{issue_num}")
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
# #953 F2: a swallowed failure here is what made the rollback
|
||||||
|
# report success while leaving an unrecoverable lock behind.
|
||||||
|
# Record it so the post-compensation classification can see the
|
||||||
|
# lock survived and recommend accordingly.
|
||||||
|
rolled_back.append(
|
||||||
|
f"lock_release_failed:issue-{issue_num}:{type(exc).__name__}"
|
||||||
|
)
|
||||||
artifacts["lock_created"] = False
|
artifacts["lock_created"] = False
|
||||||
|
|
||||||
worktree_created = (
|
worktree_created = (
|
||||||
@@ -1198,26 +1240,31 @@ def bootstrap_author_issue_worktree(
|
|||||||
save_phase_journal(journal, journal_dir=lock_dir)
|
save_phase_journal(journal, journal_dir=lock_dir)
|
||||||
|
|
||||||
# Phase 6: STATE_ESTABLISHED — Issue Lock Acquisition
|
# Phase 6: STATE_ESTABLISHED — Issue Lock Acquisition
|
||||||
|
#
|
||||||
|
# #953: this used to hand-build a thinner record — claimant at the top
|
||||||
|
# level, no work_lease, no lock_provenance, no expiry — which every
|
||||||
|
# downstream reader then refused. It now builds through the one shared
|
||||||
|
# canonical contract, so the lock bootstrap writes is the same lock
|
||||||
|
# gitea_lock_issue writes.
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
try:
|
try:
|
||||||
lock_data = {
|
lock_data = author_lock_contract.build_canonical_issue_lock(
|
||||||
"remote": remote,
|
issue_number=issue_number,
|
||||||
"org": org or "Scaled-Tech-Consulting",
|
branch_name=target_branch,
|
||||||
"repo": repo or "Gitea-Tools",
|
worktree_path=target_worktree,
|
||||||
"issue_number": issue_number,
|
remote=remote,
|
||||||
"branch": target_branch,
|
org=org or "Scaled-Tech-Consulting",
|
||||||
"branch_name": target_branch,
|
repo=repo or "Gitea-Tools",
|
||||||
"worktree_path": target_worktree,
|
identity=identity,
|
||||||
"owner_session": session,
|
profile=profile,
|
||||||
"claimant": {
|
tool="gitea_bootstrap_author_issue_worktree",
|
||||||
"username": identity,
|
source=author_lock_contract.SOURCE_BOOTSTRAP,
|
||||||
"profile": profile,
|
owner_session=session,
|
||||||
},
|
assignment_id=assignment_id,
|
||||||
"assignment_id": assignment_id,
|
lease_id=lease_id,
|
||||||
"lease_id": lease_id,
|
expected_base_sha=live_master_sha,
|
||||||
"expected_base_sha": live_master_sha,
|
)
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
lock_data["created_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
}
|
|
||||||
journal.setdefault("pending_creations", {})["lock"] = True
|
journal.setdefault("pending_creations", {})["lock"] = True
|
||||||
journal["artifacts_created"]["lock_created"] = True
|
journal["artifacts_created"]["lock_created"] = True
|
||||||
save_phase_journal(journal, journal_dir=lock_dir)
|
save_phase_journal(journal, journal_dir=lock_dir)
|
||||||
@@ -1235,9 +1282,65 @@ def bootstrap_author_issue_worktree(
|
|||||||
"exact_next_action": "Verify lease/assignment state and retry.",
|
"exact_next_action": "Verify lease/assignment state and retry.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── #953 AC7: verify the lock that was actually written ──
|
||||||
|
# Reporting "lock_created: true" and then directing the author to
|
||||||
|
# implement is what produced the unrecoverable state: by the time any
|
||||||
|
# reader refused the lock, the branch already carried commits and every
|
||||||
|
# sanctioned recovery path had become ineligible. The lock is therefore
|
||||||
|
# read back from disk and structurally verified *before* this function
|
||||||
|
# can report success, and a partial lock fails closed here — while the
|
||||||
|
# branch is still base-equivalent and recovery is still cheap.
|
||||||
|
written_lock = issue_lock_store.read_lock_file(lock_res)
|
||||||
|
contract = author_lock_contract.assess_lock_contract(written_lock)
|
||||||
|
if not contract["canonical"]:
|
||||||
|
journal["failure_reason"] = author_lock_contract.format_contract_refusal(
|
||||||
|
contract
|
||||||
|
)
|
||||||
|
compensation = run_compensating_recovery(
|
||||||
|
journal, root, journal_dir=lock_dir
|
||||||
|
)
|
||||||
|
# AC5/AC15: the recommendation must describe the state compensation
|
||||||
|
# actually left, not the state that provoked it.
|
||||||
|
# ``run_compensating_recovery`` has by now released the lock, removed
|
||||||
|
# the worktree, and deleted the branch, so recommending
|
||||||
|
# incomplete-lock recovery for those exact artifacts would refuse
|
||||||
|
# twice over. Observe what survived and answer for that.
|
||||||
|
post_state = author_lock_contract.assess_post_compensation_state(
|
||||||
|
compensation,
|
||||||
|
lock_present=bool(lock_res) and os.path.exists(lock_res),
|
||||||
|
worktree_present=os.path.isdir(target_worktree),
|
||||||
|
branch_present=_branch_exists(root, target_branch),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"reason_code": "incomplete_issue_lock_contract",
|
||||||
|
"message": author_lock_contract.format_contract_refusal(contract),
|
||||||
|
"issue_number": issue_number,
|
||||||
|
"branch_name": target_branch,
|
||||||
|
"worktree_path": target_worktree,
|
||||||
|
"lock_state": lock_res,
|
||||||
|
"lock_contract": contract,
|
||||||
|
"missing_fields": contract["missing_fields"],
|
||||||
|
"implementation_allowed": False,
|
||||||
|
"compensating_recovery": compensation,
|
||||||
|
"post_compensation_state": post_state,
|
||||||
|
# AC15: never strand a branch or worktree without a structured
|
||||||
|
# recovery recommendation — and never name an artifact the
|
||||||
|
# rollback has already deleted.
|
||||||
|
"exact_next_action": author_lock_contract.post_compensation_action(
|
||||||
|
post_state,
|
||||||
|
issue_number=issue_number,
|
||||||
|
branch_name=target_branch,
|
||||||
|
worktree_path=target_worktree,
|
||||||
|
missing_fields=contract["missing_fields"],
|
||||||
|
),
|
||||||
|
"phase_journal": journal,
|
||||||
|
}
|
||||||
|
|
||||||
journal["phases"][PHASE_6_STATE_ESTABLISHED] = {
|
journal["phases"][PHASE_6_STATE_ESTABLISHED] = {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"lock": lock_res,
|
"lock": lock_res,
|
||||||
|
"lock_contract": contract["contract"],
|
||||||
}
|
}
|
||||||
journal["phases"][PHASE_7_TRANSITION_COMPLETED] = {
|
journal["phases"][PHASE_7_TRANSITION_COMPLETED] = {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
@@ -1261,9 +1364,16 @@ def bootstrap_author_issue_worktree(
|
|||||||
"assignment_id": assignment_id,
|
"assignment_id": assignment_id,
|
||||||
"idempotency_key": key,
|
"idempotency_key": key,
|
||||||
"lock_state": lock_res,
|
"lock_state": lock_res,
|
||||||
|
"lock_contract": contract,
|
||||||
|
# #953 AC6: the canonical ownership token for this claim. Never null
|
||||||
|
# on a successful bootstrap — it is the fencing token every
|
||||||
|
# subsequent heartbeat and renewal is checked against.
|
||||||
|
"task_session_id": contract["task_session_id"],
|
||||||
|
"implementation_allowed": True,
|
||||||
"phase_journal": journal,
|
"phase_journal": journal,
|
||||||
"exact_next_action": (
|
# #953 AC5: executable under the state actually returned. The lock
|
||||||
"Call gitea_whoami, then gitea_resolve_task_capability(task='work_issue') "
|
# has been read back and verified canonical, so proceeding to
|
||||||
"and proceed with author implementation in the bootstrapped worktree."
|
# implementation is genuinely the correct next step here — which is
|
||||||
),
|
# exactly what the old unconditional wording could not promise.
|
||||||
|
"exact_next_action": author_lock_contract.recommended_action(contract),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
"""One canonical author issue-lock contract shared by every writer (#953).
|
||||||
|
|
||||||
|
Before this module, ``gitea_lock_issue`` and
|
||||||
|
``gitea_bootstrap_author_issue_worktree`` each wrote their own lock record.
|
||||||
|
``gitea_lock_issue`` wrote the canonical shape — ``work_lease`` carrying the
|
||||||
|
claimant plus a sanctioned ``lock_provenance`` — while bootstrap wrote a thinner
|
||||||
|
record with the claimant at the lock top level, ``lease_id: null``, and no
|
||||||
|
``work_lease``, ``lock_provenance``, or expiry at all.
|
||||||
|
|
||||||
|
Every downstream reader was written against the canonical shape, so a lock that
|
||||||
|
bootstrap reported as successfully created was simultaneously:
|
||||||
|
|
||||||
|
* un-heartbeatable — the ownership check read the claimant only from
|
||||||
|
``work_lease.claimant``;
|
||||||
|
* un-renewable — expiry is read only from ``work_lease.expires_at``, so a
|
||||||
|
missing lease read as "never expires", and #760 exact-owner renewal only ever
|
||||||
|
assesses an *expired* lease;
|
||||||
|
* un-re-lockable — the branch had by then advanced past its base;
|
||||||
|
* and rejected by the #447 create-PR provenance guard.
|
||||||
|
|
||||||
|
Each of those gates is individually correct. The defect was that two writers
|
||||||
|
disagreed about what a lock *is*. This module is the single definition, and both
|
||||||
|
writers now build through it.
|
||||||
|
|
||||||
|
Nothing here weakens a guard. ``build_sanctioned_lock_provenance`` remains the
|
||||||
|
only provenance source, provenance is never accepted from a caller, and the
|
||||||
|
#447 guard is untouched — this module simply makes bootstrap satisfy it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
import issue_lock_provenance
|
||||||
|
import issue_lock_store
|
||||||
|
import lease_policy
|
||||||
|
|
||||||
|
# Bootstrap writes through the same sanctioned source as gitea_lock_issue: the
|
||||||
|
# lock it produces *is* a canonical lock, not a second dialect that readers must
|
||||||
|
# learn. Adding a distinct source would have required widening
|
||||||
|
# SANCTIONED_LOCK_SOURCES, which is exactly the #447 weakening this issue's
|
||||||
|
# safety requirements forbid.
|
||||||
|
SOURCE_BOOTSTRAP = issue_lock_provenance.SOURCE_LOCK_ISSUE
|
||||||
|
|
||||||
|
# Recovery of an incomplete bootstrap lock (#953 AC8-AC11) deliberately writes
|
||||||
|
# through SOURCE_LOCK_ISSUE too, and records its distinctness in
|
||||||
|
# ``lock_provenance.written_by_tool`` plus the ``bootstrap_lock_recovery``
|
||||||
|
# transition block instead. There is no distinct recovery *source* constant, for
|
||||||
|
# the same reason bootstrap has none: minting one would require widening
|
||||||
|
# SANCTIONED_LOCK_SOURCES, which the #447 safety requirements forbid.
|
||||||
|
|
||||||
|
#: Top-level keys every canonical author issue lock must carry.
|
||||||
|
REQUIRED_LOCK_FIELDS: tuple[str, ...] = (
|
||||||
|
"remote",
|
||||||
|
"org",
|
||||||
|
"repo",
|
||||||
|
"issue_number",
|
||||||
|
"branch_name",
|
||||||
|
"worktree_path",
|
||||||
|
"work_lease",
|
||||||
|
"lock_provenance",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Keys every canonical ``work_lease`` must carry.
|
||||||
|
REQUIRED_WORK_LEASE_FIELDS: tuple[str, ...] = (
|
||||||
|
"operation_type",
|
||||||
|
"issue_number",
|
||||||
|
"branch",
|
||||||
|
"worktree_path",
|
||||||
|
"claimant",
|
||||||
|
"created_at",
|
||||||
|
"expires_at",
|
||||||
|
"last_heartbeat_at",
|
||||||
|
"task_session_id",
|
||||||
|
"lifecycle_version",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Explicit expiration states (AC12) ──
|
||||||
|
# The bug this replaces: a lock with no recorded expiry produced
|
||||||
|
# ``is_lease_expired() -> False``, which reads as "not yet expired" and made the
|
||||||
|
# lock permanently non-expiring *and* permanently ineligible for the renewal
|
||||||
|
# path, which only ever assesses an expired lease. "Absent" and "in the future"
|
||||||
|
# are different facts and are now named differently.
|
||||||
|
EXPIRATION_RECORDED = "recorded"
|
||||||
|
EXPIRATION_MISSING = "missing"
|
||||||
|
EXPIRATION_UNPARSEABLE = "unparseable"
|
||||||
|
|
||||||
|
#: Structural verdicts returned by :func:`assess_lock_contract`.
|
||||||
|
CONTRACT_CANONICAL = "canonical"
|
||||||
|
CONTRACT_INCOMPLETE = "incomplete"
|
||||||
|
CONTRACT_LEGACY = "legacy"
|
||||||
|
CONTRACT_ABSENT = "absent"
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def now_utc() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def format_timestamp(value: datetime) -> str:
|
||||||
|
"""Serialize in the durable ``...Z`` form already used on disk."""
|
||||||
|
return (
|
||||||
|
value.astimezone(timezone.utc)
|
||||||
|
.replace(microsecond=0)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def lock_claimant(lock: Mapping[str, Any] | None) -> dict[str, str]:
|
||||||
|
"""Read the claimant from either canonical or legacy placement.
|
||||||
|
|
||||||
|
``work_lease.claimant`` is canonical and is preferred. A top-level
|
||||||
|
``claimant`` is the legacy/bootstrap placement and is accepted as a
|
||||||
|
fallback (AC14) — three separate readers already disagreed about this
|
||||||
|
(``issue_lock_store``, ``issue_lock_renewal``, ``issue_lock_recovery``),
|
||||||
|
which is why it now lives in one place.
|
||||||
|
|
||||||
|
Reading a legacy placement is *not* a widening: every caller still compares
|
||||||
|
the values it returns against server-resolved identity and profile. This
|
||||||
|
only decides where to look, never whether ownership is proven.
|
||||||
|
|
||||||
|
Delegates to ``issue_lock_store.lock_claimant`` rather than reimplementing
|
||||||
|
the rule. A second copy here would be a fourth reader that could drift from
|
||||||
|
the other three, which is the exact failure #953 exists to end. It lives in
|
||||||
|
the store because ``author_lock_contract`` imports the store, so defining it
|
||||||
|
here would make that import circular.
|
||||||
|
"""
|
||||||
|
recorded = issue_lock_store.lock_claimant(dict(lock) if isinstance(lock, Mapping) else None)
|
||||||
|
return {
|
||||||
|
"username": _text(recorded.get("username")),
|
||||||
|
"profile": _text(recorded.get("profile")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def claimant_placement(lock: Mapping[str, Any] | None) -> str:
|
||||||
|
"""Where the claimant was found: ``work_lease``, ``top_level``, or ``absent``."""
|
||||||
|
if not isinstance(lock, Mapping):
|
||||||
|
return "absent"
|
||||||
|
lease = lock.get("work_lease")
|
||||||
|
if isinstance(lease, Mapping) and isinstance(lease.get("claimant"), Mapping):
|
||||||
|
return "work_lease"
|
||||||
|
if isinstance(lock.get("claimant"), Mapping):
|
||||||
|
return "top_level"
|
||||||
|
return "absent"
|
||||||
|
|
||||||
|
|
||||||
|
def build_claimant(*, username: str | None, profile: str | None) -> dict[str, str]:
|
||||||
|
"""Build the canonical claimant pair from server-resolved values."""
|
||||||
|
return {"username": _text(username), "profile": _text(profile)}
|
||||||
|
|
||||||
|
|
||||||
|
def build_author_issue_work_lease(
|
||||||
|
*,
|
||||||
|
issue_number: int,
|
||||||
|
branch_name: str,
|
||||||
|
worktree_path: str,
|
||||||
|
claimant: Mapping[str, Any],
|
||||||
|
task_session_id: str | None = None,
|
||||||
|
created: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build the canonical author ``work_lease``.
|
||||||
|
|
||||||
|
The single definition behind both writers. The TTL comes from the central
|
||||||
|
policy rather than a literal, and the window slides from the last valid
|
||||||
|
heartbeat (#790), so an abandoned task releases its claim within one TTL.
|
||||||
|
"""
|
||||||
|
started = created or now_utc()
|
||||||
|
policy = lease_policy.policy_for(lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK)
|
||||||
|
expires = started + timedelta(minutes=policy.initial_ttl_minutes)
|
||||||
|
session_id = _text(task_session_id) or issue_lock_store.mint_task_session_id(
|
||||||
|
issue_lock_store.AUTHOR_ISSUE_WORK_LEASE
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||||
|
"issue_number": int(issue_number),
|
||||||
|
"pr_number": None,
|
||||||
|
"branch": branch_name,
|
||||||
|
"worktree_path": worktree_path,
|
||||||
|
"claimant": dict(claimant),
|
||||||
|
"created_at": format_timestamp(started),
|
||||||
|
"expires_at": format_timestamp(expires),
|
||||||
|
"last_heartbeat_at": format_timestamp(started),
|
||||||
|
# #790 AC-N1: the ownership key for this task, distinct from the
|
||||||
|
# recorded PID, which is the shared daemon and identifies no task.
|
||||||
|
"task_session_id": session_id,
|
||||||
|
# #790 AC-N8: the explicit lifecycle marker. Its absence — never a
|
||||||
|
# timestamp comparison — is what makes a lock legacy.
|
||||||
|
"lifecycle_version": lease_policy.LIFECYCLE_HEARTBEAT_V1,
|
||||||
|
"heartbeat_count": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_canonical_issue_lock(
|
||||||
|
*,
|
||||||
|
issue_number: int,
|
||||||
|
branch_name: str,
|
||||||
|
worktree_path: str,
|
||||||
|
remote: str,
|
||||||
|
org: str,
|
||||||
|
repo: str,
|
||||||
|
identity: str | None,
|
||||||
|
profile: str | None,
|
||||||
|
tool: str,
|
||||||
|
source: str = issue_lock_provenance.SOURCE_LOCK_ISSUE,
|
||||||
|
owner_session: str | None = None,
|
||||||
|
assignment_id: str | None = None,
|
||||||
|
lease_id: str | None = None,
|
||||||
|
expected_base_sha: str | None = None,
|
||||||
|
task_session_id: str | None = None,
|
||||||
|
created: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build a complete canonical lock record.
|
||||||
|
|
||||||
|
``tool`` and ``source`` are server-supplied. There is deliberately no
|
||||||
|
parameter through which a caller could inject provenance: the #953 safety
|
||||||
|
requirements forbid caller-manufactured provenance, so provenance is always
|
||||||
|
minted here from ``build_sanctioned_lock_provenance``.
|
||||||
|
"""
|
||||||
|
claimant = build_claimant(username=identity, profile=profile)
|
||||||
|
work_lease = build_author_issue_work_lease(
|
||||||
|
issue_number=issue_number,
|
||||||
|
branch_name=branch_name,
|
||||||
|
worktree_path=worktree_path,
|
||||||
|
claimant=claimant,
|
||||||
|
task_session_id=task_session_id,
|
||||||
|
created=created,
|
||||||
|
)
|
||||||
|
record: dict[str, Any] = {
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"issue_number": int(issue_number),
|
||||||
|
"branch": branch_name,
|
||||||
|
"branch_name": branch_name,
|
||||||
|
"worktree_path": worktree_path,
|
||||||
|
"work_lease": work_lease,
|
||||||
|
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||||||
|
tool=tool,
|
||||||
|
source=source,
|
||||||
|
claimant=claimant,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if owner_session is not None:
|
||||||
|
record["owner_session"] = owner_session
|
||||||
|
if assignment_id is not None:
|
||||||
|
record["assignment_id"] = assignment_id
|
||||||
|
# #953 AC6: a null lease id is recorded only when no workflow lease was
|
||||||
|
# allocated for this bootstrap. The task-session identifier in the
|
||||||
|
# work_lease is what downstream ownership checks fence on, and it is never
|
||||||
|
# null on a canonical lock.
|
||||||
|
if lease_id is not None:
|
||||||
|
record["lease_id"] = lease_id
|
||||||
|
if expected_base_sha is not None:
|
||||||
|
record["expected_base_sha"] = expected_base_sha
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def expiration_state(lock: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
"""Classify a lock's recorded expiry explicitly (AC12).
|
||||||
|
|
||||||
|
Distinguishes "no expiry was ever recorded" from "an expiry was recorded
|
||||||
|
and is still in the future". Collapsing those two into a single ``False``
|
||||||
|
from ``is_lease_expired`` is what let a malformed lock be treated as
|
||||||
|
permanently live and simultaneously never renewable.
|
||||||
|
"""
|
||||||
|
if not isinstance(lock, Mapping):
|
||||||
|
return {"state": EXPIRATION_MISSING, "expires_at": None, "expired": None}
|
||||||
|
lease = lock.get("work_lease")
|
||||||
|
raw = lease.get("expires_at") if isinstance(lease, Mapping) else None
|
||||||
|
text = _text(raw)
|
||||||
|
if not text:
|
||||||
|
return {"state": EXPIRATION_MISSING, "expires_at": None, "expired": None}
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(
|
||||||
|
timezone.utc
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return {"state": EXPIRATION_UNPARSEABLE, "expires_at": text, "expired": None}
|
||||||
|
return {
|
||||||
|
"state": EXPIRATION_RECORDED,
|
||||||
|
"expires_at": text,
|
||||||
|
"expired": parsed <= now_utc(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def missing_contract_fields(lock: Mapping[str, Any] | None) -> list[str]:
|
||||||
|
"""Name every canonical field a lock does not carry (AC7)."""
|
||||||
|
if not isinstance(lock, Mapping):
|
||||||
|
return ["<no lock record>"]
|
||||||
|
missing: list[str] = []
|
||||||
|
for field in REQUIRED_LOCK_FIELDS:
|
||||||
|
value = lock.get(field)
|
||||||
|
if value is None or (isinstance(value, str) and not value.strip()):
|
||||||
|
missing.append(field)
|
||||||
|
lease = lock.get("work_lease")
|
||||||
|
if not isinstance(lease, Mapping):
|
||||||
|
if "work_lease" not in missing:
|
||||||
|
missing.append("work_lease")
|
||||||
|
else:
|
||||||
|
for field in REQUIRED_WORK_LEASE_FIELDS:
|
||||||
|
value = lease.get(field)
|
||||||
|
if value is None or (isinstance(value, str) and not value.strip()):
|
||||||
|
missing.append(f"work_lease.{field}")
|
||||||
|
provenance = lock.get("lock_provenance")
|
||||||
|
if isinstance(provenance, Mapping):
|
||||||
|
if (
|
||||||
|
_text(provenance.get("source"))
|
||||||
|
not in issue_lock_provenance.SANCTIONED_LOCK_SOURCES
|
||||||
|
):
|
||||||
|
missing.append("lock_provenance.source (not sanctioned)")
|
||||||
|
if not _text(provenance.get("written_by_tool")):
|
||||||
|
missing.append("lock_provenance.written_by_tool")
|
||||||
|
claimant = lock_claimant(lock)
|
||||||
|
if not claimant["username"]:
|
||||||
|
missing.append("claimant.username")
|
||||||
|
if not claimant["profile"]:
|
||||||
|
missing.append("claimant.profile")
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
|
def assess_lock_contract(lock: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
"""Structural, read-only verdict on a durable lock record (AC7, AC16).
|
||||||
|
|
||||||
|
Pure inspection: it reads the record it is handed and mutates nothing —
|
||||||
|
no lock, lease, branch, worktree, issue, or PR. Callers use it both to
|
||||||
|
verify a lock they just wrote and to report on one they found.
|
||||||
|
"""
|
||||||
|
if not isinstance(lock, Mapping) or not lock:
|
||||||
|
return {
|
||||||
|
"contract": CONTRACT_ABSENT,
|
||||||
|
"canonical": False,
|
||||||
|
"missing_fields": ["<no lock record>"],
|
||||||
|
"claimant": {"username": "", "profile": ""},
|
||||||
|
"claimant_placement": "absent",
|
||||||
|
"expiration": {
|
||||||
|
"state": EXPIRATION_MISSING,
|
||||||
|
"expires_at": None,
|
||||||
|
"expired": None,
|
||||||
|
},
|
||||||
|
"heartbeatable": False,
|
||||||
|
"create_pr_eligible": False,
|
||||||
|
"lock_generation": None,
|
||||||
|
"task_session_id": None,
|
||||||
|
"reasons": ["no durable lock record"],
|
||||||
|
}
|
||||||
|
|
||||||
|
missing = missing_contract_fields(lock)
|
||||||
|
claimant = lock_claimant(lock)
|
||||||
|
placement = claimant_placement(lock)
|
||||||
|
expiration = expiration_state(lock)
|
||||||
|
provenance_check = issue_lock_provenance.assess_lock_file_for_create_pr(dict(lock))
|
||||||
|
|
||||||
|
# Canonical means: every required field present, the claimant in the
|
||||||
|
# canonical placement, an expiry actually recorded, and the untouched #447
|
||||||
|
# guard satisfied.
|
||||||
|
canonical = (
|
||||||
|
not missing
|
||||||
|
and placement == "work_lease"
|
||||||
|
and expiration["state"] == EXPIRATION_RECORDED
|
||||||
|
and bool(provenance_check.get("proven"))
|
||||||
|
)
|
||||||
|
if canonical:
|
||||||
|
contract = CONTRACT_CANONICAL
|
||||||
|
elif placement == "top_level" and claimant["username"] and claimant["profile"]:
|
||||||
|
contract = CONTRACT_LEGACY
|
||||||
|
else:
|
||||||
|
contract = CONTRACT_INCOMPLETE
|
||||||
|
|
||||||
|
reasons: list[str] = []
|
||||||
|
if missing:
|
||||||
|
reasons.append("missing canonical fields: " + ", ".join(missing))
|
||||||
|
if placement == "top_level":
|
||||||
|
reasons.append(
|
||||||
|
"claimant recorded at the lock top level rather than in work_lease "
|
||||||
|
"(legacy/bootstrap placement)"
|
||||||
|
)
|
||||||
|
if expiration["state"] == EXPIRATION_MISSING:
|
||||||
|
reasons.append(
|
||||||
|
"no expiration recorded; the lock is neither expirable nor renewable "
|
||||||
|
"until it is upgraded"
|
||||||
|
)
|
||||||
|
elif expiration["state"] == EXPIRATION_UNPARSEABLE:
|
||||||
|
reasons.append(f"unparseable expires_at '{expiration['expires_at']}'")
|
||||||
|
if provenance_check.get("block"):
|
||||||
|
reasons.extend(provenance_check.get("reasons") or [])
|
||||||
|
|
||||||
|
# Heartbeat needs the claimant pair (from either placement, post-fix) plus a
|
||||||
|
# task-session identifier to fence on.
|
||||||
|
lease = lock.get("work_lease")
|
||||||
|
task_session_id = (
|
||||||
|
_text(lease.get("task_session_id")) if isinstance(lease, Mapping) else ""
|
||||||
|
)
|
||||||
|
heartbeatable = bool(
|
||||||
|
claimant["username"] and claimant["profile"] and task_session_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"contract": contract,
|
||||||
|
"canonical": canonical,
|
||||||
|
"missing_fields": missing,
|
||||||
|
"claimant": claimant,
|
||||||
|
"claimant_placement": placement,
|
||||||
|
"expiration": expiration,
|
||||||
|
"heartbeatable": heartbeatable,
|
||||||
|
"create_pr_eligible": bool(provenance_check.get("proven")),
|
||||||
|
"lock_generation": lock.get("lock_generation"),
|
||||||
|
"task_session_id": task_session_id or None,
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_contract_refusal(assessment: Mapping[str, Any]) -> str:
|
||||||
|
"""Human-readable refusal naming exactly what the lock is missing."""
|
||||||
|
missing = ", ".join(assessment.get("missing_fields") or []) or "unknown fields"
|
||||||
|
return (
|
||||||
|
"Issue lock contract incomplete (#953): "
|
||||||
|
f"{missing}. The lock cannot be heartbeated, renewed, or accepted by "
|
||||||
|
"gitea_create_pr in this state (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def recommended_action(assessment: Mapping[str, Any]) -> str:
|
||||||
|
"""The one executable next step for a lock in this state (AC5, AC15)."""
|
||||||
|
contract = assessment.get("contract")
|
||||||
|
if contract == CONTRACT_CANONICAL:
|
||||||
|
return (
|
||||||
|
"Lock is canonical. Call gitea_whoami, then "
|
||||||
|
"gitea_resolve_task_capability(task='work_issue'), then proceed with "
|
||||||
|
"author implementation in the bootstrapped worktree."
|
||||||
|
)
|
||||||
|
if contract == CONTRACT_ABSENT:
|
||||||
|
return (
|
||||||
|
"No durable lock exists. Call gitea_lock_issue for this issue and "
|
||||||
|
"branch before writing any implementation bytes."
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"Do not begin implementation. Call "
|
||||||
|
"gitea_recover_incomplete_bootstrap_lock for this exact issue, branch, "
|
||||||
|
"and worktree to upgrade the lock to the canonical contract, or "
|
||||||
|
"gitea_lock_issue while the worktree is still base-equivalent."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Post-compensation recovery guidance (#953 AC5/AC15, review 632 F2) ──
|
||||||
|
#
|
||||||
|
# ``recommended_action`` above answers "what can be done about a lock in this
|
||||||
|
# shape". That is the wrong question on the bootstrap AC7 refusal path, because
|
||||||
|
# ``run_compensating_recovery`` has already run by the time the answer is
|
||||||
|
# reported: it releases the lock, removes the worktree when clean — which it
|
||||||
|
# always is there, no implementation bytes having been written — and deletes the
|
||||||
|
# created branch. Recommending incomplete-lock recovery for those artifacts
|
||||||
|
# hands the author two refusals in a row (``no_durable_lock``, then
|
||||||
|
# ``worktree_invalid``) for a state that a plain bootstrap retry would fix. The
|
||||||
|
# advice must describe the state that actually *remains*.
|
||||||
|
|
||||||
|
#: Compensation removed every artifact this transition created.
|
||||||
|
CLEANUP_COMPLETE = "complete"
|
||||||
|
#: Compensation removed some artifacts; others survive and are still actionable.
|
||||||
|
CLEANUP_PARTIAL = "partial"
|
||||||
|
#: Compensation itself failed or could not be observed; nothing is provable.
|
||||||
|
CLEANUP_FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def assess_post_compensation_state(
|
||||||
|
recovery: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
lock_present: bool,
|
||||||
|
worktree_present: bool,
|
||||||
|
branch_present: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Classify what survived compensation, from observed durable state.
|
||||||
|
|
||||||
|
Pure. The caller observes the filesystem and git; this decides. Observation
|
||||||
|
is authoritative over the journal's ``rolled_back`` list, which records what
|
||||||
|
compensation *attempted*: ``run_compensating_recovery`` swallows a failed
|
||||||
|
lock release and appends nothing, so an absent marker proves nothing either
|
||||||
|
way. The list is still carried through as corroborating evidence.
|
||||||
|
|
||||||
|
The three states are distinct facts, not degrees of the same one:
|
||||||
|
|
||||||
|
* ``CLEANUP_COMPLETE`` — compensation ran and nothing it created remains.
|
||||||
|
* ``CLEANUP_PARTIAL`` — compensation ran and artifacts survive, whether by
|
||||||
|
design (a worktree dirty at rollback time, a branch carrying commits) or
|
||||||
|
because a rollback step errored. Either way the surviving set was observed
|
||||||
|
directly, so it is known and actionable; ``failed_rollback_steps`` records
|
||||||
|
which cause applies.
|
||||||
|
* ``CLEANUP_FAILED`` — compensation never ran to completion, so nothing it
|
||||||
|
would have removed can be assumed removed.
|
||||||
|
"""
|
||||||
|
rolled_back = list((recovery or {}).get("rolled_back") or [])
|
||||||
|
executed = bool((recovery or {}).get("executed"))
|
||||||
|
failed_steps = [entry for entry in rolled_back if "_failed" in entry]
|
||||||
|
|
||||||
|
surviving: list[str] = []
|
||||||
|
if lock_present:
|
||||||
|
surviving.append("lock")
|
||||||
|
if worktree_present:
|
||||||
|
surviving.append("worktree")
|
||||||
|
if branch_present:
|
||||||
|
surviving.append("branch")
|
||||||
|
|
||||||
|
if not executed:
|
||||||
|
state = CLEANUP_FAILED
|
||||||
|
elif surviving:
|
||||||
|
state = CLEANUP_PARTIAL
|
||||||
|
else:
|
||||||
|
state = CLEANUP_COMPLETE
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cleanup_state": state,
|
||||||
|
"compensation_executed": executed,
|
||||||
|
"lock_present": bool(lock_present),
|
||||||
|
"worktree_present": bool(worktree_present),
|
||||||
|
"branch_present": bool(branch_present),
|
||||||
|
"surviving_artifacts": surviving,
|
||||||
|
"removed_artifacts": [
|
||||||
|
name
|
||||||
|
for name, present in (
|
||||||
|
("lock", lock_present),
|
||||||
|
("worktree", worktree_present),
|
||||||
|
("branch", branch_present),
|
||||||
|
)
|
||||||
|
if not present
|
||||||
|
],
|
||||||
|
"failed_rollback_steps": failed_steps,
|
||||||
|
"rolled_back": rolled_back,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def post_compensation_action(
|
||||||
|
state: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
issue_number: int,
|
||||||
|
branch_name: str,
|
||||||
|
worktree_path: str,
|
||||||
|
missing_fields: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""The one executable next step for the state compensation actually left.
|
||||||
|
|
||||||
|
Every branch names only artifacts the classification says still exist, so no
|
||||||
|
recommendation can point at something the rollback deleted.
|
||||||
|
"""
|
||||||
|
missing = ", ".join(missing_fields or []) or "the reported missing fields"
|
||||||
|
cleanup_state = state.get("cleanup_state")
|
||||||
|
lock_present = bool(state.get("lock_present"))
|
||||||
|
worktree_present = bool(state.get("worktree_present"))
|
||||||
|
branch_present = bool(state.get("branch_present"))
|
||||||
|
|
||||||
|
if not state.get("compensation_executed"):
|
||||||
|
# Compensation never ran, so nothing was rolled back and nothing about
|
||||||
|
# the remaining state was decided. The read-only surface is the only
|
||||||
|
# action executable under any state.
|
||||||
|
return (
|
||||||
|
"Compensating rollback did not complete, so the remaining state is "
|
||||||
|
f"not proven. Call gitea_inspect_issue_lock_contract for issue "
|
||||||
|
f"#{issue_number} (read-only) to establish what survives before any "
|
||||||
|
"further action. Do not retry bootstrap until it is known."
|
||||||
|
)
|
||||||
|
|
||||||
|
prefix = ""
|
||||||
|
failed_steps = state.get("failed_rollback_steps") or []
|
||||||
|
if failed_steps:
|
||||||
|
prefix = (
|
||||||
|
"Compensating rollback reported a failed step "
|
||||||
|
f"({', '.join(failed_steps)}); what survives was observed directly "
|
||||||
|
"and the action below is scoped to exactly that. "
|
||||||
|
)
|
||||||
|
|
||||||
|
if cleanup_state == CLEANUP_COMPLETE:
|
||||||
|
return (
|
||||||
|
"Compensating rollback removed the malformed lock, the branch, and "
|
||||||
|
f"the worktree, so nothing from this attempt remains. Resolve "
|
||||||
|
f"{missing} and re-run gitea_bootstrap_author_issue_worktree for "
|
||||||
|
f"issue #{issue_number} from the clean pre-bootstrap state. Do not "
|
||||||
|
"call gitea_recover_incomplete_bootstrap_lock: there is no lock, "
|
||||||
|
"branch, or worktree left for it to act on."
|
||||||
|
)
|
||||||
|
|
||||||
|
if lock_present and worktree_present and branch_present:
|
||||||
|
return prefix + (
|
||||||
|
"The lock, branch, and worktree all survive. Call "
|
||||||
|
"gitea_recover_incomplete_bootstrap_lock for issue "
|
||||||
|
f"#{issue_number}, branch '{branch_name}', and worktree "
|
||||||
|
f"'{worktree_path}', passing the worktree's current head as "
|
||||||
|
"expected_head, to upgrade the lock to the canonical contract."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not lock_present and worktree_present and branch_present:
|
||||||
|
return prefix + (
|
||||||
|
"The malformed lock was released but the branch and worktree "
|
||||||
|
"survive. No implementation bytes were written, so the worktree is "
|
||||||
|
f"still base-equivalent: call gitea_lock_issue for issue "
|
||||||
|
f"#{issue_number} on branch '{branch_name}' from worktree "
|
||||||
|
f"'{worktree_path}' to acquire a canonical lock."
|
||||||
|
)
|
||||||
|
|
||||||
|
if lock_present and not worktree_present:
|
||||||
|
return prefix + (
|
||||||
|
f"The worktree for issue #{issue_number} is gone but the durable "
|
||||||
|
"lock survived, so neither gitea_recover_incomplete_bootstrap_lock "
|
||||||
|
"(it would refuse worktree_invalid) nor gitea_lock_issue (it has no "
|
||||||
|
"worktree to bind) is executable. Call "
|
||||||
|
"gitea_inspect_issue_lock_contract for issue "
|
||||||
|
f"#{issue_number} (read-only) to confirm the surviving lock; it "
|
||||||
|
"must be released by its recorded owner before bootstrap is "
|
||||||
|
"retried."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lock gone, worktree gone, some git artifact left (a branch with commits,
|
||||||
|
# or a branch this transition did not create).
|
||||||
|
return prefix + (
|
||||||
|
"Compensating rollback removed the lock and worktree; branch "
|
||||||
|
f"'{branch_name}' survives and was not deleted. Call "
|
||||||
|
f"gitea_inspect_issue_lock_contract for issue #{issue_number} "
|
||||||
|
"(read-only) to confirm no durable lock remains, then re-run "
|
||||||
|
"gitea_bootstrap_author_issue_worktree, which will adopt the existing "
|
||||||
|
"branch rather than recreating it."
|
||||||
|
)
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
"""Target-specific recovery for incomplete bootstrap issue locks (#953).
|
||||||
|
|
||||||
|
The situation this exists for: ``gitea_bootstrap_author_issue_worktree``
|
||||||
|
reported success, wrote an incomplete lock, and told the author to implement.
|
||||||
|
The author did — legitimately, following the tool's own reported next action —
|
||||||
|
and the branch now carries real committed and pushed work. At that point every
|
||||||
|
pre-existing recovery path is simultaneously ineligible:
|
||||||
|
|
||||||
|
* heartbeat refuses, because the claimant is not where it looks;
|
||||||
|
* ``gitea_lock_issue`` refuses, because the branch is no longer base-equivalent;
|
||||||
|
* #760 exact-owner renewal never engages, because a lock with no recorded
|
||||||
|
expiry is never *expired*;
|
||||||
|
* the #447 create-PR guard refuses, because there is no provenance.
|
||||||
|
|
||||||
|
Distinct from every neighbouring path: #753 ``issue_lock_recovery`` requires a
|
||||||
|
dead owner PID, #760 ``issue_lock_renewal`` requires an *expired* lease, and
|
||||||
|
#442 ``issue_lock_adoption`` decides branch adoption. None of them addresses a
|
||||||
|
lock that is structurally incomplete and therefore never expires at all.
|
||||||
|
|
||||||
|
**What this will not do.** It never moves, resets, or rewinds a branch, and
|
||||||
|
never requires base-equivalence — the committed work is the thing being
|
||||||
|
preserved. It never pushes and never opens a pull request. It touches only the
|
||||||
|
one lock file named by (remote, org, repo, issue). It accepts no caller-supplied
|
||||||
|
provenance and no caller-supplied authorization flag; both are minted
|
||||||
|
server-side. It refuses a healthy foreign-owned lock outright, and a matching
|
||||||
|
username alone is never accepted as proof of ownership — the profile must match
|
||||||
|
too, and the lock's recorded binding must agree with the observed branch,
|
||||||
|
worktree, and head.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
import author_lock_contract
|
||||||
|
import issue_lock_store
|
||||||
|
|
||||||
|
#: Refusal codes, so callers can branch on cause rather than parse prose.
|
||||||
|
REFUSAL_NO_LOCK = "no_durable_lock"
|
||||||
|
REFUSAL_ALREADY_CANONICAL = "already_canonical"
|
||||||
|
REFUSAL_FOREIGN_CLAIMANT = "foreign_claimant"
|
||||||
|
REFUSAL_HEALTHY_FOREIGN = "healthy_foreign_lock"
|
||||||
|
REFUSAL_IDENTITY_UNRESOLVED = "identity_unresolved"
|
||||||
|
REFUSAL_BINDING_MISMATCH = "binding_mismatch"
|
||||||
|
REFUSAL_WORKTREE_INVALID = "worktree_invalid"
|
||||||
|
REFUSAL_HEAD_MISMATCH = "head_mismatch"
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _same_realpath(left: str | None, right: str | None) -> bool:
|
||||||
|
lhs, rhs = _text(left), _text(right)
|
||||||
|
if not lhs or not rhs:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return os.path.realpath(lhs) == os.path.realpath(rhs)
|
||||||
|
except OSError:
|
||||||
|
return lhs == rhs
|
||||||
|
|
||||||
|
|
||||||
|
def assess_bootstrap_lock_recovery(
|
||||||
|
existing_lock: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
issue_number: int,
|
||||||
|
branch_name: str,
|
||||||
|
worktree_path: str,
|
||||||
|
remote: str,
|
||||||
|
org: str,
|
||||||
|
repo: str,
|
||||||
|
identity: str | None,
|
||||||
|
profile: str | None,
|
||||||
|
observed_head: str | None,
|
||||||
|
declared_head: str | None,
|
||||||
|
worktree_exists: bool,
|
||||||
|
worktree_registered: bool,
|
||||||
|
current_branch: str | None,
|
||||||
|
now: Any = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Decide whether this exact lock may be upgraded by this exact caller.
|
||||||
|
|
||||||
|
Pure: every input is an observation the caller already made, and nothing
|
||||||
|
here reads or writes the filesystem, git, or Gitea. That is what makes the
|
||||||
|
same decision testable in isolation and reusable by the read-only
|
||||||
|
inspection surface, which must not mutate anything (AC16).
|
||||||
|
|
||||||
|
Returns a dict with ``recovery_sanctioned`` plus the full evidence set. A
|
||||||
|
refusal never raises — it reports, so the caller can surface exactly which
|
||||||
|
piece of evidence was missing.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
refusal_code: str | None = None
|
||||||
|
|
||||||
|
contract = author_lock_contract.assess_lock_contract(existing_lock)
|
||||||
|
|
||||||
|
if not existing_lock:
|
||||||
|
return {
|
||||||
|
"recovery_sanctioned": False,
|
||||||
|
"refusal_code": REFUSAL_NO_LOCK,
|
||||||
|
"reasons": [
|
||||||
|
f"no durable issue lock exists for issue #{issue_number}; there is "
|
||||||
|
"nothing to recover (fail closed)"
|
||||||
|
],
|
||||||
|
"contract": contract,
|
||||||
|
"evidence": {},
|
||||||
|
"expected_generation": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
active_identity = _text(identity)
|
||||||
|
active_profile = _text(profile)
|
||||||
|
recorded = author_lock_contract.lock_claimant(existing_lock)
|
||||||
|
freshness = issue_lock_store.assess_lock_freshness(dict(existing_lock), now=now)
|
||||||
|
generation = issue_lock_store.lock_generation(existing_lock)
|
||||||
|
|
||||||
|
evidence: dict[str, Any] = {
|
||||||
|
"recorded_claimant": recorded,
|
||||||
|
"active_identity": active_identity,
|
||||||
|
"active_profile": active_profile,
|
||||||
|
"recorded_branch": existing_lock.get("branch_name"),
|
||||||
|
"recorded_worktree": existing_lock.get("worktree_path"),
|
||||||
|
"recorded_owner_session": existing_lock.get("owner_session"),
|
||||||
|
"recorded_generation": generation,
|
||||||
|
"recorded_remote": existing_lock.get("remote"),
|
||||||
|
"recorded_org": existing_lock.get("org"),
|
||||||
|
"recorded_repo": existing_lock.get("repo"),
|
||||||
|
"observed_head": _text(observed_head),
|
||||||
|
"declared_head": _text(declared_head),
|
||||||
|
"current_branch": _text(current_branch),
|
||||||
|
"worktree_exists": bool(worktree_exists),
|
||||||
|
"worktree_registered": bool(worktree_registered),
|
||||||
|
"freshness": freshness,
|
||||||
|
"claimant_placement": contract.get("claimant_placement"),
|
||||||
|
"expiration_state": contract.get("expiration", {}).get("state"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Repository and issue identity (AC10) ──
|
||||||
|
if _text(existing_lock.get("remote")) != _text(remote):
|
||||||
|
reasons.append(
|
||||||
|
f"recorded remote '{existing_lock.get('remote')}' does not match '{remote}'"
|
||||||
|
)
|
||||||
|
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
|
||||||
|
if _text(existing_lock.get("org")) != _text(org):
|
||||||
|
reasons.append(
|
||||||
|
f"recorded org '{existing_lock.get('org')}' does not match '{org}'"
|
||||||
|
)
|
||||||
|
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
|
||||||
|
if _text(existing_lock.get("repo")) != _text(repo):
|
||||||
|
reasons.append(
|
||||||
|
f"recorded repo '{existing_lock.get('repo')}' does not match '{repo}'"
|
||||||
|
)
|
||||||
|
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
|
||||||
|
if existing_lock.get("issue_number") != issue_number:
|
||||||
|
reasons.append(
|
||||||
|
f"lock targets issue #{existing_lock.get('issue_number')}, not "
|
||||||
|
f"#{issue_number}"
|
||||||
|
)
|
||||||
|
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
|
||||||
|
|
||||||
|
# ── Branch and worktree binding (AC10) ──
|
||||||
|
if _text(existing_lock.get("branch_name")) != _text(branch_name):
|
||||||
|
reasons.append(
|
||||||
|
f"recorded branch '{existing_lock.get('branch_name')}' does not match "
|
||||||
|
f"'{branch_name}'"
|
||||||
|
)
|
||||||
|
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
|
||||||
|
if not _same_realpath(existing_lock.get("worktree_path"), worktree_path):
|
||||||
|
reasons.append(
|
||||||
|
f"recorded worktree '{existing_lock.get('worktree_path')}' does not "
|
||||||
|
f"match '{worktree_path}'"
|
||||||
|
)
|
||||||
|
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
|
||||||
|
|
||||||
|
# ── The worktree is real, registered, and on the branch (AC10) ──
|
||||||
|
# Deliberately no base-equivalence requirement and no constraint on how far
|
||||||
|
# the branch has advanced: the whole point is that it already carries the
|
||||||
|
# author's legitimate commits (AC9).
|
||||||
|
if not worktree_exists:
|
||||||
|
reasons.append(f"declared worktree '{worktree_path}' does not exist")
|
||||||
|
refusal_code = refusal_code or REFUSAL_WORKTREE_INVALID
|
||||||
|
if not worktree_registered:
|
||||||
|
reasons.append(f"worktree '{worktree_path}' is not a registered git worktree")
|
||||||
|
refusal_code = refusal_code or REFUSAL_WORKTREE_INVALID
|
||||||
|
if _text(current_branch) != _text(branch_name):
|
||||||
|
reasons.append(
|
||||||
|
f"worktree is on branch '{_text(current_branch) or 'unknown'}', not "
|
||||||
|
f"'{branch_name}'"
|
||||||
|
)
|
||||||
|
refusal_code = refusal_code or REFUSAL_WORKTREE_INVALID
|
||||||
|
|
||||||
|
# ── Current head fencing (AC10) ──
|
||||||
|
# The caller names the commit it believes it is recovering. A mismatch means
|
||||||
|
# the worktree moved under the caller, so the decision is stale.
|
||||||
|
if not _text(observed_head):
|
||||||
|
reasons.append("could not observe the worktree head")
|
||||||
|
refusal_code = refusal_code or REFUSAL_HEAD_MISMATCH
|
||||||
|
elif _text(declared_head) and _text(declared_head) != _text(observed_head):
|
||||||
|
reasons.append(
|
||||||
|
f"declared head '{_text(declared_head)}' does not match observed head "
|
||||||
|
f"'{_text(observed_head)}'"
|
||||||
|
)
|
||||||
|
refusal_code = refusal_code or REFUSAL_HEAD_MISMATCH
|
||||||
|
|
||||||
|
# ── Ownership (AC10, AC11) ──
|
||||||
|
# A matching username alone is never sufficient: the profile must match too,
|
||||||
|
# and both are compared against server-resolved values the caller cannot set.
|
||||||
|
if not active_identity or not active_profile:
|
||||||
|
reasons.append(
|
||||||
|
"active identity and profile could not both be resolved; ownership "
|
||||||
|
"cannot be proven"
|
||||||
|
)
|
||||||
|
refusal_code = refusal_code or REFUSAL_IDENTITY_UNRESOLVED
|
||||||
|
if not recorded["username"] or not recorded["profile"]:
|
||||||
|
reasons.append(
|
||||||
|
"durable lock does not record both a claimant username and profile"
|
||||||
|
)
|
||||||
|
refusal_code = refusal_code or REFUSAL_FOREIGN_CLAIMANT
|
||||||
|
elif (
|
||||||
|
recorded["username"] != active_identity
|
||||||
|
or recorded["profile"] != active_profile
|
||||||
|
):
|
||||||
|
# AC11: a foreign-owned lock is never recoverable through this path,
|
||||||
|
# healthy or not. The healthy case is reported distinctly so the refusal
|
||||||
|
# is legible, but both refuse.
|
||||||
|
if freshness.get("live"):
|
||||||
|
reasons.append(
|
||||||
|
f"lock is owned by a healthy foreign claimant "
|
||||||
|
f"'{recorded['username']}/{recorded['profile']}'; takeover is not "
|
||||||
|
"a recovery path"
|
||||||
|
)
|
||||||
|
refusal_code = REFUSAL_HEALTHY_FOREIGN
|
||||||
|
else:
|
||||||
|
reasons.append(
|
||||||
|
f"lock claimant '{recorded['username']}/{recorded['profile']}' "
|
||||||
|
f"does not match active '{active_identity}/{active_profile}'"
|
||||||
|
)
|
||||||
|
refusal_code = refusal_code or REFUSAL_FOREIGN_CLAIMANT
|
||||||
|
|
||||||
|
# ── Nothing to recover ──
|
||||||
|
# A lock that is already canonical is left strictly alone. Rewriting it would
|
||||||
|
# mint a new task-session identifier and invalidate the heartbeat token the
|
||||||
|
# legitimate owner is already using.
|
||||||
|
if contract.get("canonical") and not reasons:
|
||||||
|
return {
|
||||||
|
"recovery_sanctioned": False,
|
||||||
|
"refusal_code": REFUSAL_ALREADY_CANONICAL,
|
||||||
|
"reasons": [
|
||||||
|
"lock already satisfies the canonical contract; no recovery is "
|
||||||
|
"required"
|
||||||
|
],
|
||||||
|
"contract": contract,
|
||||||
|
"evidence": evidence,
|
||||||
|
"expected_generation": generation,
|
||||||
|
}
|
||||||
|
|
||||||
|
sanctioned = not reasons
|
||||||
|
return {
|
||||||
|
"recovery_sanctioned": sanctioned,
|
||||||
|
"refusal_code": None if sanctioned else refusal_code,
|
||||||
|
"reasons": reasons,
|
||||||
|
"contract": contract,
|
||||||
|
"evidence": evidence,
|
||||||
|
"expected_generation": generation,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_recovery_record(
|
||||||
|
assessment: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
recovered_at: str,
|
||||||
|
new_task_session_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Auditable record of the ownership and generation transition (AC10).
|
||||||
|
|
||||||
|
A recovered lock must never read as an original claim, so both sides of the
|
||||||
|
transition are preserved: what the incomplete lock recorded, and what
|
||||||
|
replaced it.
|
||||||
|
"""
|
||||||
|
evidence = dict(assessment.get("evidence") or {})
|
||||||
|
contract = dict(assessment.get("contract") or {})
|
||||||
|
return {
|
||||||
|
"recovery_kind": "incomplete_bootstrap_lock",
|
||||||
|
"recovered_at": recovered_at,
|
||||||
|
"prior_contract": contract.get("contract"),
|
||||||
|
"prior_missing_fields": list(contract.get("missing_fields") or []),
|
||||||
|
"prior_claimant_placement": evidence.get("claimant_placement"),
|
||||||
|
"prior_expiration_state": evidence.get("expiration_state"),
|
||||||
|
"prior_generation": evidence.get("recorded_generation"),
|
||||||
|
"prior_owner_session": evidence.get("recorded_owner_session"),
|
||||||
|
"prior_freshness": (evidence.get("freshness") or {}).get("status"),
|
||||||
|
"replacement_task_session_id": new_task_session_id,
|
||||||
|
"preserved_head": evidence.get("observed_head"),
|
||||||
|
"branch_reset": False,
|
||||||
|
"base_equivalence_required": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_recovery_refusal(assessment: Mapping[str, Any]) -> str:
|
||||||
|
reasons = "; ".join(
|
||||||
|
assessment.get("reasons") or ["unknown bootstrap lock recovery refusal"]
|
||||||
|
)
|
||||||
|
code = assessment.get("refusal_code") or "refused"
|
||||||
|
return f"Bootstrap lock recovery refused ({code}): {reasons} (fail closed)"
|
||||||
+592
-35
@@ -37,6 +37,76 @@ CANONICAL_ROOT_ENV = "GITEA_CANONICAL_REPOSITORY_ROOT"
|
|||||||
# Candidate git remote names probed when deriving repository identity.
|
# Candidate git remote names probed when deriving repository identity.
|
||||||
_IDENTITY_REMOTE_CANDIDATES = ("prgs", "origin", "dadeschools", "mdcps")
|
_IDENTITY_REMOTE_CANDIDATES = ("prgs", "origin", "dadeschools", "mdcps")
|
||||||
|
|
||||||
|
# Fallback integration-branch names, probed only when the checkout declares no
|
||||||
|
# configured upstream (#983). Mirrors the stable base branches recognised
|
||||||
|
# elsewhere in the workflow (``stacked_pr_support``, ``root_checkout_guard``).
|
||||||
|
# The fallback is deliberately *not* ordered-first-wins: when more than one of
|
||||||
|
# these refs exists and the checkout records no upstream, the integration branch
|
||||||
|
# is genuinely ambiguous and resolution fails closed instead of guessing.
|
||||||
|
INTEGRATION_BRANCH_CANDIDATES: tuple[str, ...] = ("master", "main", "dev")
|
||||||
|
|
||||||
|
# Sources for a *proven* base-ref derivation (#983 B1).
|
||||||
|
#
|
||||||
|
# ``refs/remotes/<remote>/HEAD`` is deliberately absent from this list. It is a
|
||||||
|
# local symbolic-ref *cache* written once at clone time and refreshed only by an
|
||||||
|
# explicit ``git remote set-head``; an ordinary fetch never updates it. When the
|
||||||
|
# upstream default branch changes afterwards the cache keeps naming the old
|
||||||
|
# branch, so trusting it derives the wrong integration branch for a checkout
|
||||||
|
# that is sitting exactly on its tip. The cache is still read, but only as a
|
||||||
|
# corroborating observation reported back to the caller — never as an authority,
|
||||||
|
# and never as a tie-breaker between otherwise ambiguous candidates.
|
||||||
|
BASE_REF_SOURCE_CONFIGURED_UPSTREAM = "configured_branch_upstream"
|
||||||
|
BASE_REF_SOURCE_UNIQUE_CANDIDATE = "unique_integration_branch_ref"
|
||||||
|
|
||||||
|
# Reason codes for base-ref derivation outcomes (#983), so callers and tests can
|
||||||
|
# assert the refusal cause instead of string-matching prose.
|
||||||
|
DENY_NO_IDENTITY_REMOTE = "no_identity_remote"
|
||||||
|
DENY_AMBIGUOUS_REMOTE = "ambiguous_identity_remote"
|
||||||
|
DENY_AMBIGUOUS_BASE_BRANCH = "ambiguous_integration_branch"
|
||||||
|
DENY_NO_BASE_BRANCH = "no_integration_branch_ref"
|
||||||
|
|
||||||
|
# Repository-authority modes (#973 B10). Exactly two values are supported.
|
||||||
|
# ``mode`` selects how repository authority is established, so an unrecognised
|
||||||
|
# value must never be normalised onto one of these: aliasing a trusted mode is
|
||||||
|
# precisely the defect. Omitting the argument keeps the documented safe default,
|
||||||
|
# ``validation``.
|
||||||
|
MODE_VALIDATION = "validation"
|
||||||
|
MODE_DERIVATION = "derivation"
|
||||||
|
SUPPORTED_MODES: tuple[str, ...] = (MODE_VALIDATION, MODE_DERIVATION)
|
||||||
|
|
||||||
|
# Reason code emitted when an unsupported mode is refused. Mirrors the existing
|
||||||
|
# ``webui.sanctioned_restart.DENY_UNKNOWN_MODE`` convention so callers and tests
|
||||||
|
# can assert the refusal cause rather than string-matching prose.
|
||||||
|
DENY_UNKNOWN_MODE = "unknown_mode"
|
||||||
|
|
||||||
|
|
||||||
|
def unsupported_mode_reason(mode: object) -> str | None:
|
||||||
|
"""Precise rejection reason for *mode*, or None when *mode* is supported.
|
||||||
|
|
||||||
|
Only the two documented string values are accepted, compared exactly — no
|
||||||
|
stripping, no case folding — so misspellings and whitespace variants are
|
||||||
|
refused rather than coerced. An empty string, ``None``, and any non-string
|
||||||
|
are all *explicitly supplied* unsupported values and are refused on the same
|
||||||
|
footing; none of them is normalised to a supported mode. Omitting the
|
||||||
|
argument entirely never reaches here with an unsupported value because the
|
||||||
|
parameter default is ``"validation"``.
|
||||||
|
"""
|
||||||
|
if isinstance(mode, str) and mode in SUPPORTED_MODES:
|
||||||
|
return None
|
||||||
|
supported = ", ".join(repr(m) for m in SUPPORTED_MODES)
|
||||||
|
if not isinstance(mode, str):
|
||||||
|
return (
|
||||||
|
f"unsupported repository-authority mode {mode!r} of type "
|
||||||
|
f"{type(mode).__name__}: only {supported} are supported; the mode "
|
||||||
|
"is refused before any repository assessment and no repository "
|
||||||
|
"identity was resolved through it (fail closed)"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"unsupported repository-authority mode {mode!r}: only {supported} are "
|
||||||
|
"supported; the mode is refused before any repository assessment and no "
|
||||||
|
"repository identity was resolved through it (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def configured_canonical_root(
|
def configured_canonical_root(
|
||||||
profile: Mapping | None,
|
profile: Mapping | None,
|
||||||
@@ -78,17 +148,15 @@ def resolve_repo_toplevel(path: str) -> str | None:
|
|||||||
return os.path.realpath(top) if top else None
|
return os.path.realpath(top) if top else None
|
||||||
|
|
||||||
|
|
||||||
def repository_identity_slug(path: str, *, remote: str | None = None) -> str | None:
|
def _identity_remote_candidates(path: str, remote: str | None) -> list[str]:
|
||||||
"""``owner/repository`` derived from a git remote configured at *path*.
|
"""Ordered remote names to probe for identity at *path*.
|
||||||
|
|
||||||
Tries the caller-named remote first, then a small set of known remote names,
|
Names are used verbatim — never case-folded. Git config subsection names are
|
||||||
then whatever remote the repository actually has. Returns None when no remote
|
case-sensitive, so a repository whose remote is ``MDCPS`` is reached only by
|
||||||
URL is parseable (identity cannot be proven).
|
the exact string ``MDCPS``; the lowercase entry in
|
||||||
|
:data:`_IDENTITY_REMOTE_CANDIDATES` simply does not resolve, and the exact
|
||||||
|
name arrives from ``git remote`` below (#983).
|
||||||
"""
|
"""
|
||||||
text = (path or "").strip()
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
|
|
||||||
ordered: list[str] = []
|
ordered: list[str] = []
|
||||||
for name in (remote, *_IDENTITY_REMOTE_CANDIDATES):
|
for name in (remote, *_IDENTITY_REMOTE_CANDIDATES):
|
||||||
clean = (name or "").strip()
|
clean = (name or "").strip()
|
||||||
@@ -97,7 +165,7 @@ def repository_identity_slug(path: str, *, remote: str | None = None) -> str | N
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
listed = subprocess.run(
|
listed = subprocess.run(
|
||||||
["git", "-C", text, "remote"],
|
["git", "-C", path, "remote"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=True,
|
check=True,
|
||||||
@@ -107,11 +175,20 @@ def repository_identity_slug(path: str, *, remote: str | None = None) -> str | N
|
|||||||
for name in listed:
|
for name in listed:
|
||||||
if name and name not in ordered:
|
if name and name not in ordered:
|
||||||
ordered.append(name)
|
ordered.append(name)
|
||||||
|
return ordered
|
||||||
|
|
||||||
for name in ordered:
|
|
||||||
|
def _configured_remote_identities(path: str, remote: str | None) -> list[tuple[str, str]]:
|
||||||
|
"""``(remote_name, owner/repository)`` for every probe name that resolves.
|
||||||
|
|
||||||
|
Remote names are returned exactly as configured so downstream tracking refs
|
||||||
|
(``refs/remotes/<remote>/<branch>``) address the real ref (#983).
|
||||||
|
"""
|
||||||
|
found: list[tuple[str, str]] = []
|
||||||
|
for name in _identity_remote_candidates(path, remote):
|
||||||
try:
|
try:
|
||||||
url = subprocess.run(
|
url = subprocess.run(
|
||||||
["git", "-C", text, "remote", "get-url", name],
|
["git", "-C", path, "remote", "get-url", name],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=True,
|
check=True,
|
||||||
@@ -120,8 +197,415 @@ def repository_identity_slug(path: str, *, remote: str | None = None) -> str | N
|
|||||||
continue
|
continue
|
||||||
parsed = remote_repo_guard.parse_org_repo_from_remote_url(url)
|
parsed = remote_repo_guard.parse_org_repo_from_remote_url(url)
|
||||||
if parsed:
|
if parsed:
|
||||||
return f"{parsed[0]}/{parsed[1]}"
|
found.append((name, f"{parsed[0]}/{parsed[1]}"))
|
||||||
return None
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_branch_upstream(path: str) -> tuple[str | None, str | None]:
|
||||||
|
"""``(remote_name, branch)`` the checked-out branch is configured to track.
|
||||||
|
|
||||||
|
Reads ``branch.<current>.remote`` and ``branch.<current>.merge`` — the
|
||||||
|
checkout's own explicitly configured integration target, equivalent to
|
||||||
|
``@{upstream}``. Unlike ``refs/remotes/<remote>/HEAD`` this is not a
|
||||||
|
clone-time cache: it is written when the branch is set up to track an
|
||||||
|
upstream and rewritten whenever that tracking changes, so it states what the
|
||||||
|
checkout actually integrates onto today (#983 B1).
|
||||||
|
|
||||||
|
Returns ``(None, None)`` on a detached HEAD or an untracked branch. Names are
|
||||||
|
returned verbatim; git remote names are case-sensitive.
|
||||||
|
"""
|
||||||
|
text = (path or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
branch = _git_read(text, "symbolic-ref", "--quiet", "--short", "HEAD")
|
||||||
|
if not branch:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
remote = _git_read(text, "config", "--get", f"branch.{branch}.remote")
|
||||||
|
merge = _git_read(text, "config", "--get", f"branch.{branch}.merge")
|
||||||
|
if not remote or not merge:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
prefix = "refs/heads/"
|
||||||
|
upstream_branch = merge[len(prefix):].strip() if merge.startswith(prefix) else merge.strip()
|
||||||
|
if not upstream_branch:
|
||||||
|
return None, None
|
||||||
|
return remote, upstream_branch
|
||||||
|
|
||||||
|
|
||||||
|
def _cached_remote_head_branch(path: str, remote: str) -> str | None:
|
||||||
|
"""Branch named by the *cached* ``refs/remotes/<remote>/HEAD`` symref.
|
||||||
|
|
||||||
|
Read for observability only. This value is never authoritative (see
|
||||||
|
:data:`BASE_REF_SOURCE_CONFIGURED_UPSTREAM`); it is surfaced so an operator
|
||||||
|
can see that the local cache disagrees with the configured upstream, and so
|
||||||
|
a refusal can name the misleading signal explicitly.
|
||||||
|
"""
|
||||||
|
prefix = f"refs/remotes/{remote}/"
|
||||||
|
symref = _git_read(path, "symbolic-ref", "--quiet", f"{prefix}HEAD")
|
||||||
|
if not symref or not symref.startswith(prefix):
|
||||||
|
return None
|
||||||
|
branch = symref[len(prefix):].strip()
|
||||||
|
return branch or None
|
||||||
|
|
||||||
|
|
||||||
|
def assess_identity_remote(
|
||||||
|
path: str, *, explicit_remote: str | None = None
|
||||||
|
) -> dict:
|
||||||
|
"""Resolve the identity remote, failing closed when the target is ambiguous.
|
||||||
|
|
||||||
|
This is the ambiguity-aware counterpart to :func:`resolve_identity_remote`,
|
||||||
|
and the reason gating and reporting can no longer disagree (#983 B2).
|
||||||
|
|
||||||
|
*explicit_remote* is a **caller-supplied disambiguation** and nothing else.
|
||||||
|
It must come from an operator or an explicitly sanctioned repository
|
||||||
|
context; a value this module inferred while probing must never be handed
|
||||||
|
back in through it, because doing so re-labels an internal first-wins guess
|
||||||
|
as deliberate caller intent and silently suppresses the ambiguity gate.
|
||||||
|
|
||||||
|
Decision table:
|
||||||
|
|
||||||
|
* No remote yields a parseable identity -> :data:`DENY_NO_IDENTITY_REMOTE`.
|
||||||
|
* *explicit_remote* names one of the resolving remotes -> that remote is
|
||||||
|
authoritative, ``explicit`` True.
|
||||||
|
* Otherwise, when every resolving remote claims the **same** repository the
|
||||||
|
target is unambiguous and is accepted, ``explicit`` False. The remote
|
||||||
|
named by the checkout's configured upstream is preferred among equals so
|
||||||
|
the choice is deterministic rather than probe-order dependent.
|
||||||
|
* Otherwise distinct remotes claim different repositories and there is no
|
||||||
|
sanctioned disambiguation -> :data:`DENY_AMBIGUOUS_REMOTE`.
|
||||||
|
|
||||||
|
Returns a dict with ``remote``, ``slug``, ``identities``, ``ambiguous``,
|
||||||
|
``explicit``, ``reason_code`` and ``reasons``. ``remote``/``slug`` are None
|
||||||
|
on any refusal, so a caller cannot report a repository the gate refuses.
|
||||||
|
"""
|
||||||
|
text = (path or "").strip()
|
||||||
|
result: dict = {
|
||||||
|
"remote": None,
|
||||||
|
"slug": None,
|
||||||
|
"identities": [],
|
||||||
|
"ambiguous": False,
|
||||||
|
"explicit": False,
|
||||||
|
"reason_code": None,
|
||||||
|
"reasons": [],
|
||||||
|
}
|
||||||
|
if not text:
|
||||||
|
result["reason_code"] = DENY_NO_IDENTITY_REMOTE
|
||||||
|
result["reasons"].append(
|
||||||
|
"no repository path supplied for identity-remote resolution (fail closed)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
named = (explicit_remote or "").strip() or None
|
||||||
|
identities = _configured_remote_identities(text, named)
|
||||||
|
result["identities"] = list(identities)
|
||||||
|
if not identities:
|
||||||
|
result["reason_code"] = DENY_NO_IDENTITY_REMOTE
|
||||||
|
result["reasons"].append(
|
||||||
|
f"no git remote at '{text}' yields a parseable repository identity "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if named:
|
||||||
|
for name, slug in identities:
|
||||||
|
if name == named:
|
||||||
|
result["remote"] = name
|
||||||
|
result["slug"] = slug
|
||||||
|
result["explicit"] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
distinct = {slug for _, slug in identities}
|
||||||
|
if len(distinct) > 1:
|
||||||
|
listed = ", ".join(f"{name} -> {slug}" for name, slug in identities)
|
||||||
|
result["ambiguous"] = True
|
||||||
|
result["reason_code"] = DENY_AMBIGUOUS_REMOTE
|
||||||
|
detail = (
|
||||||
|
f"explicitly named remote '{named}' does not resolve a repository identity "
|
||||||
|
"there, so it cannot disambiguate; "
|
||||||
|
if named
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
result["reasons"].append(
|
||||||
|
f"ambiguous repository identity at '{text}': {detail}remotes resolve to "
|
||||||
|
f"different repositories ({listed}); no single authoritative target can "
|
||||||
|
"be established (fail closed)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# One repository, possibly reachable through several remote names (a mirror).
|
||||||
|
# Prefer the remote the checkout is actually configured to track so the
|
||||||
|
# choice is deterministic instead of probe-order dependent.
|
||||||
|
upstream_remote, _ = _configured_branch_upstream(text)
|
||||||
|
chosen = identities[0]
|
||||||
|
if upstream_remote:
|
||||||
|
for entry in identities:
|
||||||
|
if entry[0] == upstream_remote:
|
||||||
|
chosen = entry
|
||||||
|
break
|
||||||
|
result["remote"], result["slug"] = chosen
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_identity_remote(
|
||||||
|
path: str, *, remote: str | None = None
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""``(remote_name, owner/repository)`` for the remote that proves identity.
|
||||||
|
|
||||||
|
The remote *name* is the piece historically thrown away by
|
||||||
|
:func:`repository_identity_slug`, even though resolving the slug already
|
||||||
|
required discovering it. Cross-repository base-ref derivation needs that
|
||||||
|
name to build ``refs/remotes/<remote>/<branch>``, so it is now returned
|
||||||
|
rather than discarded (#983). Returns ``(None, None)`` when no remote URL is
|
||||||
|
parseable (identity cannot be proven).
|
||||||
|
|
||||||
|
First-wins by design: this is the identity lookup behind
|
||||||
|
:func:`repository_identity_slug` and the #706/#973 canonical-root
|
||||||
|
validation, which compare an observed slug against an independently trusted
|
||||||
|
expected slug and therefore do not need an ambiguity verdict. Callers that
|
||||||
|
*derive* a target rather than validate one — the mutation guard and the
|
||||||
|
parity report — must use :func:`assess_identity_remote`, which fails closed
|
||||||
|
on ambiguity (#983 B2).
|
||||||
|
"""
|
||||||
|
text = (path or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None, None
|
||||||
|
for name, slug in _configured_remote_identities(text, remote):
|
||||||
|
return name, slug
|
||||||
|
return None, 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).
|
||||||
|
"""
|
||||||
|
return resolve_identity_remote(path, remote=remote)[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _base_ref_result(
|
||||||
|
*,
|
||||||
|
proven: bool,
|
||||||
|
reasons: list[str],
|
||||||
|
remote: str | None = None,
|
||||||
|
branch: str | None = None,
|
||||||
|
repository_slug: str | None = None,
|
||||||
|
source: str | None = None,
|
||||||
|
reason_code: str | None = None,
|
||||||
|
identity_explicit: bool = False,
|
||||||
|
configured_upstream_remote: str | None = None,
|
||||||
|
configured_upstream_branch: str | None = None,
|
||||||
|
cached_remote_head_branch: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Build the base-ref derivation payload.
|
||||||
|
|
||||||
|
``tracking_refs`` is the ordered probe tuple downstream guards hand to
|
||||||
|
``git rev-parse``: the ``<remote>/<branch>`` shorthand first, then the fully
|
||||||
|
qualified ``refs/remotes/<remote>/<branch>``. It is empty whenever the
|
||||||
|
derivation is not ``proven``, so an unresolved target can never be probed
|
||||||
|
against some other repository's ref.
|
||||||
|
|
||||||
|
``cached_remote_head_branch`` reports what the local
|
||||||
|
``refs/remotes/<remote>/HEAD`` cache claims, and
|
||||||
|
``cached_remote_head_conflicts`` whether that claim disagrees with the branch
|
||||||
|
actually derived. Both are observability only: the cache never decides the
|
||||||
|
outcome (#983 B1).
|
||||||
|
"""
|
||||||
|
tracking_ref = f"refs/remotes/{remote}/{branch}" if proven and remote and branch else None
|
||||||
|
tracking_refs: tuple[str, ...] = (
|
||||||
|
(f"{remote}/{branch}", tracking_ref) if tracking_ref else ()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"proven": proven,
|
||||||
|
"block": not proven,
|
||||||
|
"remote": remote,
|
||||||
|
"branch": branch,
|
||||||
|
"repository_slug": repository_slug,
|
||||||
|
"tracking_ref": tracking_ref,
|
||||||
|
"tracking_refs": tracking_refs,
|
||||||
|
"source": source,
|
||||||
|
"reason_code": reason_code,
|
||||||
|
"identity_explicit": identity_explicit,
|
||||||
|
"configured_upstream_remote": configured_upstream_remote,
|
||||||
|
"configured_upstream_branch": configured_upstream_branch,
|
||||||
|
"cached_remote_head_branch": cached_remote_head_branch,
|
||||||
|
"cached_remote_head_conflicts": bool(
|
||||||
|
cached_remote_head_branch and branch and cached_remote_head_branch != branch
|
||||||
|
),
|
||||||
|
"reasons": list(reasons),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _git_read(path: str, *args: str) -> str | None:
|
||||||
|
"""Run a read-only git command in *path*; ``None`` on any failure."""
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", path, *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if res.returncode != 0:
|
||||||
|
return None
|
||||||
|
return (res.stdout or "").strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def _ref_exists(path: str, ref: str) -> bool:
|
||||||
|
"""Whether *ref* resolves in the checkout at *path*."""
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", path, "rev-parse", "--verify", "--quiet", ref],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return res.returncode == 0 and bool((res.stdout or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_target_base_ref(path: str, *, explicit_remote: str | None = None) -> dict:
|
||||||
|
"""Derive the authoritative integration base ref for the checkout at *path*.
|
||||||
|
|
||||||
|
This is the single resolved target shared by cross-repository mutation
|
||||||
|
gating and parity reporting, so the two can never disagree about which
|
||||||
|
commit a checkout is supposed to match (#983).
|
||||||
|
|
||||||
|
*explicit_remote* is a caller-supplied disambiguation only; see
|
||||||
|
:func:`assess_identity_remote` for why an internally inferred remote must
|
||||||
|
never be passed back in here (#983 B2).
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
|
||||||
|
1. **Identity remote** — via :func:`assess_identity_remote`, with its exact
|
||||||
|
configured case preserved (``MDCPS`` stays ``MDCPS``). Ambiguous identity
|
||||||
|
fails closed here rather than resolving to whichever remote probed first.
|
||||||
|
2. **The checkout's configured upstream** — ``branch.<current>.remote`` plus
|
||||||
|
``branch.<current>.merge``, accepted when it names the identity remote
|
||||||
|
and its remote-tracking ref actually exists. This is the checkout's own
|
||||||
|
declaration of what it integrates onto, and unlike the remote-HEAD cache
|
||||||
|
it is rewritten whenever that tracking changes.
|
||||||
|
3. **Fallback** — only when the checkout declares no usable upstream,
|
||||||
|
exactly one of :data:`INTEGRATION_BRANCH_CANDIDATES` present as a
|
||||||
|
remote-tracking ref.
|
||||||
|
|
||||||
|
``refs/remotes/<remote>/HEAD`` is **not** a step. It is read for reporting
|
||||||
|
(``cached_remote_head_branch`` / ``cached_remote_head_conflicts``) and never
|
||||||
|
decides the branch, because it is a clone-time cache that an ordinary fetch
|
||||||
|
does not refresh: a checkout whose upstream default moved on still has the
|
||||||
|
old branch cached, and trusting it gates that checkout against a ref it does
|
||||||
|
not integrate onto (#983 B1).
|
||||||
|
|
||||||
|
Fails closed — ``proven`` False, empty ``tracking_refs``, and a
|
||||||
|
``reason_code`` — when identity is unprovable, when distinct remotes claim
|
||||||
|
different repositories, when several candidate branches exist with no
|
||||||
|
configured upstream, or when no candidate exists at all. Nothing here
|
||||||
|
invents a branch, writes a ref, runs a fetch, or falls back to another
|
||||||
|
repository's base. Every ``proven`` result names a tracking ref that
|
||||||
|
resolves in this checkout.
|
||||||
|
"""
|
||||||
|
text = (path or "").strip()
|
||||||
|
if not text:
|
||||||
|
return _base_ref_result(
|
||||||
|
proven=False,
|
||||||
|
reasons=["no repository path supplied for base-ref derivation (fail closed)"],
|
||||||
|
reason_code=DENY_NO_IDENTITY_REMOTE,
|
||||||
|
)
|
||||||
|
|
||||||
|
identity = assess_identity_remote(text, explicit_remote=explicit_remote)
|
||||||
|
remote_name, slug = identity["remote"], identity["slug"]
|
||||||
|
if not remote_name:
|
||||||
|
return _base_ref_result(
|
||||||
|
proven=False,
|
||||||
|
reasons=list(identity["reasons"]),
|
||||||
|
reason_code=identity["reason_code"],
|
||||||
|
identity_explicit=bool(identity["explicit"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
prefix = f"refs/remotes/{remote_name}/"
|
||||||
|
cached = _cached_remote_head_branch(text, remote_name)
|
||||||
|
upstream_remote, upstream_branch = _configured_branch_upstream(text)
|
||||||
|
common = {
|
||||||
|
"repository_slug": slug,
|
||||||
|
"identity_explicit": bool(identity["explicit"]),
|
||||||
|
"configured_upstream_remote": upstream_remote,
|
||||||
|
"configured_upstream_branch": upstream_branch,
|
||||||
|
"cached_remote_head_branch": cached,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. The checkout's configured upstream, when it belongs to the identity
|
||||||
|
# remote and its tracking ref is actually present. Requiring the ref to
|
||||||
|
# exist keeps 'proven' honest: a proven target is always resolvable.
|
||||||
|
if (
|
||||||
|
upstream_remote == remote_name
|
||||||
|
and upstream_branch
|
||||||
|
and _ref_exists(text, f"{prefix}{upstream_branch}")
|
||||||
|
):
|
||||||
|
return _base_ref_result(
|
||||||
|
proven=True,
|
||||||
|
reasons=[],
|
||||||
|
remote=remote_name,
|
||||||
|
branch=upstream_branch,
|
||||||
|
source=BASE_REF_SOURCE_CONFIGURED_UPSTREAM,
|
||||||
|
**common,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Exactly one known integration branch present as a remote-tracking ref.
|
||||||
|
present = [
|
||||||
|
candidate
|
||||||
|
for candidate in INTEGRATION_BRANCH_CANDIDATES
|
||||||
|
if _ref_exists(text, f"{prefix}{candidate}")
|
||||||
|
]
|
||||||
|
if len(present) == 1:
|
||||||
|
return _base_ref_result(
|
||||||
|
proven=True,
|
||||||
|
reasons=[],
|
||||||
|
remote=remote_name,
|
||||||
|
branch=present[0],
|
||||||
|
source=BASE_REF_SOURCE_UNIQUE_CANDIDATE,
|
||||||
|
**common,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The cached remote HEAD is named in the refusal so the operator can see the
|
||||||
|
# signal that looks authoritative but is not, and is told the read-only fix.
|
||||||
|
cache_note = (
|
||||||
|
f" the cached '{prefix}HEAD' names '{cached}', but that cache is written at "
|
||||||
|
"clone time and is not refreshed by fetch, so it cannot break the tie;"
|
||||||
|
if cached
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
remedy = (
|
||||||
|
f" Configure the checkout's upstream (git branch --set-upstream-to={remote_name}/"
|
||||||
|
"<branch>) so the integration target is declared rather than guessed."
|
||||||
|
)
|
||||||
|
if len(present) > 1:
|
||||||
|
return _base_ref_result(
|
||||||
|
proven=False,
|
||||||
|
reasons=[
|
||||||
|
f"the checkout at '{text}' declares no upstream on remote "
|
||||||
|
f"'{remote_name}' and several integration branches exist "
|
||||||
|
f"({', '.join(present)});{cache_note} the integration base ref is "
|
||||||
|
f"ambiguous (fail closed).{remedy}"
|
||||||
|
],
|
||||||
|
reason_code=DENY_AMBIGUOUS_BASE_BRANCH,
|
||||||
|
**common,
|
||||||
|
)
|
||||||
|
return _base_ref_result(
|
||||||
|
proven=False,
|
||||||
|
reasons=[
|
||||||
|
f"the checkout at '{text}' declares no upstream on remote '{remote_name}' "
|
||||||
|
f"and none of {'/'.join(INTEGRATION_BRANCH_CANDIDATES)} exists as a "
|
||||||
|
f"remote-tracking ref under '{prefix}';{cache_note} the integration base "
|
||||||
|
f"ref cannot be derived (fail closed; no fetch is performed here).{remedy}"
|
||||||
|
],
|
||||||
|
reason_code=DENY_NO_BASE_BRANCH,
|
||||||
|
**common,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def assess_canonical_repository_root(
|
def assess_canonical_repository_root(
|
||||||
@@ -132,6 +616,7 @@ def assess_canonical_repository_root(
|
|||||||
process_project_root: str,
|
process_project_root: str,
|
||||||
remote: str | None = None,
|
remote: str | None = None,
|
||||||
require_binding: bool = False,
|
require_binding: bool = False,
|
||||||
|
mode: str = "validation",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Validate the canonical repository root binding, failing closed on forgery.
|
"""Validate the canonical repository root binding, failing closed on forgery.
|
||||||
|
|
||||||
@@ -140,15 +625,41 @@ def assess_canonical_repository_root(
|
|||||||
``configured`` (whether a cross-repo binding was declared),
|
``configured`` (whether a cross-repo binding was declared),
|
||||||
``resolved_slug`` and ``source``.
|
``resolved_slug`` and ``source``.
|
||||||
|
|
||||||
Without a configured binding the single-repo default is preserved: the
|
*mode* accepts exactly the two values in :data:`SUPPORTED_MODES`:
|
||||||
canonical root is derived from *process_project_root* and never blocks
|
- ``"validation"`` (default): an independently trusted expected repository
|
||||||
(unless *require_binding* explicitly demands one).
|
slug is known (or required). Candidate root's observed identity must match.
|
||||||
|
Unprovable or missing expected identity fails closed when *require_binding* is True.
|
||||||
|
- ``"derivation"``: caller is deriving the canonical repository identity.
|
||||||
|
No expected slug exists yet by design. Derivation succeeds if the configured
|
||||||
|
root exists, is a git repository, and carries a resolvable git remote identity.
|
||||||
|
|
||||||
With a configured binding the path must exist, be a git repository, and —
|
Every other explicitly supplied value — unknown strings, misspellings, the
|
||||||
when *expected_slug* is known — carry a matching repository identity. A
|
empty string, ``None``, and non-strings — is refused with ``proven`` False,
|
||||||
mismatched or (when *require_binding*) unprovable identity is a forged or
|
``block`` True, and ``reason_code`` :data:`DENY_UNKNOWN_MODE` (#973 B10).
|
||||||
conflicting binding and fails closed.
|
Omitting *mode* entirely keeps the documented ``"validation"`` default.
|
||||||
"""
|
"""
|
||||||
|
# #973 B10: refuse an unsupported repository-authority mode as the very first
|
||||||
|
# act, before any candidate-root existence check, path or symlink resolution,
|
||||||
|
# git top-level discovery, remote-URL or repository-identity discovery, and
|
||||||
|
# before any expected-versus-observed comparison or validation/derivation
|
||||||
|
# behaviour. An unsupported mode previously fell into the catch-all ``else``
|
||||||
|
# on the configured-root path (silently receiving validation semantics) and
|
||||||
|
# skipped the identity comparison entirely on the single-repository default
|
||||||
|
# path (strictly weaker than validation), so matching identities could return
|
||||||
|
# ``proven`` True. No repository identity may be resolved through a mode the
|
||||||
|
# contract does not define.
|
||||||
|
mode_reason = unsupported_mode_reason(mode)
|
||||||
|
if mode_reason is not None:
|
||||||
|
return _assessment(
|
||||||
|
proven=False,
|
||||||
|
reasons=[mode_reason],
|
||||||
|
configured=bool((configured_value or "").strip()),
|
||||||
|
canonical_repo_root=None,
|
||||||
|
resolved_slug=None,
|
||||||
|
source=source,
|
||||||
|
reason_code=DENY_UNKNOWN_MODE,
|
||||||
|
)
|
||||||
|
|
||||||
process_root = os.path.realpath(process_project_root)
|
process_root = os.path.realpath(process_project_root)
|
||||||
declared = (configured_value or "").strip()
|
declared = (configured_value or "").strip()
|
||||||
|
|
||||||
@@ -168,12 +679,31 @@ def assess_canonical_repository_root(
|
|||||||
)
|
)
|
||||||
# Single-repo default: canonical root follows the install checkout.
|
# Single-repo default: canonical root follows the install checkout.
|
||||||
derived = resolve_repo_toplevel(process_root) or process_root
|
derived = resolve_repo_toplevel(process_root) or process_root
|
||||||
|
resolved_slug = repository_identity_slug(derived, remote=remote)
|
||||||
|
reasons: list[str] = []
|
||||||
|
# #973 B10: the allowlist above guarantees *mode* is one of the two
|
||||||
|
# supported values here, so an unsupported value can no longer skip this
|
||||||
|
# identity comparison and end up strictly weaker than validation.
|
||||||
|
if mode == MODE_VALIDATION and expected_slug:
|
||||||
|
expected = expected_slug.strip()
|
||||||
|
if resolved_slug and resolved_slug.lower() != expected.lower():
|
||||||
|
reasons.append(
|
||||||
|
f"canonical repository root identity mismatch: '{derived}' resolves "
|
||||||
|
f"to repository '{resolved_slug}' but expected repository identity "
|
||||||
|
f"is '{expected}' (forged or conflicting binding, fail closed)"
|
||||||
|
)
|
||||||
|
elif not resolved_slug and require_binding:
|
||||||
|
reasons.append(
|
||||||
|
f"canonical repository root '{derived}' has no resolvable git "
|
||||||
|
f"remote identity to confirm authorization for '{expected}' "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
return _assessment(
|
return _assessment(
|
||||||
proven=True,
|
proven=not reasons,
|
||||||
reasons=[],
|
reasons=reasons,
|
||||||
configured=False,
|
configured=False,
|
||||||
canonical_repo_root=derived,
|
canonical_repo_root=derived,
|
||||||
resolved_slug=None,
|
resolved_slug=resolved_slug,
|
||||||
source=None,
|
source=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -207,19 +737,36 @@ def assess_canonical_repository_root(
|
|||||||
|
|
||||||
resolved_slug = repository_identity_slug(toplevel, remote=remote)
|
resolved_slug = repository_identity_slug(toplevel, remote=remote)
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
expected = (expected_slug or "").strip() or None
|
|
||||||
if expected:
|
if mode == MODE_DERIVATION:
|
||||||
if resolved_slug and resolved_slug.lower() != expected.lower():
|
if not resolved_slug:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
f"canonical repository root identity mismatch: '{toplevel}' resolves "
|
f"configured canonical repository root '{toplevel}' has no resolvable "
|
||||||
f"to repository '{resolved_slug}' but the session is authorized for "
|
"git remote identity (fail closed)"
|
||||||
f"'{expected}' (forged or conflicting binding, fail closed)"
|
|
||||||
)
|
)
|
||||||
elif not resolved_slug and require_binding:
|
else:
|
||||||
|
# #973 B10: MODE_VALIDATION only. This arm is no longer a catch-all — the
|
||||||
|
# allowlist above admits no third value, so an unsupported mode can no
|
||||||
|
# longer silently receive validation semantics here.
|
||||||
|
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)"
|
||||||
|
)
|
||||||
|
elif require_binding:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
f"canonical repository root '{toplevel}' has no resolvable git "
|
f"canonical repository root '{toplevel}' has configured value "
|
||||||
f"remote identity to confirm authorization for '{expected}' "
|
f"'{configured_value}' but authoritative expected repository identity "
|
||||||
"(fail closed)"
|
"is unprovable or missing (fail closed)"
|
||||||
)
|
)
|
||||||
|
|
||||||
return _assessment(
|
return _assessment(
|
||||||
@@ -252,10 +799,19 @@ def _assessment(
|
|||||||
proven: bool,
|
proven: bool,
|
||||||
reasons: list[str],
|
reasons: list[str],
|
||||||
configured: bool,
|
configured: bool,
|
||||||
canonical_repo_root: str,
|
canonical_repo_root: str | None,
|
||||||
resolved_slug: str | None,
|
resolved_slug: str | None,
|
||||||
source: str | None,
|
source: str | None,
|
||||||
|
reason_code: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
"""Build the assessment payload.
|
||||||
|
|
||||||
|
``canonical_repo_root`` is None only when the assessment refused to resolve
|
||||||
|
one at all — today exactly the unsupported-mode refusal (#973 B10), which
|
||||||
|
must not derive a trusted repository identity through an undefined mode.
|
||||||
|
``reason_code`` is a machine-checkable refusal cause; None for every
|
||||||
|
ordinary (non-coded) outcome.
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
"proven": proven,
|
"proven": proven,
|
||||||
"block": not proven,
|
"block": not proven,
|
||||||
@@ -264,4 +820,5 @@ def _assessment(
|
|||||||
"canonical_repo_root": canonical_repo_root,
|
"canonical_repo_root": canonical_repo_root,
|
||||||
"resolved_slug": resolved_slug,
|
"resolved_slug": resolved_slug,
|
||||||
"source": source,
|
"source": source,
|
||||||
|
"reason_code": reason_code,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,6 +280,22 @@ def _ts(dt: datetime | None = None) -> str:
|
|||||||
return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _realpath_or_raw(value: str | None) -> str:
|
||||||
|
"""Normalize a filesystem path for compare-and-swap equality (#970).
|
||||||
|
|
||||||
|
Symlinks and ``..`` segments must not make two spellings of the same path
|
||||||
|
look different, but an unresolvable path must still compare as itself
|
||||||
|
rather than collapsing to empty — an empty result means "no path given".
|
||||||
|
"""
|
||||||
|
text = (value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return os.path.realpath(os.path.abspath(text))
|
||||||
|
except Exception:
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _parse_ts(value: str | None) -> datetime | None:
|
def _parse_ts(value: str | None) -> datetime | None:
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
@@ -1913,6 +1929,168 @@ class ControlPlaneDB:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def retire_lease_worktree_path(
|
||||||
|
self,
|
||||||
|
lease_id: str,
|
||||||
|
*,
|
||||||
|
expected_path: str | None = None,
|
||||||
|
expected_status: str | None = None,
|
||||||
|
expected_session_id: str | None = None,
|
||||||
|
expected_owner_pid: int | None = None,
|
||||||
|
reason: str = "missing_worktree_path_retired",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Retire a missing worktree_path binding from a control-plane lease (#970).
|
||||||
|
|
||||||
|
Clears worktree_path on the lease row, updates provenance_json with
|
||||||
|
durable retirement audit proof, and writes a worktree_binding_retired
|
||||||
|
event.
|
||||||
|
|
||||||
|
The update is a compare-and-swap (#970 review 644 B1/B3): the caller
|
||||||
|
states the exact path it audited and, when known, the lease status,
|
||||||
|
owning session, and owner pid it classified against. Every stated value
|
||||||
|
must still match the stored row, and the ``UPDATE`` itself is keyed on
|
||||||
|
the stored ``worktree_path``, so a concurrent writer that moved or
|
||||||
|
replaced the binding between audit and apply loses the race instead of
|
||||||
|
having its value silently overwritten. A mismatch raises and mutates
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
``expected_path`` is mandatory: a retirement that does not name the path
|
||||||
|
it intends to clear cannot be safe against concurrent recreation.
|
||||||
|
"""
|
||||||
|
now_s = _ts()
|
||||||
|
expected_norm = _realpath_or_raw(expected_path)
|
||||||
|
if not expected_norm:
|
||||||
|
raise ControlPlaneError(
|
||||||
|
f"cannot retire lease {lease_id} worktree_path: expected_path is "
|
||||||
|
"required for compare-and-swap retirement (fail closed)"
|
||||||
|
)
|
||||||
|
with self._tx() as conn:
|
||||||
|
cols = self._lease_columns(conn)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM leases WHERE lease_id = ?",
|
||||||
|
(lease_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ControlPlaneError(f"unknown lease_id {lease_id}")
|
||||||
|
|
||||||
|
record = dict(row)
|
||||||
|
current_wt = (record.get("worktree_path") or "").strip()
|
||||||
|
|
||||||
|
if not current_wt:
|
||||||
|
# Idempotent: the binding this caller audited is already gone.
|
||||||
|
return {
|
||||||
|
"lease_id": lease_id,
|
||||||
|
"retired": False,
|
||||||
|
"already_retired": True,
|
||||||
|
"prior_worktree_path": "",
|
||||||
|
"expected_worktree_path": expected_path,
|
||||||
|
"reason": reason,
|
||||||
|
"compare_and_swap": {
|
||||||
|
"matched": True,
|
||||||
|
"outcome": "already_retired",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _realpath_or_raw(current_wt) != expected_norm:
|
||||||
|
raise ControlPlaneError(
|
||||||
|
f"cannot retire lease {lease_id} worktree_path: expected "
|
||||||
|
f"'{expected_path}' does not match current '{current_wt}' "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
for field, expected_value in (
|
||||||
|
("status", expected_status),
|
||||||
|
("session_id", expected_session_id),
|
||||||
|
):
|
||||||
|
if expected_value is None:
|
||||||
|
continue
|
||||||
|
current_value = record.get(field)
|
||||||
|
if str(current_value or "").strip() != str(expected_value).strip():
|
||||||
|
raise ControlPlaneError(
|
||||||
|
f"cannot retire lease {lease_id} worktree_path: lease "
|
||||||
|
f"{field} changed since audit (expected "
|
||||||
|
f"'{expected_value}', found '{current_value}'); fail closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
if expected_owner_pid is not None:
|
||||||
|
current_pid = record.get("owner_pid")
|
||||||
|
if current_pid is not None and int(current_pid) != int(expected_owner_pid):
|
||||||
|
raise ControlPlaneError(
|
||||||
|
f"cannot retire lease {lease_id} worktree_path: lease "
|
||||||
|
f"owner_pid changed since audit (expected "
|
||||||
|
f"{expected_owner_pid}, found {current_pid}); fail closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse and update provenance_json
|
||||||
|
raw_prov = record.get("provenance_json") or "{}"
|
||||||
|
try:
|
||||||
|
prov = json.loads(raw_prov) if isinstance(raw_prov, str) else dict(raw_prov)
|
||||||
|
except Exception:
|
||||||
|
prov = {}
|
||||||
|
if not isinstance(prov, dict):
|
||||||
|
prov = {}
|
||||||
|
|
||||||
|
prior_path = current_wt
|
||||||
|
prov.update({
|
||||||
|
"worktree_path_retired": True,
|
||||||
|
"retired_worktree_path": prior_path,
|
||||||
|
"retired_at": now_s,
|
||||||
|
"retirement_reason": reason,
|
||||||
|
"retired_from_status": record.get("status"),
|
||||||
|
"retired_from_session_id": record.get("session_id"),
|
||||||
|
"worktree_path": "",
|
||||||
|
})
|
||||||
|
prov_json = json.dumps(prov)
|
||||||
|
|
||||||
|
if "worktree_path" in cols:
|
||||||
|
# CAS: keyed on the exact stored path this caller audited.
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE leases SET worktree_path = '', provenance_json = ? "
|
||||||
|
"WHERE lease_id = ? AND worktree_path = ?",
|
||||||
|
(prov_json, lease_id, record.get("worktree_path")),
|
||||||
|
)
|
||||||
|
if cur.rowcount != 1:
|
||||||
|
raise ControlPlaneError(
|
||||||
|
f"cannot retire lease {lease_id} worktree_path: "
|
||||||
|
"compare-and-swap matched no row (concurrent change); "
|
||||||
|
"fail closed"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE leases SET provenance_json = ? WHERE lease_id = ?",
|
||||||
|
(prov_json, lease_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO events(work_item_id, event_type, message, created_at)
|
||||||
|
VALUES (?, 'worktree_binding_retired', ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
record["work_item_id"],
|
||||||
|
f"lease {lease_id} worktree_path '{prior_path}' retired: {reason}",
|
||||||
|
now_s,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"lease_id": lease_id,
|
||||||
|
"retired": True,
|
||||||
|
"already_retired": False,
|
||||||
|
"prior_worktree_path": prior_path,
|
||||||
|
"expected_worktree_path": expected_path,
|
||||||
|
"retired_at": now_s,
|
||||||
|
"reason": reason,
|
||||||
|
"compare_and_swap": {
|
||||||
|
"matched": True,
|
||||||
|
"outcome": "retired",
|
||||||
|
"expected_status": expected_status,
|
||||||
|
"expected_session_id": expected_session_id,
|
||||||
|
"expected_owner_pid": expected_owner_pid,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def abandon_lease(
|
def abandon_lease(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -3051,3 +3229,123 @@ class ControlPlaneDB:
|
|||||||
"live_lease_id": None if live_lease_id is None else str(live_lease_id),
|
"live_lease_id": None if live_lease_id is None else str(live_lease_id),
|
||||||
"reconcile_action": "reconcile_required" if stale else "safe_to_resume",
|
"reconcile_action": "reconcile_required" if stale else "safe_to_resume",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def retire_session_checkpoint_worktree_path(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
*,
|
||||||
|
checkpoint_id: str | None = None,
|
||||||
|
expected_path: str | None = None,
|
||||||
|
expected_status: str | None = None,
|
||||||
|
reason: str = "missing_worktree_path_retired",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Retire a missing worktree_path from session_checkpoints (#970).
|
||||||
|
|
||||||
|
Compare-and-swap, mirroring :meth:`retire_lease_worktree_path` (#970
|
||||||
|
review 644 B3). ``expected_path`` names the exact stored path the caller
|
||||||
|
audited; the guarded ``UPDATE`` is keyed on that stored value, so a
|
||||||
|
checkpoint whose path was moved, replaced, or concurrently rewritten
|
||||||
|
after the audit is refused without mutation rather than blindly cleared.
|
||||||
|
|
||||||
|
Exactly one checkpoint row is targeted: by ``checkpoint_id`` when given,
|
||||||
|
otherwise by ``session_id``, which must identify a single row.
|
||||||
|
"""
|
||||||
|
now_s = _ts()
|
||||||
|
expected_norm = _realpath_or_raw(expected_path)
|
||||||
|
if not expected_norm:
|
||||||
|
raise ControlPlaneError(
|
||||||
|
"cannot retire session checkpoint worktree_path: expected_path "
|
||||||
|
"is required for compare-and-swap retirement (fail closed)"
|
||||||
|
)
|
||||||
|
if not checkpoint_id and not (session_id or "").strip():
|
||||||
|
raise ControlPlaneError(
|
||||||
|
"cannot retire session checkpoint worktree_path: checkpoint_id "
|
||||||
|
"or session_id is required (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
with self._tx() as conn:
|
||||||
|
if checkpoint_id:
|
||||||
|
selector_sql = "SELECT * FROM session_checkpoints WHERE checkpoint_id = ?"
|
||||||
|
selector_params: tuple[Any, ...] = (checkpoint_id,)
|
||||||
|
selector_desc = f"checkpoint_id '{checkpoint_id}'"
|
||||||
|
else:
|
||||||
|
selector_sql = "SELECT * FROM session_checkpoints WHERE session_id = ?"
|
||||||
|
selector_params = (session_id,)
|
||||||
|
selector_desc = f"session_id '{session_id}'"
|
||||||
|
|
||||||
|
rows = [dict(r) for r in conn.execute(selector_sql, selector_params).fetchall()]
|
||||||
|
if not rows:
|
||||||
|
raise ControlPlaneError(
|
||||||
|
f"cannot retire session checkpoint worktree_path: no "
|
||||||
|
f"checkpoint matches {selector_desc} (fail closed)"
|
||||||
|
)
|
||||||
|
if len(rows) > 1:
|
||||||
|
raise ControlPlaneError(
|
||||||
|
f"cannot retire session checkpoint worktree_path: "
|
||||||
|
f"{selector_desc} matches {len(rows)} checkpoints; supply an "
|
||||||
|
"exact checkpoint_id (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
record = rows[0]
|
||||||
|
target_checkpoint_id = record.get("checkpoint_id")
|
||||||
|
current_wt = (record.get("worktree_path") or "").strip()
|
||||||
|
|
||||||
|
if not current_wt:
|
||||||
|
# Idempotent: the binding this caller audited is already gone.
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"checkpoint_id": target_checkpoint_id,
|
||||||
|
"retired": False,
|
||||||
|
"already_retired": True,
|
||||||
|
"prior_worktree_path": "",
|
||||||
|
"expected_worktree_path": expected_path,
|
||||||
|
"reason": reason,
|
||||||
|
"compare_and_swap": {
|
||||||
|
"matched": True,
|
||||||
|
"outcome": "already_retired",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _realpath_or_raw(current_wt) != expected_norm:
|
||||||
|
raise ControlPlaneError(
|
||||||
|
f"cannot retire session checkpoint worktree_path for "
|
||||||
|
f"{selector_desc}: expected '{expected_path}' does not match "
|
||||||
|
f"current '{current_wt}' (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if expected_status is not None:
|
||||||
|
current_status = record.get("status")
|
||||||
|
if str(current_status or "").strip() != str(expected_status).strip():
|
||||||
|
raise ControlPlaneError(
|
||||||
|
f"cannot retire session checkpoint worktree_path for "
|
||||||
|
f"{selector_desc}: status changed since audit (expected "
|
||||||
|
f"'{expected_status}', found '{current_status}'); fail closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE session_checkpoints SET worktree_path = '', updated_at = ? "
|
||||||
|
"WHERE checkpoint_id = ? AND worktree_path = ?",
|
||||||
|
(now_s, target_checkpoint_id, record.get("worktree_path")),
|
||||||
|
)
|
||||||
|
if cur.rowcount != 1:
|
||||||
|
raise ControlPlaneError(
|
||||||
|
f"cannot retire session checkpoint worktree_path for "
|
||||||
|
f"{selector_desc}: compare-and-swap matched no row "
|
||||||
|
"(concurrent change); fail closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"checkpoint_id": target_checkpoint_id,
|
||||||
|
"retired": True,
|
||||||
|
"already_retired": False,
|
||||||
|
"prior_worktree_path": current_wt,
|
||||||
|
"expected_worktree_path": expected_path,
|
||||||
|
"retired_at": now_s,
|
||||||
|
"reason": reason,
|
||||||
|
"compare_and_swap": {
|
||||||
|
"matched": True,
|
||||||
|
"outcome": "retired",
|
||||||
|
"expected_status": expected_status,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# The canonical author issue-lock contract (#953)
|
||||||
|
|
||||||
|
Every author issue lock has exactly one shape. Both writers —
|
||||||
|
`gitea_bootstrap_author_issue_worktree` and `gitea_lock_issue` — build it
|
||||||
|
through `author_lock_contract.build_canonical_issue_lock`, and every reader
|
||||||
|
consumes that same shape.
|
||||||
|
|
||||||
|
Before #953 the two writers disagreed. `gitea_lock_issue` wrote the canonical
|
||||||
|
record; bootstrap wrote a thinner one with the claimant at the lock top level,
|
||||||
|
`lease_id: null`, and no `work_lease`, `lock_provenance`, or expiry. Because
|
||||||
|
every reader was written against the canonical shape, a lock that bootstrap
|
||||||
|
reported as successfully created could not be heartbeated, renewed, re-locked,
|
||||||
|
or accepted by `gitea_create_pr`. Each of those gates was individually correct;
|
||||||
|
the defect was that two writers disagreed about what a lock *is*.
|
||||||
|
|
||||||
|
## Required ordering
|
||||||
|
|
||||||
|
**Finalize the lock before writing any implementation bytes.** This ordering is
|
||||||
|
what keeps recovery cheap: while the worktree is still base-equivalent, a lock
|
||||||
|
problem can be fixed by simply calling `gitea_lock_issue` again. Once the branch
|
||||||
|
carries commits, base-equivalence is gone and the ordinary re-lock path is no
|
||||||
|
longer available.
|
||||||
|
|
||||||
|
1. `gitea_whoami` — resolve identity and profile.
|
||||||
|
2. `gitea_resolve_task_capability(task='work_issue')`.
|
||||||
|
3. `gitea_bootstrap_author_issue_worktree` — creates the branch, the registered
|
||||||
|
worktree under `branches/`, and a **canonical** lock. It reads the lock back
|
||||||
|
and verifies it structurally before reporting success; a partial lock fails
|
||||||
|
closed here, with the missing fields named, and never reports
|
||||||
|
`implementation_allowed: true`.
|
||||||
|
4. `gitea_heartbeat_issue_lock` — prove the lock is usable, using the
|
||||||
|
`task_session_id` bootstrap returned.
|
||||||
|
5. Implement, commit, push.
|
||||||
|
6. `gitea_create_pr`.
|
||||||
|
|
||||||
|
If bootstrap returns `success: false` with
|
||||||
|
`reason_code: incomplete_issue_lock_contract`, **do not implement**. Its
|
||||||
|
`exact_next_action` names the executable recovery step. Bootstrap's reported
|
||||||
|
next action always matches the state it actually returned.
|
||||||
|
|
||||||
|
### What that refusal leaves behind
|
||||||
|
|
||||||
|
The AC7 refusal runs `run_compensating_recovery` *before* it reports, so the
|
||||||
|
advice has to describe the post-rollback state rather than the shape of the lock
|
||||||
|
that provoked it. Recommending incomplete-lock recovery for artifacts the
|
||||||
|
rollback already deleted would produce `no_durable_lock` and then
|
||||||
|
`worktree_invalid` — two refusals for a state a plain retry fixes.
|
||||||
|
|
||||||
|
The refusal therefore carries `compensating_recovery` and
|
||||||
|
`post_compensation_state`, and derives `exact_next_action` from what was
|
||||||
|
observed on disk. `cleanup_state` is one of:
|
||||||
|
|
||||||
|
| `cleanup_state` | Meaning | Next action |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `complete` | lock, branch, and worktree all removed | resolve `missing_fields` and re-run `gitea_bootstrap_author_issue_worktree` |
|
||||||
|
| `partial` | rollback ran; some artifacts survive, by design or because a step errored | scoped to exactly what survives — see below |
|
||||||
|
| `failed` | rollback never completed, so nothing is proven removed | `gitea_inspect_issue_lock_contract` (read-only) before anything else |
|
||||||
|
|
||||||
|
Within `partial`, the surviving set decides the action:
|
||||||
|
|
||||||
|
| Survives | Next action |
|
||||||
|
| --- | --- |
|
||||||
|
| lock + branch + worktree | `gitea_recover_incomplete_bootstrap_lock` for that exact issue, branch, and worktree |
|
||||||
|
| branch + worktree (lock released) | `gitea_lock_issue` — no implementation bytes were written, so the worktree is still base-equivalent |
|
||||||
|
| lock only (worktree removed) | `gitea_inspect_issue_lock_contract`; the surviving lock must be released by its recorded owner before bootstrap is retried |
|
||||||
|
| branch only | `gitea_inspect_issue_lock_contract`, then re-run bootstrap, which adopts the existing branch |
|
||||||
|
|
||||||
|
`failed_rollback_steps` names any rollback step that errored, and the returned
|
||||||
|
action says so rather than presenting the surviving state as intentional.
|
||||||
|
|
||||||
|
> The lock half of that rollback was dead code until #953 review 632 F2:
|
||||||
|
> `run_compensating_recovery` called `issue_lock_store.release_session_lock`,
|
||||||
|
> which did not exist, inside a bare `except Exception: pass`. Every rollback
|
||||||
|
> removed the branch and worktree and silently left the lock — the exact
|
||||||
|
> uninspectable, unrecoverable state this issue exists to eliminate. The
|
||||||
|
> function now exists, releases only a lock whose recorded `owner_session`
|
||||||
|
> matches, and its failures are recorded rather than swallowed.
|
||||||
|
|
||||||
|
## The contract
|
||||||
|
|
||||||
|
A canonical lock carries every field in
|
||||||
|
`author_lock_contract.REQUIRED_LOCK_FIELDS`:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `remote`, `org`, `repo`, `issue_number` | repository and issue identity |
|
||||||
|
| `branch_name`, `worktree_path` | the binding this claim owns |
|
||||||
|
| `work_lease` | the canonical lease block, below |
|
||||||
|
| `lock_provenance` | sanctioned source, minted server-side |
|
||||||
|
| `lock_generation` | monotonic; every write advances it |
|
||||||
|
|
||||||
|
`work_lease` carries every field in
|
||||||
|
`author_lock_contract.REQUIRED_WORK_LEASE_FIELDS`, notably:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `claimant.{username,profile}` | **canonical** claimant placement |
|
||||||
|
| `expires_at` | sliding TTL from `lease_policy` |
|
||||||
|
| `last_heartbeat_at`, `heartbeat_count` | liveness evidence |
|
||||||
|
| `task_session_id` | the ownership fencing token — never null |
|
||||||
|
| `lifecycle_version` | `heartbeat-v1`; its absence is what makes a lock legacy |
|
||||||
|
|
||||||
|
### Claimant placement and legacy compatibility
|
||||||
|
|
||||||
|
`work_lease.claimant` is canonical. A top-level `claimant` is the legacy
|
||||||
|
placement written by pre-#953 bootstrap and is still **read** — through the one
|
||||||
|
shared reader, `issue_lock_store.lock_claimant` — so an existing lock is not
|
||||||
|
refused for "not recording a claimant" when it plainly records one.
|
||||||
|
|
||||||
|
Tolerating the placement is not a widening. Every caller still compares the
|
||||||
|
values against server-resolved identity and profile, so a legacy placement
|
||||||
|
grants nothing the canonical placement would not. When both are present, the
|
||||||
|
`work_lease` copy wins: after an upgrade, a stale top-level copy must never
|
||||||
|
decide ownership.
|
||||||
|
|
||||||
|
### Expiration is explicit
|
||||||
|
|
||||||
|
A lock with no recorded expiry is **not** "not yet expired". `is_lease_expired`
|
||||||
|
returns `False` for it, which used to make such a lock permanently non-expiring
|
||||||
|
*and* permanently ineligible for #760 exact-owner renewal, which only ever
|
||||||
|
assesses an expired lease. `author_lock_contract.expiration_state` names the
|
||||||
|
real fact: `recorded`, `missing`, or `unparseable`. A `missing` expiry makes the
|
||||||
|
lock eligible for the recovery path below rather than stranding it.
|
||||||
|
|
||||||
|
## Recovering an existing incomplete bootstrap lock
|
||||||
|
|
||||||
|
For locks already written by the old bootstrap — including those whose branches
|
||||||
|
already carry legitimate committed and pushed work — use:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gitea_inspect_issue_lock_contract(issue_number, branch_name, worktree_path, remote=...)
|
||||||
|
gitea_recover_incomplete_bootstrap_lock(issue_number, branch_name, worktree_path, expected_head, remote=...)
|
||||||
|
```
|
||||||
|
|
||||||
|
`gitea_inspect_issue_lock_contract` is strictly read-only: it performs no lock,
|
||||||
|
lease, branch, worktree, issue, or pull-request mutation. Use it first to see
|
||||||
|
which fields are missing and what the recommended action is; pass `dry_run=True`
|
||||||
|
to the recovery tool to preview the decision without writing.
|
||||||
|
|
||||||
|
`gitea_recover_incomplete_bootstrap_lock` upgrades that one lock to the
|
||||||
|
canonical contract. Before writing anything it verifies:
|
||||||
|
|
||||||
|
* repository (`remote`, `org`, `repo`) and issue number
|
||||||
|
* claimant username **and** profile against the server-resolved values — a
|
||||||
|
matching username alone is never accepted
|
||||||
|
* branch, worktree path, worktree existence, and worktree registration
|
||||||
|
* the worktree is on the recorded branch
|
||||||
|
* the observed head equals the caller's `expected_head`
|
||||||
|
* the existing lock's generation and provenance state
|
||||||
|
* the absence of healthy foreign ownership
|
||||||
|
|
||||||
|
What it deliberately does **not** do:
|
||||||
|
|
||||||
|
* it never moves, resets, or rewinds the branch, and never requires
|
||||||
|
base-equivalence — preserving the committed work is the entire point;
|
||||||
|
* it never pushes and never creates a pull request;
|
||||||
|
* it touches only the single lock file for that exact remote/org/repo/issue;
|
||||||
|
* it accepts no caller-supplied provenance and no caller-supplied authorization
|
||||||
|
flag — both are minted server-side.
|
||||||
|
|
||||||
|
A recovered lock records a `bootstrap_lock_recovery` block holding both sides of
|
||||||
|
the transition — prior contract, prior missing fields, prior generation, prior
|
||||||
|
owning session, the replacement `task_session_id`, and the preserved head — so a
|
||||||
|
recovered claim never reads as an original one.
|
||||||
|
|
||||||
|
### Gates, in order
|
||||||
|
|
||||||
|
`gitea_recover_incomplete_bootstrap_lock` is an author-only durable-lock
|
||||||
|
mutation and carries the same three gates as every comparable author operation,
|
||||||
|
in this order:
|
||||||
|
|
||||||
|
1. `role_session_router.check_author_mutation_after_reviewer_stop` — no author
|
||||||
|
fallback after a reviewer `wrong_role_stop`.
|
||||||
|
2. `_namespace_mutation_block(task, remote=remote, author_role_exclusive=True)` —
|
||||||
|
the namespace wall. It refuses a reviewer-bound session and, because this
|
||||||
|
task's required permission is `gitea.issue.comment` (which merger,
|
||||||
|
controller, and reconciler profiles also hold), additionally requires the
|
||||||
|
active profile's derived role kind to be exactly `author`. A refusal carries
|
||||||
|
`namespace_block: true` and emits a `BLOCKED` audit record naming the
|
||||||
|
namespace and profile.
|
||||||
|
3. `_profile_permission_block` — operation, provenance, and session-context
|
||||||
|
gates.
|
||||||
|
|
||||||
|
Exact-owner claimant matching inside `assess_bootstrap_lock_recovery` runs
|
||||||
|
*after* all three. It is a further layer, never a substitute for them: on its
|
||||||
|
own it refuses one step too late and leaves the audit trail silent about the
|
||||||
|
attempt.
|
||||||
|
|
||||||
|
### Refusals
|
||||||
|
|
||||||
|
| `refusal_code` | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `no_durable_lock` | nothing to recover |
|
||||||
|
| `already_canonical` | lock is fine; rewriting would invalidate a live heartbeat token |
|
||||||
|
| `foreign_claimant` | recorded claimant is not the active identity/profile pair |
|
||||||
|
| `healthy_foreign_lock` | a live foreign-owned lock; takeover is not a recovery path |
|
||||||
|
| `identity_unresolved` | identity or profile could not be resolved |
|
||||||
|
| `binding_mismatch` | repository, issue, branch, or worktree does not match |
|
||||||
|
| `worktree_invalid` | worktree missing, unregistered, or on another branch |
|
||||||
|
| `head_mismatch` | the worktree moved under the caller |
|
||||||
|
|
||||||
|
## The #447 create-PR provenance guard is unchanged
|
||||||
|
|
||||||
|
`issue_lock_provenance.assess_lock_file_for_create_pr` still requires both a
|
||||||
|
sanctioned `lock_provenance` and a `work_lease`, and the sanctioned source set
|
||||||
|
was **not** widened. Bootstrap writes through
|
||||||
|
`issue_lock_provenance.SOURCE_LOCK_ISSUE` — the lock it produces *is* a
|
||||||
|
canonical lock, not a second dialect with its own exemption. Bootstrap now
|
||||||
|
satisfies the guard rather than the guard being relaxed to admit bootstrap.
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# Instance-level fleet identity and health snapshots
|
||||||
|
|
||||||
|
Issue **#978**. Companion primitives: **#948** (worker ownership), **#975**
|
||||||
|
(heartbeat lifecycle), **#951** (restart receipts).
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
The multi-instance fleet gate (#963) needs a *native* answer to:
|
||||||
|
|
||||||
|
* which application launches are live;
|
||||||
|
* which of the five namespace workers belong to each launch;
|
||||||
|
* whether two Codex (or Claude, Grok, …) launches are distinct;
|
||||||
|
* whether any live collision is real rather than a shared client type.
|
||||||
|
|
||||||
|
Process tables, PID proximity, configuration files, and direct SQLite access
|
||||||
|
are **not** production evidence. The sanctioned surface is the read-only MCP
|
||||||
|
tool `gitea_snapshot_instance_fleet` on **controller** and **reconciler**
|
||||||
|
namespaces.
|
||||||
|
|
||||||
|
## Identity hierarchy
|
||||||
|
|
||||||
|
| Identity | Scope | Who generates it | Lifetime |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `client_type` | Application family (`codex`, `claude_code`, `gemini`, `grok`, …) | Launcher sets `GITEA_MCP_CLIENT` | Stable for the product |
|
||||||
|
| `client_instance_id` | One running application launch | **Trusted launcher**, once per launch, as `GITEA_MCP_CLIENT_INSTANCE` | Fresh launch → new ID; reconnect of same launch → same ID; full app restart → new ID |
|
||||||
|
| `fleet_run_id` | Operator-approved enrollment / canary cohort | Operator / controller sets `GITEA_MCP_FLEET_RUN_ID` | Duration of the approved rollout |
|
||||||
|
| `namespace` | `author` \| `reviewer` \| `merger` \| `controller` \| `reconciler` | Profile / MCP server binding | Process lifetime |
|
||||||
|
| `worker_id` / `worker_identity` | One namespace worker process | Runtime registry at first registration | New on worker restart; not reused |
|
||||||
|
| `session_id` | Client session ownership | Launcher `GITEA_MCP_CLIENT_SESSION` or runtime | Session lifetime |
|
||||||
|
| `generation_id` | One daemon launch | Runtime at process boot | New on process restart |
|
||||||
|
| `process_identity` / PID | OS process | Runtime | Process lifetime |
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
1. Multiple active instances **may** share the same `client_type`.
|
||||||
|
2. Every application launch receives a **distinct** `client_instance_id`.
|
||||||
|
3. All five namespace workers of one launch report the **same**
|
||||||
|
`client_instance_id`.
|
||||||
|
4. Each namespace worker has a **distinct** `worker_identity`, process
|
||||||
|
identity, generation, and PID.
|
||||||
|
5. Instance identity is **never** inferred from PID proximity, timestamps, or
|
||||||
|
client type alone.
|
||||||
|
6. Live reuse of one `client_instance_id` with conflicting workers fails closed.
|
||||||
|
7. Sharing only a profile or `client_type` is **not** a duplicate.
|
||||||
|
|
||||||
|
This deliberately **replaces** any permanent `exactly_one_per_profile` fleet
|
||||||
|
model (#949 assumption) as the operating rule for multi-instance fleets.
|
||||||
|
|
||||||
|
## How five workers join one instance
|
||||||
|
|
||||||
|
1. The host starts one application instance (for example one Codex session).
|
||||||
|
2. The **production application launcher**
|
||||||
|
(`mcp_application_launcher.build_application_mcp_servers` /
|
||||||
|
`gitea_config.multi_namespace_launcher_entries`) mints exactly one
|
||||||
|
`client_instance_id` via `mcp_fleet_snapshot.generate_client_instance_id`
|
||||||
|
and injects the same env into every namespace worker:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GITEA_MCP_CLIENT=<client_type>
|
||||||
|
GITEA_MCP_CLIENT_INSTANCE=<client_instance_id>
|
||||||
|
GITEA_MCP_INSTANCE_PROVENANCE=trusted_launcher
|
||||||
|
GITEA_MCP_CLIENT_SESSION=<session_id> # optional but recommended
|
||||||
|
GITEA_MCP_FLEET_RUN_ID=<enrollment id> # when on an approved canary
|
||||||
|
GITEA_CLIENT_MANAGED=1
|
||||||
|
GITEA_MCP_PROFILE=<role-profile>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. The MCP client starts the five namespace processes (`gitea-author`,
|
||||||
|
`gitea-reviewer`, `gitea-merger`, `gitea-controller`, `gitea-reconciler`)
|
||||||
|
from that generated `mcpServers` map — each entry carries the **same**
|
||||||
|
instance ID.
|
||||||
|
4. Each worker registers once into the worker registry with its own
|
||||||
|
`worker_identity`, `namespace`, `generation_id`, and PID, but the shared
|
||||||
|
`client_instance_id`. Workers never mint a trusted instance ID themselves.
|
||||||
|
|
||||||
|
Only IDs matching the launcher format `inst-<client>-<timestamp>-<digest>`
|
||||||
|
are trusted. Missing, `legacy-pid-*` / `pid-*` placeholders, and ordinary
|
||||||
|
user-supplied strings fail closed as untrusted and cannot authorize
|
||||||
|
multi-instance fleet mutation safety.
|
||||||
|
|
||||||
|
Worker reconnect (same process, same registration) keeps the instance ID.
|
||||||
|
Worker restart (new process) mints a new worker identity and generation but
|
||||||
|
must still receive the same `GITEA_MCP_CLIENT_INSTANCE` from the parent
|
||||||
|
application if it is the same launch. Full application restart mints a new
|
||||||
|
`client_instance_id` (call the launcher without a prior ID).
|
||||||
|
|
||||||
|
### Mutation gate (instance-aware)
|
||||||
|
|
||||||
|
The capability / runtime diagnostic gate
|
||||||
|
(`_check_mcp_runtimes_diagnostics`) is **instance-aware**:
|
||||||
|
|
||||||
|
* Two legitimate application instances that share a profile (same
|
||||||
|
`GITEA_MCP_PROFILE`) are allowed when each has a distinct trusted
|
||||||
|
`client_instance_id`.
|
||||||
|
* More than one live worker for the same
|
||||||
|
`(client_instance_id, profile/namespace)` fails closed as a duplicate
|
||||||
|
namespace worker.
|
||||||
|
* Multiple processes sharing a profile **without** trusted instance
|
||||||
|
evidence still fail closed (indistinguishable from a duplicate).
|
||||||
|
|
||||||
|
## Approved fleet manifest
|
||||||
|
|
||||||
|
An operator (or controller enrollment step) obtains an approved manifest as a
|
||||||
|
list of expected instances, for example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"instances": [
|
||||||
|
{
|
||||||
|
"client_type": "codex",
|
||||||
|
"client_instance_id": "inst-codex-20260730T120000Z-abc123def456",
|
||||||
|
"fleet_run_id": "canary-963-2026-07-30",
|
||||||
|
"namespaces": ["author", "reviewer", "merger", "controller", "reconciler"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_type": "codex",
|
||||||
|
"client_instance_id": "inst-codex-20260730T120100Z-fed654cba321",
|
||||||
|
"fleet_run_id": "canary-963-2026-07-30",
|
||||||
|
"namespaces": ["author", "reviewer", "merger", "controller", "reconciler"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass that list as `expected_manifest` to `gitea_snapshot_instance_fleet`.
|
||||||
|
When a manifest is supplied:
|
||||||
|
|
||||||
|
* instances on the manifest but not live → `missing_expected`;
|
||||||
|
* live instances not on the manifest → `unmanifested`;
|
||||||
|
* both are **active blockers** for fleet-gate safety.
|
||||||
|
|
||||||
|
Without a manifest, the snapshot still enumerates the live fleet and classifies
|
||||||
|
identity collisions; it does not invent enrollment policy.
|
||||||
|
|
||||||
|
## Snapshot consistency
|
||||||
|
|
||||||
|
Each snapshot includes:
|
||||||
|
|
||||||
|
* `snapshot_at` — UTC timestamp;
|
||||||
|
* `consistency_token` / `registry_revision` — content digest over worker
|
||||||
|
identity, instance id, status, heartbeat, fencing, and generation.
|
||||||
|
|
||||||
|
Two successive snapshots can prove heartbeat continuity via
|
||||||
|
`mcp_fleet_snapshot.compare_snapshot_heartbeats`.
|
||||||
|
|
||||||
|
## Classification (active vs historical)
|
||||||
|
|
||||||
|
**Active blockers** (make `live_fleet_safe=false`):
|
||||||
|
|
||||||
|
* missing expected instance;
|
||||||
|
* unmanifested extra instance;
|
||||||
|
* duplicate namespace worker within one instance;
|
||||||
|
* live `client_instance_id` collision;
|
||||||
|
* reused worker / session / generation / process / PID / fencing identity;
|
||||||
|
* orphaned or unowned workers;
|
||||||
|
* unknown client;
|
||||||
|
* foreign-repository workers;
|
||||||
|
* old-revision workers;
|
||||||
|
* stale workers still marked active;
|
||||||
|
* legacy incomplete instance identity.
|
||||||
|
|
||||||
|
**Historical dead rows** (`status` released/superseded) are reported as
|
||||||
|
`historical_dead` findings with `active_blocker=false`. They never
|
||||||
|
automatically make the live fleet unsafe.
|
||||||
|
|
||||||
|
## Fail-closed behaviour and recovery
|
||||||
|
|
||||||
|
| Condition | Mutation safety | Diagnostic reads | Recovery |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Two instances, same `client_type`, distinct IDs | Safe (if otherwise healthy) | Available | None needed |
|
||||||
|
| Live reuse of one `client_instance_id` | Unsafe | Available | Stop the colliding launch or re-issue a distinct ID |
|
||||||
|
| Two workers, same namespace, one instance | Unsafe | Available | Stop the extra worker |
|
||||||
|
| Legacy registration without trusted instance ID | Unsafe for fleet mutation | Available | Relaunch with launcher-issued `GITEA_MCP_CLIENT_INSTANCE` |
|
||||||
|
| Historical dead row only | Does not block alone | Available | No action required for fleet safety |
|
||||||
|
| Registry unavailable | Snapshot fails closed | N/A | Repair registry path / reconnect namespaces |
|
||||||
|
|
||||||
|
Incomplete or untrusted instance identity **cannot** authorize unsafe
|
||||||
|
mutation. Diagnostic reads remain available where `gitea.read` allows.
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
* **Allowed:** `controller`, `reconciler` with `gitea.read`.
|
||||||
|
* **Denied:** author, reviewer, merger (even with `gitea.read` for other tools).
|
||||||
|
* **No new mutation permissions** are granted to any role.
|
||||||
|
|
||||||
|
## Related surfaces
|
||||||
|
|
||||||
|
* `mcp_worker_identity` — registry, worker identity, heartbeats (#948, #975).
|
||||||
|
* `mcp_fleet_snapshot` — pure snapshot + classification (#978).
|
||||||
|
* `gitea_snapshot_instance_fleet` — sanctioned MCP tool (#978).
|
||||||
|
* `gitea_get_runtime_context` — single-process view (not fleet-wide).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
* Starting the #963 canary.
|
||||||
|
* Purging historical registry rows.
|
||||||
|
* Preserving “exactly one process per profile” as the permanent model.
|
||||||
|
* Using shell / process-table / SQLite inspection as production fleet evidence.
|
||||||
@@ -86,3 +86,74 @@ When a namespace returns EOF, follow
|
|||||||
|
|
||||||
When blocked, repair the IDE namespace and re-record a healthy
|
When blocked, repair the IDE namespace and re-record a healthy
|
||||||
`client_namespace` assessment before retrying the mutation.
|
`client_namespace` assessment before retrying the mutation.
|
||||||
|
|
||||||
|
## Connected vs Attached Tool Surface (#708)
|
||||||
|
|
||||||
|
MCP servers can report **Connected** at the CLI / host inventory layer while the **active LLM session exposes none of their tool namespaces**.
|
||||||
|
|
||||||
|
### Core principle
|
||||||
|
|
||||||
|
* **Connected status at host layer ≠ attached tools in active session.**
|
||||||
|
* Required preflight proof is **live tool visibility + `gitea_whoami` call** through the target namespace, not host `Connected` status alone.
|
||||||
|
* When servers report Connected but namespaces are absent from attached tools, classify as `mcp_connected_namespaces_missing`.
|
||||||
|
|
||||||
|
### Forbidden unsafe fallbacks
|
||||||
|
|
||||||
|
When `mcp_connected_namespaces_missing` is detected, workflows must **fail closed** and must **never** encourage or perform:
|
||||||
|
|
||||||
|
* direct imports of MCP server Python modules
|
||||||
|
* CLI or raw Gitea API mutations as a substitute for native tools
|
||||||
|
* profile hopping to another MCP profile/namespace to bypass the empty session
|
||||||
|
* session-state overrides or hand-edited session/ledger files
|
||||||
|
* process kills (`pkill`), config mtime touches, or `.env` edits
|
||||||
|
|
||||||
|
Only sanctioned recovery: **client reconnect path**, followed by full preflight (`whoami` → capability resolve → task).
|
||||||
|
|
||||||
|
### Native detection tool
|
||||||
|
|
||||||
|
`gitea_assess_mcp_namespace_attachment` classifies the condition and records it in
|
||||||
|
the session. Pass the namespaces the host reports Connected and the namespaces
|
||||||
|
actually attached to the active session tool surface:
|
||||||
|
|
||||||
|
| Argument | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `connected_servers` | Namespaces the host/CLI reports Connected |
|
||||||
|
| `attached_session_namespaces` | Namespaces exposed in the active session tool surface |
|
||||||
|
| `required_namespaces` | Namespaces this workflow needs (defaults to the role namespaces) |
|
||||||
|
| `discovery_cache_hit` / `discovery_cache_age_seconds` | Client tool-discovery cache state |
|
||||||
|
| `auto_attach_attempted` / `auto_attach_succeeded` | Whether the runtime auto-attached |
|
||||||
|
| `session_tool_snapshot_at` / `namespace_connected_at` | Epoch seconds, to detect startup ordering races |
|
||||||
|
|
||||||
|
It returns `discovery_status`
|
||||||
|
(`namespaces_attached` | `connected_but_namespaces_missing` | `disconnected`),
|
||||||
|
`missing_namespaces`, `proof_of_connected_vs_attached` (per namespace
|
||||||
|
`{connected, attached}`), `error_type`, `reconnect_required`, `auto_recovered`,
|
||||||
|
`startup_ordering_race`, `late_attaching_namespaces`, `sanctioned_recovery_tool`,
|
||||||
|
and a reconnect-only `exact_next_action`.
|
||||||
|
|
||||||
|
### Startup ordering
|
||||||
|
|
||||||
|
`session_tool_snapshot_at` earlier than a namespace's `namespace_connected_at`
|
||||||
|
means that namespace could not have been in the session snapshot, however healthy
|
||||||
|
it looks now. That is reported as `startup_ordering_race` with the affected
|
||||||
|
namespaces listed — the multi-role parallel-connect case where Connected flips
|
||||||
|
true after the session tool list was already captured.
|
||||||
|
|
||||||
|
### Fail-closed gate
|
||||||
|
|
||||||
|
The recorded verdict gates mutations, mirroring the #543 health gate: a namespace
|
||||||
|
that has not been assessed does not gate, but one recorded Connected-but-unattached
|
||||||
|
blocks the mutation whose role namespace it is
|
||||||
|
(`review_pr` / `submit_review` → `gitea-reviewer`, `merge_pr` → `gitea-merger`,
|
||||||
|
`work_issue` / `create_pr` → `gitea-author`). `gitea_submit_pr_review` and
|
||||||
|
`gitea_merge_pr` return the block reason plus the hard-stop policy string, and the
|
||||||
|
only offered recovery is `gitea_request_mcp_reconnect`.
|
||||||
|
|
||||||
|
### Telemetry
|
||||||
|
|
||||||
|
The `telemetry` block carries `connected_count`, `attached_count`,
|
||||||
|
`required_count`, `missing_count`, `discovery_status`, `discovery_cache_hit`,
|
||||||
|
`discovery_cache_age_seconds`, `reconnect_required`, `auto_attach_attempted`,
|
||||||
|
`auto_recovered`, `startup_ordering_race`, and `error_type`. It contains namespace
|
||||||
|
names and counts only — never tokens, endpoints, env values, or filesystem paths.
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ that gates each call, not which tools exist.
|
|||||||
- `gitea_assess_conflict_fix_push`
|
- `gitea_assess_conflict_fix_push`
|
||||||
- `gitea_assess_gitea_operation_path`
|
- `gitea_assess_gitea_operation_path`
|
||||||
- `gitea_assess_master_parity`
|
- `gitea_assess_master_parity`
|
||||||
|
- `gitea_assess_mcp_namespace_attachment`
|
||||||
- `gitea_assess_mcp_namespace_health`
|
- `gitea_assess_mcp_namespace_health`
|
||||||
- `gitea_assess_pr_sync_status`
|
- `gitea_assess_pr_sync_status`
|
||||||
- `gitea_assess_review_merge_state_machine`
|
- `gitea_assess_review_merge_state_machine`
|
||||||
@@ -64,6 +65,7 @@ that gates each call, not which tools exist.
|
|||||||
- `gitea_assess_work_issue_duplicate`
|
- `gitea_assess_work_issue_duplicate`
|
||||||
- `gitea_assess_worktree_cleanup_integrity`
|
- `gitea_assess_worktree_cleanup_integrity`
|
||||||
- `gitea_audit_config`
|
- `gitea_audit_config`
|
||||||
|
- `gitea_audit_missing_worktree_bindings`
|
||||||
- `gitea_audit_runtime_recovery_contamination`
|
- `gitea_audit_runtime_recovery_contamination`
|
||||||
- `gitea_audit_stable_branch_contamination`
|
- `gitea_audit_stable_branch_contamination`
|
||||||
- `gitea_audit_worktree_cleanup`
|
- `gitea_audit_worktree_cleanup`
|
||||||
@@ -103,6 +105,7 @@ that gates each call, not which tools exist.
|
|||||||
- `gitea_get_shell_health`
|
- `gitea_get_shell_health`
|
||||||
- `gitea_heartbeat_issue_lock`
|
- `gitea_heartbeat_issue_lock`
|
||||||
- `gitea_heartbeat_reviewer_pr_lease`
|
- `gitea_heartbeat_reviewer_pr_lease`
|
||||||
|
- `gitea_inspect_issue_lock_contract`
|
||||||
- `gitea_inspect_workflow_lease`
|
- `gitea_inspect_workflow_lease`
|
||||||
- `gitea_issue_irrecoverable_provenance_authorization`
|
- `gitea_issue_irrecoverable_provenance_authorization`
|
||||||
- `gitea_list_dependency_edges`
|
- `gitea_list_dependency_edges`
|
||||||
@@ -124,16 +127,21 @@ that gates each call, not which tools exist.
|
|||||||
- `gitea_post_heartbeat`
|
- `gitea_post_heartbeat`
|
||||||
- `gitea_publish_unpublished_issue_branch`
|
- `gitea_publish_unpublished_issue_branch`
|
||||||
- `gitea_quarantine_contaminated_review`
|
- `gitea_quarantine_contaminated_review`
|
||||||
|
- `gitea_rebind_dirty_same_claimant_author_session`
|
||||||
- `gitea_reclaim_expired_workflow_lease`
|
- `gitea_reclaim_expired_workflow_lease`
|
||||||
|
- `gitea_reconcile_after_restart`
|
||||||
- `gitea_reconcile_already_landed_pr`
|
- `gitea_reconcile_already_landed_pr`
|
||||||
- `gitea_reconcile_issue_claims`
|
- `gitea_reconcile_issue_claims`
|
||||||
- `gitea_reconcile_merged_cleanups`
|
- `gitea_reconcile_merged_cleanups`
|
||||||
|
- `gitea_reconcile_missing_worktree_bindings`
|
||||||
- `gitea_reconcile_superseded_by_merged_pr`
|
- `gitea_reconcile_superseded_by_merged_pr`
|
||||||
- `gitea_record_daemon_process_kill_attempt`
|
- `gitea_record_daemon_process_kill_attempt`
|
||||||
- `gitea_record_irrecoverable_decision_lock_provenance`
|
- `gitea_record_irrecoverable_decision_lock_provenance`
|
||||||
- `gitea_record_pre_review_command`
|
- `gitea_record_pre_review_command`
|
||||||
- `gitea_record_shell_spawn_outcome`
|
- `gitea_record_shell_spawn_outcome`
|
||||||
- `gitea_record_stable_branch_push_attempt`
|
- `gitea_record_stable_branch_push_attempt`
|
||||||
|
- `gitea_recover_dirty_orphaned_issue_worktree`
|
||||||
|
- `gitea_recover_incomplete_bootstrap_lock`
|
||||||
- `gitea_release_merger_pr_lease`
|
- `gitea_release_merger_pr_lease`
|
||||||
- `gitea_release_reviewer_pr_lease`
|
- `gitea_release_reviewer_pr_lease`
|
||||||
- `gitea_release_workflow_lease`
|
- `gitea_release_workflow_lease`
|
||||||
@@ -151,6 +159,7 @@ that gates each call, not which tools exist.
|
|||||||
- `gitea_sentry_reconcile_issue`
|
- `gitea_sentry_reconcile_issue`
|
||||||
- `gitea_sentry_watchdog`
|
- `gitea_sentry_watchdog`
|
||||||
- `gitea_set_issue_labels`
|
- `gitea_set_issue_labels`
|
||||||
|
- `gitea_snapshot_instance_fleet`
|
||||||
- `gitea_submit_pr_review`
|
- `gitea_submit_pr_review`
|
||||||
- `gitea_update_pr_branch_by_merge`
|
- `gitea_update_pr_branch_by_merge`
|
||||||
- `gitea_validate_review_final_report`
|
- `gitea_validate_review_final_report`
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
{
|
||||||
|
"_comment": [
|
||||||
|
"Machine-checkable anchor table for docs/remote-mcp/threat-model.md (#956).",
|
||||||
|
"Every file:line anchor cited in the threat model must appear here, and the",
|
||||||
|
"source line at that anchor must contain the 'expect' substring.",
|
||||||
|
"tests/test_issue_956_threat_model.py enforces both directions, so a refactor",
|
||||||
|
"that shifts a line number fails the suite instead of silently rotting the",
|
||||||
|
"document. #930's inventory had no such guard and its gitea_mcp_server.py",
|
||||||
|
"anchors drifted between 7bf4f125 and aad5c8b4."
|
||||||
|
],
|
||||||
|
"generated_against_commit": "1dd30ecb1508b559868c2d5d94367bc055d5138e",
|
||||||
|
"anchors": [
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:25087",
|
||||||
|
"expect": "mcp_daemon_guard.bind_native_mcp_transport()"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "mcp_daemon_guard.py:49",
|
||||||
|
"expect": "_PRODUCTION_TRANSPORTS = mcp_transport_config.SUPPORTED_TRANSPORTS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "mcp_daemon_guard.py:195",
|
||||||
|
"expect": "def bind_native_mcp_transport"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "irrecoverable_provenance.py:497",
|
||||||
|
"expect": "def assess_transport_for_auth_mint"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:9192",
|
||||||
|
"expect": "assess_transport_for_auth_mint()"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:9441",
|
||||||
|
"expect": "assess_transport_for_auth_mint()"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "mcp_server.py:4",
|
||||||
|
"expect": "The transport is selected by deployment configuration"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:15630",
|
||||||
|
"expect": "def _is_client_managed_process"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:15644",
|
||||||
|
"expect": "def _provenance_mutation_block"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:15652",
|
||||||
|
"expect": "unsupported_manual_launch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:19217",
|
||||||
|
"expect": "server_provenance"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:21741",
|
||||||
|
"expect": "def _check_mcp_runtimes_diagnostics"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:21761",
|
||||||
|
"expect": "\"ps\", \"-o\", \"pid,lstart,command\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:21805",
|
||||||
|
"expect": "\"ps\", \"eww\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_config.py:1172",
|
||||||
|
"expect": "RECOGNIZED_GITEA_ENV_KEYS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_config.py:1233",
|
||||||
|
"expect": "GITEA_CLIENT_MANAGED"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_config.py:54",
|
||||||
|
"expect": "ENV_PROFILE = \"GITEA_MCP_PROFILE\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_config.py:97",
|
||||||
|
"expect": "_REVIEW_MERGE_OPS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_config.py:499",
|
||||||
|
"expect": "repository authorization scope"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_config.py:956",
|
||||||
|
"expect": "def _keychain_token"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_config.py:974",
|
||||||
|
"expect": "def resolve_token"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_config.py:1015",
|
||||||
|
"expect": "def keychain_auth"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_config.py:294",
|
||||||
|
"expect": "def _validate_identity_auth"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "mcp_daemon_guard.py:583",
|
||||||
|
"expect": "def assert_keychain_access_allowed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:19487",
|
||||||
|
"expect": "def gitea_list_profiles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:19538",
|
||||||
|
"expect": "gitea_config.resolve_token(p)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:19851",
|
||||||
|
"expect": "def gitea_audit_config"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:19873",
|
||||||
|
"expect": "service_summaries(config)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_config.py:704",
|
||||||
|
"expect": "def resolve_service"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_config.py:837",
|
||||||
|
"expect": "def service_summaries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_config.py:851",
|
||||||
|
"expect": "_keychain_token(auth.get(\"id\"))"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:17909",
|
||||||
|
"expect": "\"jenkins-mcp\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:17915",
|
||||||
|
"expect": "external-mcp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:17936",
|
||||||
|
"expect": "\"glitchtip-mcp\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:17941",
|
||||||
|
"expect": "external-mcp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "mcp_discoverability.py:9",
|
||||||
|
"expect": "EXPECTED_JENKINS_TOOLS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "mcp_discoverability.py:17",
|
||||||
|
"expect": "EXPECTED_GLITCHTIP_TOOLS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "sentry_incident_bridge.py:36",
|
||||||
|
"expect": "SENTRY_AUTH_TOKEN"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "sentry_incident_bridge.py:190",
|
||||||
|
"expect": "def resolve_token"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "sentry_incident_bridge.py:289",
|
||||||
|
"expect": "Authorization"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "sentry_observability.py:55",
|
||||||
|
"expect": "SENTRY_DSN"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "master_parity_gate.py:168",
|
||||||
|
"expect": "def capture_startup_parity"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "master_parity_gate.py:255",
|
||||||
|
"expect": "mutation_safe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:19331",
|
||||||
|
"expect": "def gitea_assess_master_parity"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:193",
|
||||||
|
"expect": "ACTIVE_WORKTREE_ENV"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:194",
|
||||||
|
"expect": "AUTHOR_WORKTREE_ENV"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:2352",
|
||||||
|
"expect": "/tmp/gitea_issue_lock.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:10957",
|
||||||
|
"expect": "def gitea_bootstrap_author_issue_worktree"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "mcp_server.py:13",
|
||||||
|
"expect": "/tmp/mcp_server_stderr.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "issue_lock_store.py:26",
|
||||||
|
"expect": "DEFAULT_LOCK_DIR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "issue_lock_store.py:83",
|
||||||
|
"expect": "def session_pointer_path"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "issue_lock_store.py:98",
|
||||||
|
"expect": "def is_process_alive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "mcp_session_state.py:27",
|
||||||
|
"expect": "DEFAULT_STATE_DIR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "control_plane_db.py:47",
|
||||||
|
"expect": "DEFAULT_DB_PATH"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "control_plane_db.py:380",
|
||||||
|
"expect": "mode=0o700"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "control_plane_db.py:386",
|
||||||
|
"expect": "sqlite3.connect"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "control_plane_db.py:1145",
|
||||||
|
"expect": "os.getpid()"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor": "gitea_mcp_server.py:12871",
|
||||||
|
"expect": "owner_pid_alive"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
# Remote-MCP threat model, trust boundaries, and service decomposition
|
||||||
|
|
||||||
|
What the adversary is, what each boundary protects, and which services may share a process.
|
||||||
|
|
||||||
|
- **Issue:** #956 (Remote-MCP threat model), child of epic #929, cross-linked to #955.
|
||||||
|
- **Depends on:** #930 (closed) — `docs/remote-mcp/coupling-inventory.md`.
|
||||||
|
- **Blocks:** #932, #933, #934, #938.
|
||||||
|
- **Generated against commit:** `1dd30ecb1508b559868c2d5d94367bc055d5138e` (#708's
|
||||||
|
namespace-attachment gate). Originally generated against
|
||||||
|
`aad5c8b42361d380a8eeb07b94b90815e594c2c5` (`master`), re-anchored at
|
||||||
|
`a143cd065ba06e1a2bdc5143a19ec156e53650ef` when #931's transport bind seam shifted the
|
||||||
|
cited lines, and re-anchored again when #708 shifted them further.
|
||||||
|
- **Scope:** documentation only. This child changes no server behavior. It adds one
|
||||||
|
document, one anchor fixture, and the test that enforces them.
|
||||||
|
|
||||||
|
## Relationship to #930
|
||||||
|
|
||||||
|
#930 asked *what breaks when the process stops being local*. This document asks *what an
|
||||||
|
attacker gets, and where we stop them*. The two are deliberately different axes: #930
|
||||||
|
classifies each coupling as portable, seam, replacement, or cannot-be-remote; this document
|
||||||
|
classifies each **credential** by blast radius and each **boundary** by what crossing it
|
||||||
|
requires. An entry can be perfectly portable and still be a trust disaster —
|
||||||
|
`gitea_config.py:851` is portable Python that reads a CI secret from inside the Gitea server.
|
||||||
|
|
||||||
|
### Anchors are enforced, not asserted
|
||||||
|
|
||||||
|
Every `file:line` in this document is declared in `docs/remote-mcp/threat-model-anchors.json`
|
||||||
|
with the substring that must appear at that line, and
|
||||||
|
`tests/test_issue_956_threat_model.py` fails if any anchor does not resolve or if the
|
||||||
|
document cites an anchor the fixture does not cover.
|
||||||
|
|
||||||
|
This guard exists because #930 did not have one. Its inventory was generated at
|
||||||
|
`7bf4f125`; by `aad5c8b4` its `gitea_mcp_server.py` anchors had drifted — the transport
|
||||||
|
bind it cited at line 23750 now lives at `gitea_mcp_server.py:25087`, and its
|
||||||
|
client-managed provenance anchor at 14588 now lands in an unrelated function. Nothing
|
||||||
|
failed, because nothing checked. Anchors into a ~24,700-line module rot silently, and a
|
||||||
|
security document that cannot prove its own citations is worse than none, because it is
|
||||||
|
trusted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Assets
|
||||||
|
|
||||||
|
What an adversary wants. Ordered by consequence, not by likelihood.
|
||||||
|
|
||||||
|
| ID | Asset | Why it matters |
|
||||||
|
| -- | ----- | -------------- |
|
||||||
|
| A1 | Merge authority on `Scaled-Tech-Consulting/Gitea-Tools` | This repository *is* the control plane. Code merged here becomes the gate that authorizes every future mutation, so merge authority is self-amplifying: one merge can disable every other control in this document. |
|
||||||
|
| A2 | Write authority on the `mdcps` tenant | A second, unrelated organization reachable from the same configuration. Compromise here is a cross-organization incident, not an internal one. |
|
||||||
|
| A3 | The eight Gitea role credentials | Long-lived bearer tokens. Possession is authority; there is no second factor at the API. |
|
||||||
|
| A4 | Jenkins read access (`mdcps`, enabled) | Build logs routinely carry deployment topology, internal hostnames, and accidentally-echoed secrets. |
|
||||||
|
| A5 | Error-tracking read access (GlitchTip / Sentry) | Event payloads carry stack frames, request context, and production user data. |
|
||||||
|
| A6 | Coordination-state integrity | The locks, leases, and review-decision records that make "exactly one owner" true. Corrupting them needs no Gitea credential and produces duplicate or lost work. |
|
||||||
|
| A7 | The operator's checkout and worktrees | Unmerged code, branch state, and the filesystem the author tools write to. |
|
||||||
|
| A8 | The macOS login keychain | The meta-credential. Everything in A3, A4, and A5 resolves from it. |
|
||||||
|
| A9 | Separation of duty between review and merge | The property that no single actor both approves and lands a change. An *asset*, not a control, because it is what the controls exist to produce. |
|
||||||
|
| A10 | Audit and provenance records | Determine whether an incident is reconstructable. An attacker who can forge provenance makes an intrusion indistinguishable from normal work. |
|
||||||
|
|
||||||
|
## 2. Adversaries
|
||||||
|
|
||||||
|
| ID | Adversary | Capability assumed | Not assumed |
|
||||||
|
| -- | --------- | ------------------ | ----------- |
|
||||||
|
| ADV1 | **Compromised LLM client** | Full control of one MCP client. Issues arbitrary tool calls, in any order, with any arguments, at machine speed. Sees every tool result. | Cannot read the operator's disk except through tools; cannot execute arbitrary local code outside the tool surface. |
|
||||||
|
| ADV2 | **Prompt injection** via repository content | Controls text the model reads and treats as instruction — issue bodies, PR descriptions, review comments, commit messages, file contents. Reaches the model on any read of untrusted content. | Holds no credential and issues no call directly. Its entire power is causing an *authorized* client to act. |
|
||||||
|
| ADV3 | **Malicious tool arguments** | Supplies hostile values to any parameter — paths, branch names, session identifiers, worktree paths, issue numbers — including traversal, injection, and confusion between look-alike identifiers. | Cannot bypass a gate that actually validates its input. |
|
||||||
|
| ADV4 | **Network attacker** | Observes and modifies traffic between client, server, and Gitea. Attempts downgrade, replay, and endpoint impersonation. | Does not hold a valid credential at the start. |
|
||||||
|
| ADV5 | **Curious operator** | Legitimate local access to the workstation: process table, `/tmp`, home directory, keychain prompts. Not malicious, but not authorized for every role either. | Does not defeat the OS keychain's own access control without a prompt. |
|
||||||
|
|
||||||
|
ADV2 is the adversary this architecture most under-models. Every other adversary must first
|
||||||
|
obtain something. Prompt injection obtains nothing: it borrows authority the client already
|
||||||
|
holds and is indistinguishable at the tool boundary from legitimate work. Each boundary
|
||||||
|
below therefore states whether it constrains ADV2 at all — and most do not, because they
|
||||||
|
authenticate the *caller*, not the *intent*.
|
||||||
|
|
||||||
|
## 3. Trust boundaries
|
||||||
|
|
||||||
|
"Crossing requires today" is what the code actually enforces at
|
||||||
|
`1dd30ecb1508b559868c2d5d94367bc055d5138e`, not what the design intends.
|
||||||
|
|
||||||
|
| ID | Boundary | Protects | Crossing requires today | Crossing must require remotely |
|
||||||
|
| -- | -------- | -------- | ----------------------- | ------------------------------ |
|
||||||
|
| B1 | LLM client ↔ MCP server session | A1, A3, A10 — that a mutating session was established through the sanctioned client path | A single configured bind (`gitea_mcp_server.py:25087`) validated against one closed allowlist (`mcp_daemon_guard.py:49`, `mcp_daemon_guard.py:195`) — since #931 the identifier comes from deployment configuration and defaults to the local transport, so the boundary no longer rests on a literal, but it still rests on the *bind* rather than on an authenticated caller; client-managed provenance (`gitea_mcp_server.py:15630`) or a refusal (`gitea_mcp_server.py:15652`); production transport before recovery-authorization mint (`irrecoverable_provenance.py:497`, consumed at `gitea_mcp_server.py:9192` and `gitea_mcp_server.py:9441`) | An authenticated handshake issuing a server-side session identity bound to a principal, with the transport recorded in provenance. The physical proof (a pipe) must become a cryptographic one. |
|
||||||
|
| B2 | Role ↔ role | A9 — that author, reviewer, merger, and reconciler are distinct authorities | **The process boundary only.** The role is a property of the process, read once from `GITEA_MCP_PROFILE` (`gitea_config.py:54`). A caller gets author permissions by connecting to the author process. Review and merge are the operations singled out for extra care (`gitea_config.py:97`) | A per-request principal, so the role follows from the credential presented and cannot be selected by reaching a different endpoint. |
|
||||||
|
| B3 | MCP server ↔ credential store | A3, A8 — that only sanctioned code turns a profile into a token | `_keychain_token` shelling out to the login keychain (`gitea_config.py:956`), dispatched by `resolve_token` (`gitea_config.py:974`) with the reference type built at `gitea_config.py:1015`, gated by `assert_keychain_access_allowed` (`mcp_daemon_guard.py:583`). Inline secrets are rejected at config load (`gitea_config.py:294`) | A credential provider keyed by the *request* principal, returning only that principal's credential, with the source recorded and the value never returned. |
|
||||||
|
| B4 | MCP server ↔ Gitea | A1, A2 — that only authorized calls reach the forge | A bearer token over TLS. Server-side, nothing distinguishes one role's token from another beyond the account it belongs to | Unchanged at the forge; the endpoint in front of it must refuse unauthenticated and plaintext connections before tool dispatch. |
|
||||||
|
| B5 | MCP server ↔ caller's filesystem | A7 — that a tool acts on the *caller's* disk or refuses | Nothing. The server's disk *is* the caller's disk. Worktree bootstrap writes directly (`gitea_mcp_server.py:10957`); the active workspace is process-global (`gitea_mcp_server.py:193`, `gitea_mcp_server.py:194`) | An explicit per-tool classification, enforced at dispatch, refusing filesystem tools over a transport that cannot reach the caller's disk. A green verdict about the wrong disk is the failure to prevent. |
|
||||||
|
| B6 | MCP server ↔ coordination state | A6, A9 — mutual exclusion | Local files and a local SQLite database, with liveness judged from the local process table (`issue_lock_store.py:98`), keyed on paths under one user's home (`issue_lock_store.py:26`, `mcp_session_state.py:27`, `control_plane_db.py:47`) and on `os.getpid()` (`control_plane_db.py:1145`, `gitea_mcp_server.py:12871`). A legacy global slot still exists at `gitea_mcp_server.py:2352`, and the session-pointer file is named per PID (`issue_lock_store.py:83`) | One authority per ownership question, with liveness from session identity and expiry, and atomic acquire, renew, and release across hosts. |
|
||||||
|
| B7 | Gitea integration ↔ unrelated integrations | A4, A5 — that a Gitea compromise is not a CI and observability compromise | **Nothing.** See §5. The Gitea server reads Jenkins and GlitchTip secrets (`gitea_config.py:851`, reached from `gitea_config.py:837`) and holds the Sentry token (`sentry_incident_bridge.py:190`) | A hard process boundary. This is the boundary #956 exists to create. |
|
||||||
|
| B8 | Tenant ↔ tenant (`prgs` / `mdcps` / `local-lab`) | A2 — that one organization's compromise is not another's | Convention. One configuration declares all three contexts; `resolve_service` fails closed on a *disabled* context (`gitea_config.py:704`) but the credentials of enabled ones remain reachable in-process. A per-profile repository scope exists (`gitea_config.py:499`) | Separate deployments, or at minimum per-tenant credential scopes with no process able to resolve both. |
|
||||||
|
| B9 | Deployed code ↔ merged policy | A1, A10 — that the running server enforces the rules that were actually merged | Comparing this process's startup commit against this disk (`master_parity_gate.py:168`), conjoined into a single verdict (`master_parity_gate.py:255`) published by `gitea_mcp_server.py:19331` | Freshness defined against the deployed build identity, with an explicit fail-closed verdict when undeterminable. |
|
||||||
|
|
||||||
|
### What no boundary constrains
|
||||||
|
|
||||||
|
None of B1–B9 constrains **ADV2**. Every one authenticates a caller or a process; prompt
|
||||||
|
injection supplies neither. An injected instruction that reaches an authorized author
|
||||||
|
session crosses B1, B2, B3, and B5 legitimately, because at each of those boundaries it *is*
|
||||||
|
the author. The only controls that bite ADV2 are those constraining what an authenticated
|
||||||
|
principal may do regardless of what it asks for — the per-role permission split (B2), the
|
||||||
|
repository scope at `gitea_config.py:499`, and separation of duty (A9). Sizing those
|
||||||
|
controls correctly matters more after the migration, not less, because a remote endpoint
|
||||||
|
raises the number of clients that can be injected into.
|
||||||
|
|
||||||
|
## 4. Data flows
|
||||||
|
|
||||||
|
Flows that cross a boundary. `==>` carries a credential; `-->` does not.
|
||||||
|
|
||||||
|
```
|
||||||
|
B1 B4
|
||||||
|
[LLM client] ====================> [MCP server] ========> [Gitea]
|
||||||
|
^ stdio pipe today | ^ (A1,A2)
|
||||||
|
| session identity | |
|
||||||
|
| after migration | |
|
||||||
|
| | | B3
|
||||||
|
untrusted repository content | +======> [macOS login keychain] (A8)
|
||||||
|
read back into the model (ADV2) | resolves A3, A4, A5
|
||||||
|
^ |
|
||||||
|
+----------------------------------+
|
||||||
|
|
|
||||||
|
B5 | B6
|
||||||
|
[operator checkout / worktrees] <--------+-------> [locks · leases · sqlite]
|
||||||
|
(A7) | (A6)
|
||||||
|
|
|
||||||
|
B7 <-- boundary does not exist today
|
||||||
|
|
|
||||||
|
+========================+========================+
|
||||||
|
| | |
|
||||||
|
[Jenkins] (A4) [GlitchTip] (A5) [Sentry] (A5)
|
||||||
|
external MCP server external MCP server in-process bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
Two flows deserve attention because neither is obvious from the code:
|
||||||
|
|
||||||
|
1. **The keychain flow fans out.** B3 is drawn once but resolves credentials for *every*
|
||||||
|
configured profile and service, not only the active one. `gitea_list_profiles`
|
||||||
|
(`gitea_mcp_server.py:19487`) reports each profile's credential status by calling
|
||||||
|
`resolve_token` on it (`gitea_mcp_server.py:19538`), and `gitea_audit_config`
|
||||||
|
(`gitea_mcp_server.py:19851`) reports service credential status through
|
||||||
|
`service_summaries` (`gitea_mcp_server.py:19873`).
|
||||||
|
2. **The return path is a flow too.** Content read from Gitea travels back into the model
|
||||||
|
and is treated as instruction. This is the ADV2 edge, and it is the only edge in the
|
||||||
|
diagram with no authentication on it, because it is not a request.
|
||||||
|
|
||||||
|
## 5. Per-boundary credential inventory
|
||||||
|
|
||||||
|
**14 credentials in total.** Blast radius is stated as what the credential yields *on its
|
||||||
|
own*, assuming every gate not backed by the credential itself has been bypassed — because
|
||||||
|
an attacker holding a token calls the API, not our tools.
|
||||||
|
|
||||||
|
| ID | Credential | Holder | Boundary | Blast radius |
|
||||||
|
| -- | ---------- | ------ | -------- | ------------ |
|
||||||
|
| CR1 | `prgs-author` Gitea token — account `jcwalker3` | macOS keychain; resolved in-process (`gitea_config.py:974`) | B3 → B4 | Create branches, push, commit, open PRs, create/close/comment issues on the control-plane repo. Cannot approve or merge. The one credential whose identity is genuinely distinct. |
|
||||||
|
| CR2 | `prgs-reviewer` Gitea token — account `sysadmin` | macOS keychain | B3 → B4 | Approve and request changes. **Shares one Gitea account with CR3, CR4, CR5.** |
|
||||||
|
| CR3 | `prgs-merger` Gitea token — account `sysadmin` | macOS keychain | B3 → B4 | Merge to `master` — A1 in full. Same account as CR2. |
|
||||||
|
| CR4 | `prgs-reconciler` Gitea token — account `sysadmin` | macOS keychain | B3 → B4 | Close PRs, delete branches, irrecoverable decision-lock recovery. Same account as CR2. |
|
||||||
|
| CR5 | `prgs-controller` Gitea token — account `sysadmin` | macOS keychain | B3 → B4 | Same operation set as CR4. Same account as CR2. |
|
||||||
|
| CR6 | `mdcps-author` Gitea token — account `913443` | macOS keychain | B3 → B4, B8 | Author operations on a second organization. **Shares one account with CR7 and CR8.** |
|
||||||
|
| CR7 | `mdcps-reviewer` Gitea token — account `913443` | macOS keychain | B3 → B4, B8 | Approve and request changes on `mdcps`. Same account as CR6. |
|
||||||
|
| CR8 | `mdcps-merger` Gitea token — account `913443` | macOS keychain | B3 → B4, B8 | Merge on `mdcps` — A2 in full. Same account as CR6. |
|
||||||
|
| CR9 | MDCPS Jenkins read credential | macOS keychain, read from the Gitea server process (`gitea_config.py:851`) | B7 | Read CI jobs, builds, and logs (A4). Enabled today. |
|
||||||
|
| CR10 | MDCPS GlitchTip read credential | macOS keychain, read from the Gitea server process (`gitea_config.py:851`) | B7 | Read error events and their payloads (A5). Enabled today. |
|
||||||
|
| CR11 | `SENTRY_AUTH_TOKEN` | Process environment, read in-process (`sentry_incident_bridge.py:36`, `sentry_incident_bridge.py:190`), sent as a bearer header (`sentry_incident_bridge.py:289`) | B7 | Read and reconcile Sentry issues (A5). Not a keychain credential — an env var, so it is inherited by anything the process spawns. |
|
||||||
|
| CR12 | `SENTRY_DSN` | Process environment (`sentry_observability.py:55`) | B7 | Write events into the observability project. Low read value, real forgery value: an attacker can inject fabricated events into the record (A10). |
|
||||||
|
| CR13 | macOS login keychain access | The operator's login session; gated by `assert_keychain_access_allowed` (`mcp_daemon_guard.py:583`) | B3, ADV5 | **Every other credential in this table except CR11 and CR12.** This is the aggregation point. |
|
||||||
|
| CR14 | Coordination-store access (no secret) | Filesystem permissions — `control_plane_db.py:47`, created `0o700` (`control_plane_db.py:380`), opened with a local file lock (`control_plane_db.py:386`) | B6, ADV5 | Full read/write of locks, leases, and decision records (A6). **There is no credential here at all** — anything running as the operator can rewrite ownership. |
|
||||||
|
|
||||||
|
### Findings
|
||||||
|
|
||||||
|
**Finding 1 — Role separation is not credential separation.** Four `prgs` roles resolve to
|
||||||
|
one Gitea account (`sysadmin`): reviewer, merger, reconciler, and controller. A stolen
|
||||||
|
reviewer credential *is* a merger credential. A9 — separation of duty between approving and
|
||||||
|
landing — is therefore enforced entirely by which local process a call reaches (B2), and not
|
||||||
|
at all by the forge. It survives exactly as long as B2 does, and B2 is the boundary the
|
||||||
|
migration dissolves.
|
||||||
|
|
||||||
|
**Finding 2 — The `mdcps` tenant has no role separation at all.** Author, reviewer, and
|
||||||
|
merger all resolve to account `913443`. One credential can open a PR, approve it, and merge
|
||||||
|
it. The in-process self-review check compares the authenticated username against the PR
|
||||||
|
author and would refuse — but that check runs on our side of B4. It is not a property of
|
||||||
|
the credential, and an attacker holding the token does not call our tools.
|
||||||
|
|
||||||
|
**Finding 3 — Any one role process can resolve every other role's credential.** This is not
|
||||||
|
inferred; it is demonstrated by tool output. `gitea_list_profiles`
|
||||||
|
(`gitea_mcp_server.py:19487`) called from the **author** session reports
|
||||||
|
`identity_status: "credentials present"` for `prgs-merger`, `prgs-reviewer`,
|
||||||
|
`prgs-reconciler`, and every `mdcps` profile, because it calls `resolve_token` on each one
|
||||||
|
(`gitea_mcp_server.py:19538`). The author process does not merely *have access to* the
|
||||||
|
merger's credential — it reads it to answer a status query. B2 is not a credential boundary
|
||||||
|
in either direction.
|
||||||
|
|
||||||
|
**Finding 4 — The Gitea server reads CI and observability secrets.** `gitea_audit_config`
|
||||||
|
(`gitea_mcp_server.py:19851`) reports `MDCPS Jenkins: enabled, read-only, authenticated`.
|
||||||
|
That word `authenticated` is produced by `service_summaries` (`gitea_mcp_server.py:19873`,
|
||||||
|
defined at `gitea_config.py:837`), whose default check calls `_keychain_token` on the
|
||||||
|
service's own keychain reference (`gitea_config.py:851`). Producing that one line requires
|
||||||
|
the Gitea MCP server to read the Jenkins secret and the GlitchTip secret out of the
|
||||||
|
keychain. B7 does not exist.
|
||||||
|
|
||||||
|
**Finding 5 — Jenkins and GlitchTip are already decomposed; the reach is residual.** Their
|
||||||
|
tools live in separately registered servers, marked `external-mcp`
|
||||||
|
(`gitea_mcp_server.py:17909`, `gitea_mcp_server.py:17915`, `gitea_mcp_server.py:17936`,
|
||||||
|
`gitea_mcp_server.py:17941`) with their own expected tool sets (`mcp_discoverability.py:9`,
|
||||||
|
`mcp_discoverability.py:17`). The correct decomposition was already chosen. What remains is
|
||||||
|
a leak across it: the credential *references* still live in the Gitea configuration and are
|
||||||
|
still resolved by the Gitea process. #75 bundled these services into one control-plane
|
||||||
|
umbrella; the tools were separated afterwards, the credentials were not.
|
||||||
|
|
||||||
|
**Finding 6 — Sentry is the exception that is not decomposed.** Unlike Jenkins and
|
||||||
|
GlitchTip, the Sentry bridge runs *inside* the Gitea server, resolving its token from the
|
||||||
|
process environment (`sentry_incident_bridge.py:190`) and sending it as a bearer header
|
||||||
|
(`sentry_incident_bridge.py:289`). Being an environment variable rather than a keychain item
|
||||||
|
makes it strictly worse: it needs no keychain prompt and is inherited by every subprocess the
|
||||||
|
server spawns — including the `ps` invocations at `gitea_mcp_server.py:21761` and
|
||||||
|
`gitea_mcp_server.py:21805`, reached from `gitea_mcp_server.py:21741`.
|
||||||
|
|
||||||
|
**Finding 7 — The highest-value coordination asset has the weakest gate.** A6 is protected
|
||||||
|
by filesystem permissions alone (CR14). Corrupting a lease requires no Gitea credential,
|
||||||
|
produces no forge-side audit record, and breaks the mutual exclusion the entire workflow
|
||||||
|
assumes. Every other asset costs an attacker a credential; this one costs nothing beyond
|
||||||
|
local access, which is exactly ADV5's position.
|
||||||
|
|
||||||
|
**Finding 8 — Provenance authenticates the launch, not the caller.** `server_provenance` is
|
||||||
|
reported as exactly `client_managed` or `manual_launch` (`gitea_mcp_server.py:19217`),
|
||||||
|
derived from environment inspection (`gitea_mcp_server.py:15630`) with the recognized-key
|
||||||
|
allowlist at `gitea_config.py:1172` and the generator that emits the marker at
|
||||||
|
`gitea_config.py:1233`. Every one of those facts is fixed at process start. A client that is
|
||||||
|
trustworthy at launch and compromised a minute later remains `client_managed` for the life
|
||||||
|
of the process, and the transport contract that underwrites it is stated as a property of
|
||||||
|
the server itself (`mcp_server.py:4`). Since #931 that contract names the configured
|
||||||
|
transport rather than asserting stdio, but it is still fixed once, at bind, for the life of
|
||||||
|
the process.
|
||||||
|
|
||||||
|
## 6. Decomposition ruling
|
||||||
|
|
||||||
|
This section is the ruling #956 requires. It is a decision, not a recommendation.
|
||||||
|
|
||||||
|
**D1 — No unrelated co-residency.** A single integration process **must not** hold, resolve,
|
||||||
|
or be able to resolve credentials for services it does not itself integrate with.
|
||||||
|
Concretely: the Gitea MCP service may hold Gitea credentials and nothing else. Jenkins,
|
||||||
|
GlitchTip, Sentry, and any database credential are **not permitted** to co-reside with Gitea
|
||||||
|
credentials in one process.
|
||||||
|
|
||||||
|
*Rationale.* A process is the smallest unit an attacker takes whole. Once ADV1 or ADV2
|
||||||
|
controls execution in a process, every credential that process can resolve is theirs, and no
|
||||||
|
in-process check helps, because the checks are in the process too. Blast radius is therefore
|
||||||
|
a property of the process boundary and nothing finer. Findings 4 and 6 show that today one
|
||||||
|
compromise of the Gitea server yields CI read access, error-tracking read access, and — via
|
||||||
|
CR13 — every role credential on both tenants. That is the single largest reduction in blast
|
||||||
|
radius available anywhere in epic #929, and it costs no new mechanism: the decomposition
|
||||||
|
already exists (Finding 5) and is merely leaked across.
|
||||||
|
|
||||||
|
**D2 — Separation of duty must be backed by credentials.** Two roles whose separation is a
|
||||||
|
security property must not resolve to the same forge account. Specifically, reviewer and
|
||||||
|
merger must be distinct accounts. Today they are not, on either tenant (Findings 1 and 2).
|
||||||
|
|
||||||
|
*Rationale.* B2 is a process boundary, and the migration's entire purpose is to replace
|
||||||
|
process boundaries with request-level ones. A separation enforced only by which process a
|
||||||
|
call reaches does not survive that replacement — and it is already bypassable by anyone who
|
||||||
|
holds the token and calls the API instead of the tool.
|
||||||
|
|
||||||
|
**D3 — Credential resolution is scoped to the request principal.** A session must resolve its
|
||||||
|
own credential and must have no path to any other principal's. The resolve-every-profile
|
||||||
|
behavior behind `gitea_mcp_server.py:19538` and `gitea_mcp_server.py:19873` must report
|
||||||
|
configured-or-not from configuration alone, without resolving the secret.
|
||||||
|
|
||||||
|
*Rationale.* Finding 3. An audit surface that proves a credential exists by fetching it is a
|
||||||
|
credential-aggregation primitive wearing a diagnostic's clothes.
|
||||||
|
|
||||||
|
**D4 — Coordination state is a protected asset with its own authority.** Access to locks,
|
||||||
|
leases, and decision records must require an authenticated session, not merely local
|
||||||
|
filesystem access.
|
||||||
|
|
||||||
|
*Rationale.* Finding 7. #937 already moves this store for concurrency reasons; the
|
||||||
|
authorization requirement must land with it, or the store becomes remotely reachable while
|
||||||
|
still being authorized by nothing.
|
||||||
|
|
||||||
|
### Exceptions
|
||||||
|
|
||||||
|
**One, time-boxed.** During the dual-run window defined by #939, the **local** stdio fleet
|
||||||
|
may continue to resolve Jenkins and GlitchTip credential *references* from the shared
|
||||||
|
configuration, because removing them from the local configuration is not a prerequisite for
|
||||||
|
standing up the remote endpoint and would strand the operator's existing local workflow.
|
||||||
|
|
||||||
|
This exception is bounded by all of:
|
||||||
|
|
||||||
|
- It applies to the local stdio deployment only. The remote endpoint (#938) must be
|
||||||
|
configured with Gitea credentials and no others from its first day.
|
||||||
|
- It expires when #939 completes. It does not survive cutover.
|
||||||
|
- It does not extend to Sentry: CR11 and CR12 are process-environment credentials in the
|
||||||
|
Gitea server (Finding 6) and must be absent from the remote deployment's environment
|
||||||
|
regardless of dual-run state.
|
||||||
|
|
||||||
|
No exception is granted to D2, D3, or D4.
|
||||||
|
|
||||||
|
### Consequences for the target architecture
|
||||||
|
|
||||||
|
- The remote endpoint serves **Gitea only**. It is not a general control-plane endpoint.
|
||||||
|
- Jenkins and GlitchTip keep their existing separate servers, and their credential
|
||||||
|
references move out of the Gitea configuration.
|
||||||
|
- The Sentry bridge either moves behind its own service boundary or is absent from the
|
||||||
|
remote deployment. It does not travel with the Gitea server.
|
||||||
|
- Reviewer and merger accounts diverge before the endpoint is trusted for merges, or A9 is
|
||||||
|
recorded as unenforced.
|
||||||
|
|
||||||
|
## 7. Child-to-boundary mapping
|
||||||
|
|
||||||
|
Every #929 child from 2 through 10, mapped to the boundary it implements. A child
|
||||||
|
implementing more than one boundary names its primary first.
|
||||||
|
|
||||||
|
| Child | Issue | Boundaries | What it must establish | Rulings it must honor |
|
||||||
|
| ----: | ----- | ---------- | ---------------------- | --------------------- |
|
||||||
|
| 2 | #931 | B1, B9 | The bound transport becomes a validated value that provenance and freshness can both key on. Without it neither B1 nor B9 has an input. | — |
|
||||||
|
| 3 | #932 | B2 | The role becomes a property of the request, not the process — the boundary the migration otherwise deletes. | D2, D3 |
|
||||||
|
| 4 | #933 | B3, B7 | Credentials come from a provider keyed by principal. This is where D1 and D3 are either enforced or permanently lost. | D1, D3 |
|
||||||
|
| 5 | #934 | B1 | Session provenance replaces pipe-and-process-table proof with an authenticated session identity. | — |
|
||||||
|
| 6 | #935 | B9 | Freshness redefined against deployed build identity, with an explicit undeterminable verdict. | — |
|
||||||
|
| 7 | #936 | B5 | Every tool classified and the filesystem boundary enforced at dispatch, so a tool cannot return green about the wrong disk. | — |
|
||||||
|
| 8 | #937 | B6 | One authority per ownership question, with session-identity liveness and atomic transitions. | D4 |
|
||||||
|
| 9 | #938 | B4, B1, B8 | The endpoint: authentication, principal binding, transport security, and — critically — the deployed credential set. | D1, D2, D3 |
|
||||||
|
| 10 | #939 | B6 | Dual-run with exactly one coordination authority at every instant, and the rollback that proves the way back. | D1 exception expiry |
|
||||||
|
|
||||||
|
Boundary coverage: B1 (#931, #934, #938), B2 (#932), B3 (#933), B4 (#938), B5 (#936),
|
||||||
|
B6 (#937, #939), B7 (#933), B8 (#938), B9 (#931, #935).
|
||||||
|
|
||||||
|
B7 has exactly one owner, #933, and that is deliberate. B7 is not created by standing up an
|
||||||
|
endpoint; it is created by deciding which credentials a process may resolve, which is
|
||||||
|
precisely what the credential-provider child does.
|
||||||
|
|
||||||
|
## 8. Adversarial walkthrough: a fully compromised LLM client
|
||||||
|
|
||||||
|
The required negative case. ADV1 holds one MCP client and issues arbitrary tool calls. What
|
||||||
|
does it reach at each boundary?
|
||||||
|
|
||||||
|
### Before the migration
|
||||||
|
|
||||||
|
The client is attached to the local fleet over stdio.
|
||||||
|
|
||||||
|
| Boundary | What ADV1 reaches | Stopped by |
|
||||||
|
| -------- | ----------------- | ---------- |
|
||||||
|
| B1 | Everything the fleet serves. The client *is* the sanctioned launcher: it satisfies the client-managed check (`gitea_mcp_server.py:15630`) by construction, and provenance is never re-verified after launch (Finding 8). | Nothing. The guard authenticates the launch, not the caller. |
|
||||||
|
| B2 | All five roles — it is attached to all five namespaces. It can author a PR, approve it from the reviewer namespace, and merge it from the merger namespace. | Only the in-process self-review check, which compares `jcwalker3` (author) against `sysadmin` (reviewer) and **passes**, because Finding 1 made them different accounts while leaving reviewer and merger identical. A9 falls in one sequence of legitimate calls. |
|
||||||
|
| B3 | Every credential in CR1–CR10 via CR13, with no additional prompt — the daemon is already sanctioned, so `assert_keychain_access_allowed` (`mcp_daemon_guard.py:583`) returns immediately. | Nothing. |
|
||||||
|
| B4 | A1 and A2 in full. | Branch protection at the forge, to the extent configured. |
|
||||||
|
| B5 | The operator's checkout and every worktree, through the author tools (`gitea_mcp_server.py:10957`), plus the shared stderr path at `mcp_server.py:13`. | Nothing; the server's disk is the target disk. |
|
||||||
|
| B6 | All coordination state — no credential required (CR14). It can forge lease ownership and clear decision locks. | Filesystem permissions, which it already satisfies. |
|
||||||
|
| B7 | Jenkins (A4) and GlitchTip (A5) secrets via Finding 4, and CR11/CR12 from its own environment. | Nothing. |
|
||||||
|
| B8 | Both tenants. | Nothing in-process; only the disabled-context check (`gitea_config.py:704`), which does not apply to enabled contexts. |
|
||||||
|
| B9 | Can it run stale code? Only by advancing the checkout, which parity detects (`master_parity_gate.py:255`). | This gate holds — the one boundary that behaves as designed against ADV1. |
|
||||||
|
|
||||||
|
**Result: total.** One compromised client yields A1 through A8 and A10. The only asset with
|
||||||
|
real resistance is A1 via branch protection, and the client holds the merger credential
|
||||||
|
anyway. Nine boundaries, one meaningful stop.
|
||||||
|
|
||||||
|
### After the migration
|
||||||
|
|
||||||
|
The same client authenticates to the remote endpoint with one role's credential, assuming
|
||||||
|
#931–#939 land **and honor D1–D4**.
|
||||||
|
|
||||||
|
| Boundary | What ADV1 reaches | Stopped by |
|
||||||
|
| -------- | ----------------- | ---------- |
|
||||||
|
| B1 | One authenticated session, bound to one principal. | #934: a forged or expired session identity is refused; the client cannot mint one. |
|
||||||
|
| B2 | **One role.** Presenting the author credential yields author permissions only. | #932: the principal comes from the credential, not from which endpoint was reached. |
|
||||||
|
| B3 | **One credential — its own.** | #933 with D3: the provider resolves by principal, and no diagnostic resolves the others. |
|
||||||
|
| B4 | That role's authority on the forge. | Endpoint authentication (#938); plaintext and unauthenticated attempts refused before dispatch. |
|
||||||
|
| B5 | **Nothing.** Filesystem tools are refused over the remote transport with a named blocker. | #936. |
|
||||||
|
| B6 | Its own leases; contention resolves to exactly one winner. | #937 with D4: authenticated session required, not filesystem access. |
|
||||||
|
| B7 | **Nothing.** No CI or observability credential exists in the process. | D1 — the single largest reduction on this table. |
|
||||||
|
| B8 | One tenant. | D1 and #938: the deployment carries one tenant's credentials. |
|
||||||
|
| B9 | Cannot induce stale enforcement. | #935: explicit fail-closed verdict, including undeterminable. |
|
||||||
|
|
||||||
|
**Result: bounded.** The compromise is contained to one role on one tenant, with no
|
||||||
|
filesystem reach and no lateral credential access. A9 survives *only if D2 lands* — if
|
||||||
|
reviewer and merger still share `sysadmin`, a compromised reviewer session still merges, and
|
||||||
|
this row reads the same after the migration as before it.
|
||||||
|
|
||||||
|
### What the migration does not fix
|
||||||
|
|
||||||
|
Against **ADV2**, both tables are identical. Prompt injection does not need to cross a
|
||||||
|
boundary: it arrives inside an authorized session and asks that session to do what it is
|
||||||
|
already permitted to do. Every "stopped by" above authenticates a principal, and the
|
||||||
|
injected instruction has the correct principal. The migration reduces ADV1's blast radius by
|
||||||
|
roughly an order of magnitude and reduces ADV2's by nothing.
|
||||||
|
|
||||||
|
The controls that do constrain ADV2 are per-principal permission scope (#932), repository
|
||||||
|
scope (`gitea_config.py:499`), and credential-backed separation of duty (D2) — each limiting
|
||||||
|
what an authenticated session may do *regardless of what it is asked for*. #955's
|
||||||
|
secure-isolation end state should be read with that distinction in mind: removing credentials
|
||||||
|
from clients defeats ADV1 and ADV5, and does not by itself defeat ADV2.
|
||||||
|
|
||||||
|
Two further items are explicitly out of scope here and unowned by #929:
|
||||||
|
|
||||||
|
- **Session-credential rotation and revocation.** #938 names rotation as documentation, but
|
||||||
|
no child owns proving that a revoked credential stops an in-flight session.
|
||||||
|
- **ADV3** (malicious tool arguments) is diffused across every child rather than owned. The
|
||||||
|
per-request principal work in #932 is the natural place to assert that identifiers taken
|
||||||
|
from the request never authorize anything on their own.
|
||||||
|
|
||||||
|
## 9. How to verify this document
|
||||||
|
|
||||||
|
1. `PYTHONPATH=. pytest tests/test_issue_956_threat_model.py` — resolves every anchor
|
||||||
|
against the working tree and checks the document's structural obligations.
|
||||||
|
2. Pick any five anchors at random and read them; the fixture states what each line must
|
||||||
|
contain.
|
||||||
|
3. Reproduce Findings 3 and 4 live: call `gitea_list_profiles` and `gitea_audit_config`
|
||||||
|
from the **author** namespace. Credential presence reported for roles other than the
|
||||||
|
active one is Finding 3; `MDCPS Jenkins: enabled, read-only, authenticated` is Finding 4.
|
||||||
|
|
||||||
|
If the anchor test fails after an unrelated refactor, the anchors moved and the fixture
|
||||||
|
needs regenerating — the claims are still true, but they are no longer traceable, which
|
||||||
|
#956 treats as the same defect.
|
||||||
+90
-15
@@ -1180,6 +1180,10 @@ RECOGNIZED_GITEA_ENV_KEYS = frozenset({
|
|||||||
"GITEA_SERVER_PROVENANCE",
|
"GITEA_SERVER_PROVENANCE",
|
||||||
"GITEA_AUTHOR_WORKTREE",
|
"GITEA_AUTHOR_WORKTREE",
|
||||||
"GITEA_ACTIVE_WORKTREE",
|
"GITEA_ACTIVE_WORKTREE",
|
||||||
|
"GITEA_REVIEWER_WORKTREE",
|
||||||
|
"GITEA_MERGER_WORKTREE",
|
||||||
|
"GITEA_CANONICAL_REPOSITORY_ROOT",
|
||||||
|
"GITEA_MCP_SESSION_STATE_TTL_HOURS",
|
||||||
"GITEA_DISABLE_KEYCHAIN",
|
"GITEA_DISABLE_KEYCHAIN",
|
||||||
"GITEA_CONTROL_PLANE_DB",
|
"GITEA_CONTROL_PLANE_DB",
|
||||||
"GITEA_DB_PATH",
|
"GITEA_DB_PATH",
|
||||||
@@ -1189,6 +1193,31 @@ RECOGNIZED_GITEA_ENV_KEYS = frozenset({
|
|||||||
"GITEA_IRRECOVERABLE_HMAC_SECRET",
|
"GITEA_IRRECOVERABLE_HMAC_SECRET",
|
||||||
"GITEA_FORCE_MCP_RUNTIME_CHECK",
|
"GITEA_FORCE_MCP_RUNTIME_CHECK",
|
||||||
"GITEA_FORCE_CLIENT_MANAGED",
|
"GITEA_FORCE_CLIENT_MANAGED",
|
||||||
|
# #975: the client-identity inputs the server actually consumes at startup
|
||||||
|
# (CLIENT_NAME_ENV / CLIENT_INSTANCE_ENV / CLIENT_SESSION_ENV in
|
||||||
|
# gitea_mcp_server). Production read them while this allowlist omitted them,
|
||||||
|
# so the peer-env scan classified them as unsupported overrides and the
|
||||||
|
# capability resolver refused every mutation fleet-wide. Named individually
|
||||||
|
# on purpose: no prefix is added, so an unrecognised GITEA_* override is
|
||||||
|
# still refused exactly as it was before.
|
||||||
|
"GITEA_MCP_CLIENT",
|
||||||
|
"GITEA_MCP_CLIENT_INSTANCE",
|
||||||
|
"GITEA_MCP_CLIENT_SESSION",
|
||||||
|
# #978: operator-approved fleet enrollment identity shared by one launch.
|
||||||
|
"GITEA_MCP_FLEET_RUN_ID",
|
||||||
|
"GITEA_MCP_PROCESS_IDENTITY",
|
||||||
|
# #978 B1/B2: launcher-sealed provenance + peer-visible worker identity so
|
||||||
|
# the instance-aware mutation gate can distinguish two legitimate
|
||||||
|
# application instances that share a profile from a true duplicate worker.
|
||||||
|
"GITEA_MCP_INSTANCE_PROVENANCE",
|
||||||
|
"GITEA_MCP_WORKER_IDENTITY",
|
||||||
|
"GITEA_MCP_GENERATION_ID",
|
||||||
|
# #975 review 652 B1: production also consumes HEARTBEAT_INTERVAL_ENV from
|
||||||
|
# mcp_worker_identity via gitea_mcp_server._start_worker_heartbeat. Omitting
|
||||||
|
# it reproduced the same unsupported-env → runtime_reconnect_required
|
||||||
|
# failure mode for the documented operator override. Named individually;
|
||||||
|
# no GITEA_* / GITEA_WORKER_* prefix is added.
|
||||||
|
"GITEA_WORKER_HEARTBEAT_INTERVAL_SECONDS",
|
||||||
})
|
})
|
||||||
|
|
||||||
RECOGNIZED_GITEA_ENV_PREFIXES = (
|
RECOGNIZED_GITEA_ENV_PREFIXES = (
|
||||||
@@ -1216,24 +1245,70 @@ def get_unconsumed_gitea_env_overrides(env=None) -> dict[str, str]:
|
|||||||
return unconsumed
|
return unconsumed
|
||||||
|
|
||||||
|
|
||||||
def launcher_entry(profile_name, config_path=None):
|
def launcher_entry(
|
||||||
|
profile_name,
|
||||||
|
config_path=None,
|
||||||
|
*,
|
||||||
|
client_type=None,
|
||||||
|
client_instance_id=None,
|
||||||
|
fleet_run_id=None,
|
||||||
|
session_id=None,
|
||||||
|
launch_nonce=None,
|
||||||
|
):
|
||||||
"""Return a thin MCP launcher entry for *profile_name*.
|
"""Return a thin MCP launcher entry for *profile_name*.
|
||||||
|
|
||||||
Contains command/args and the GITEA_MCP_* / GITEA_CLIENT_MANAGED env vars — never a token
|
Contains command/args and the GITEA_MCP_* / GITEA_CLIENT_MANAGED env vars —
|
||||||
or password. Suitable for Claude / Gemini / Codex ``mcpServers`` blocks.
|
never a token or password. Suitable for Claude / Gemini / Codex
|
||||||
|
``mcpServers`` blocks.
|
||||||
|
|
||||||
|
#978 B1: production launches mint (or reuse) a trusted
|
||||||
|
``GITEA_MCP_CLIENT_INSTANCE`` so workers never fall back to the legacy
|
||||||
|
placeholder identity on the real serve path. Pass *client_instance_id* to
|
||||||
|
resume the same launch; omit it for a fresh launch (new ID).
|
||||||
"""
|
"""
|
||||||
command, args = server_command()
|
import mcp_application_launcher as app_launcher
|
||||||
return {
|
|
||||||
"gitea-tools": {
|
built = app_launcher.launcher_entry_for_profile(
|
||||||
"command": command,
|
profile_name,
|
||||||
"args": args,
|
client_type=client_type or "unknown",
|
||||||
"env": {
|
config_path=config_path or DEFAULT_CONFIG_PATH,
|
||||||
"GITEA_MCP_CONFIG": config_path or DEFAULT_CONFIG_PATH,
|
client_instance_id=client_instance_id,
|
||||||
"GITEA_MCP_PROFILE": profile_name,
|
fleet_run_id=fleet_run_id,
|
||||||
"GITEA_CLIENT_MANAGED": "1",
|
session_id=session_id,
|
||||||
},
|
launch_nonce=launch_nonce,
|
||||||
}
|
server_key="gitea-tools",
|
||||||
}
|
)
|
||||||
|
# Public shape stays {server_key: {command, args, env}} for existing callers.
|
||||||
|
return {"gitea-tools": built["gitea-tools"]}
|
||||||
|
|
||||||
|
|
||||||
|
def multi_namespace_launcher_entries(
|
||||||
|
profile_by_namespace,
|
||||||
|
*,
|
||||||
|
client_type,
|
||||||
|
config_path=None,
|
||||||
|
client_instance_id=None,
|
||||||
|
fleet_run_id=None,
|
||||||
|
session_id=None,
|
||||||
|
launch_nonce=None,
|
||||||
|
):
|
||||||
|
"""Build production mcpServers for all five namespaces of one application launch.
|
||||||
|
|
||||||
|
One shared trusted ``client_instance_id`` is minted (or reused) and
|
||||||
|
propagated to every namespace worker env. See
|
||||||
|
:func:`mcp_application_launcher.build_application_mcp_servers`.
|
||||||
|
"""
|
||||||
|
import mcp_application_launcher as app_launcher
|
||||||
|
|
||||||
|
return app_launcher.build_application_mcp_servers(
|
||||||
|
profile_by_namespace,
|
||||||
|
client_type=client_type,
|
||||||
|
config_path=config_path or DEFAULT_CONFIG_PATH,
|
||||||
|
client_instance_id=client_instance_id,
|
||||||
|
fleet_run_id=fleet_run_id,
|
||||||
|
session_id=session_id,
|
||||||
|
launch_nonce=launch_nonce,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1493
-90
File diff suppressed because it is too large
Load Diff
@@ -500,6 +500,11 @@ def assess_transport_for_auth_mint() -> dict[str, Any]:
|
|||||||
native = mcp_daemon_guard.is_native_mcp_transport()
|
native = mcp_daemon_guard.is_native_mcp_transport()
|
||||||
pytest = mcp_daemon_guard.is_pytest_runtime()
|
pytest = mcp_daemon_guard.is_pytest_runtime()
|
||||||
production = mcp_daemon_guard.is_production_native_mcp_transport()
|
production = mcp_daemon_guard.is_production_native_mcp_transport()
|
||||||
|
# #931: report which transport underwrites the verdict, read through the
|
||||||
|
# one shared accessor rather than assumed to be stdio. The gate's decision
|
||||||
|
# is unchanged here; naming the transport is what lets #932 re-derive the
|
||||||
|
# guarantee from an authenticated session instead of from the bind.
|
||||||
|
bound = mcp_daemon_guard.bound_transport()
|
||||||
if not native and not pytest:
|
if not native and not pytest:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"irrecoverable provenance authorization requires production native "
|
"irrecoverable provenance authorization requires production native "
|
||||||
@@ -510,6 +515,7 @@ def assess_transport_for_auth_mint() -> dict[str, Any]:
|
|||||||
"allowed": not reasons,
|
"allowed": not reasons,
|
||||||
"native_mcp_transport": native,
|
"native_mcp_transport": native,
|
||||||
"production_native_mcp_transport": production,
|
"production_native_mcp_transport": production,
|
||||||
|
"bound_transport": bound,
|
||||||
"pytest": pytest,
|
"pytest": pytest,
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
}
|
}
|
||||||
|
|||||||
+128
-9
@@ -299,9 +299,13 @@ def _ownership_refusals(
|
|||||||
f"lock worktree '{lock.get('worktree_path')}' does not match "
|
f"lock worktree '{lock.get('worktree_path')}' does not match "
|
||||||
f"'{worktree_path}'"
|
f"'{worktree_path}'"
|
||||||
)
|
)
|
||||||
lease = lock.get("work_lease") if isinstance(lock, dict) else None
|
# #953 AC2/AC13/AC14: read through the shared claimant reader so a lock
|
||||||
claimant = lease.get("claimant") if isinstance(lease, dict) else None
|
# written by bootstrap — which records the claimant at the top level — is
|
||||||
claimant = claimant if isinstance(claimant, dict) else {}
|
# not refused for "not recording a claimant" when it plainly records one.
|
||||||
|
# This is not a widening: the values are still compared against the
|
||||||
|
# server-resolved identity and profile immediately below, so a legacy
|
||||||
|
# placement grants nothing that the canonical placement would not.
|
||||||
|
claimant = lock_claimant(lock) if isinstance(lock, dict) else {}
|
||||||
recorded_identity = str(claimant.get("username") or "").strip()
|
recorded_identity = str(claimant.get("username") or "").strip()
|
||||||
recorded_profile = str(claimant.get("profile") or "").strip()
|
recorded_profile = str(claimant.get("profile") or "").strip()
|
||||||
if not recorded_identity or not recorded_profile:
|
if not recorded_identity or not recorded_profile:
|
||||||
@@ -636,6 +640,99 @@ def iter_lock_files(lock_dir: str | None = None) -> list[str]:
|
|||||||
return sorted(paths)
|
return sorted(paths)
|
||||||
|
|
||||||
|
|
||||||
|
def release_session_lock(
|
||||||
|
*,
|
||||||
|
issue_number: int,
|
||||||
|
session: str,
|
||||||
|
lock_dir: str | None = None,
|
||||||
|
remote: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Remove exactly the durable lock *session* created for *issue_number*.
|
||||||
|
|
||||||
|
``author_issue_bootstrap.run_compensating_recovery`` has called this name
|
||||||
|
since #850, but it was never defined: the call raised ``AttributeError``
|
||||||
|
into a bare ``except Exception: pass``, so the lock half of every
|
||||||
|
compensating rollback silently did nothing. The branch and worktree were
|
||||||
|
removed and the lock was left behind — a state no sanctioned tool can act
|
||||||
|
on, since recovery refuses ``worktree_invalid`` and ``gitea_lock_issue`` has
|
||||||
|
no worktree to bind (#953 review 632 F2).
|
||||||
|
|
||||||
|
Ownership is proven, not asserted. A record is removed only when its
|
||||||
|
recorded ``owner_session`` equals *session* and its issue number matches;
|
||||||
|
``remote``/``org``/``repo`` narrow it further when supplied. Zero matches or
|
||||||
|
more than one both raise, so a caller can never delete a lock it does not
|
||||||
|
own and an ambiguous directory is never guessed at. The ``.json.lock`` flock
|
||||||
|
sidecar is deliberately left in place — it is a zero-byte mutex another
|
||||||
|
process may hold, and removing it under contention would be a race.
|
||||||
|
|
||||||
|
Returns the removed lock file path.
|
||||||
|
"""
|
||||||
|
target_issue = int(issue_number)
|
||||||
|
owner = str(session or "").strip()
|
||||||
|
if not owner:
|
||||||
|
raise ValueError(
|
||||||
|
"release_session_lock requires the owning session id (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _is_owned_durable_lock(record: dict[str, Any] | None) -> bool:
|
||||||
|
# A durable lock, not a bootstrap phase journal or a session pointer,
|
||||||
|
# both of which can share a directory and carry the same issue number
|
||||||
|
# and owner_session.
|
||||||
|
if not record or "lock_generation" not in record:
|
||||||
|
return False
|
||||||
|
if not str(record.get("branch_name") or "").strip():
|
||||||
|
return False
|
||||||
|
if not str(record.get("worktree_path") or "").strip():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if int(record.get("issue_number") or 0) != target_issue:
|
||||||
|
return False
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
return str(record.get("owner_session") or "").strip() == owner
|
||||||
|
|
||||||
|
# Prefer the exact keyed path when the caller knows the repository; scanning
|
||||||
|
# is the fallback for callers that only carry the issue number.
|
||||||
|
if remote and org and repo:
|
||||||
|
exact = lock_file_path(
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
issue_number=target_issue,
|
||||||
|
lock_dir=lock_dir,
|
||||||
|
)
|
||||||
|
if not _is_owned_durable_lock(read_lock_file(exact)):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"durable issue lock '{exact}' is absent or is not owned by "
|
||||||
|
f"session '{owner}' (fail closed; nothing released)"
|
||||||
|
)
|
||||||
|
os.remove(exact)
|
||||||
|
return exact
|
||||||
|
|
||||||
|
matches: list[str] = []
|
||||||
|
for path in iter_lock_files(lock_dir):
|
||||||
|
if _is_owned_durable_lock(read_lock_file(path)):
|
||||||
|
matches.append(path)
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"no durable issue lock for issue #{target_issue} is owned by "
|
||||||
|
f"session '{owner}' (fail closed; nothing released)"
|
||||||
|
)
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{len(matches)} durable locks for issue #{target_issue} claim "
|
||||||
|
f"session '{owner}'; refusing to guess which to release "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
path = matches[0]
|
||||||
|
os.remove(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def find_lock_for_branch(
|
def find_lock_for_branch(
|
||||||
*,
|
*,
|
||||||
remote: str,
|
remote: str,
|
||||||
@@ -1112,21 +1209,43 @@ def assess_same_issue_lease_conflict(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _lock_claimant(lock: dict[str, Any] | None) -> dict[str, str]:
|
def lock_claimant(lock: dict[str, Any] | None) -> dict[str, str]:
|
||||||
|
"""Read the claimant from either canonical or legacy placement (#953 AC13/AC14).
|
||||||
|
|
||||||
|
``work_lease.claimant`` is the canonical placement and is preferred; a
|
||||||
|
top-level ``claimant`` is the legacy/bootstrap placement and is accepted as
|
||||||
|
a fallback. This is the single definition. Before #953 the readers
|
||||||
|
disagreed: this module, ``issue_lock_renewal``, and ``issue_lock_recovery``
|
||||||
|
tolerated both placements, while ``_ownership_refusals`` looked only in
|
||||||
|
``work_lease`` — which is what made a bootstrap-written lock
|
||||||
|
un-heartbeatable.
|
||||||
|
|
||||||
|
Preferring ``work_lease`` over the top level is deliberate: once a legacy
|
||||||
|
lock is upgraded, the canonical placement is authoritative and a stale
|
||||||
|
top-level copy must never win.
|
||||||
|
|
||||||
|
This decides *where to look*, never whether ownership is proven — every
|
||||||
|
caller still compares these values against server-resolved identity and
|
||||||
|
profile.
|
||||||
|
"""
|
||||||
if not isinstance(lock, dict):
|
if not isinstance(lock, dict):
|
||||||
return {}
|
return {}
|
||||||
claimant = lock.get("claimant")
|
lease = lock.get("work_lease")
|
||||||
|
claimant = lease.get("claimant") if isinstance(lease, dict) else None
|
||||||
if not isinstance(claimant, dict):
|
if not isinstance(claimant, dict):
|
||||||
lease = lock.get("work_lease")
|
claimant = lock.get("claimant")
|
||||||
claimant = lease.get("claimant") if isinstance(lease, dict) else None
|
|
||||||
if not isinstance(claimant, dict):
|
if not isinstance(claimant, dict):
|
||||||
return {}
|
return {}
|
||||||
return {
|
return {
|
||||||
"username": str(claimant.get("username") or ""),
|
"username": str(claimant.get("username") or "").strip(),
|
||||||
"profile": str(claimant.get("profile") or ""),
|
"profile": str(claimant.get("profile") or "").strip(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#: Back-compatible alias for the pre-#953 private name.
|
||||||
|
_lock_claimant = lock_claimant
|
||||||
|
|
||||||
|
|
||||||
def assess_foreign_lock_overwrite(
|
def assess_foreign_lock_overwrite(
|
||||||
existing_lock: dict[str, Any] | None,
|
existing_lock: dict[str, Any] | None,
|
||||||
incoming_lock: dict[str, Any],
|
incoming_lock: dict[str, Any],
|
||||||
|
|||||||
+60
-11
@@ -378,6 +378,11 @@ def format_parity(assessment: dict) -> str:
|
|||||||
# feeds the mutation gate and never changes startup_head/current_head.
|
# feeds the mutation gate and never changes startup_head/current_head.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Legacy hardcoded target tracking ref. Retained for callers that still pass an
|
||||||
|
# explicit ref, but no longer the default: assuming a remote named ``origin``
|
||||||
|
# read an unrelated (often orphaned) remote-tracking ref in any checkout whose
|
||||||
|
# remote is named something else, and reported the target stale against a commit
|
||||||
|
# from a remote that may no longer even be configured (#983).
|
||||||
DEFAULT_TARGET_TRACKING_REF = "refs/remotes/origin/master"
|
DEFAULT_TARGET_TRACKING_REF = "refs/remotes/origin/master"
|
||||||
|
|
||||||
|
|
||||||
@@ -408,7 +413,7 @@ def assess_target_repository_parity(
|
|||||||
*,
|
*,
|
||||||
canonical_root: str | None,
|
canonical_root: str | None,
|
||||||
source: str | None,
|
source: str | None,
|
||||||
tracking_ref: str = DEFAULT_TARGET_TRACKING_REF,
|
tracking_ref: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Assess the configured cross-repository target checkout.
|
"""Assess the configured cross-repository target checkout.
|
||||||
|
|
||||||
@@ -421,6 +426,11 @@ def assess_target_repository_parity(
|
|||||||
An unconfigured namespace is ``configured=False`` and never ``stale`` — the
|
An unconfigured namespace is ``configured=False`` and never ``stale`` — the
|
||||||
single-repository default has no second dimension to be stale about. A
|
single-repository default has no second dimension to be stale about. A
|
||||||
configured root that cannot be read is ``determinable=False`` with reasons.
|
configured root that cannot be read is ``determinable=False`` with reasons.
|
||||||
|
|
||||||
|
*tracking_ref* defaults to None, meaning **derive the target from the
|
||||||
|
repository itself** through the same resolver the mutation guard uses, so
|
||||||
|
gating and reporting can never disagree about which ref is authoritative
|
||||||
|
(#983). Passing an explicit ref preserves the previous behaviour verbatim.
|
||||||
"""
|
"""
|
||||||
result = {
|
result = {
|
||||||
"configured": bool(canonical_root),
|
"configured": bool(canonical_root),
|
||||||
@@ -429,9 +439,13 @@ def assess_target_repository_parity(
|
|||||||
"repository_slug": None,
|
"repository_slug": None,
|
||||||
"checkout_head": None,
|
"checkout_head": None,
|
||||||
"tracking_ref": tracking_ref,
|
"tracking_ref": tracking_ref,
|
||||||
|
"base_remote": None,
|
||||||
|
"base_branch": None,
|
||||||
|
"tracking_ref_source": "explicit_tracking_ref" if tracking_ref else None,
|
||||||
"remote_tracking_head": None,
|
"remote_tracking_head": None,
|
||||||
"determinable": False,
|
"determinable": False,
|
||||||
"stale": False,
|
"stale": False,
|
||||||
|
"reason_code": None,
|
||||||
"reasons": [],
|
"reasons": [],
|
||||||
}
|
}
|
||||||
if not canonical_root:
|
if not canonical_root:
|
||||||
@@ -463,18 +477,53 @@ def assess_target_repository_parity(
|
|||||||
result["checkout_head"] = head
|
result["checkout_head"] = head
|
||||||
result["determinable"] = True
|
result["determinable"] = True
|
||||||
|
|
||||||
remote_url = _git_capture(canonical_root, "remote", "get-url", "origin")
|
# Local import keeps this module dependency-light for its startup role.
|
||||||
if remote_url:
|
import canonical_repository_root as _crr
|
||||||
# Local import keeps this module dependency-light for its startup role.
|
|
||||||
import remote_repo_guard
|
|
||||||
|
|
||||||
parsed = remote_repo_guard.parse_org_repo_from_remote_url(remote_url)
|
# #983: identity comes from whichever remote actually proves it, not from a
|
||||||
if parsed:
|
# remote assumed to be named 'origin'. In a checkout whose only remote is
|
||||||
result["repository_slug"] = f"{parsed[0]}/{parsed[1]}"
|
# 'prgs', the old lookup failed outright and reported the identity as
|
||||||
if not result["repository_slug"]:
|
# underivable while a leftover refs/remotes/origin/master still resolved.
|
||||||
result["reasons"].append(
|
#
|
||||||
"target repository identity could not be derived from its git remote"
|
# #983 B2: this must be the *ambiguity-aware* resolver. The first-wins
|
||||||
|
# `resolve_identity_remote` picks whichever remote probes first, so on a
|
||||||
|
# target where distinct remotes claim different repositories the report
|
||||||
|
# confidently named one of them while the mutation gate refused the same
|
||||||
|
# target — gating and reporting evaluating different repositories, which is
|
||||||
|
# precisely the divergence this issue exists to end.
|
||||||
|
identity = _crr.assess_identity_remote(canonical_root)
|
||||||
|
result["repository_slug"] = identity["slug"]
|
||||||
|
if not identity["slug"]:
|
||||||
|
result["reason_code"] = identity["reason_code"]
|
||||||
|
result["reasons"].extend(
|
||||||
|
identity["reasons"]
|
||||||
|
or ["target repository identity could not be derived from its git remote"]
|
||||||
)
|
)
|
||||||
|
if identity["ambiguous"]:
|
||||||
|
# An ambiguous target has no single authoritative base, so reporting one
|
||||||
|
# would be a guess. Fail closed here exactly as the gate does.
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Identity is otherwise resolved independently of the base ref: a target that
|
||||||
|
# has never been fetched still has a provable repository identity, and
|
||||||
|
# reporting it as unidentifiable would lose real information over an
|
||||||
|
# unrelated missing ref.
|
||||||
|
if not tracking_ref:
|
||||||
|
# No remote argument. The identity remote resolved just above was
|
||||||
|
# *inferred here*, and feeding it back in would tell the resolver a
|
||||||
|
# caller had explicitly disambiguated the repository, suppressing its
|
||||||
|
# ambiguity gate (#983 B2). Only an operator-supplied remote may do that,
|
||||||
|
# and this call site has none.
|
||||||
|
base = _crr.resolve_target_base_ref(canonical_root)
|
||||||
|
if not base.get("proven"):
|
||||||
|
result["reason_code"] = base.get("reason_code")
|
||||||
|
result["reasons"].extend(base.get("reasons") or [])
|
||||||
|
return result
|
||||||
|
tracking_ref = base["tracking_ref"]
|
||||||
|
result["tracking_ref"] = tracking_ref
|
||||||
|
result["tracking_ref_source"] = base.get("source")
|
||||||
|
result["base_remote"] = base.get("remote")
|
||||||
|
result["base_branch"] = base.get("branch")
|
||||||
|
|
||||||
tracking_head = _git_capture(canonical_root, "rev-parse", tracking_ref)
|
tracking_head = _git_capture(canonical_root, "rev-parse", tracking_ref)
|
||||||
if not tracking_head:
|
if not tracking_head:
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
"""Production application launcher for multi-namespace MCP fleets (#978 B1).
|
||||||
|
|
||||||
|
One real LLM application launch mints exactly one trusted
|
||||||
|
``client_instance_id`` and propagates it to every Gitea MCP namespace worker
|
||||||
|
started for that launch. Workers never invent a trusted instance identity from
|
||||||
|
PID proximity, timestamps, or ordinary untrusted environment values.
|
||||||
|
|
||||||
|
This module is the production serve-path authority for instance identity.
|
||||||
|
Tests and fixtures may call the same functions, but production registration
|
||||||
|
receives the identity from the env this launcher builds — not from a hand-set
|
||||||
|
test-only helper that bypasses it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
import mcp_fleet_snapshot as fleet
|
||||||
|
import mcp_worker_identity as mwi
|
||||||
|
|
||||||
|
#: Canonical MCP server names for the five role namespaces.
|
||||||
|
NAMESPACE_SERVER_NAMES: tuple[str, ...] = (
|
||||||
|
"gitea-author",
|
||||||
|
"gitea-reviewer",
|
||||||
|
"gitea-merger",
|
||||||
|
"gitea-controller",
|
||||||
|
"gitea-reconciler",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Map MCP server name → role/namespace kind.
|
||||||
|
SERVER_TO_NAMESPACE: dict[str, str] = {
|
||||||
|
"gitea-author": "author",
|
||||||
|
"gitea-reviewer": "reviewer",
|
||||||
|
"gitea-merger": "merger",
|
||||||
|
"gitea-controller": "controller",
|
||||||
|
"gitea-reconciler": "reconciler",
|
||||||
|
}
|
||||||
|
|
||||||
|
SANCTIONED_NAMESPACES: tuple[str, ...] = (
|
||||||
|
"author",
|
||||||
|
"reviewer",
|
||||||
|
"merger",
|
||||||
|
"controller",
|
||||||
|
"reconciler",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Env the launcher may set on every worker of one application launch.
|
||||||
|
CLIENT_NAME_ENV = "GITEA_MCP_CLIENT"
|
||||||
|
CLIENT_INSTANCE_ENV = fleet.CLIENT_INSTANCE_ENV
|
||||||
|
FLEET_RUN_ENV = fleet.FLEET_RUN_ENV
|
||||||
|
CLIENT_SESSION_ENV = "GITEA_MCP_CLIENT_SESSION"
|
||||||
|
CLIENT_MANAGED_ENV = "GITEA_CLIENT_MANAGED"
|
||||||
|
PROFILE_ENV = "GITEA_MCP_PROFILE"
|
||||||
|
CONFIG_ENV = "GITEA_MCP_CONFIG"
|
||||||
|
INSTANCE_PROVENANCE_ENV = "GITEA_MCP_INSTANCE_PROVENANCE"
|
||||||
|
WORKER_IDENTITY_ENV = "GITEA_MCP_WORKER_IDENTITY"
|
||||||
|
GENERATION_ID_ENV = "GITEA_MCP_GENERATION_ID"
|
||||||
|
|
||||||
|
# Marker the trusted launcher alone writes; ordinary user env without this
|
||||||
|
# marker is never classified as launcher-trusted provenance.
|
||||||
|
LAUNCHER_PROVENANCE_VALUE = fleet.INSTANCE_ID_PROVENANCE_TRUSTED
|
||||||
|
|
||||||
|
|
||||||
|
def mint_application_launch(
|
||||||
|
client_type: str | None,
|
||||||
|
*,
|
||||||
|
launch_nonce: str | None = None,
|
||||||
|
fleet_run_id: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
now=None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Mint one trusted application-instance identity for a production launch.
|
||||||
|
|
||||||
|
Called exactly once per real application launch. The returned
|
||||||
|
``client_instance_id`` is injected into every namespace worker environment
|
||||||
|
for that launch. A second call (separate launch) yields a different ID.
|
||||||
|
"""
|
||||||
|
client = mwi.normalize_client_name(client_type)
|
||||||
|
instance_id = fleet.generate_client_instance_id(
|
||||||
|
client, launch_nonce=launch_nonce, now=now
|
||||||
|
)
|
||||||
|
assessment = fleet.assess_instance_identity(instance_id)
|
||||||
|
if not assessment["trusted"]:
|
||||||
|
# generate_client_instance_id always produces a trusted format; fail
|
||||||
|
# closed if that invariant ever breaks rather than shipping untrusted.
|
||||||
|
raise RuntimeError(
|
||||||
|
f"launcher produced untrusted client_instance_id {instance_id!r}: "
|
||||||
|
f"{assessment.get('reasons')}"
|
||||||
|
)
|
||||||
|
session = (session_id or "").strip() or f"launch-{secrets.token_hex(12)}"
|
||||||
|
return {
|
||||||
|
"client_type": client,
|
||||||
|
"client_instance_id": instance_id,
|
||||||
|
"instance_id_provenance": LAUNCHER_PROVENANCE_VALUE,
|
||||||
|
"instance_identity_trusted": True,
|
||||||
|
"fleet_run_id": (fleet_run_id or "").strip() or None,
|
||||||
|
"session_id": session,
|
||||||
|
"namespaces": list(SANCTIONED_NAMESPACES),
|
||||||
|
"namespace_server_names": list(NAMESPACE_SERVER_NAMES),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def namespace_worker_env(
|
||||||
|
*,
|
||||||
|
profile_name: str,
|
||||||
|
client_type: str | None,
|
||||||
|
client_instance_id: str,
|
||||||
|
config_path: str | None = None,
|
||||||
|
fleet_run_id: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
extra_env: Mapping[str, str] | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Build the environment for one namespace worker of a trusted launch.
|
||||||
|
|
||||||
|
Never invents a client_instance_id. The caller must supply the launch-minted
|
||||||
|
identity so all five workers receive the same value.
|
||||||
|
"""
|
||||||
|
assessment = fleet.assess_instance_identity(client_instance_id)
|
||||||
|
if not assessment["trusted"]:
|
||||||
|
raise ValueError(
|
||||||
|
"namespace_worker_env refuses untrusted client_instance_id "
|
||||||
|
f"{client_instance_id!r}: {assessment.get('reasons')}"
|
||||||
|
)
|
||||||
|
client = mwi.normalize_client_name(client_type)
|
||||||
|
env: dict[str, str] = {
|
||||||
|
PROFILE_ENV: str(profile_name),
|
||||||
|
CLIENT_MANAGED_ENV: "1",
|
||||||
|
"GITEA_MCP_CLIENT": client,
|
||||||
|
CLIENT_INSTANCE_ENV: assessment["client_instance_id"],
|
||||||
|
INSTANCE_PROVENANCE_ENV: LAUNCHER_PROVENANCE_VALUE,
|
||||||
|
}
|
||||||
|
if config_path:
|
||||||
|
env[CONFIG_ENV] = str(config_path)
|
||||||
|
if fleet_run_id:
|
||||||
|
env[FLEET_RUN_ENV] = str(fleet_run_id)
|
||||||
|
if session_id:
|
||||||
|
env[CLIENT_SESSION_ENV] = str(session_id)
|
||||||
|
if extra_env:
|
||||||
|
# Never let untrusted callers override the trusted instance keys.
|
||||||
|
protected = {
|
||||||
|
CLIENT_INSTANCE_ENV,
|
||||||
|
INSTANCE_PROVENANCE_ENV,
|
||||||
|
"GITEA_MCP_CLIENT",
|
||||||
|
CLIENT_MANAGED_ENV,
|
||||||
|
}
|
||||||
|
for key, value in extra_env.items():
|
||||||
|
if key in protected:
|
||||||
|
continue
|
||||||
|
env[str(key)] = str(value)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def build_application_mcp_servers(
|
||||||
|
profile_by_namespace: Mapping[str, str],
|
||||||
|
*,
|
||||||
|
client_type: str | None,
|
||||||
|
config_path: str | None = None,
|
||||||
|
client_instance_id: str | None = None,
|
||||||
|
fleet_run_id: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
launch_nonce: str | None = None,
|
||||||
|
command: str | None = None,
|
||||||
|
args: list[str] | None = None,
|
||||||
|
now=None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build a production ``mcpServers`` map for one application launch.
|
||||||
|
|
||||||
|
Mints one ``client_instance_id`` when *client_instance_id* is omitted (fresh
|
||||||
|
launch). When the caller supplies a previously minted trusted ID (resume of
|
||||||
|
the same launch / config rewrite), that ID is reused so reconnect keeps
|
||||||
|
attribution. A full new application restart omits the ID and receives a
|
||||||
|
fresh mint.
|
||||||
|
|
||||||
|
Every namespace server entry receives the **same** instance ID. Separate
|
||||||
|
calls with no supplied ID receive distinct IDs.
|
||||||
|
"""
|
||||||
|
missing = [
|
||||||
|
ns for ns in SANCTIONED_NAMESPACES if not profile_by_namespace.get(ns)
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
"build_application_mcp_servers requires a profile for every "
|
||||||
|
f"sanctioned namespace; missing: {missing}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if client_instance_id is None:
|
||||||
|
launch = mint_application_launch(
|
||||||
|
client_type,
|
||||||
|
launch_nonce=launch_nonce,
|
||||||
|
fleet_run_id=fleet_run_id,
|
||||||
|
session_id=session_id,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
assessment = fleet.assess_instance_identity(client_instance_id)
|
||||||
|
if not assessment["trusted"]:
|
||||||
|
raise ValueError(
|
||||||
|
"refusing to propagate untrusted client_instance_id "
|
||||||
|
f"{client_instance_id!r} into production launch envs: "
|
||||||
|
f"{assessment.get('reasons')}"
|
||||||
|
)
|
||||||
|
launch = {
|
||||||
|
"client_type": mwi.normalize_client_name(client_type),
|
||||||
|
"client_instance_id": assessment["client_instance_id"],
|
||||||
|
"instance_id_provenance": LAUNCHER_PROVENANCE_VALUE,
|
||||||
|
"instance_identity_trusted": True,
|
||||||
|
"fleet_run_id": (fleet_run_id or "").strip() or None,
|
||||||
|
"session_id": (session_id or "").strip()
|
||||||
|
or f"launch-{secrets.token_hex(12)}",
|
||||||
|
"namespaces": list(SANCTIONED_NAMESPACES),
|
||||||
|
"namespace_server_names": list(NAMESPACE_SERVER_NAMES),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve command/args from the production server entry when not provided.
|
||||||
|
if command is None or args is None:
|
||||||
|
import gitea_config
|
||||||
|
|
||||||
|
cmd, cmd_args = gitea_config.server_command()
|
||||||
|
command = command or cmd
|
||||||
|
args = args if args is not None else list(cmd_args)
|
||||||
|
|
||||||
|
servers: dict[str, Any] = {}
|
||||||
|
shared_id = launch["client_instance_id"]
|
||||||
|
for namespace in SANCTIONED_NAMESPACES:
|
||||||
|
server_name = f"gitea-{namespace}"
|
||||||
|
profile = profile_by_namespace[namespace]
|
||||||
|
env = namespace_worker_env(
|
||||||
|
profile_name=profile,
|
||||||
|
client_type=launch["client_type"],
|
||||||
|
client_instance_id=shared_id,
|
||||||
|
config_path=config_path,
|
||||||
|
fleet_run_id=launch.get("fleet_run_id"),
|
||||||
|
session_id=launch.get("session_id"),
|
||||||
|
)
|
||||||
|
servers[server_name] = {
|
||||||
|
"command": command,
|
||||||
|
"args": list(args),
|
||||||
|
"env": env,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mcpServers": servers,
|
||||||
|
"launch": launch,
|
||||||
|
"client_instance_id": shared_id,
|
||||||
|
"client_type": launch["client_type"],
|
||||||
|
"namespaces": list(SANCTIONED_NAMESPACES),
|
||||||
|
"shared_instance_id_across_namespaces": True,
|
||||||
|
"namespace_count": len(SANCTIONED_NAMESPACES),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def launcher_entry_for_profile(
|
||||||
|
profile_name: str,
|
||||||
|
*,
|
||||||
|
client_type: str | None = None,
|
||||||
|
config_path: str | None = None,
|
||||||
|
client_instance_id: str | None = None,
|
||||||
|
fleet_run_id: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
server_key: str = "gitea-tools",
|
||||||
|
launch_nonce: str | None = None,
|
||||||
|
now=None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Thin single-server production launcher entry with trusted instance ID.
|
||||||
|
|
||||||
|
Used when only one namespace is being configured. Still mints (or reuses)
|
||||||
|
a trusted ``client_instance_id`` so production never relies on the legacy
|
||||||
|
placeholder identity for normal launches.
|
||||||
|
"""
|
||||||
|
import gitea_config
|
||||||
|
|
||||||
|
if client_instance_id is None:
|
||||||
|
launch = mint_application_launch(
|
||||||
|
client_type or "unknown",
|
||||||
|
launch_nonce=launch_nonce,
|
||||||
|
fleet_run_id=fleet_run_id,
|
||||||
|
session_id=session_id,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
client_instance_id = launch["client_instance_id"]
|
||||||
|
client = launch["client_type"]
|
||||||
|
fleet_run = launch.get("fleet_run_id")
|
||||||
|
session = launch.get("session_id")
|
||||||
|
else:
|
||||||
|
assessment = fleet.assess_instance_identity(client_instance_id)
|
||||||
|
if not assessment["trusted"]:
|
||||||
|
raise ValueError(
|
||||||
|
f"untrusted client_instance_id {client_instance_id!r}"
|
||||||
|
)
|
||||||
|
client = mwi.normalize_client_name(client_type)
|
||||||
|
fleet_run = (fleet_run_id or "").strip() or None
|
||||||
|
session = (session_id or "").strip() or None
|
||||||
|
client_instance_id = assessment["client_instance_id"]
|
||||||
|
|
||||||
|
command, args = gitea_config.server_command()
|
||||||
|
env = namespace_worker_env(
|
||||||
|
profile_name=profile_name,
|
||||||
|
client_type=client,
|
||||||
|
client_instance_id=client_instance_id,
|
||||||
|
config_path=config_path or gitea_config.DEFAULT_CONFIG_PATH,
|
||||||
|
fleet_run_id=fleet_run,
|
||||||
|
session_id=session,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
server_key: {
|
||||||
|
"command": command,
|
||||||
|
"args": args,
|
||||||
|
"env": env,
|
||||||
|
},
|
||||||
|
"client_instance_id": client_instance_id,
|
||||||
|
"client_type": client,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def collect_instance_ids_from_mcp_servers(
|
||||||
|
mcp_servers: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Inspect a production mcpServers map for shared instance attribution.
|
||||||
|
|
||||||
|
Returns the unique set of client_instance_id values across gitea-* servers
|
||||||
|
and whether all five namespaces share exactly one trusted ID.
|
||||||
|
"""
|
||||||
|
ids: list[str] = []
|
||||||
|
by_server: dict[str, str | None] = {}
|
||||||
|
for name in NAMESPACE_SERVER_NAMES:
|
||||||
|
entry = mcp_servers.get(name) or {}
|
||||||
|
env = entry.get("env") or {}
|
||||||
|
raw = (env.get(CLIENT_INSTANCE_ENV) or "").strip() or None
|
||||||
|
by_server[name] = raw
|
||||||
|
if raw:
|
||||||
|
ids.append(raw)
|
||||||
|
unique = sorted(set(ids))
|
||||||
|
trusted = [
|
||||||
|
i
|
||||||
|
for i in unique
|
||||||
|
if fleet.assess_instance_identity(i)["trusted"]
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"server_instance_ids": by_server,
|
||||||
|
"unique_instance_ids": unique,
|
||||||
|
"trusted_instance_ids": trusted,
|
||||||
|
"shared_single_trusted_id": (
|
||||||
|
len(unique) == 1
|
||||||
|
and len(trusted) == 1
|
||||||
|
and all(by_server.get(n) == unique[0] for n in NAMESPACE_SERVER_NAMES)
|
||||||
|
),
|
||||||
|
"namespace_server_count": sum(
|
||||||
|
1 for n in NAMESPACE_SERVER_NAMES if n in mcp_servers
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def inherit_or_refuse_client_instance(
|
||||||
|
env: Mapping[str, str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Resolve instance identity for a worker process at serve time.
|
||||||
|
|
||||||
|
Production workers inherit the launcher-issued ID. They never mint a
|
||||||
|
trusted ID themselves. Missing / legacy / malformed values fail soft into
|
||||||
|
an untrusted assessment so registration can still record a diagnostic row
|
||||||
|
without authorizing multi-instance fleet mutation.
|
||||||
|
"""
|
||||||
|
source = dict(env if env is not None else os.environ)
|
||||||
|
raw = (source.get(CLIENT_INSTANCE_ENV) or "").strip() or None
|
||||||
|
provenance_marker = (source.get(INSTANCE_PROVENANCE_ENV) or "").strip()
|
||||||
|
assessment = fleet.assess_instance_identity(raw)
|
||||||
|
# Ordinary user-supplied values without launcher provenance marker are
|
||||||
|
# still format-checked by assess_instance_identity. When the format is
|
||||||
|
# trusted but the launcher marker is absent, keep the ID but note that
|
||||||
|
# provenance is not launcher-sealed (operator hand-set or legacy config).
|
||||||
|
if assessment["trusted"] and provenance_marker != LAUNCHER_PROVENANCE_VALUE:
|
||||||
|
assessment = dict(assessment)
|
||||||
|
assessment["launcher_sealed"] = False
|
||||||
|
assessment["reasons"] = list(assessment.get("reasons") or []) + [
|
||||||
|
f"{INSTANCE_PROVENANCE_ENV} is not {LAUNCHER_PROVENANCE_VALUE!r}; "
|
||||||
|
"identity format is valid but not sealed by the production launcher"
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
assessment = dict(assessment)
|
||||||
|
assessment["launcher_sealed"] = bool(
|
||||||
|
assessment["trusted"]
|
||||||
|
and provenance_marker == LAUNCHER_PROVENANCE_VALUE
|
||||||
|
)
|
||||||
|
assessment["fleet_run_id"] = (source.get(FLEET_RUN_ENV) or "").strip() or None
|
||||||
|
assessment["session_id"] = (
|
||||||
|
(source.get(CLIENT_SESSION_ENV) or "").strip() or None
|
||||||
|
)
|
||||||
|
assessment["client_type"] = mwi.normalize_client_name(
|
||||||
|
(source.get("GITEA_MCP_CLIENT") or "").strip() or None
|
||||||
|
)
|
||||||
|
return assessment
|
||||||
+21
-9
@@ -92,7 +92,15 @@ OPERATOR_UI_STEPS: dict[str, tuple[str, ...]] = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_CLIENT = "codex"
|
#: What an *unidentified* client gets. #948: this is deliberately the
|
||||||
|
#: host-agnostic step set rather than a specific product. Defaulting to one
|
||||||
|
#: vendor emitted Codex UI steps to a Gemini/Antigravity operator, who then had
|
||||||
|
#: no reachable recovery path — the guidance named a panel they do not have.
|
||||||
|
DEFAULT_CLIENT = "generic"
|
||||||
|
|
||||||
|
#: The historical default, kept addressable by name so Codex callers still get
|
||||||
|
#: Codex steps, without it silently becoming the fallback for unknown clients.
|
||||||
|
LEGACY_DEFAULT_CLIENT = "codex"
|
||||||
|
|
||||||
|
|
||||||
def normalize_reason(reason: str | None) -> str:
|
def normalize_reason(reason: str | None) -> str:
|
||||||
@@ -128,14 +136,18 @@ def normalize_reason(reason: str | None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def normalize_client(client: str | None) -> str:
|
def normalize_client(client: str | None) -> str:
|
||||||
"""Return a known client key for operator UI steps."""
|
"""Return the UI-step key for a client.
|
||||||
text = (client or "").strip().lower().replace(" ", "_").replace("-", "_")
|
|
||||||
if text in ("codex", "openai_codex", "openai"):
|
#948: alias resolution is shared with ``mcp_worker_identity`` so a client
|
||||||
return "codex"
|
name means the same thing wherever it is read. A name we recognise but have
|
||||||
if text in ("claude", "claude_code", "claude_desktop", "anthropic"):
|
no bespoke steps for — Gemini, Antigravity, Grok — resolves to the generic
|
||||||
return "claude_code"
|
host-agnostic steps rather than to another vendor's panel.
|
||||||
if text in OPERATOR_UI_STEPS:
|
"""
|
||||||
return text
|
import mcp_worker_identity
|
||||||
|
|
||||||
|
canonical = mcp_worker_identity.normalize_client_name(client)
|
||||||
|
if canonical in OPERATOR_UI_STEPS:
|
||||||
|
return canonical
|
||||||
return DEFAULT_CLIENT
|
return DEFAULT_CLIENT
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+176
-13
@@ -32,6 +32,8 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import mcp_transport_config
|
||||||
|
|
||||||
SANCTIONED_DAEMON_ENV = "GITEA_MCP_SANCTIONED_DAEMON"
|
SANCTIONED_DAEMON_ENV = "GITEA_MCP_SANCTIONED_DAEMON"
|
||||||
ALLOW_DIRECT_IMPORT_ENV = "GITEA_ALLOW_DIRECT_MCP_IMPORT"
|
ALLOW_DIRECT_IMPORT_ENV = "GITEA_ALLOW_DIRECT_MCP_IMPORT"
|
||||||
ALLOW_KEYCHAIN_CLI_ENV = "GITEA_ALLOW_KEYCHAIN_CLI"
|
ALLOW_KEYCHAIN_CLI_ENV = "GITEA_ALLOW_KEYCHAIN_CLI"
|
||||||
@@ -42,7 +44,9 @@ FORCE_PROVENANCE_FAIL_ENV = "GITEA_TEST_FORCE_UNSANCTIONED"
|
|||||||
_NATIVE_RUNTIME: dict[str, Any] | None = None
|
_NATIVE_RUNTIME: dict[str, Any] | None = None
|
||||||
|
|
||||||
# Production transport identifiers accepted by bind_native_mcp_transport.
|
# Production transport identifiers accepted by bind_native_mcp_transport.
|
||||||
_PRODUCTION_TRANSPORTS = frozenset({"stdio"})
|
# #931: the permitted set is defined once, in mcp_transport_config. This name
|
||||||
|
# is kept as an alias so the guard never restates a transport identifier.
|
||||||
|
_PRODUCTION_TRANSPORTS = mcp_transport_config.SUPPORTED_TRANSPORTS
|
||||||
_RUNTIME_MODE_PRODUCTION = "production"
|
_RUNTIME_MODE_PRODUCTION = "production"
|
||||||
_RUNTIME_MODE_TEST = "test"
|
_RUNTIME_MODE_TEST = "test"
|
||||||
_PHASE_ENTRYPOINT_CLAIMED = "entrypoint_claimed"
|
_PHASE_ENTRYPOINT_CLAIMED = "entrypoint_claimed"
|
||||||
@@ -58,6 +62,23 @@ class UnsanctionedRuntimeError(RuntimeError):
|
|||||||
"""Raised when mutation/credential code runs outside a native MCP daemon."""
|
"""Raised when mutation/credential code runs outside a native MCP daemon."""
|
||||||
|
|
||||||
|
|
||||||
|
class TransportExecutionError(UnsanctionedRuntimeError):
|
||||||
|
"""Raised when a bound transport may not be served by this entrypoint (#931).
|
||||||
|
|
||||||
|
Subclasses :class:`UnsanctionedRuntimeError` so every existing fail-closed
|
||||||
|
handler still catches it, while letting a caller that cares distinguish
|
||||||
|
"nothing is bound" from "something valid is bound but its listener has not
|
||||||
|
been commissioned". Carries the structured verdict on ``.assessment``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, assessment: dict[str, Any] | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.assessment = assessment or {}
|
||||||
|
self.blocker_kind = self.assessment.get("blocker_kind")
|
||||||
|
self.owner_issue = self.assessment.get("owner_issue")
|
||||||
|
self.transport = self.assessment.get("transport")
|
||||||
|
|
||||||
|
|
||||||
def is_pytest_runtime() -> bool:
|
def is_pytest_runtime() -> bool:
|
||||||
if (os.environ.get(FORCE_PROVENANCE_FAIL_ENV) or "").strip() in {
|
if (os.environ.get(FORCE_PROVENANCE_FAIL_ENV) or "").strip() in {
|
||||||
"1",
|
"1",
|
||||||
@@ -171,23 +192,46 @@ def mark_sanctioned_daemon() -> dict[str, Any]:
|
|||||||
return native_runtime_status()
|
return native_runtime_status()
|
||||||
|
|
||||||
|
|
||||||
def bind_native_mcp_transport(*, transport: str) -> dict[str, Any]:
|
def bind_native_mcp_transport(*, transport: str | None = None) -> dict[str, Any]:
|
||||||
"""Bind the live native MCP transport lifecycle (#695).
|
"""Bind the live native MCP transport lifecycle (#695 / #931).
|
||||||
|
|
||||||
Must be called from the resolved canonical entrypoint immediately before
|
Must be called from the resolved canonical entrypoint immediately before
|
||||||
the real MCP server transport loop (e.g. ``mcp.run(transport=\"stdio\")``).
|
the real MCP server transport loop (``mcp.run``). Requires a prior
|
||||||
Requires a prior successful :func:`mark_sanctioned_daemon` claim in this
|
successful :func:`mark_sanctioned_daemon` claim in this process.
|
||||||
process. Import-only or offline launch without this bind leaves
|
Import-only or offline launch without this bind leaves
|
||||||
:func:`is_native_mcp_transport` false.
|
:func:`is_native_mcp_transport` false.
|
||||||
|
|
||||||
|
#931: ``transport`` is now optional. Omitting it — which is what the
|
||||||
|
production entrypoint does — resolves the identifier from deployment
|
||||||
|
configuration via :func:`mcp_transport_config.resolve_configured_transport`,
|
||||||
|
yielding :data:`mcp_transport_config.DEFAULT_TRANSPORT` when nothing is
|
||||||
|
configured. An explicit argument remains supported for tests and for a
|
||||||
|
launcher that has already resolved the value. Either way the identifier is
|
||||||
|
validated against the single permitted set before the runtime record is
|
||||||
|
written, so no tool can dispatch over an unregistered transport.
|
||||||
|
|
||||||
|
The resolved value is pinned into the process-local record and is read back
|
||||||
|
only through :func:`bound_transport`. Rebinding to a different transport is
|
||||||
|
refused, so two guards can never observe different values in one process.
|
||||||
"""
|
"""
|
||||||
global _NATIVE_RUNTIME
|
global _NATIVE_RUNTIME
|
||||||
transport_name = (transport or "").strip().lower()
|
if transport is None:
|
||||||
if transport_name not in _PRODUCTION_TRANSPORTS:
|
resolution = mcp_transport_config.resolve_configured_transport()
|
||||||
raise UnsanctionedRuntimeError(
|
transport_name = str(resolution["transport"])
|
||||||
f"bind_native_mcp_transport rejected: transport {transport!r} is "
|
if not resolution["supported"]:
|
||||||
f"not a production MCP transport (#695). Allowed: "
|
raise UnsanctionedRuntimeError(
|
||||||
f"{sorted(_PRODUCTION_TRANSPORTS)}."
|
"bind_native_mcp_transport rejected: "
|
||||||
)
|
+ "; ".join(resolution["reasons"])
|
||||||
|
+ " No tool is served over an unregistered transport."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
transport_name = mcp_transport_config.normalize_transport(transport)
|
||||||
|
if transport_name not in _PRODUCTION_TRANSPORTS:
|
||||||
|
raise UnsanctionedRuntimeError(
|
||||||
|
f"bind_native_mcp_transport rejected: transport {transport!r} is "
|
||||||
|
f"not a production MCP transport (#695). Allowed: "
|
||||||
|
f"{sorted(_PRODUCTION_TRANSPORTS)}."
|
||||||
|
)
|
||||||
|
|
||||||
entrypoint_path = _caller_official_entrypoint_path()
|
entrypoint_path = _caller_official_entrypoint_path()
|
||||||
if entrypoint_path is None:
|
if entrypoint_path is None:
|
||||||
@@ -216,6 +260,23 @@ def bind_native_mcp_transport(*, transport: str) -> dict[str, Any]:
|
|||||||
"between mark and bind (#695)."
|
"between mark and bind (#695)."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# #931: one process binds one transport. Re-binding the same identifier is
|
||||||
|
# idempotent (a retried launch step must not fail); re-binding a different
|
||||||
|
# one is refused, because a guard that already read the first value would
|
||||||
|
# otherwise disagree with a guard that reads the second.
|
||||||
|
already_bound = (_NATIVE_RUNTIME.get("transport") or "").strip()
|
||||||
|
if (
|
||||||
|
already_bound
|
||||||
|
and _NATIVE_RUNTIME.get("phase") == _PHASE_TRANSPORT_BOUND
|
||||||
|
and already_bound != transport_name
|
||||||
|
):
|
||||||
|
raise UnsanctionedRuntimeError(
|
||||||
|
"bind_native_mcp_transport rejected: transport is already bound to "
|
||||||
|
f"{already_bound!r} in this process; rebinding to "
|
||||||
|
f"{transport_name!r} is forbidden (#931). Restart the server to "
|
||||||
|
"change the deployment transport."
|
||||||
|
)
|
||||||
|
|
||||||
# Pin session-state root for this server lifetime (#695 AC2 / PR #701).
|
# Pin session-state root for this server lifetime (#695 AC2 / PR #701).
|
||||||
# Changing GITEA_MCP_SESSION_STATE_DIR after bind must not manufacture a
|
# Changing GITEA_MCP_SESSION_STATE_DIR after bind must not manufacture a
|
||||||
# second authority domain for decision locks / workflow proofs.
|
# second authority domain for decision locks / workflow proofs.
|
||||||
@@ -353,6 +414,88 @@ def is_production_native_mcp_transport() -> bool:
|
|||||||
return (_NATIVE_RUNTIME or {}).get("mode") == _RUNTIME_MODE_PRODUCTION
|
return (_NATIVE_RUNTIME or {}).get("mode") == _RUNTIME_MODE_PRODUCTION
|
||||||
|
|
||||||
|
|
||||||
|
def bound_transport() -> str | None:
|
||||||
|
"""The one authoritative bound transport identifier, or ``None`` (#931).
|
||||||
|
|
||||||
|
This is the shared accessor every transport-aware guard reads. It reports
|
||||||
|
the value pinned at bind time, never the environment, so changing
|
||||||
|
``GITEA_MCP_TRANSPORT`` after the bind cannot move what a guard observes —
|
||||||
|
the same rule :func:`pinned_session_state_dir` applies to session state.
|
||||||
|
|
||||||
|
``None`` means unbound: an offline import or a launch that never reached
|
||||||
|
the bind. Callers must treat that as fail-closed, exactly as they already
|
||||||
|
treat :func:`is_native_mcp_transport` returning false.
|
||||||
|
"""
|
||||||
|
if not is_native_mcp_transport():
|
||||||
|
return None
|
||||||
|
return (_NATIVE_RUNTIME or {}).get("transport") or None
|
||||||
|
|
||||||
|
|
||||||
|
def assert_transport_bound(context: str = "tool service") -> str:
|
||||||
|
"""Return the bound transport, or fail closed before *context* (#931).
|
||||||
|
|
||||||
|
Called immediately before the server enters its transport loop so an
|
||||||
|
invalid or absent bind stops the process rather than serving tools over a
|
||||||
|
transport no guard can name.
|
||||||
|
"""
|
||||||
|
transport = bound_transport()
|
||||||
|
if transport:
|
||||||
|
return transport
|
||||||
|
raise UnsanctionedRuntimeError(
|
||||||
|
f"No MCP transport is bound; refusing {context} (#931). "
|
||||||
|
"bind_native_mcp_transport must succeed from the canonical entrypoint "
|
||||||
|
"before any tool is served. Offline import and standalone launch "
|
||||||
|
"cannot reconstruct a bind."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assess_serve_authorization() -> dict[str, Any]:
|
||||||
|
"""Structured verdict on whether this process may serve tools (#931).
|
||||||
|
|
||||||
|
This is the decision that consumes :func:`bound_transport`. It is what stops
|
||||||
|
the bound identifier from being reporting-only metadata: the serve path
|
||||||
|
cannot proceed unless the value pinned at bind is one this entrypoint is
|
||||||
|
commissioned to execute.
|
||||||
|
|
||||||
|
Never raises; returns the verdict so callers and diagnostics can inspect it.
|
||||||
|
"""
|
||||||
|
return mcp_transport_config.assess_transport_execution(bound_transport())
|
||||||
|
|
||||||
|
|
||||||
|
def authorize_transport_execution(context: str = "tool service") -> str:
|
||||||
|
"""Return the transport this process may serve, or fail closed (#931).
|
||||||
|
|
||||||
|
Two distinct boundaries, in order:
|
||||||
|
|
||||||
|
1. **Bind presence** — :func:`assert_transport_bound` enforces the
|
||||||
|
pre-existing #695 contract, so an unbound runtime keeps its established
|
||||||
|
failure and reason code.
|
||||||
|
2. **Execution authorization** — the bound identifier must be one this
|
||||||
|
entrypoint is commissioned to serve. A registered transport whose
|
||||||
|
listener has not been commissioned is refused here, before any listener
|
||||||
|
is created and before any tool can dispatch.
|
||||||
|
|
||||||
|
That ordering matters: recognition, validation and durable recording all
|
||||||
|
still happen for a remote identifier, so #931's seam is intact; only the act
|
||||||
|
of *serving* it is withheld until its owning issue commissions it.
|
||||||
|
"""
|
||||||
|
# Boundary 1: unbound stays exactly as fail-closed as it was under #695.
|
||||||
|
assert_transport_bound(context)
|
||||||
|
|
||||||
|
# Boundary 2: bound, but is this entrypoint allowed to serve it?
|
||||||
|
assessment = assess_serve_authorization()
|
||||||
|
if assessment.get("allowed"):
|
||||||
|
return str(assessment["transport"])
|
||||||
|
|
||||||
|
reasons = "; ".join(assessment.get("reasons") or []) or "not authorized"
|
||||||
|
next_action = assessment.get("exact_next_action") or ""
|
||||||
|
raise TransportExecutionError(
|
||||||
|
f"Refusing {context} (#931) [{assessment.get('blocker_kind')}]: "
|
||||||
|
f"{reasons} {next_action}".strip(),
|
||||||
|
assessment,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_sanctioned_mcp_daemon() -> bool:
|
def is_sanctioned_mcp_daemon() -> bool:
|
||||||
"""Backward-compatible name; #695 requires native transport, not env alone."""
|
"""Backward-compatible name; #695 requires native transport, not env alone."""
|
||||||
if is_production_native_mcp_transport():
|
if is_production_native_mcp_transport():
|
||||||
@@ -476,6 +619,21 @@ def native_runtime_status() -> dict[str, Any]:
|
|||||||
"entrypoint_path": rt.get("entrypoint_path"),
|
"entrypoint_path": rt.get("entrypoint_path"),
|
||||||
"phase": rt.get("phase"),
|
"phase": rt.get("phase"),
|
||||||
"transport": rt.get("transport"),
|
"transport": rt.get("transport"),
|
||||||
|
# #931: the authoritative bound identifier, plus the seam that defines
|
||||||
|
# what may be bound. ``bound_transport`` is None until a bind succeeds,
|
||||||
|
# so an offline import is distinguishable from a stdio session.
|
||||||
|
"bound_transport": bound_transport(),
|
||||||
|
"transport_bound": bound_transport() is not None,
|
||||||
|
"default_transport": mcp_transport_config.DEFAULT_TRANSPORT,
|
||||||
|
"supported_transports": list(mcp_transport_config.supported_transports()),
|
||||||
|
"transport_env": mcp_transport_config.TRANSPORT_ENV,
|
||||||
|
# #931 review 635: recognition and execution authorization are distinct.
|
||||||
|
# ``supported`` is what may be bound; ``executable`` is what this
|
||||||
|
# entrypoint may actually serve. A recognized-but-uncommissioned
|
||||||
|
# transport reports serve_authorized False with a named blocker.
|
||||||
|
"executable_transports": list(mcp_transport_config.executable_transports()),
|
||||||
|
"serve_authorized": bool(assess_serve_authorization().get("allowed")),
|
||||||
|
"serve_authorization": assess_serve_authorization(),
|
||||||
"mode": rt.get("mode"),
|
"mode": rt.get("mode"),
|
||||||
"session_state_dir": pinned_session_state_dir() or rt.get("session_state_dir"),
|
"session_state_dir": pinned_session_state_dir() or rt.get("session_state_dir"),
|
||||||
"session_state_dir_pinned": pinned_session_state_dir() is not None,
|
"session_state_dir_pinned": pinned_session_state_dir() is not None,
|
||||||
@@ -502,7 +660,12 @@ def mutation_provenance_fields() -> dict[str, Any]:
|
|||||||
if st.get("mode") == _RUNTIME_MODE_TEST and st["native_mcp_transport"]:
|
if st.get("mode") == _RUNTIME_MODE_TEST and st["native_mcp_transport"]:
|
||||||
transport = "test_native_mcp"
|
transport = "test_native_mcp"
|
||||||
return {
|
return {
|
||||||
|
# ``transport`` stays the trust *class* it has always been, so existing
|
||||||
|
# durable records keep their shape. ``bound_transport`` (#931) adds the
|
||||||
|
# bound identifier itself, which is what lets an operator tell from a
|
||||||
|
# durable record which transport performed a mutation.
|
||||||
"transport": transport,
|
"transport": transport,
|
||||||
|
"bound_transport": st.get("bound_transport"),
|
||||||
"native_mcp_transport": bool(st["native_mcp_transport"]),
|
"native_mcp_transport": bool(st["native_mcp_transport"]),
|
||||||
"production_native_mcp_transport": bool(
|
"production_native_mcp_transport": bool(
|
||||||
st.get("production_native_mcp_transport")
|
st.get("production_native_mcp_transport")
|
||||||
|
|||||||
@@ -0,0 +1,869 @@
|
|||||||
|
"""Instance-level fleet identity and health snapshots (#978).
|
||||||
|
|
||||||
|
#948 established per-worker ownership; #975 made heartbeats keep those rows
|
||||||
|
live. Neither surface could enumerate the fleet at *instance* granularity:
|
||||||
|
which application launch owns which five namespace workers, whether two
|
||||||
|
Codex launches are distinct, or whether a live collision is real rather than
|
||||||
|
a shared client type.
|
||||||
|
|
||||||
|
This module is pure. Callers supply registry rows (and optional enrichments);
|
||||||
|
nothing here opens SQLite, scans process tables, or mutates state. Production
|
||||||
|
evidence for the fleet gate is the snapshot returned by the sanctioned
|
||||||
|
controller/reconciler tool that wraps this assessor.
|
||||||
|
|
||||||
|
Identity hierarchy (highest → lowest):
|
||||||
|
|
||||||
|
* ``client_type`` — application family (``codex``, ``claude_code``, …)
|
||||||
|
* ``client_instance_id`` — one running application launch (trusted launcher)
|
||||||
|
* ``fleet_run_id`` — operator-approved enrollment / canary cohort
|
||||||
|
* ``worker_id`` / ``worker_identity`` — one namespace worker process
|
||||||
|
* ``namespace`` — author | reviewer | merger | controller | reconciler
|
||||||
|
|
||||||
|
Multiple simultaneous instances of the same ``client_type`` are first-class.
|
||||||
|
Sharing only a profile or client type is never a duplicate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Callable, Iterable, Mapping
|
||||||
|
|
||||||
|
import mcp_worker_identity as mwi
|
||||||
|
|
||||||
|
# --- Classification labels ------------------------------------------------
|
||||||
|
|
||||||
|
CLASS_EXPECTED = "expected_enrolled"
|
||||||
|
CLASS_MISSING = "missing_expected"
|
||||||
|
CLASS_UNMANIFESTED = "unmanifested"
|
||||||
|
CLASS_DUPLICATE_NAMESPACE = "duplicate_namespace_worker"
|
||||||
|
CLASS_INSTANCE_ID_COLLISION = "instance_id_collision"
|
||||||
|
CLASS_WORKER_ID_COLLISION = "worker_identity_collision"
|
||||||
|
CLASS_SESSION_COLLISION = "session_identity_collision"
|
||||||
|
CLASS_GENERATION_COLLISION = "generation_identity_collision"
|
||||||
|
CLASS_PROCESS_COLLISION = "process_identity_collision"
|
||||||
|
CLASS_PID_COLLISION = "pid_collision"
|
||||||
|
CLASS_OWNERSHIP_COLLISION = "ownership_fencing_collision"
|
||||||
|
CLASS_ORPHANED = "orphaned_unowned"
|
||||||
|
CLASS_UNKNOWN_CLIENT = "unknown_client"
|
||||||
|
CLASS_FOREIGN_REPOSITORY = "foreign_repository"
|
||||||
|
CLASS_OLD_REVISION = "old_revision"
|
||||||
|
CLASS_STALE_WORKER = "stale_orphaned_worker"
|
||||||
|
CLASS_LEGACY_INCOMPLETE = "legacy_incomplete_identity"
|
||||||
|
CLASS_HISTORICAL = "historical_dead"
|
||||||
|
CLASS_HEALTHY = "healthy"
|
||||||
|
|
||||||
|
#: Active blockers that make the live fleet unsafe for mutation-gated work.
|
||||||
|
ACTIVE_BLOCKER_CLASSES = frozenset(
|
||||||
|
{
|
||||||
|
CLASS_MISSING,
|
||||||
|
CLASS_UNMANIFESTED,
|
||||||
|
CLASS_DUPLICATE_NAMESPACE,
|
||||||
|
CLASS_INSTANCE_ID_COLLISION,
|
||||||
|
CLASS_WORKER_ID_COLLISION,
|
||||||
|
CLASS_SESSION_COLLISION,
|
||||||
|
CLASS_GENERATION_COLLISION,
|
||||||
|
CLASS_PROCESS_COLLISION,
|
||||||
|
CLASS_PID_COLLISION,
|
||||||
|
CLASS_OWNERSHIP_COLLISION,
|
||||||
|
CLASS_ORPHANED,
|
||||||
|
CLASS_UNKNOWN_CLIENT,
|
||||||
|
CLASS_FOREIGN_REPOSITORY,
|
||||||
|
CLASS_OLD_REVISION,
|
||||||
|
CLASS_STALE_WORKER,
|
||||||
|
CLASS_LEGACY_INCOMPLETE,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SANCTIONED_NAMESPACES = frozenset(
|
||||||
|
{"author", "reviewer", "merger", "controller", "reconciler"}
|
||||||
|
)
|
||||||
|
|
||||||
|
INSTANCE_ID_PROVENANCE_TRUSTED = "trusted_launcher"
|
||||||
|
INSTANCE_ID_PROVENANCE_LEGACY = "legacy_incomplete"
|
||||||
|
INSTANCE_ID_PROVENANCE_MISSING = "missing"
|
||||||
|
|
||||||
|
CLIENT_INSTANCE_ENV = "GITEA_MCP_CLIENT_INSTANCE"
|
||||||
|
FLEET_RUN_ENV = "GITEA_MCP_FLEET_RUN_ID"
|
||||||
|
PROCESS_IDENTITY_ENV = "GITEA_MCP_PROCESS_IDENTITY"
|
||||||
|
INSTANCE_PROVENANCE_ENV = "GITEA_MCP_INSTANCE_PROVENANCE"
|
||||||
|
|
||||||
|
_LEGACY_INSTANCE_PREFIXES = ("pid-", "proc-", "legacy-")
|
||||||
|
#: Trusted launcher-issued IDs use the reserved ``inst-`` prefix
|
||||||
|
#: (see :func:`generate_client_instance_id`). Ordinary user-supplied strings
|
||||||
|
#: without that prefix never count as trusted attribution.
|
||||||
|
_TRUSTED_INSTANCE_PREFIX = "inst-"
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _ts(value: datetime) -> str:
|
||||||
|
return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_client_instance_id(
|
||||||
|
client_type: str | None,
|
||||||
|
*,
|
||||||
|
launch_nonce: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Mint a distinct instance ID for one application launch (#978).
|
||||||
|
|
||||||
|
The trusted launcher (or host that starts all five namespace workers)
|
||||||
|
generates this once per launch and injects it as ``GITEA_MCP_CLIENT_INSTANCE``
|
||||||
|
into every worker environment. Workers never invent their own instance ID
|
||||||
|
from PID proximity or timestamps.
|
||||||
|
"""
|
||||||
|
client = mwi.normalize_client_name(client_type)
|
||||||
|
stamp = (now or _utc_now()).astimezone(timezone.utc).strftime(
|
||||||
|
mwi.IDENTITY_TIMESTAMP_FORMAT
|
||||||
|
)
|
||||||
|
nonce = launch_nonce if launch_nonce is not None else secrets.token_hex(16)
|
||||||
|
digest = hashlib.sha256(
|
||||||
|
f"{client}\x1f{stamp}\x1f{nonce}".encode("utf-8")
|
||||||
|
).hexdigest()[:12]
|
||||||
|
return f"inst-{client}-{stamp}-{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def assess_instance_identity(
|
||||||
|
raw_instance_id: str | None,
|
||||||
|
*,
|
||||||
|
source: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Classify whether a client_instance_id is trusted enough for mutation.
|
||||||
|
|
||||||
|
Trusted instance IDs are non-empty, match the launcher-minted ``inst-…``
|
||||||
|
format from :func:`generate_client_instance_id`, and are not pre-#978
|
||||||
|
PID/proc/legacy placeholders. Ordinary user-supplied or malformed values
|
||||||
|
fail closed as untrusted so they cannot spoof multi-instance attribution.
|
||||||
|
Incomplete identities remain visible for diagnosis but cannot authorize
|
||||||
|
unsafe mutation.
|
||||||
|
"""
|
||||||
|
text = (raw_instance_id or "").strip()
|
||||||
|
if not text:
|
||||||
|
return {
|
||||||
|
"client_instance_id": None,
|
||||||
|
"complete": False,
|
||||||
|
"trusted": False,
|
||||||
|
"provenance": INSTANCE_ID_PROVENANCE_MISSING,
|
||||||
|
"reasons": [
|
||||||
|
"client_instance_id is missing; the trusted launcher must set "
|
||||||
|
f"{CLIENT_INSTANCE_ENV} once per application launch"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
lowered = text.lower()
|
||||||
|
if lowered.startswith(_LEGACY_INSTANCE_PREFIXES) or source == "pid_fallback":
|
||||||
|
return {
|
||||||
|
"client_instance_id": text,
|
||||||
|
"complete": False,
|
||||||
|
"trusted": False,
|
||||||
|
"provenance": INSTANCE_ID_PROVENANCE_LEGACY,
|
||||||
|
"reasons": [
|
||||||
|
f"client_instance_id {text!r} is a legacy PID/process fallback, "
|
||||||
|
"not a trusted launcher-issued instance identity"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if not text.startswith(_TRUSTED_INSTANCE_PREFIX) or len(text) <= len(
|
||||||
|
_TRUSTED_INSTANCE_PREFIX
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"client_instance_id": text,
|
||||||
|
"complete": False,
|
||||||
|
"trusted": False,
|
||||||
|
"provenance": INSTANCE_ID_PROVENANCE_LEGACY,
|
||||||
|
"reasons": [
|
||||||
|
f"client_instance_id {text!r} is malformed or user-supplied and "
|
||||||
|
f"does not use the trusted launcher prefix "
|
||||||
|
f"{_TRUSTED_INSTANCE_PREFIX!r}; refusing trusted attribution"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"client_instance_id": text,
|
||||||
|
"complete": True,
|
||||||
|
"trusted": True,
|
||||||
|
"provenance": INSTANCE_ID_PROVENANCE_TRUSTED,
|
||||||
|
"reasons": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_client_instance_from_env(
|
||||||
|
env: Mapping[str, str] | None = None,
|
||||||
|
*,
|
||||||
|
pid: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Resolve instance identity from launcher env without inventing one.
|
||||||
|
|
||||||
|
When the trusted key is absent, return incomplete evidence rather than a
|
||||||
|
silent ``pid-<n>`` identity. Callers that still need a non-empty registry
|
||||||
|
key may choose a legacy placeholder deliberately; they must not treat it as
|
||||||
|
trusted.
|
||||||
|
"""
|
||||||
|
source = dict(env or {})
|
||||||
|
raw = (source.get(CLIENT_INSTANCE_ENV) or "").strip()
|
||||||
|
assessment = assess_instance_identity(raw or None)
|
||||||
|
assessment["fleet_run_id"] = (source.get(FLEET_RUN_ENV) or "").strip() or None
|
||||||
|
assessment["process_identity"] = (
|
||||||
|
(source.get(PROCESS_IDENTITY_ENV) or "").strip()
|
||||||
|
or (f"pid-{pid}" if pid is not None else None)
|
||||||
|
)
|
||||||
|
return assessment
|
||||||
|
|
||||||
|
|
||||||
|
def _public_worker(record: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"worker_id": record.get("worker_identity") or record.get("worker_id"),
|
||||||
|
"worker_identity": record.get("worker_identity") or record.get("worker_id"),
|
||||||
|
"client_type": mwi.normalize_client_name(
|
||||||
|
record.get("client_name") or record.get("client_type")
|
||||||
|
),
|
||||||
|
"client_instance_id": record.get("client_instance_id"),
|
||||||
|
"fleet_run_id": record.get("fleet_run_id"),
|
||||||
|
"namespace": record.get("namespace"),
|
||||||
|
"profile": record.get("profile"),
|
||||||
|
"declared_role": record.get("role") or record.get("declared_role"),
|
||||||
|
"authenticated_account": record.get("authenticated_account"),
|
||||||
|
"session_id": record.get("session_id"),
|
||||||
|
"generation_id": record.get("generation_id"),
|
||||||
|
"process_identity": record.get("process_identity")
|
||||||
|
or (
|
||||||
|
f"pid-{record['pid']}"
|
||||||
|
if record.get("pid") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"pid": record.get("pid"),
|
||||||
|
"repository_binding": record.get("repository_binding"),
|
||||||
|
"remote": record.get("remote"),
|
||||||
|
"startup_revision": record.get("startup_revision"),
|
||||||
|
"loaded_revision": record.get("loaded_revision"),
|
||||||
|
"parity_revision": record.get("parity_revision"),
|
||||||
|
"live_revision": record.get("live_revision"),
|
||||||
|
"runtime_provenance": record.get("runtime_provenance")
|
||||||
|
or record.get("transport"),
|
||||||
|
"transport": record.get("transport"),
|
||||||
|
"started_at": record.get("started_at"),
|
||||||
|
"last_heartbeat_at": record.get("last_heartbeat_at"),
|
||||||
|
"heartbeat_ttl_seconds": record.get("heartbeat_ttl_seconds"),
|
||||||
|
"fencing_epoch": record.get("fencing_epoch"),
|
||||||
|
"status": record.get("status"),
|
||||||
|
"instance_id_provenance": record.get("instance_id_provenance"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _liveness(
|
||||||
|
record: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
now: datetime | None,
|
||||||
|
pid_alive_probe: Callable[[int | None], bool | None] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
pid_alive = None
|
||||||
|
if pid_alive_probe is not None and record.get("pid") is not None:
|
||||||
|
try:
|
||||||
|
pid_alive = pid_alive_probe(record.get("pid"))
|
||||||
|
except Exception:
|
||||||
|
pid_alive = None
|
||||||
|
return mwi.WorkerRegistry.is_live(record, now=now, pid_alive=pid_alive)
|
||||||
|
|
||||||
|
|
||||||
|
def _consistency_token(rows: Iterable[Mapping[str, Any]], snapshot_at: str) -> str:
|
||||||
|
material = [snapshot_at]
|
||||||
|
for row in sorted(
|
||||||
|
rows,
|
||||||
|
key=lambda r: (
|
||||||
|
str(r.get("worker_identity") or ""),
|
||||||
|
str(r.get("last_heartbeat_at") or ""),
|
||||||
|
str(r.get("fencing_epoch") or ""),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
material.append(
|
||||||
|
"|".join(
|
||||||
|
[
|
||||||
|
str(row.get("worker_identity") or ""),
|
||||||
|
str(row.get("client_instance_id") or ""),
|
||||||
|
str(row.get("status") or ""),
|
||||||
|
str(row.get("last_heartbeat_at") or ""),
|
||||||
|
str(row.get("fencing_epoch") or ""),
|
||||||
|
str(row.get("generation_id") or ""),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
digest = hashlib.sha256("\n".join(material).encode("utf-8")).hexdigest()[:16]
|
||||||
|
return f"fleetrev-{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_worker_snapshot_row(
|
||||||
|
record: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
pid_alive_probe: Callable[[int | None], bool | None] | None = None,
|
||||||
|
canonical_repository: str | None = None,
|
||||||
|
expected_live_revision: str | None = None,
|
||||||
|
heartbeat_supervised: bool | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""One point-in-time worker row for the fleet snapshot."""
|
||||||
|
stamp = now or _utc_now()
|
||||||
|
base = _public_worker(record)
|
||||||
|
identity = assess_instance_identity(
|
||||||
|
base.get("client_instance_id"),
|
||||||
|
source=record.get("instance_id_source"),
|
||||||
|
)
|
||||||
|
liveness = _liveness(record, now=stamp, pid_alive_probe=pid_alive_probe)
|
||||||
|
is_historical = str(record.get("status") or "") != mwi.STATUS_ACTIVE
|
||||||
|
live = bool(liveness.get("live")) and not is_historical
|
||||||
|
|
||||||
|
repo = (base.get("repository_binding") or "").strip() or None
|
||||||
|
foreign_repo = bool(
|
||||||
|
canonical_repository
|
||||||
|
and repo
|
||||||
|
and repo.rstrip("/") != str(canonical_repository).rstrip("/")
|
||||||
|
)
|
||||||
|
old_revision = False
|
||||||
|
if expected_live_revision:
|
||||||
|
for key in ("startup_revision", "loaded_revision", "parity_revision", "live_revision"):
|
||||||
|
rev = (base.get(key) or "").strip()
|
||||||
|
if rev and rev != expected_live_revision:
|
||||||
|
old_revision = True
|
||||||
|
break
|
||||||
|
|
||||||
|
ownership_state = "historical" if is_historical else (
|
||||||
|
"live" if live else "stale"
|
||||||
|
)
|
||||||
|
if live and not identity["trusted"]:
|
||||||
|
ownership_state = "live_untrusted_identity"
|
||||||
|
if live and not base.get("session_id"):
|
||||||
|
ownership_state = "orphaned"
|
||||||
|
|
||||||
|
mutation_safe = bool(
|
||||||
|
live
|
||||||
|
and identity["trusted"]
|
||||||
|
and not foreign_repo
|
||||||
|
and not old_revision
|
||||||
|
and ownership_state == "live"
|
||||||
|
and base.get("client_type") != mwi.UNKNOWN_CLIENT
|
||||||
|
)
|
||||||
|
|
||||||
|
restart_required = bool(
|
||||||
|
old_revision
|
||||||
|
or (live and not liveness.get("heartbeat_fresh", True))
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
**base,
|
||||||
|
"instance_identity": identity,
|
||||||
|
"client_instance_id": identity["client_instance_id"] or base.get("client_instance_id"),
|
||||||
|
"instance_id_provenance": identity["provenance"],
|
||||||
|
"instance_identity_trusted": identity["trusted"],
|
||||||
|
"live": live,
|
||||||
|
"historical": is_historical,
|
||||||
|
"liveness": liveness,
|
||||||
|
"heartbeat": {
|
||||||
|
"registered": bool(base.get("last_heartbeat_at")),
|
||||||
|
"supervised": heartbeat_supervised,
|
||||||
|
"age_seconds": liveness.get("heartbeat_age_seconds"),
|
||||||
|
"ttl_seconds": liveness.get("heartbeat_ttl_seconds"),
|
||||||
|
"fresh": liveness.get("heartbeat_fresh"),
|
||||||
|
"last_heartbeat_at": base.get("last_heartbeat_at"),
|
||||||
|
},
|
||||||
|
"fencing": {
|
||||||
|
"fencing_epoch": base.get("fencing_epoch"),
|
||||||
|
"generation_id": base.get("generation_id"),
|
||||||
|
},
|
||||||
|
"ownership_state": ownership_state,
|
||||||
|
"foreign_repository": foreign_repo,
|
||||||
|
"old_revision": old_revision,
|
||||||
|
"stale": not live and not is_historical,
|
||||||
|
"restart_required": restart_required,
|
||||||
|
"mutation_safe": mutation_safe,
|
||||||
|
"conflicting_live_sessions": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _collision_groups(
|
||||||
|
live_rows: list[dict[str, Any]],
|
||||||
|
key_fn,
|
||||||
|
) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||||
|
for row in live_rows:
|
||||||
|
key = key_fn(row)
|
||||||
|
if key is None or key == "" or key == "None":
|
||||||
|
continue
|
||||||
|
groups[str(key)].append(row)
|
||||||
|
return {k: v for k, v in groups.items() if len(v) > 1}
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_instance_fleet(
|
||||||
|
records: Iterable[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
expected_manifest: list[Mapping[str, Any]] | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
pid_alive_probe: Callable[[int | None], bool | None] | None = None,
|
||||||
|
canonical_repository: str | None = None,
|
||||||
|
expected_live_revision: str | None = None,
|
||||||
|
registry_revision: str | None = None,
|
||||||
|
known_client_types: Iterable[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Authoritative point-in-time fleet snapshot with classification (#978).
|
||||||
|
|
||||||
|
Historical dead rows are reported separately and never automatically make
|
||||||
|
the live fleet unsafe.
|
||||||
|
"""
|
||||||
|
stamp = now or _utc_now()
|
||||||
|
snapshot_at = _ts(stamp)
|
||||||
|
known = {
|
||||||
|
mwi.normalize_client_name(c)
|
||||||
|
for c in (known_client_types or mwi.CLIENT_ALIASES.values())
|
||||||
|
}
|
||||||
|
known.discard(mwi.UNKNOWN_CLIENT)
|
||||||
|
|
||||||
|
all_rows: list[dict[str, Any]] = []
|
||||||
|
for record in records:
|
||||||
|
all_rows.append(
|
||||||
|
build_worker_snapshot_row(
|
||||||
|
record,
|
||||||
|
now=stamp,
|
||||||
|
pid_alive_probe=pid_alive_probe,
|
||||||
|
canonical_repository=canonical_repository,
|
||||||
|
expected_live_revision=expected_live_revision,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
live_rows = [r for r in all_rows if r["live"]]
|
||||||
|
historical_rows = [r for r in all_rows if r["historical"]]
|
||||||
|
stale_rows = [r for r in all_rows if r["stale"]]
|
||||||
|
|
||||||
|
# --- identity collisions among live workers ---
|
||||||
|
findings: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def _finding(
|
||||||
|
classification: str,
|
||||||
|
*,
|
||||||
|
severity: str,
|
||||||
|
workers: list[dict[str, Any]] | None = None,
|
||||||
|
instance_ids: list[str] | None = None,
|
||||||
|
detail: str,
|
||||||
|
active_blocker: bool,
|
||||||
|
) -> None:
|
||||||
|
findings.append(
|
||||||
|
{
|
||||||
|
"classification": classification,
|
||||||
|
"severity": severity,
|
||||||
|
"active_blocker": active_blocker,
|
||||||
|
"detail": detail,
|
||||||
|
"client_instance_ids": instance_ids or sorted(
|
||||||
|
{
|
||||||
|
str(w.get("client_instance_id"))
|
||||||
|
for w in (workers or [])
|
||||||
|
if w.get("client_instance_id")
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"worker_identities": [
|
||||||
|
w.get("worker_identity") for w in (workers or [])
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Duplicate worker identity (should not happen with PK, still detect)
|
||||||
|
for wid, group in _collision_groups(
|
||||||
|
live_rows, lambda r: r.get("worker_identity")
|
||||||
|
).items():
|
||||||
|
_finding(
|
||||||
|
CLASS_WORKER_ID_COLLISION,
|
||||||
|
severity="blocker",
|
||||||
|
workers=group,
|
||||||
|
detail=f"worker identity {wid!r} is claimed by {len(group)} live workers",
|
||||||
|
active_blocker=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reused session identity across live workers
|
||||||
|
for sid, group in _collision_groups(live_rows, lambda r: r.get("session_id")).items():
|
||||||
|
# Same session may appear once; collision only when multiple workers share it
|
||||||
|
# across different worker identities (always true for group size > 1).
|
||||||
|
_finding(
|
||||||
|
CLASS_SESSION_COLLISION,
|
||||||
|
severity="blocker",
|
||||||
|
workers=group,
|
||||||
|
detail=f"session identity {sid!r} is reused by {len(group)} live workers",
|
||||||
|
active_blocker=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generation claimed by multiple live sessions/workers is a conflict when
|
||||||
|
# the workers are not the five sanctioned namespaces of one instance.
|
||||||
|
for gen, group in _collision_groups(
|
||||||
|
live_rows, lambda r: r.get("generation_id")
|
||||||
|
).items():
|
||||||
|
namespaces = {g.get("namespace") for g in group if g.get("namespace")}
|
||||||
|
instance_ids = {g.get("client_instance_id") for g in group}
|
||||||
|
# Multiple workers under one generation is only valid if they share one
|
||||||
|
# instance and distinct namespaces. Same generation + same namespace = bad.
|
||||||
|
by_ns: dict[str, list] = defaultdict(list)
|
||||||
|
for g in group:
|
||||||
|
by_ns[str(g.get("namespace") or "")].append(g)
|
||||||
|
ns_dups = {ns: rows for ns, rows in by_ns.items() if ns and len(rows) > 1}
|
||||||
|
if ns_dups or len(instance_ids) > 1:
|
||||||
|
_finding(
|
||||||
|
CLASS_GENERATION_COLLISION,
|
||||||
|
severity="blocker",
|
||||||
|
workers=group,
|
||||||
|
detail=(
|
||||||
|
f"generation {gen!r} is contested across namespaces/instances "
|
||||||
|
f"(namespaces={sorted(namespaces)}, "
|
||||||
|
f"instances={sorted(str(i) for i in instance_ids if i)})"
|
||||||
|
),
|
||||||
|
active_blocker=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process identity / PID collisions across distinct workers
|
||||||
|
for proc, group in _collision_groups(
|
||||||
|
live_rows, lambda r: r.get("process_identity")
|
||||||
|
).items():
|
||||||
|
if len({r.get("worker_identity") for r in group}) > 1:
|
||||||
|
_finding(
|
||||||
|
CLASS_PROCESS_COLLISION,
|
||||||
|
severity="blocker",
|
||||||
|
workers=group,
|
||||||
|
detail=f"process identity {proc!r} is shared by distinct live workers",
|
||||||
|
active_blocker=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
for pid, group in _collision_groups(live_rows, lambda r: r.get("pid")).items():
|
||||||
|
if len({r.get("worker_identity") for r in group}) > 1:
|
||||||
|
_finding(
|
||||||
|
CLASS_PID_COLLISION,
|
||||||
|
severity="blocker",
|
||||||
|
workers=group,
|
||||||
|
detail=f"PID {pid} is shared by distinct live workers",
|
||||||
|
active_blocker=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fencing/ownership: same fencing epoch on different workers of different instances
|
||||||
|
for epoch, group in _collision_groups(
|
||||||
|
live_rows,
|
||||||
|
lambda r: (
|
||||||
|
f"{r.get('generation_id')}:{r.get('fencing_epoch')}"
|
||||||
|
if r.get("generation_id") is not None and r.get("fencing_epoch") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
).items():
|
||||||
|
if len({r.get("client_instance_id") for r in group}) > 1:
|
||||||
|
_finding(
|
||||||
|
CLASS_OWNERSHIP_COLLISION,
|
||||||
|
severity="blocker",
|
||||||
|
workers=group,
|
||||||
|
detail=(
|
||||||
|
f"fencing token {epoch!r} spans more than one client_instance_id"
|
||||||
|
),
|
||||||
|
active_blocker=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-instance grouping
|
||||||
|
by_instance: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||||
|
unkeyed_live: list[dict[str, Any]] = []
|
||||||
|
for row in live_rows:
|
||||||
|
iid = row.get("client_instance_id")
|
||||||
|
if not iid:
|
||||||
|
unkeyed_live.append(row)
|
||||||
|
continue
|
||||||
|
by_instance[str(iid)].append(row)
|
||||||
|
|
||||||
|
instances: list[dict[str, Any]] = []
|
||||||
|
for iid, workers in sorted(by_instance.items()):
|
||||||
|
client_types = sorted({w.get("client_type") for w in workers if w.get("client_type")})
|
||||||
|
trusted = all(w.get("instance_identity_trusted") for w in workers)
|
||||||
|
namespaces = [w.get("namespace") for w in workers]
|
||||||
|
ns_counts: dict[str, int] = defaultdict(int)
|
||||||
|
for ns in namespaces:
|
||||||
|
if ns:
|
||||||
|
ns_counts[str(ns)] += 1
|
||||||
|
dup_ns = sorted(ns for ns, n in ns_counts.items() if n > 1)
|
||||||
|
if dup_ns:
|
||||||
|
_finding(
|
||||||
|
CLASS_DUPLICATE_NAMESPACE,
|
||||||
|
severity="blocker",
|
||||||
|
workers=[w for w in workers if w.get("namespace") in dup_ns],
|
||||||
|
instance_ids=[iid],
|
||||||
|
detail=(
|
||||||
|
f"instance {iid!r} has more than one live worker for "
|
||||||
|
f"namespace(s) {dup_ns}"
|
||||||
|
),
|
||||||
|
active_blocker=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Live reuse of one instance ID with incompatible client types
|
||||||
|
if len(client_types) > 1:
|
||||||
|
_finding(
|
||||||
|
CLASS_INSTANCE_ID_COLLISION,
|
||||||
|
severity="blocker",
|
||||||
|
workers=workers,
|
||||||
|
instance_ids=[iid],
|
||||||
|
detail=(
|
||||||
|
f"client_instance_id {iid!r} is live under multiple client "
|
||||||
|
f"types {client_types}"
|
||||||
|
),
|
||||||
|
active_blocker=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not trusted:
|
||||||
|
_finding(
|
||||||
|
CLASS_LEGACY_INCOMPLETE,
|
||||||
|
severity="blocker",
|
||||||
|
workers=workers,
|
||||||
|
instance_ids=[iid],
|
||||||
|
detail=(
|
||||||
|
f"instance {iid!r} lacks trusted launcher-issued instance "
|
||||||
|
"identity; diagnostic reads remain available"
|
||||||
|
),
|
||||||
|
active_blocker=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
unknown = [w for w in workers if w.get("client_type") == mwi.UNKNOWN_CLIENT]
|
||||||
|
if unknown:
|
||||||
|
_finding(
|
||||||
|
CLASS_UNKNOWN_CLIENT,
|
||||||
|
severity="blocker",
|
||||||
|
workers=unknown,
|
||||||
|
instance_ids=[iid],
|
||||||
|
detail=f"instance {iid!r} has worker(s) with unknown client_type",
|
||||||
|
active_blocker=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
foreign = [w for w in workers if w.get("foreign_repository")]
|
||||||
|
if foreign:
|
||||||
|
_finding(
|
||||||
|
CLASS_FOREIGN_REPOSITORY,
|
||||||
|
severity="blocker",
|
||||||
|
workers=foreign,
|
||||||
|
instance_ids=[iid],
|
||||||
|
detail=f"instance {iid!r} has foreign-repository workers",
|
||||||
|
active_blocker=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
old = [w for w in workers if w.get("old_revision")]
|
||||||
|
if old:
|
||||||
|
_finding(
|
||||||
|
CLASS_OLD_REVISION,
|
||||||
|
severity="blocker",
|
||||||
|
workers=old,
|
||||||
|
instance_ids=[iid],
|
||||||
|
detail=f"instance {iid!r} has old-revision workers",
|
||||||
|
active_blocker=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
orphans = [w for w in workers if w.get("ownership_state") == "orphaned"]
|
||||||
|
if orphans:
|
||||||
|
_finding(
|
||||||
|
CLASS_ORPHANED,
|
||||||
|
severity="blocker",
|
||||||
|
workers=orphans,
|
||||||
|
instance_ids=[iid],
|
||||||
|
detail=f"instance {iid!r} has orphaned/unowned workers",
|
||||||
|
active_blocker=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
instances.append(
|
||||||
|
{
|
||||||
|
"client_instance_id": iid,
|
||||||
|
"client_types": client_types,
|
||||||
|
"client_type": client_types[0] if len(client_types) == 1 else None,
|
||||||
|
"fleet_run_ids": sorted(
|
||||||
|
{w.get("fleet_run_id") for w in workers if w.get("fleet_run_id")}
|
||||||
|
),
|
||||||
|
"worker_count": len(workers),
|
||||||
|
"namespaces": sorted({n for n in namespaces if n}),
|
||||||
|
"namespace_counts": dict(ns_counts),
|
||||||
|
"duplicate_namespaces": dup_ns,
|
||||||
|
"trusted_instance_identity": trusted,
|
||||||
|
"workers": workers,
|
||||||
|
"mutation_safe": all(w.get("mutation_safe") for w in workers)
|
||||||
|
and not dup_ns
|
||||||
|
and trusted,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in unkeyed_live:
|
||||||
|
_finding(
|
||||||
|
CLASS_LEGACY_INCOMPLETE,
|
||||||
|
severity="blocker",
|
||||||
|
workers=[row],
|
||||||
|
detail="live worker has no client_instance_id",
|
||||||
|
active_blocker=True,
|
||||||
|
)
|
||||||
|
if row.get("client_type") == mwi.UNKNOWN_CLIENT:
|
||||||
|
_finding(
|
||||||
|
CLASS_UNKNOWN_CLIENT,
|
||||||
|
severity="blocker",
|
||||||
|
workers=[row],
|
||||||
|
detail="live worker has unknown client_type and no instance id",
|
||||||
|
active_blocker=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in stale_rows:
|
||||||
|
_finding(
|
||||||
|
CLASS_STALE_WORKER,
|
||||||
|
severity="warning",
|
||||||
|
workers=[row],
|
||||||
|
detail=(
|
||||||
|
f"worker {row.get('worker_identity')!r} is active in the registry "
|
||||||
|
"but not live (stale heartbeat or dead pid)"
|
||||||
|
),
|
||||||
|
active_blocker=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in historical_rows:
|
||||||
|
_finding(
|
||||||
|
CLASS_HISTORICAL,
|
||||||
|
severity="info",
|
||||||
|
workers=[row],
|
||||||
|
detail=(
|
||||||
|
f"historical registration {row.get('worker_identity')!r} "
|
||||||
|
f"(status={row.get('status')!r}) is not an active blocker"
|
||||||
|
),
|
||||||
|
active_blocker=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Manifest comparison
|
||||||
|
expected = list(expected_manifest or [])
|
||||||
|
expected_ids = {
|
||||||
|
str(item.get("client_instance_id")).strip()
|
||||||
|
for item in expected
|
||||||
|
if (item.get("client_instance_id") or "").strip()
|
||||||
|
}
|
||||||
|
live_ids = set(by_instance.keys())
|
||||||
|
missing_ids = sorted(expected_ids - live_ids)
|
||||||
|
unmanifested_ids = sorted(live_ids - expected_ids) if expected_ids else []
|
||||||
|
|
||||||
|
for iid in missing_ids:
|
||||||
|
_finding(
|
||||||
|
CLASS_MISSING,
|
||||||
|
severity="blocker",
|
||||||
|
instance_ids=[iid],
|
||||||
|
detail=f"expected enrolled instance {iid!r} is missing from the live fleet",
|
||||||
|
active_blocker=True,
|
||||||
|
)
|
||||||
|
for iid in unmanifested_ids:
|
||||||
|
_finding(
|
||||||
|
CLASS_UNMANIFESTED,
|
||||||
|
severity="blocker",
|
||||||
|
instance_ids=[iid],
|
||||||
|
workers=by_instance.get(iid, []),
|
||||||
|
detail=(
|
||||||
|
f"live instance {iid!r} is not on the approved fleet manifest "
|
||||||
|
"(unmanifested)"
|
||||||
|
),
|
||||||
|
active_blocker=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Same client_type multi-instance is healthy when each has distinct instance IDs
|
||||||
|
by_type: dict[str, list[str]] = defaultdict(list)
|
||||||
|
for inst in instances:
|
||||||
|
for ct in inst.get("client_types") or []:
|
||||||
|
by_type[str(ct)].append(inst["client_instance_id"])
|
||||||
|
multi_instance_same_type = {
|
||||||
|
ct: ids for ct, ids in by_type.items() if len(ids) > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
active_blockers = [f for f in findings if f.get("active_blocker")]
|
||||||
|
historical_only = [f for f in findings if f.get("classification") == CLASS_HISTORICAL]
|
||||||
|
live_safe = not active_blockers
|
||||||
|
|
||||||
|
consistency = registry_revision or _consistency_token(all_rows, snapshot_at)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"read_only": True,
|
||||||
|
"snapshot_at": snapshot_at,
|
||||||
|
"consistency_token": consistency,
|
||||||
|
"registry_revision": consistency,
|
||||||
|
"live_worker_count": len(live_rows),
|
||||||
|
"historical_worker_count": len(historical_rows),
|
||||||
|
"stale_worker_count": len(stale_rows),
|
||||||
|
"instance_count": len(instances),
|
||||||
|
"workers": all_rows,
|
||||||
|
"live_workers": live_rows,
|
||||||
|
"historical_workers": historical_rows,
|
||||||
|
"stale_workers": stale_rows,
|
||||||
|
"instances": instances,
|
||||||
|
"multi_instance_same_client_type": multi_instance_same_type,
|
||||||
|
"same_client_type_not_duplicate": True,
|
||||||
|
"expected_manifest": [
|
||||||
|
{
|
||||||
|
"client_instance_id": item.get("client_instance_id"),
|
||||||
|
"client_type": item.get("client_type"),
|
||||||
|
"fleet_run_id": item.get("fleet_run_id"),
|
||||||
|
"namespaces": item.get("namespaces"),
|
||||||
|
}
|
||||||
|
for item in expected
|
||||||
|
],
|
||||||
|
"missing_expected_instance_ids": missing_ids,
|
||||||
|
"unmanifested_instance_ids": unmanifested_ids,
|
||||||
|
"findings": findings,
|
||||||
|
"active_blockers": active_blockers,
|
||||||
|
"historical_findings": historical_only,
|
||||||
|
"live_fleet_safe": live_safe,
|
||||||
|
"mutation_safe": live_safe and all(
|
||||||
|
inst.get("mutation_safe") for inst in instances
|
||||||
|
)
|
||||||
|
if instances
|
||||||
|
else live_safe,
|
||||||
|
"classification_model": {
|
||||||
|
"exactly_one_process_per_profile": False,
|
||||||
|
"exactly_one_instance_per_client_type": False,
|
||||||
|
"multiple_instances_per_client_type": True,
|
||||||
|
"duplicate_requires": [
|
||||||
|
"live client_instance_id collision",
|
||||||
|
"duplicate namespace worker within one instance",
|
||||||
|
"reused worker/session/generation/process/pid/fencing identity",
|
||||||
|
"cross-instance ownership collision",
|
||||||
|
"unmanifested instance when a manifest is required",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"reasons": [f["detail"] for f in active_blockers],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compare_snapshot_heartbeats(
|
||||||
|
earlier: Mapping[str, Any],
|
||||||
|
later: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Prove heartbeat continuity and stable ownership across two snapshots."""
|
||||||
|
earlier_live = {
|
||||||
|
w.get("worker_identity"): w for w in earlier.get("live_workers") or []
|
||||||
|
}
|
||||||
|
later_live = {
|
||||||
|
w.get("worker_identity"): w for w in later.get("live_workers") or []
|
||||||
|
}
|
||||||
|
shared = sorted(set(earlier_live) & set(later_live))
|
||||||
|
continuity: list[dict[str, Any]] = []
|
||||||
|
stable_ownership = True
|
||||||
|
for wid in shared:
|
||||||
|
a = earlier_live[wid]
|
||||||
|
b = later_live[wid]
|
||||||
|
same_instance = a.get("client_instance_id") == b.get("client_instance_id")
|
||||||
|
same_session = a.get("session_id") == b.get("session_id")
|
||||||
|
same_generation = a.get("generation_id") == b.get("generation_id")
|
||||||
|
hb_advanced_or_equal = True
|
||||||
|
if a.get("last_heartbeat_at") and b.get("last_heartbeat_at"):
|
||||||
|
hb_advanced_or_equal = b["last_heartbeat_at"] >= a["last_heartbeat_at"]
|
||||||
|
if not (same_instance and same_session and same_generation):
|
||||||
|
stable_ownership = False
|
||||||
|
continuity.append(
|
||||||
|
{
|
||||||
|
"worker_identity": wid,
|
||||||
|
"same_client_instance_id": same_instance,
|
||||||
|
"same_session_id": same_session,
|
||||||
|
"same_generation_id": same_generation,
|
||||||
|
"heartbeat_non_decreasing": hb_advanced_or_equal,
|
||||||
|
"earlier_heartbeat": a.get("last_heartbeat_at"),
|
||||||
|
"later_heartbeat": b.get("last_heartbeat_at"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"shared_live_workers": shared,
|
||||||
|
"continuity": continuity,
|
||||||
|
"stable_ownership": stable_ownership
|
||||||
|
and all(c["heartbeat_non_decreasing"] for c in continuity),
|
||||||
|
"dropped_workers": sorted(set(earlier_live) - set(later_live)),
|
||||||
|
"new_workers": sorted(set(later_live) - set(earlier_live)),
|
||||||
|
}
|
||||||
+397
-5
@@ -51,6 +51,42 @@ EOF_PATTERNS = (
|
|||||||
"eof",
|
"eof",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ERROR_CONNECTED_NAMESPACES_MISSING = "mcp_connected_namespaces_missing"
|
||||||
|
|
||||||
|
# Distinct from the condition above. ``mcp_connected_namespaces_missing`` is reserved for
|
||||||
|
# the actual #708 defect: the host *does* report the service Connected, yet the namespace
|
||||||
|
# never entered the active session tool surface. A required namespace that is absent from
|
||||||
|
# the connected-service inventory has no Connected claim behind it at all, so reporting it
|
||||||
|
# under the #708 condition would assert something the evidence does not support and would
|
||||||
|
# point an operator at the wrong recovery.
|
||||||
|
ERROR_REQUIRED_NAMESPACES_NOT_CONNECTED = "mcp_required_namespaces_not_connected"
|
||||||
|
|
||||||
|
DISCOVERY_STATUS_ATTACHED = "namespaces_attached"
|
||||||
|
DISCOVERY_STATUS_CONNECTED_MISSING = "connected_but_namespaces_missing"
|
||||||
|
DISCOVERY_STATUS_NOT_CONNECTED = "required_namespaces_not_connected"
|
||||||
|
DISCOVERY_STATUS_DISCONNECTED = "disconnected"
|
||||||
|
|
||||||
|
# Namespaces that must be *attached to the active session* for a mutation task (#708).
|
||||||
|
# Connected-at-host is not attached-in-session; these are gated separately from the
|
||||||
|
# #543 health map because a namespace can be healthy on probe yet absent from the
|
||||||
|
# session tool surface.
|
||||||
|
ATTACHMENT_GATED_TASKS = {
|
||||||
|
"review_pr": "gitea-reviewer",
|
||||||
|
"submit_review": "gitea-reviewer",
|
||||||
|
"merge_pr": "gitea-merger",
|
||||||
|
"work_issue": "gitea-author",
|
||||||
|
"create_pr": "gitea-author",
|
||||||
|
}
|
||||||
|
|
||||||
|
# The only sanctioned recovery for an unattached namespace (#678 exposes it natively).
|
||||||
|
SANCTIONED_ATTACH_RECOVERY_TOOL = "gitea_request_mcp_reconnect"
|
||||||
|
|
||||||
|
UNSAFE_FALLBACK_WARNING = (
|
||||||
|
"Workflow Safety Hard Stop (#708): Connected-but-namespaces-missing recovery must "
|
||||||
|
"NEVER use direct imports, Gitea API mutations, profile hopping, session-state "
|
||||||
|
"overrides, PID kills, or config mtime touches. Use client reconnect only."
|
||||||
|
)
|
||||||
|
|
||||||
SAFE_ENV_KEYS = (
|
SAFE_ENV_KEYS = (
|
||||||
"GITEA_MCP_PROFILE",
|
"GITEA_MCP_PROFILE",
|
||||||
"GITEA_PROFILE_NAME",
|
"GITEA_PROFILE_NAME",
|
||||||
@@ -59,6 +95,318 @@ SAFE_ENV_KEYS = (
|
|||||||
"GITEA_MCP_CONFIG",
|
"GITEA_MCP_CONFIG",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# #948: provenance used to be derived from the summary this allowlist produces.
|
||||||
|
# The allowlist never carried a provenance key, so that derivation could only
|
||||||
|
# ever evaluate to ``manual_launch`` — whatever the process actually was — while
|
||||||
|
# ``gitea_get_runtime_context`` read the live environment and reported
|
||||||
|
# ``client_managed`` for the same process. Provenance is no longer derived here.
|
||||||
|
# It comes from ``mcp_worker_identity.assess_provenance``, the single authority
|
||||||
|
# every surface shares. This allowlist keeps its original and only job: deciding
|
||||||
|
# which env values are safe to echo back in diagnostics.
|
||||||
|
|
||||||
|
|
||||||
|
def assess_connected_namespace_attachment(
|
||||||
|
*,
|
||||||
|
connected_servers: list[str] | tuple[str, ...] | set[str] | None = None,
|
||||||
|
attached_session_namespaces: list[str] | tuple[str, ...] | set[str] | None = None,
|
||||||
|
required_namespaces: list[str] | tuple[str, ...] | set[str] | None = None,
|
||||||
|
discovery_cache_age_seconds: float | int | None = None,
|
||||||
|
discovery_cache_hit: bool | None = None,
|
||||||
|
auto_attach_attempted: bool = False,
|
||||||
|
auto_attach_succeeded: bool = False,
|
||||||
|
session_tool_snapshot_at: float | int | None = None,
|
||||||
|
namespace_connected_at: dict[str, float] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess whether host-connected MCP servers have attached tool namespaces in the active session (#708).
|
||||||
|
|
||||||
|
Addresses the Connected-but-namespaces-missing defect: CLI/host status may report Connected
|
||||||
|
while the active LLM session tool surface exposes 0 attached tool namespaces.
|
||||||
|
|
||||||
|
This is a *distinct* condition from config drift (#672), transport-closed (#584),
|
||||||
|
and resolver EOF (#685): the transport is up and the host reports Connected, yet the
|
||||||
|
namespace never entered the session tool surface.
|
||||||
|
|
||||||
|
Startup ordering (``session_tool_snapshot_at`` + ``namespace_connected_at``) identifies
|
||||||
|
the race where the session tool snapshot was taken before a role server finished
|
||||||
|
``initialize``/``list_tools``, which is why parallel multi-role startup can leave the
|
||||||
|
session with an empty namespace set while Connected later flips true.
|
||||||
|
|
||||||
|
Returns structured detection details plus secret-free telemetry.
|
||||||
|
"""
|
||||||
|
connected = [str(s).strip() for s in (connected_servers or []) if str(s).strip()]
|
||||||
|
attached = set(str(ns).strip() for ns in (attached_session_namespaces or []) if str(ns).strip())
|
||||||
|
req = [str(r).strip() for r in (required_namespaces or DEFAULT_NAMESPACES) if str(r).strip()]
|
||||||
|
|
||||||
|
required = list(dict.fromkeys(req))
|
||||||
|
connected_set = set(connected)
|
||||||
|
|
||||||
|
proof: dict[str, dict[str, bool]] = {}
|
||||||
|
for s in connected:
|
||||||
|
proof[s] = {"connected": True, "attached": s in attached}
|
||||||
|
for r in required:
|
||||||
|
if r not in proof:
|
||||||
|
proof[r] = {"connected": r in connected_set, "attached": r in attached}
|
||||||
|
|
||||||
|
# The genuine #708 condition: the host reports the service Connected and the namespace
|
||||||
|
# still never entered the active session tool surface.
|
||||||
|
missing = [r for r in required if r in connected_set and r not in attached]
|
||||||
|
# A separate condition: the service is required but absent from the connected-service
|
||||||
|
# inventory. Nothing here is Connected, so this must not borrow the #708 wording or its
|
||||||
|
# recovery — see ERROR_REQUIRED_NAMESPACES_NOT_CONNECTED.
|
||||||
|
not_connected = [r for r in required if r not in connected_set]
|
||||||
|
# Evidence that disagrees with itself: reported attached to the session while absent
|
||||||
|
# from the connected inventory. Neither statement is proof, so it fails closed.
|
||||||
|
contradictory = [r for r in not_connected if r in attached]
|
||||||
|
|
||||||
|
# No required namespace may lack attachment proof and still be called healthy.
|
||||||
|
attachment_healthy = (
|
||||||
|
not missing
|
||||||
|
and not not_connected
|
||||||
|
and len(connected) > 0
|
||||||
|
and len(required) > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if attachment_healthy:
|
||||||
|
discovery_status = DISCOVERY_STATUS_ATTACHED
|
||||||
|
elif not connected:
|
||||||
|
discovery_status = DISCOVERY_STATUS_DISCONNECTED
|
||||||
|
elif not_connected:
|
||||||
|
discovery_status = DISCOVERY_STATUS_NOT_CONNECTED
|
||||||
|
else:
|
||||||
|
discovery_status = DISCOVERY_STATUS_CONNECTED_MISSING
|
||||||
|
|
||||||
|
# Every condition actually present is reported; ``error_type`` names the primary one.
|
||||||
|
# Not-connected outranks Connected-but-unattached because a service that never
|
||||||
|
# connected cannot be recovered by attaching its namespace.
|
||||||
|
error_types: list[str] = []
|
||||||
|
if not_connected:
|
||||||
|
error_types.append(ERROR_REQUIRED_NAMESPACES_NOT_CONNECTED)
|
||||||
|
if missing:
|
||||||
|
error_types.append(ERROR_CONNECTED_NAMESPACES_MISSING)
|
||||||
|
if not attachment_healthy and not error_types:
|
||||||
|
error_types.append(ERROR_REQUIRED_NAMESPACES_NOT_CONNECTED)
|
||||||
|
error_type = None if attachment_healthy else error_types[0]
|
||||||
|
|
||||||
|
# Per-namespace verdict, so a mutation gate never has to infer one namespace's state
|
||||||
|
# from a whole-session summary. Each entry states only what its own evidence supports.
|
||||||
|
namespace_conditions: dict[str, dict[str, Any]] = {}
|
||||||
|
for ns_name, state in proof.items():
|
||||||
|
ns_connected = bool(state["connected"])
|
||||||
|
ns_attached = bool(state["attached"])
|
||||||
|
if ns_connected and ns_attached:
|
||||||
|
condition = None
|
||||||
|
elif ns_connected:
|
||||||
|
condition = ERROR_CONNECTED_NAMESPACES_MISSING
|
||||||
|
else:
|
||||||
|
condition = ERROR_REQUIRED_NAMESPACES_NOT_CONNECTED
|
||||||
|
namespace_conditions[ns_name] = {
|
||||||
|
"namespace": ns_name,
|
||||||
|
"required": ns_name in required,
|
||||||
|
"connected": ns_connected,
|
||||||
|
"attached": ns_attached,
|
||||||
|
"attachment_healthy": ns_connected and ns_attached,
|
||||||
|
"condition": condition,
|
||||||
|
"contradictory_evidence": ns_attached and not ns_connected,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Startup-ordering race: a namespace that finished connecting *after* the session
|
||||||
|
# tool snapshot was taken cannot be in that snapshot, however healthy it looks now.
|
||||||
|
connected_at = {
|
||||||
|
str(k).strip(): v
|
||||||
|
for k, v in (namespace_connected_at or {}).items()
|
||||||
|
if str(k).strip() and isinstance(v, (int, float))
|
||||||
|
}
|
||||||
|
late_attaching: list[str] = []
|
||||||
|
if isinstance(session_tool_snapshot_at, (int, float)):
|
||||||
|
for ns_name, ts in connected_at.items():
|
||||||
|
if ts > session_tool_snapshot_at and ns_name not in attached:
|
||||||
|
late_attaching.append(ns_name)
|
||||||
|
late_attaching.sort()
|
||||||
|
startup_ordering_race = bool(late_attaching)
|
||||||
|
|
||||||
|
auto_recovered = bool(auto_attach_attempted and auto_attach_succeeded and attachment_healthy)
|
||||||
|
reconnect_required = not attachment_healthy
|
||||||
|
|
||||||
|
reasons: list[str] = []
|
||||||
|
remediation: list[str] = []
|
||||||
|
if not connected:
|
||||||
|
reasons.append("No MCP servers reported Connected.")
|
||||||
|
remediation.append("Start or reconnect Gitea MCP servers in client config.")
|
||||||
|
if missing:
|
||||||
|
reasons.append(
|
||||||
|
f"MCP server(s) {missing} report Connected at host/CLI layer but tool namespaces "
|
||||||
|
f"are missing from active session attached tools (Connected ≠ attached tools, #708)."
|
||||||
|
)
|
||||||
|
remediation.append(
|
||||||
|
"Reconnect the IDE/client MCP session to attach namespaces to the active session "
|
||||||
|
f"(sanctioned path: {SANCTIONED_ATTACH_RECOVERY_TOOL}), then re-run full preflight "
|
||||||
|
"(gitea_whoami -> gitea_resolve_task_capability -> task). "
|
||||||
|
"Do not use direct imports, CLI API mutations, profile hopping, or session file overrides."
|
||||||
|
)
|
||||||
|
if not_connected:
|
||||||
|
reasons.append(
|
||||||
|
f"required MCP namespace(s) {not_connected} are absent from the connected-service "
|
||||||
|
"inventory: nothing reports them Connected, so there is no attachment to claim and "
|
||||||
|
"the Connected-but-unattached condition does not apply to them (#708)."
|
||||||
|
)
|
||||||
|
remediation.append(
|
||||||
|
f"Connect the required MCP server(s) {not_connected} through the client, then "
|
||||||
|
"reconnect the IDE/client MCP session so their namespaces attach "
|
||||||
|
f"(sanctioned path: {SANCTIONED_ATTACH_RECOVERY_TOOL}), and re-run full preflight. "
|
||||||
|
"Do not use direct imports, CLI API mutations, profile hopping, or session file overrides."
|
||||||
|
)
|
||||||
|
if contradictory:
|
||||||
|
reasons.append(
|
||||||
|
f"contradictory evidence for namespace(s) {contradictory}: reported attached to the "
|
||||||
|
"active session while absent from the connected-service inventory; neither statement "
|
||||||
|
"is proof, so attachment is treated as unproven (fail closed, #708)."
|
||||||
|
)
|
||||||
|
if attachment_healthy:
|
||||||
|
reasons.append(
|
||||||
|
"All required MCP server namespaces are connected and attached to the active session."
|
||||||
|
)
|
||||||
|
|
||||||
|
if startup_ordering_race:
|
||||||
|
reasons.append(
|
||||||
|
f"startup ordering race: namespace(s) {late_attaching} finished connecting after the "
|
||||||
|
"active session tool snapshot was taken, so they cannot appear in that snapshot (#708)."
|
||||||
|
)
|
||||||
|
if auto_attach_attempted and not auto_attach_succeeded:
|
||||||
|
reasons.append(
|
||||||
|
"automatic namespace attachment was attempted and did not succeed; only the sanctioned "
|
||||||
|
"client reconnect path remains."
|
||||||
|
)
|
||||||
|
if auto_recovered:
|
||||||
|
reasons.append("namespaces were automatically attached; no operator reconnect was required.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": attachment_healthy,
|
||||||
|
"attachment_healthy": attachment_healthy,
|
||||||
|
"discovery_status": discovery_status,
|
||||||
|
"connected_servers": connected,
|
||||||
|
"attached_session_namespaces": list(attached),
|
||||||
|
"missing_namespaces": missing,
|
||||||
|
"not_connected_namespaces": not_connected,
|
||||||
|
"contradictory_namespaces": contradictory,
|
||||||
|
"proof_of_connected_vs_attached": proof,
|
||||||
|
"namespace_conditions": namespace_conditions,
|
||||||
|
"error_type": error_type,
|
||||||
|
"error_types": error_types,
|
||||||
|
"reasons": reasons,
|
||||||
|
"remediation": remediation,
|
||||||
|
"exact_next_action": (
|
||||||
|
"None; session tool namespaces attached."
|
||||||
|
if attachment_healthy
|
||||||
|
else (
|
||||||
|
f"Connect the required MCP server(s) {not_connected} through the client, then "
|
||||||
|
"reconnect the IDE/client MCP session so their tool namespaces attach to the "
|
||||||
|
"active session. Do not use direct imports, CLI API mutations, profile hopping, "
|
||||||
|
"or session-state overrides."
|
||||||
|
if not_connected
|
||||||
|
else (
|
||||||
|
"Reconnect the IDE/client MCP session so tool namespaces attach to the active "
|
||||||
|
"session. Do not use direct imports, CLI API mutations, profile hopping, or "
|
||||||
|
"session-state overrides."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"unsafe_fallback_policy": UNSAFE_FALLBACK_WARNING,
|
||||||
|
"sanctioned_recovery_tool": SANCTIONED_ATTACH_RECOVERY_TOOL,
|
||||||
|
"reconnect_required": reconnect_required,
|
||||||
|
"auto_attach_attempted": bool(auto_attach_attempted),
|
||||||
|
"auto_recovered": auto_recovered,
|
||||||
|
"startup_ordering_race": startup_ordering_race,
|
||||||
|
"late_attaching_namespaces": late_attaching,
|
||||||
|
# Secret-free structured signals (#708 AC5). Namespace names and counts only:
|
||||||
|
# never tokens, endpoints, env values, or filesystem paths.
|
||||||
|
"telemetry": {
|
||||||
|
"connected_count": len(connected),
|
||||||
|
"attached_count": len(attached),
|
||||||
|
"required_count": len(required),
|
||||||
|
"missing_count": len(missing),
|
||||||
|
"not_connected_count": len(not_connected),
|
||||||
|
"contradictory_count": len(contradictory),
|
||||||
|
"required_attached_count": sum(1 for r in required if r in attached),
|
||||||
|
"discovery_status": discovery_status,
|
||||||
|
"discovery_cache_hit": (
|
||||||
|
None if discovery_cache_hit is None else bool(discovery_cache_hit)
|
||||||
|
),
|
||||||
|
"discovery_cache_age_seconds": (
|
||||||
|
float(discovery_cache_age_seconds)
|
||||||
|
if isinstance(discovery_cache_age_seconds, (int, float))
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"reconnect_required": reconnect_required,
|
||||||
|
"auto_attach_attempted": bool(auto_attach_attempted),
|
||||||
|
"auto_recovered": auto_recovered,
|
||||||
|
"startup_ordering_race": startup_ordering_race,
|
||||||
|
"error_type": error_type,
|
||||||
|
"error_types": error_types,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def required_namespace_for_attachment(task: str) -> str | None:
|
||||||
|
"""Map a mutation task to the MCP namespace that must be *attached* (#708)."""
|
||||||
|
return ATTACHMENT_GATED_TASKS.get((task or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def attachment_gate_from_session(
|
||||||
|
task: str,
|
||||||
|
session_attachment: dict[str, dict[str, Any]] | None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Fail-closed gate on recorded connected-but-unattached namespaces (#708).
|
||||||
|
|
||||||
|
Mirrors :func:`mutation_gate_from_session`: a namespace that has not been
|
||||||
|
assessed yet does not gate, so this never blocks a session that simply has
|
||||||
|
not run the assessment. Once an assessment records the namespace required
|
||||||
|
for *task* as Connected-but-unattached, the mutation fails closed and the
|
||||||
|
only offered recovery is the sanctioned client reconnect path.
|
||||||
|
"""
|
||||||
|
ns = required_namespace_for_attachment(task)
|
||||||
|
if not ns:
|
||||||
|
return []
|
||||||
|
store = session_attachment or {}
|
||||||
|
entry = store.get(ns)
|
||||||
|
if not entry:
|
||||||
|
return []
|
||||||
|
if entry.get("attached") and entry.get("attachment_healthy"):
|
||||||
|
return []
|
||||||
|
|
||||||
|
what = task or "mutation"
|
||||||
|
connected = entry.get("connected")
|
||||||
|
detail = entry.get("condition") or entry.get("error_type")
|
||||||
|
if connected is True:
|
||||||
|
# Only here has anything actually reported the service Connected, so only here may
|
||||||
|
# the block say so.
|
||||||
|
detail = detail or ERROR_CONNECTED_NAMESPACES_MISSING
|
||||||
|
blocked = (
|
||||||
|
f"live MCP namespace '{ns}' is recorded {detail}: the host reports Connected but the "
|
||||||
|
f"namespace is not attached to the active session tool surface; reconnect the "
|
||||||
|
f"IDE/client MCP session and re-run preflight before {what} "
|
||||||
|
"(fail closed, #708)"
|
||||||
|
)
|
||||||
|
elif connected is False:
|
||||||
|
detail = ERROR_REQUIRED_NAMESPACES_NOT_CONNECTED
|
||||||
|
blocked = (
|
||||||
|
f"live MCP namespace '{ns}' is recorded {detail}: it is absent from the "
|
||||||
|
f"connected-service inventory, so it is neither connected nor attached and no "
|
||||||
|
f"Connected status is claimed for it; connect the required MCP server, then "
|
||||||
|
f"reconnect the IDE/client MCP session and re-run preflight before {what} "
|
||||||
|
"(fail closed, #708)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Connected status was never recorded. Refuse without asserting either condition.
|
||||||
|
detail = detail or ERROR_CONNECTED_NAMESPACES_MISSING
|
||||||
|
blocked = (
|
||||||
|
f"live MCP namespace '{ns}' is recorded {detail} with no connected-status evidence, "
|
||||||
|
f"so attachment to the active session tool surface is unproven; reconnect the "
|
||||||
|
f"IDE/client MCP session and re-run preflight before {what} "
|
||||||
|
"(fail closed, #708)"
|
||||||
|
)
|
||||||
|
return [blocked, UNSAFE_FALLBACK_WARNING]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _as_list(value: Any) -> list[str] | None:
|
def _as_list(value: Any) -> list[str] | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -104,12 +452,23 @@ def classify_namespace_probe(
|
|||||||
profile: str | None = None,
|
profile: str | None = None,
|
||||||
configured: bool = True,
|
configured: bool = True,
|
||||||
probe_source: str | None = None,
|
probe_source: str | None = None,
|
||||||
|
worker_identity: str | None = None,
|
||||||
|
generation_id: str | None = None,
|
||||||
|
registry: Any | None = None,
|
||||||
|
pid_alive_probe: Any | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Classify whether a required tool is callable through a live namespace.
|
"""Classify whether a required tool is callable through a live namespace.
|
||||||
|
|
||||||
``registered_tools`` is static/server-side evidence. ``probe_result`` is
|
``registered_tools`` is static/server-side evidence. ``probe_result`` is
|
||||||
live invocation evidence. Only ``probe_source=client_namespace`` proves the
|
live invocation evidence. Only ``probe_source=client_namespace`` proves the
|
||||||
IDE-managed path; ``offline_spawn`` is an offline subprocess check only.
|
IDE-managed path; ``offline_spawn`` is an offline subprocess check only.
|
||||||
|
|
||||||
|
#948: ``worker_identity``/``generation_id``/``registry`` carry the
|
||||||
|
client/session ownership evidence. Provenance is resolved by
|
||||||
|
``mcp_worker_identity.assess_provenance`` — the same call
|
||||||
|
``gitea_get_runtime_context`` makes — so the two surfaces cannot report
|
||||||
|
different provenance for one process. Omitting them yields the fail-closed
|
||||||
|
``unproven`` verdict, never a fabricated ``client_managed``.
|
||||||
"""
|
"""
|
||||||
ns = (namespace or "").strip()
|
ns = (namespace or "").strip()
|
||||||
tool = required_tool or REQUIRED_NAMESPACE_TOOLS.get(ns) or "gitea_whoami"
|
tool = required_tool or REQUIRED_NAMESPACE_TOOLS.get(ns) or "gitea_whoami"
|
||||||
@@ -226,14 +585,31 @@ def classify_namespace_probe(
|
|||||||
blocks = namespace_health_blocks_task("merge_pr", healthy)
|
blocks = namespace_health_blocks_task("merge_pr", healthy)
|
||||||
|
|
||||||
import gitea_config
|
import gitea_config
|
||||||
|
import mcp_worker_identity
|
||||||
|
|
||||||
raw_env = process.get("env") if isinstance(process, dict) else None
|
raw_env = process.get("env") if isinstance(process, dict) else None
|
||||||
unconsumed_env = gitea_config.get_unconsumed_gitea_env_overrides(raw_env)
|
unconsumed_env = gitea_config.get_unconsumed_gitea_env_overrides(raw_env)
|
||||||
is_client_managed = bool(
|
|
||||||
env_summary.get("GITEA_CLIENT_MANAGED") in ("1", "true", "yes", "client_managed")
|
# #948: one authority, shared with gitea_get_runtime_context. The env is
|
||||||
or env_summary.get("GITEA_MCP_CLIENT_MANAGED") in ("1", "true", "yes", "client_managed")
|
# passed whole rather than through SAFE_ENV_KEYS — the allowlist exists to
|
||||||
or env_summary.get("GITEA_SERVER_PROVENANCE") == "client_managed"
|
# decide what may be *echoed*, and using it to decide what may be *believed*
|
||||||
|
# is what made this surface structurally unable to report client_managed.
|
||||||
|
# ``declared_only``: ``process`` describes an observed peer, not this
|
||||||
|
# interpreter. Its stdin is unavailable and its launcher-config env is
|
||||||
|
# inherited from whatever shell started it, so only an explicit declaration
|
||||||
|
# is evidence. Absence of one is ``unproven``, not an asserted manual launch.
|
||||||
|
provenance_verdict = mcp_worker_identity.assess_provenance(
|
||||||
|
registry=registry,
|
||||||
|
worker_identity=worker_identity,
|
||||||
|
generation_id=generation_id,
|
||||||
|
env=raw_env if isinstance(raw_env, dict) else {},
|
||||||
|
namespace=ns,
|
||||||
|
profile=profile_name,
|
||||||
|
pid_alive_probe=pid_alive_probe,
|
||||||
|
declared_only=True,
|
||||||
)
|
)
|
||||||
provenance = "client_managed" if is_client_managed else "manual_launch"
|
provenance = provenance_verdict["provenance"]
|
||||||
|
is_client_managed = provenance_verdict["is_client_managed"]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": healthy,
|
"success": healthy,
|
||||||
@@ -252,6 +628,15 @@ def classify_namespace_probe(
|
|||||||
"remediation": remediation,
|
"remediation": remediation,
|
||||||
"provenance": provenance,
|
"provenance": provenance,
|
||||||
"is_client_managed": is_client_managed,
|
"is_client_managed": is_client_managed,
|
||||||
|
# Every non-client-session verdict fails closed. Consumers that only
|
||||||
|
# need "may this mutate?" read this and stay correct across the #948
|
||||||
|
# vocabulary split between ``manual_launch`` and ``unproven``.
|
||||||
|
"provenance_fail_closed": provenance_verdict["fail_closed"],
|
||||||
|
"provenance_assessment": provenance_verdict,
|
||||||
|
"worker_identity": provenance_verdict["worker_identity"],
|
||||||
|
"session_id": provenance_verdict["session_id"],
|
||||||
|
"generation_id": provenance_verdict["generation_id"],
|
||||||
|
"client_name": provenance_verdict["client_name"],
|
||||||
"unconsumed_gitea_env": unconsumed_env,
|
"unconsumed_gitea_env": unconsumed_env,
|
||||||
"diagnostics": {
|
"diagnostics": {
|
||||||
"namespace": ns,
|
"namespace": ns,
|
||||||
@@ -263,6 +648,13 @@ def classify_namespace_probe(
|
|||||||
"probe_source": source,
|
"probe_source": source,
|
||||||
"provenance": provenance,
|
"provenance": provenance,
|
||||||
"is_client_managed": is_client_managed,
|
"is_client_managed": is_client_managed,
|
||||||
|
"provenance_fail_closed": provenance_verdict["fail_closed"],
|
||||||
|
"provenance_blocker_kind": provenance_verdict["blocker_kind"],
|
||||||
|
"provenance_scope": provenance_verdict["scope"],
|
||||||
|
"worker_identity": provenance_verdict["worker_identity"],
|
||||||
|
"session_id": provenance_verdict["session_id"],
|
||||||
|
"generation_id": provenance_verdict["generation_id"],
|
||||||
|
"client_name": provenance_verdict["client_name"],
|
||||||
"unconsumed_gitea_env": unconsumed_env,
|
"unconsumed_gitea_env": unconsumed_env,
|
||||||
},
|
},
|
||||||
"blocks_merge_workflow": blocks,
|
"blocks_merge_workflow": blocks,
|
||||||
|
|||||||
+7
-3
@@ -1,7 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Gitea MCP Server — exposes Gitea operations as MCP tools.
|
"""Gitea MCP Server — exposes Gitea operations as MCP tools.
|
||||||
|
|
||||||
Runs over stdio. All tools authenticate via macOS keychain (git credential fill).
|
The transport is selected by deployment configuration (GITEA_MCP_TRANSPORT) and
|
||||||
|
defaults to the local client-spawned transport when unset (#931); the permitted
|
||||||
|
set lives in mcp_transport_config. All tools authenticate via macOS keychain
|
||||||
|
(git credential fill).
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -43,8 +46,9 @@ check_conflict_markers()
|
|||||||
|
|
||||||
# #558 / #695: claim the official entrypoint before loading mutation modules.
|
# #558 / #695: claim the official entrypoint before loading mutation modules.
|
||||||
# This alone does NOT authorize mutations — gitea_mcp_server binds the live
|
# This alone does NOT authorize mutations — gitea_mcp_server binds the live
|
||||||
# native MCP transport (stdio) immediately before mcp.run. Import-only or
|
# native MCP transport immediately before mcp.run, over the configured
|
||||||
# offline launch without that bind fails closed on mutations.
|
# transport (#931). Import-only or offline launch without that bind fails
|
||||||
|
# closed on mutations.
|
||||||
try:
|
try:
|
||||||
import mcp_daemon_guard
|
import mcp_daemon_guard
|
||||||
|
|
||||||
|
|||||||
@@ -588,9 +588,13 @@ def save_state(
|
|||||||
bool(prov.get("production_native_mcp_transport")),
|
bool(prov.get("production_native_mcp_transport")),
|
||||||
)
|
)
|
||||||
body.setdefault("transport", prov.get("transport"))
|
body.setdefault("transport", prov.get("transport"))
|
||||||
|
# #931: record the bound transport identifier itself, so a durable
|
||||||
|
# decision lock names which transport performed the mutation.
|
||||||
|
body.setdefault("bound_transport", prov.get("bound_transport"))
|
||||||
except Exception:
|
except Exception:
|
||||||
body.setdefault("native_mcp_transport", False)
|
body.setdefault("native_mcp_transport", False)
|
||||||
body.setdefault("transport", "untrusted")
|
body.setdefault("transport", "untrusted")
|
||||||
|
body.setdefault("bound_transport", None)
|
||||||
|
|
||||||
envelope = {
|
envelope = {
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"""Single authoritative source for the bound MCP transport identifier (#931).
|
||||||
|
|
||||||
|
Before this module the transport was a literal, passed once at the bottom of
|
||||||
|
``gitea_mcp_server`` as ``bind_native_mcp_transport(transport="stdio")``. Every
|
||||||
|
guard that later asks "is this a trusted native session" resolves that question
|
||||||
|
through the value bound there, so the literal was effectively a constant in the
|
||||||
|
authorization chain rather than configuration.
|
||||||
|
|
||||||
|
This module is the seam. It owns three things and nothing else:
|
||||||
|
|
||||||
|
- the permitted set of transport identifiers,
|
||||||
|
- the default used when deployment configuration says nothing,
|
||||||
|
- the resolution of the configured value into a validated identifier.
|
||||||
|
|
||||||
|
It deliberately holds no state. The *bound* transport is pinned once, at bind
|
||||||
|
time, into the process-local native-runtime record owned by
|
||||||
|
:mod:`mcp_daemon_guard`, and is read back through
|
||||||
|
``mcp_daemon_guard.bound_transport()``. That split matters: configuration is
|
||||||
|
read exactly once, before any tool can dispatch, so a later environment change
|
||||||
|
cannot move the value a guard observes — the same pinning rule already applied
|
||||||
|
to the session-state root under #695 AC2.
|
||||||
|
|
||||||
|
Nothing here consumes tool arguments, request bodies, or provenance fields. The
|
||||||
|
only input is the deployment environment, read at bind time.
|
||||||
|
|
||||||
|
Standing up a listener for a non-stdio transport is #938; this module only
|
||||||
|
makes the identifier expressible and validated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
# Deployment configuration key. Read once, at bind time, and never again.
|
||||||
|
TRANSPORT_ENV = "GITEA_MCP_TRANSPORT"
|
||||||
|
|
||||||
|
# The local, client-spawned transport. Unset configuration resolves to this,
|
||||||
|
# which is what keeps every existing stdio deployment byte-identical.
|
||||||
|
DEFAULT_TRANSPORT = "stdio"
|
||||||
|
|
||||||
|
# The sanctioned remote transport identifier. Accepting it here is what makes
|
||||||
|
# the bind pluggable; the endpoint that serves it belongs to #938. The name
|
||||||
|
# matches the MCP transport name so no second vocabulary has to be mapped.
|
||||||
|
REMOTE_TRANSPORT = "streamable-http"
|
||||||
|
|
||||||
|
# The permitted set. This is the only place transport identifiers are
|
||||||
|
# enumerated; guards consult it rather than restating any member.
|
||||||
|
#
|
||||||
|
# ``sse`` is a real MCP transport and is deliberately absent: it is the
|
||||||
|
# superseded remote transport, and admitting it would give the deployment two
|
||||||
|
# remote paths to reason about. An unregistered identifier must fail closed at
|
||||||
|
# bind time, and ``sse`` is held to that rule like any other.
|
||||||
|
SUPPORTED_TRANSPORTS = frozenset({DEFAULT_TRANSPORT, REMOTE_TRANSPORT})
|
||||||
|
|
||||||
|
# Recognition is not execution authorization (#931, review 635 B1/B2).
|
||||||
|
#
|
||||||
|
# SUPPORTED_TRANSPORTS answers "is this an identifier this system knows, and may
|
||||||
|
# it be bound, pinned and recorded?". It deliberately includes the remote
|
||||||
|
# identifier, because #931 requires the bind to become pluggable.
|
||||||
|
#
|
||||||
|
# EXECUTABLE_TRANSPORTS answers a strictly narrower question: "is this entrypoint
|
||||||
|
# commissioned to actually *serve* on that transport?". Only the local transport
|
||||||
|
# is. Handing ``streamable-http`` to ``mcp.run`` would start FastMCP's HTTP
|
||||||
|
# listener with no authentication, no TLS and no per-request principal — the
|
||||||
|
# endpoint #938 owns and gates. Recognition must therefore never imply execution.
|
||||||
|
#
|
||||||
|
# #938 commissions the remote listener by adding REMOTE_TRANSPORT here, together
|
||||||
|
# with the authentication and principal boundary its acceptance criteria require.
|
||||||
|
EXECUTABLE_TRANSPORTS = frozenset({DEFAULT_TRANSPORT})
|
||||||
|
|
||||||
|
# Which issue owns commissioning each recognized-but-not-executable transport.
|
||||||
|
# Used to make the refusal actionable rather than a generic denial.
|
||||||
|
TRANSPORT_EXECUTION_OWNER = {REMOTE_TRANSPORT: "#938"}
|
||||||
|
|
||||||
|
BLOCKER_TRANSPORT_NOT_BOUND = "transport_not_bound"
|
||||||
|
BLOCKER_TRANSPORT_NOT_RECOGNIZED = "transport_not_recognized"
|
||||||
|
BLOCKER_LISTENER_NOT_COMMISSIONED = "transport_listener_not_commissioned"
|
||||||
|
|
||||||
|
SOURCE_CONFIGURED = "deployment_configuration"
|
||||||
|
SOURCE_DEFAULT = "default"
|
||||||
|
|
||||||
|
|
||||||
|
class TransportConfigurationError(ValueError):
|
||||||
|
"""Raised when configured transport is outside :data:`SUPPORTED_TRANSPORTS`."""
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_transport(value: Any) -> str:
|
||||||
|
"""Canonical form of a transport identifier; ``""`` when there is none.
|
||||||
|
|
||||||
|
Non-string values normalize to ``""`` rather than being coerced, so a
|
||||||
|
structured object smuggled in from a caller can never match a member of the
|
||||||
|
permitted set.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return ""
|
||||||
|
return value.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def supported_transports() -> tuple[str, ...]:
|
||||||
|
"""Permitted identifiers, sorted, for messages and status payloads."""
|
||||||
|
return tuple(sorted(SUPPORTED_TRANSPORTS))
|
||||||
|
|
||||||
|
|
||||||
|
def is_supported_transport(value: Any) -> bool:
|
||||||
|
"""True when *value* normalizes to a member of the permitted set."""
|
||||||
|
return normalize_transport(value) in SUPPORTED_TRANSPORTS
|
||||||
|
|
||||||
|
|
||||||
|
def is_remote_transport(value: Any) -> bool:
|
||||||
|
"""True when *value* is a permitted transport that is not the local one."""
|
||||||
|
name = normalize_transport(value)
|
||||||
|
return name in SUPPORTED_TRANSPORTS and name != DEFAULT_TRANSPORT
|
||||||
|
|
||||||
|
|
||||||
|
def executable_transports() -> tuple[str, ...]:
|
||||||
|
"""Transports this entrypoint is commissioned to serve, sorted."""
|
||||||
|
return tuple(sorted(EXECUTABLE_TRANSPORTS))
|
||||||
|
|
||||||
|
|
||||||
|
def is_executable_transport(value: Any) -> bool:
|
||||||
|
"""True when *value* may actually be served by this entrypoint (#931).
|
||||||
|
|
||||||
|
Strictly narrower than :func:`is_supported_transport`. A recognized
|
||||||
|
identifier that is not executable is a correct, fully-bound configuration
|
||||||
|
whose listener has simply not been commissioned yet.
|
||||||
|
"""
|
||||||
|
return normalize_transport(value) in EXECUTABLE_TRANSPORTS
|
||||||
|
|
||||||
|
|
||||||
|
def assess_transport_execution(value: Any) -> dict[str, Any]:
|
||||||
|
"""Structured serve-authorization verdict for a bound transport (#931).
|
||||||
|
|
||||||
|
This is the decision that separates a *recognized* transport from one this
|
||||||
|
entrypoint may execute. It is deliberately a pure function of the bound
|
||||||
|
identifier so the serve path cannot reach a listener the deployment has not
|
||||||
|
commissioned.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: The bound transport identifier, or ``None`` when unbound.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with ``transport``, ``recognized``, ``executable``, ``allowed``,
|
||||||
|
``blocker_kind``, ``owner_issue``, ``reasons`` and
|
||||||
|
``exact_next_action``. ``allowed`` is true only for a bound, recognized,
|
||||||
|
commissioned transport.
|
||||||
|
"""
|
||||||
|
name = normalize_transport(value)
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return {
|
||||||
|
"transport": None,
|
||||||
|
"recognized": False,
|
||||||
|
"executable": False,
|
||||||
|
"allowed": False,
|
||||||
|
"blocker_kind": BLOCKER_TRANSPORT_NOT_BOUND,
|
||||||
|
"owner_issue": None,
|
||||||
|
"supported_transports": list(supported_transports()),
|
||||||
|
"executable_transports": list(executable_transports()),
|
||||||
|
"reasons": [
|
||||||
|
"no transport is bound; the serve path is fail-closed until "
|
||||||
|
"bind_native_mcp_transport succeeds (#695/#931)"
|
||||||
|
],
|
||||||
|
"exact_next_action": (
|
||||||
|
"Launch through the canonical entrypoint so "
|
||||||
|
"bind_native_mcp_transport runs before tool service."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
recognized = name in SUPPORTED_TRANSPORTS
|
||||||
|
if not recognized:
|
||||||
|
return {
|
||||||
|
"transport": name,
|
||||||
|
"recognized": False,
|
||||||
|
"executable": False,
|
||||||
|
"allowed": False,
|
||||||
|
"blocker_kind": BLOCKER_TRANSPORT_NOT_RECOGNIZED,
|
||||||
|
"owner_issue": None,
|
||||||
|
"supported_transports": list(supported_transports()),
|
||||||
|
"executable_transports": list(executable_transports()),
|
||||||
|
"reasons": [
|
||||||
|
f"transport {name!r} is not a registered MCP transport (#931); "
|
||||||
|
"it should have been refused at bind time"
|
||||||
|
],
|
||||||
|
"exact_next_action": (
|
||||||
|
f"Set {TRANSPORT_ENV} to one of {list(supported_transports())}."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
if name in EXECUTABLE_TRANSPORTS:
|
||||||
|
return {
|
||||||
|
"transport": name,
|
||||||
|
"recognized": True,
|
||||||
|
"executable": True,
|
||||||
|
"allowed": True,
|
||||||
|
"blocker_kind": None,
|
||||||
|
"owner_issue": None,
|
||||||
|
"supported_transports": list(supported_transports()),
|
||||||
|
"executable_transports": list(executable_transports()),
|
||||||
|
"reasons": [],
|
||||||
|
"exact_next_action": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
owner = TRANSPORT_EXECUTION_OWNER.get(name)
|
||||||
|
owner_text = owner or "the issue that commissions this transport's listener"
|
||||||
|
return {
|
||||||
|
"transport": name,
|
||||||
|
"recognized": True,
|
||||||
|
"executable": False,
|
||||||
|
"allowed": False,
|
||||||
|
"blocker_kind": BLOCKER_LISTENER_NOT_COMMISSIONED,
|
||||||
|
"owner_issue": owner,
|
||||||
|
"supported_transports": list(supported_transports()),
|
||||||
|
"executable_transports": list(executable_transports()),
|
||||||
|
"reasons": [
|
||||||
|
f"transport {name!r} is registered and was bound and recorded, but "
|
||||||
|
f"this entrypoint is not commissioned to serve it (#931). Serving it "
|
||||||
|
f"would start a listener with no authentication, no transport "
|
||||||
|
f"security and no per-request principal; that endpoint is owned by "
|
||||||
|
f"{owner_text}."
|
||||||
|
],
|
||||||
|
"exact_next_action": (
|
||||||
|
f"Serve on {DEFAULT_TRANSPORT} until {owner_text} commissions the "
|
||||||
|
f"{name!r} listener with its authentication and principal boundary, "
|
||||||
|
f"which adds {name!r} to EXECUTABLE_TRANSPORTS."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_configured_transport(
|
||||||
|
env: Mapping[str, str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Resolve the deployment-configured transport without raising.
|
||||||
|
|
||||||
|
Returns the resolution rather than a bare string so a caller can tell an
|
||||||
|
unset value (which legitimately yields :data:`DEFAULT_TRANSPORT`) from a
|
||||||
|
configured value that is not permitted (which must fail closed, never
|
||||||
|
silently degrade to the default).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
env: Environment mapping to read; defaults to ``os.environ``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with ``transport`` (normalized; the default when unset),
|
||||||
|
``configured``, ``source``, ``raw``, ``supported``, and ``reasons``.
|
||||||
|
"""
|
||||||
|
source_env = os.environ if env is None else env
|
||||||
|
raw = source_env.get(TRANSPORT_ENV)
|
||||||
|
normalized = normalize_transport(raw)
|
||||||
|
configured = bool(normalized)
|
||||||
|
|
||||||
|
if not configured:
|
||||||
|
return {
|
||||||
|
"transport": DEFAULT_TRANSPORT,
|
||||||
|
"configured": False,
|
||||||
|
"source": SOURCE_DEFAULT,
|
||||||
|
"raw": raw,
|
||||||
|
"supported": True,
|
||||||
|
"supported_transports": list(supported_transports()),
|
||||||
|
"env_key": TRANSPORT_ENV,
|
||||||
|
"reasons": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
supported = normalized in SUPPORTED_TRANSPORTS
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not supported:
|
||||||
|
reasons.append(
|
||||||
|
f"{TRANSPORT_ENV}={normalized!r} is not a registered MCP transport "
|
||||||
|
f"(#931). Registered: {list(supported_transports())}."
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"transport": normalized,
|
||||||
|
"configured": True,
|
||||||
|
"source": SOURCE_CONFIGURED,
|
||||||
|
"raw": raw,
|
||||||
|
"supported": supported,
|
||||||
|
"supported_transports": list(supported_transports()),
|
||||||
|
"env_key": TRANSPORT_ENV,
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def require_configured_transport(env: Mapping[str, str] | None = None) -> str:
|
||||||
|
"""Resolved transport identifier, or raise when it is not permitted.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TransportConfigurationError: the configured identifier is unregistered.
|
||||||
|
"""
|
||||||
|
resolution = resolve_configured_transport(env)
|
||||||
|
if not resolution["supported"]:
|
||||||
|
raise TransportConfigurationError("; ".join(resolution["reasons"]))
|
||||||
|
return str(resolution["transport"])
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+153
-27
@@ -8,8 +8,10 @@ poison workspace purity checks in another namespace.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
import author_mutation_worktree as amw
|
import author_mutation_worktree as amw
|
||||||
|
import canonical_repository_root as crr
|
||||||
|
|
||||||
ACTIVE_WORKTREE_ENV = amw.ACTIVE_WORKTREE_ENV
|
ACTIVE_WORKTREE_ENV = amw.ACTIVE_WORKTREE_ENV
|
||||||
AUTHOR_WORKTREE_ENV = amw.AUTHOR_WORKTREE_ENV
|
AUTHOR_WORKTREE_ENV = amw.AUTHOR_WORKTREE_ENV
|
||||||
@@ -152,6 +154,60 @@ def resolve_namespace_workspace(
|
|||||||
return os.path.realpath(process_project_root), "MCP server process root (default)"
|
return os.path.realpath(process_project_root), "MCP server process root (default)"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_git_common_directory_membership(
|
||||||
|
workspace_path: str,
|
||||||
|
canonical_repo_root: str,
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
|
"""Verify that workspace_path belongs to canonical_repo_root via git common-dir or branches containment."""
|
||||||
|
ws = (workspace_path or "").strip()
|
||||||
|
root = (canonical_repo_root or "").strip()
|
||||||
|
if not ws or not root:
|
||||||
|
return False, "empty workspace or canonical root path"
|
||||||
|
|
||||||
|
try:
|
||||||
|
real_ws = os.path.realpath(os.path.abspath(ws))
|
||||||
|
real_root = os.path.realpath(os.path.abspath(root))
|
||||||
|
except Exception as exc:
|
||||||
|
return False, f"invalid workspace or root path: {exc}"
|
||||||
|
|
||||||
|
if real_ws == real_root:
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
if not os.path.isdir(real_ws):
|
||||||
|
return False, f"workspace directory '{real_ws}' does not exist"
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", real_ws, "rev-parse", "--git-common-dir"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if res.returncode == 0:
|
||||||
|
common_raw = (res.stdout or "").strip()
|
||||||
|
common_dir = amw._realpath_git_common_dir(real_ws, common_raw)
|
||||||
|
real_common = os.path.realpath(common_dir)
|
||||||
|
canonical_git = os.path.realpath(os.path.join(real_root, ".git"))
|
||||||
|
|
||||||
|
if real_common in (canonical_git, real_root):
|
||||||
|
return True, None
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"workspace '{real_ws}' git common directory '{real_common}' does not match "
|
||||||
|
f"canonical repository root '{real_root}' (.git at '{canonical_git}')"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"workspace '{real_ws}' is not a valid git repository or git rev-parse failed"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"failed to inspect git common directory for workspace '{real_ws}': {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def resolve_namespace_mutation_context(
|
def resolve_namespace_mutation_context(
|
||||||
*,
|
*,
|
||||||
role_kind: str,
|
role_kind: str,
|
||||||
@@ -163,6 +219,8 @@ def resolve_namespace_mutation_context(
|
|||||||
worktree: str | None = None,
|
worktree: str | None = None,
|
||||||
profile_name: str | None = None,
|
profile_name: str | None = None,
|
||||||
configured_canonical_root: str | None = None,
|
configured_canonical_root: str | None = None,
|
||||||
|
expected_slug: str | None = None,
|
||||||
|
remote: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Shared workspace resolution for runtime_context and mutation guards.
|
"""Shared workspace resolution for runtime_context and mutation guards.
|
||||||
|
|
||||||
@@ -180,11 +238,41 @@ def resolve_namespace_mutation_context(
|
|||||||
env_map = env if env is not None else os.environ
|
env_map = env if env is not None else os.environ
|
||||||
process_root = os.path.realpath(process_project_root)
|
process_root = os.path.realpath(process_project_root)
|
||||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||||
configured = (configured_canonical_root or "").strip()
|
|
||||||
if configured:
|
configured_val = (configured_canonical_root or "").strip()
|
||||||
canonical_root = os.path.realpath(configured)
|
if configured_val:
|
||||||
|
crr_assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=configured_val,
|
||||||
|
source="configured_canonical_root",
|
||||||
|
expected_slug=expected_slug,
|
||||||
|
process_project_root=process_root,
|
||||||
|
remote=remote,
|
||||||
|
require_binding=True,
|
||||||
|
)
|
||||||
|
# #973 B10: a refused repository-authority mode deliberately resolves no
|
||||||
|
# canonical root, so downstream guards keep evaluating the install
|
||||||
|
# checkout rather than an identity derived through an undefined mode.
|
||||||
|
# ``roots_aligned`` still follows ``proven`` and stays False, and the
|
||||||
|
# refusal (with its reason_code) rides along in
|
||||||
|
# ``canonical_root_assessment`` — this is a fail-closed fallback, never a
|
||||||
|
# normalisation of the mode.
|
||||||
|
canonical_root = crr_assessment["canonical_repo_root"] or amw.resolve_canonical_repo_root(
|
||||||
|
process_root, process_root
|
||||||
|
)
|
||||||
|
roots_aligned = crr_assessment["proven"]
|
||||||
else:
|
else:
|
||||||
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
crr_assessment = {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"reasons": [],
|
||||||
|
"configured": False,
|
||||||
|
"canonical_repo_root": amw.resolve_canonical_repo_root(process_root, process_root),
|
||||||
|
"resolved_slug": None,
|
||||||
|
"source": None,
|
||||||
|
"reason_code": None,
|
||||||
|
}
|
||||||
|
canonical_root = crr_assessment["canonical_repo_root"]
|
||||||
|
roots_aligned = (canonical_root == process_root)
|
||||||
|
|
||||||
durable: dict | None = None
|
durable: dict | None = None
|
||||||
if role == "author":
|
if role == "author":
|
||||||
@@ -234,7 +322,10 @@ def resolve_namespace_mutation_context(
|
|||||||
"ignored_bindings": demotions + (pollution.get("ignored_bindings") or []),
|
"ignored_bindings": demotions + (pollution.get("ignored_bindings") or []),
|
||||||
"process_project_root": process_root,
|
"process_project_root": process_root,
|
||||||
"canonical_repo_root": canonical_root,
|
"canonical_repo_root": canonical_root,
|
||||||
"roots_aligned": canonical_root == process_root,
|
"roots_aligned": roots_aligned,
|
||||||
|
"canonical_root_assessment": crr_assessment,
|
||||||
|
"expected_slug": expected_slug,
|
||||||
|
"remote": remote,
|
||||||
}
|
}
|
||||||
if durable is not None:
|
if durable is not None:
|
||||||
result["author_worktree_resolution"] = durable
|
result["author_worktree_resolution"] = durable
|
||||||
@@ -246,6 +337,15 @@ def resolve_namespace_mutation_context(
|
|||||||
result["author_worktree_reasons"] = list(durable.get("reasons") or [])
|
result["author_worktree_reasons"] = list(durable.get("reasons") or [])
|
||||||
result["author_worktree_blocker_kind"] = durable.get("blocker_kind")
|
result["author_worktree_blocker_kind"] = durable.get("blocker_kind")
|
||||||
result["operator_recovery"] = durable.get("operator_recovery")
|
result["operator_recovery"] = durable.get("operator_recovery")
|
||||||
|
else:
|
||||||
|
path_exists = os.path.exists(workspace)
|
||||||
|
result["path_exists"] = path_exists
|
||||||
|
result["in_git_worktree_list"] = (
|
||||||
|
amw.path_in_git_worktree_list(workspace, canonical_root)
|
||||||
|
if path_exists
|
||||||
|
else False
|
||||||
|
)
|
||||||
|
result["bound_worktree_missing"] = not path_exists
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -378,8 +478,8 @@ def format_namespace_workspace_binding_error(
|
|||||||
def assess_namespace_mutation_workspace(
|
def assess_namespace_mutation_workspace(
|
||||||
*,
|
*,
|
||||||
role_kind: str,
|
role_kind: str,
|
||||||
worktree_path: str | None,
|
worktree_path: str | None = None,
|
||||||
worktree: str | None,
|
worktree: str | None = None,
|
||||||
process_project_root: str,
|
process_project_root: str,
|
||||||
env: dict[str, str] | os._Environ | None = None,
|
env: dict[str, str] | os._Environ | None = None,
|
||||||
session_lease_worktree: str | None = None,
|
session_lease_worktree: str | None = None,
|
||||||
@@ -387,6 +487,8 @@ def assess_namespace_mutation_workspace(
|
|||||||
profile_name: str | None = None,
|
profile_name: str | None = None,
|
||||||
current_branch: str | None = None,
|
current_branch: str | None = None,
|
||||||
configured_canonical_root: str | None = None,
|
configured_canonical_root: str | None = None,
|
||||||
|
expected_slug: str | None = None,
|
||||||
|
remote: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Evaluate namespace workspace binding before preflight/mutation."""
|
"""Evaluate namespace workspace binding before preflight/mutation."""
|
||||||
ctx = resolve_namespace_mutation_context(
|
ctx = resolve_namespace_mutation_context(
|
||||||
@@ -399,6 +501,8 @@ def assess_namespace_mutation_workspace(
|
|||||||
session_lock_worktree=session_lock_worktree,
|
session_lock_worktree=session_lock_worktree,
|
||||||
profile_name=profile_name,
|
profile_name=profile_name,
|
||||||
configured_canonical_root=configured_canonical_root,
|
configured_canonical_root=configured_canonical_root,
|
||||||
|
expected_slug=expected_slug,
|
||||||
|
remote=remote,
|
||||||
)
|
)
|
||||||
mutation_workspace = ctx["workspace_path"]
|
mutation_workspace = ctx["workspace_path"]
|
||||||
binding_source = ctx["workspace_binding_source"]
|
binding_source = ctx["workspace_binding_source"]
|
||||||
@@ -422,6 +526,27 @@ def assess_namespace_mutation_workspace(
|
|||||||
|
|
||||||
reasons = list(metadata.get("reasons") or [])
|
reasons = list(metadata.get("reasons") or [])
|
||||||
operator_recovery = ctx.get("operator_recovery")
|
operator_recovery = ctx.get("operator_recovery")
|
||||||
|
|
||||||
|
crr_reasons = list(ctx.get("canonical_root_assessment", {}).get("reasons") or [])
|
||||||
|
if crr_reasons:
|
||||||
|
reasons.extend(crr_reasons)
|
||||||
|
|
||||||
|
path_exists = ctx.get("path_exists")
|
||||||
|
if path_exists is None:
|
||||||
|
path_exists = os.path.exists(mutation_workspace)
|
||||||
|
|
||||||
|
if not path_exists:
|
||||||
|
if role != "author":
|
||||||
|
reasons.append(
|
||||||
|
f"{role} mutation blocked: configured workspace directory '{mutation_workspace}' does not exist (nonexistent worktree)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
valid_common, common_err = verify_git_common_directory_membership(
|
||||||
|
mutation_workspace, ctx["canonical_repo_root"]
|
||||||
|
)
|
||||||
|
if not valid_common and common_err:
|
||||||
|
reasons.append(common_err)
|
||||||
|
|
||||||
if role == "author":
|
if role == "author":
|
||||||
# #618 durable resolution already validated existence, membership,
|
# #618 durable resolution already validated existence, membership,
|
||||||
# branches/, lock ownership, and traversal safety when present.
|
# branches/, lock ownership, and traversal safety when present.
|
||||||
@@ -438,26 +563,27 @@ def assess_namespace_mutation_workspace(
|
|||||||
)
|
)
|
||||||
if branches["block"]:
|
if branches["block"]:
|
||||||
reasons.extend(branches["reasons"])
|
reasons.extend(branches["reasons"])
|
||||||
elif (
|
elif role in {"reviewer", "merger"}:
|
||||||
role == "reviewer"
|
if mutation_workspace == process_root and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"]):
|
||||||
and mutation_workspace == process_root
|
reasons.append(
|
||||||
and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"])
|
f"{role} mutation blocked: workspace is the stable control checkout; "
|
||||||
):
|
f"create or reconnect to a session-owned worktree under branches/ "
|
||||||
reasons.append(
|
f"or set {ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} / "
|
||||||
f"{role} mutation blocked: workspace is the stable control checkout; "
|
f"{ACTIVE_WORKTREE_ENV}"
|
||||||
f"create or reconnect to a session-owned worktree under branches/ "
|
)
|
||||||
f"or set {ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} / "
|
elif mutation_workspace != process_root and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"]):
|
||||||
f"{ACTIVE_WORKTREE_ENV}"
|
reasons.append(
|
||||||
)
|
f"{role} mutation blocked: workspace '{mutation_workspace}' is not under "
|
||||||
elif (
|
f"'{ctx['canonical_repo_root']}/branches/'"
|
||||||
role in {"reviewer", "merger"}
|
)
|
||||||
and mutation_workspace != process_root
|
|
||||||
and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"])
|
if path_exists and amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"]):
|
||||||
):
|
in_list = ctx.get("in_git_worktree_list")
|
||||||
reasons.append(
|
if in_list is False:
|
||||||
f"{role} mutation blocked: workspace '{mutation_workspace}' is not under "
|
reasons.append(
|
||||||
f"'{ctx['canonical_repo_root']}/branches/'"
|
f"{role} mutation blocked: workspace '{mutation_workspace}' is under branches/ "
|
||||||
)
|
f"but is not registered in git worktree list for '{ctx['canonical_repo_root']}'"
|
||||||
|
)
|
||||||
|
|
||||||
block = bool(reasons)
|
block = bool(reasons)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -74,6 +74,43 @@ def check_author_mutation_namespace(
|
|||||||
return True, []
|
return True, []
|
||||||
|
|
||||||
|
|
||||||
|
def check_author_role_kind(
|
||||||
|
mutation_task: str,
|
||||||
|
profile: dict,
|
||||||
|
) -> tuple[bool, list[str]]:
|
||||||
|
"""Author-exclusive wall for durable-lock mutations (#953 F1).
|
||||||
|
|
||||||
|
``check_author_mutation_namespace`` walls off reviewer-bound sessions, which
|
||||||
|
is the whole gate for tasks whose required permission is itself author-only
|
||||||
|
(``gitea.pr.create``, ``gitea.repo.commit``). It is *not* sufficient for a
|
||||||
|
task gated on ``gitea.issue.comment``, which every configured role holds: a
|
||||||
|
merger, controller, or reconciler session would clear both the namespace
|
||||||
|
check and the permission gate and still reach the durable write.
|
||||||
|
|
||||||
|
Opt-in per call site and additive. It refuses any active profile whose
|
||||||
|
derived role kind is not exactly ``author`` for a task the router declares
|
||||||
|
author-required, and grants nothing to anyone — a ``mixed`` profile is
|
||||||
|
refused rather than admitted.
|
||||||
|
"""
|
||||||
|
required_role = role_session_router.required_role_for_task(mutation_task)
|
||||||
|
if required_role != "author":
|
||||||
|
return True, []
|
||||||
|
|
||||||
|
allowed = profile.get("allowed_operations") or []
|
||||||
|
forbidden = profile.get("forbidden_operations") or []
|
||||||
|
active_role = derive_role_kind(allowed, forbidden)
|
||||||
|
if active_role == "author":
|
||||||
|
return True, []
|
||||||
|
|
||||||
|
profile_name = profile.get("profile_name") or ""
|
||||||
|
namespace = infer_mcp_namespace(profile_name)
|
||||||
|
return False, [
|
||||||
|
f"author mutation '{mutation_task}' blocked: active session role kind is "
|
||||||
|
f"'{active_role}', not 'author' ({profile_name} / {namespace}); this "
|
||||||
|
"operation writes a durable author issue lock and is author-exclusive",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def mutation_audit_context(mutation_task: str, profile: dict, *,
|
def mutation_audit_context(mutation_task: str, profile: dict, *,
|
||||||
remote=None, repository=None) -> dict:
|
remote=None, repository=None) -> dict:
|
||||||
"""Structured mutation metadata for audit records (#209)."""
|
"""Structured mutation metadata for audit records (#209)."""
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ AUTHOR_TASKS = frozenset({
|
|||||||
"push_branch",
|
"push_branch",
|
||||||
"bootstrap_author_issue_worktree",
|
"bootstrap_author_issue_worktree",
|
||||||
"gitea_bootstrap_author_issue_worktree",
|
"gitea_bootstrap_author_issue_worktree",
|
||||||
|
# #953: recovery of an incomplete bootstrap lock is an author-only durable
|
||||||
|
# state mutation and belongs to the same class as bootstrap itself.
|
||||||
|
"recover_incomplete_bootstrap_lock",
|
||||||
|
"gitea_recover_incomplete_bootstrap_lock",
|
||||||
"create_pr",
|
"create_pr",
|
||||||
"comment_pr",
|
"comment_pr",
|
||||||
"address_pr_change_requests",
|
"address_pr_change_requests",
|
||||||
@@ -112,6 +116,12 @@ TASK_REQUIRED_ROLE = {
|
|||||||
"claim_issue": "author",
|
"claim_issue": "author",
|
||||||
"create_branch": "author",
|
"create_branch": "author",
|
||||||
"push_branch": "author",
|
"push_branch": "author",
|
||||||
|
# #953: without this entry ``required_role_for_task`` returns None and
|
||||||
|
# ``role_namespace_gate.check_author_mutation_namespace`` short-circuits to
|
||||||
|
# "allowed" — the namespace wall on the recovery tool would be inert. The
|
||||||
|
# capability map already records the same role; both tables must agree.
|
||||||
|
"recover_incomplete_bootstrap_lock": "author",
|
||||||
|
"gitea_recover_incomplete_bootstrap_lock": "author",
|
||||||
"create_pr": "author",
|
"create_pr": "author",
|
||||||
"comment_pr": "author",
|
"comment_pr": "author",
|
||||||
"address_pr_change_requests": "author",
|
"address_pr_change_requests": "author",
|
||||||
|
|||||||
+146
-9
@@ -1,9 +1,16 @@
|
|||||||
"""Root checkout guard (#475).
|
"""Root checkout guard (#475).
|
||||||
|
|
||||||
The project root checkout is the stable control checkout on master/prgs/master.
|
The project root checkout is the stable control checkout on its integration
|
||||||
Author/reviewer/merge flows must fail closed when the control checkout is
|
branch. Author/reviewer/merge flows must fail closed when the control checkout
|
||||||
contaminated (wrong branch, detached HEAD, dirty, or HEAD behind/ahead of
|
is contaminated (wrong branch, detached HEAD, dirty, or HEAD behind/ahead of the
|
||||||
prgs/master). Isolated ``branches/...`` worktrees remain allowed.
|
tracking integration ref). Isolated ``branches/...`` worktrees remain allowed.
|
||||||
|
|
||||||
|
The tracking ref is *derived per repository* (#983) rather than assumed to be
|
||||||
|
``prgs/master``: a namespace bound to another repository — say remote ``MDCPS``
|
||||||
|
on branch ``dev`` — is gated against ``refs/remotes/MDCPS/dev``. Derivation is
|
||||||
|
delegated to :mod:`canonical_repository_root`, the authoritative
|
||||||
|
repository/context resolver, so gating and parity reporting share one resolved
|
||||||
|
target instead of maintaining two disagreeing hardcoded defaults.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -11,6 +18,7 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
import canonical_repository_root
|
||||||
from author_mutation_worktree import is_path_under_branches
|
from author_mutation_worktree import is_path_under_branches
|
||||||
from reviewer_worktree import parse_dirty_tracked_files
|
from reviewer_worktree import parse_dirty_tracked_files
|
||||||
|
|
||||||
@@ -20,19 +28,65 @@ REMEDIATION = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
||||||
|
|
||||||
|
# Legacy PRGS-specific probe order. Retained only for callers that pass an
|
||||||
|
# explicit ``remote_refs`` override; it is no longer the silent default, because
|
||||||
|
# inheriting it in a non-PRGS checkout compared that checkout against a ref it
|
||||||
|
# can never have (#983).
|
||||||
REMOTE_MASTER_REFS = ("prgs/master", "refs/remotes/prgs/master")
|
REMOTE_MASTER_REFS = ("prgs/master", "refs/remotes/prgs/master")
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_probe_refs(root: str, explicit_remote: str | None) -> dict:
|
||||||
|
"""Derive the ordered tracking refs to probe for *root*.
|
||||||
|
|
||||||
|
Returns the derivation payload from :mod:`canonical_repository_root` plus a
|
||||||
|
``refs`` tuple, which is empty when the target is not provable.
|
||||||
|
"""
|
||||||
|
derived = canonical_repository_root.resolve_target_base_ref(
|
||||||
|
root, explicit_remote=explicit_remote
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"refs": tuple(derived.get("tracking_refs") or ()),
|
||||||
|
"remote": derived.get("remote"),
|
||||||
|
"branch": derived.get("branch"),
|
||||||
|
"source": derived.get("source"),
|
||||||
|
"proven": bool(derived.get("proven")),
|
||||||
|
"reason_code": derived.get("reason_code"),
|
||||||
|
"reasons": list(derived.get("reasons") or []),
|
||||||
|
"cached_remote_head_branch": derived.get("cached_remote_head_branch"),
|
||||||
|
"cached_remote_head_conflicts": bool(derived.get("cached_remote_head_conflicts")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def resolve_remote_master_sha(
|
def resolve_remote_master_sha(
|
||||||
canonical_repo_root: str,
|
canonical_repo_root: str,
|
||||||
*,
|
*,
|
||||||
remote_refs: tuple[str, ...] | None = None,
|
remote_refs: tuple[str, ...] | None = None,
|
||||||
|
explicit_remote: str | None = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Return the commit SHA for the tracking master ref when available."""
|
"""Return the commit SHA for the tracking integration ref when available.
|
||||||
|
|
||||||
|
This remains the single place that turns a ref into a SHA. Returns None when
|
||||||
|
the target cannot be derived or resolved — exactly what this function already
|
||||||
|
returned when ``rev-parse`` failed. Callers that must fail closed on missing
|
||||||
|
evidence (the #749/#757 bootstrap path) surface that None as *missing
|
||||||
|
evidence*, never as "no constraint".
|
||||||
|
|
||||||
|
*explicit_remote* is a caller-supplied disambiguation and is named that way
|
||||||
|
deliberately: an internally inferred remote handed back in would suppress the
|
||||||
|
resolver's ambiguity gate (#983 B2). No production caller supplies it.
|
||||||
|
"""
|
||||||
root = (canonical_repo_root or "").strip()
|
root = (canonical_repo_root or "").strip()
|
||||||
if not root:
|
if not root:
|
||||||
return None
|
return None
|
||||||
for ref in remote_refs or REMOTE_MASTER_REFS:
|
if remote_refs:
|
||||||
|
probe: tuple[str, ...] = tuple(remote_refs)
|
||||||
|
else:
|
||||||
|
derived = _derive_probe_refs(root, explicit_remote)
|
||||||
|
if not derived["proven"]:
|
||||||
|
return None
|
||||||
|
probe = derived["refs"]
|
||||||
|
for ref in probe:
|
||||||
res = subprocess.run(
|
res = subprocess.run(
|
||||||
["git", "-C", root, "rev-parse", "--verify", ref],
|
["git", "-C", root, "rev-parse", "--verify", ref],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -46,6 +100,84 @@ def resolve_remote_master_sha(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_remote_master_ref_state(
|
||||||
|
canonical_repo_root: str,
|
||||||
|
*,
|
||||||
|
remote_refs: tuple[str, ...] | None = None,
|
||||||
|
explicit_remote: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Resolve the tracking integration ref together with its commit SHA.
|
||||||
|
|
||||||
|
Returns ``sha``, the ``ref`` it came from, the derived ``remote`` /
|
||||||
|
``branch``, a machine-checkable ``reason_code``, and ``reasons``. ``sha`` is
|
||||||
|
None whenever the target cannot be resolved — never a fallback to some other
|
||||||
|
repository's commit.
|
||||||
|
|
||||||
|
The SHA itself is obtained through :func:`resolve_remote_master_sha` rather
|
||||||
|
than by re-probing here, so one public function stays authoritative for
|
||||||
|
ref-to-SHA resolution. An explicit *remote_refs* keeps the historical
|
||||||
|
behaviour exactly: those refs are probed in order and no derivation happens.
|
||||||
|
"""
|
||||||
|
root = (canonical_repo_root or "").strip()
|
||||||
|
state: dict = {
|
||||||
|
"sha": None,
|
||||||
|
"ref": None,
|
||||||
|
"remote": None,
|
||||||
|
"branch": None,
|
||||||
|
"source": None,
|
||||||
|
"reason_code": None,
|
||||||
|
"reasons": [],
|
||||||
|
"cached_remote_head_branch": None,
|
||||||
|
"cached_remote_head_conflicts": False,
|
||||||
|
}
|
||||||
|
if not root:
|
||||||
|
state["reasons"].append("no canonical repository root supplied (fail closed)")
|
||||||
|
return state
|
||||||
|
|
||||||
|
if remote_refs:
|
||||||
|
probe: tuple[str, ...] = tuple(remote_refs)
|
||||||
|
state["source"] = "explicit_remote_refs"
|
||||||
|
else:
|
||||||
|
derived = _derive_probe_refs(root, explicit_remote)
|
||||||
|
state["remote"] = derived["remote"]
|
||||||
|
state["branch"] = derived["branch"]
|
||||||
|
state["source"] = derived["source"]
|
||||||
|
state["cached_remote_head_branch"] = derived["cached_remote_head_branch"]
|
||||||
|
state["cached_remote_head_conflicts"] = derived["cached_remote_head_conflicts"]
|
||||||
|
if not derived["proven"]:
|
||||||
|
state["reason_code"] = derived["reason_code"]
|
||||||
|
state["reasons"] = derived["reasons"]
|
||||||
|
return state
|
||||||
|
probe = derived["refs"]
|
||||||
|
|
||||||
|
sha = resolve_remote_master_sha(root, remote_refs=probe, explicit_remote=explicit_remote)
|
||||||
|
if not sha:
|
||||||
|
state["reasons"].append(
|
||||||
|
"tracking integration ref "
|
||||||
|
f"{' / '.join(probe) if probe else '(none derived)'} does not resolve in "
|
||||||
|
f"'{root}' (fail closed)"
|
||||||
|
)
|
||||||
|
return state
|
||||||
|
|
||||||
|
state["sha"] = sha
|
||||||
|
# Name the ref that actually carries this commit. When the SHA comes from a
|
||||||
|
# test double no probe will match, so fall back to the first derived ref,
|
||||||
|
# which is the one the guard is conceptually comparing against.
|
||||||
|
for ref in probe:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", root, "rev-parse", "--verify", ref],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if res.returncode == 0 and (res.stdout or "").strip() == sha:
|
||||||
|
state["ref"] = ref
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
state["ref"] = probe[0] if probe else None
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
resolve_tracking_master_sha = resolve_remote_master_sha
|
resolve_tracking_master_sha = resolve_remote_master_sha
|
||||||
|
|
||||||
|
|
||||||
@@ -59,8 +191,9 @@ def assess_root_checkout_guard(
|
|||||||
remote_master_sha: str | None,
|
remote_master_sha: str | None,
|
||||||
resolved_role: str | None = None,
|
resolved_role: str | None = None,
|
||||||
actual_role: str | None = None,
|
actual_role: str | None = None,
|
||||||
|
remote_master_ref: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Fail closed when the control checkout is not clean master/prgs/master.
|
"""Fail closed when the control checkout is not clean on its integration ref.
|
||||||
|
|
||||||
``resolved_role`` is the preflight-resolved *task* role and ``actual_role``
|
``resolved_role`` is the preflight-resolved *task* role and ``actual_role``
|
||||||
is the *active profile* role (#540). The reconciler exemption honours either
|
is the *active profile* role (#540). The reconciler exemption honours either
|
||||||
@@ -102,9 +235,13 @@ def assess_root_checkout_guard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if remote_master_sha and head_sha and head_sha != remote_master_sha:
|
if remote_master_sha and head_sha and head_sha != remote_master_sha:
|
||||||
|
# #983: name the ref that was actually compared. Reporting a literal
|
||||||
|
# 'prgs/master' in a checkout gated against refs/remotes/MDCPS/dev sends
|
||||||
|
# the operator to inspect a ref that repository does not have.
|
||||||
|
ref_label = (remote_master_ref or "").strip() or "the tracking integration ref"
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"control checkout HEAD does not match prgs/master "
|
f"control checkout HEAD does not match {ref_label} "
|
||||||
f"(HEAD {head_sha[:12]}, prgs/master {remote_master_sha[:12]})"
|
f"(HEAD {head_sha[:12]}, {ref_label} {remote_master_sha[:12]})"
|
||||||
)
|
)
|
||||||
|
|
||||||
proven = not reasons
|
proven = not reasons
|
||||||
|
|||||||
@@ -204,6 +204,28 @@ proposed command before running it; `gitea_audit_runtime_recovery_contamination`
|
|||||||
to inspect or (reconciler-only) clear the marker. Full contrast in
|
to inspect or (reconciler-only) clear the marker. Full contrast in
|
||||||
`docs/mcp-namespace-eof-recovery.md`.
|
`docs/mcp-namespace-eof-recovery.md`.
|
||||||
|
|
||||||
|
## Connected is not attached (#708)
|
||||||
|
|
||||||
|
A host/CLI MCP inventory showing **Connected** is not proof the tools are usable.
|
||||||
|
The active session can expose **none** of a Connected server's tool namespaces —
|
||||||
|
a *session attachment* failure, distinct from config drift (#672),
|
||||||
|
transport-closed (#584), and resolver EOF (#685).
|
||||||
|
|
||||||
|
Required preflight proof is **live tool visibility plus `gitea_whoami` on the role
|
||||||
|
namespace**, never host Connected status alone. Call
|
||||||
|
`gitea_assess_mcp_namespace_attachment` with the Connected set and the namespaces
|
||||||
|
actually attached to the session; it returns the typed condition
|
||||||
|
**mcp_connected_namespaces_missing** with per-namespace connected-vs-attached proof,
|
||||||
|
and records the verdict so review and merge fail closed while a required namespace
|
||||||
|
is unattached.
|
||||||
|
|
||||||
|
Only sanctioned recovery: the client attach/reconnect path
|
||||||
|
(`gitea_request_mcp_reconnect`), then full preflight
|
||||||
|
(`gitea_whoami` → `gitea_resolve_task_capability` → task). Never recover by direct
|
||||||
|
module import, CLI/raw API mutation, profile hopping, session-state overrides,
|
||||||
|
process kills, or `.env`/mtime edits. A final report must not claim a healthy
|
||||||
|
session without attachment proof.
|
||||||
|
|
||||||
## Shell Spawn Hard-Stop Rule
|
## Shell Spawn Hard-Stop Rule
|
||||||
|
|
||||||
`exit_code: -1` with empty stdout/stderr means the shell failed to spawn — not a
|
`exit_code: -1` with empty stdout/stderr means the shell failed to spawn — not a
|
||||||
|
|||||||
@@ -41,6 +41,27 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.issue.comment",
|
"permission": "gitea.issue.comment",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
},
|
},
|
||||||
|
# #953: target-specific upgrade of an incomplete bootstrap lock (explicit
|
||||||
|
# operation, never a widening of lock_issue). Author-only, and the tool
|
||||||
|
# additionally proves exact-owner claimant match before writing.
|
||||||
|
"recover_incomplete_bootstrap_lock": {
|
||||||
|
"permission": "gitea.issue.comment",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_recover_incomplete_bootstrap_lock": {
|
||||||
|
"permission": "gitea.issue.comment",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
# #953: read-only lock contract inspection. Read permission only — it must
|
||||||
|
# never be able to mutate.
|
||||||
|
"inspect_issue_lock_contract": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_inspect_issue_lock_contract": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
# #860: dirty orphaned same-claimant worktree recovery (explicit operation).
|
# #860: dirty orphaned same-claimant worktree recovery (explicit operation).
|
||||||
"recover_dirty_orphaned_issue_worktree": {
|
"recover_dirty_orphaned_issue_worktree": {
|
||||||
"permission": "gitea.issue.comment",
|
"permission": "gitea.issue.comment",
|
||||||
@@ -142,6 +163,18 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.read",
|
"permission": "gitea.read",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
},
|
},
|
||||||
|
# #978: instance-level fleet identity/health snapshot. gitea.read is the
|
||||||
|
# operation gate; the tool additionally restricts role_kind to
|
||||||
|
# controller|reconciler so author/reviewer/merger cannot use it as a
|
||||||
|
# mutation surface and no unrelated write permission is introduced.
|
||||||
|
"snapshot_instance_fleet": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "controller",
|
||||||
|
},
|
||||||
|
"gitea_snapshot_instance_fleet": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "controller",
|
||||||
|
},
|
||||||
# #644: Phase 2 Web Console recovery tasks.
|
# #644: Phase 2 Web Console recovery tasks.
|
||||||
"clear_stale_binding": {
|
"clear_stale_binding": {
|
||||||
"permission": "gitea.read",
|
"permission": "gitea.read",
|
||||||
@@ -365,6 +398,25 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.branch.delete",
|
"permission": "gitea.branch.delete",
|
||||||
"role": "reconciler",
|
"role": "reconciler",
|
||||||
},
|
},
|
||||||
|
# #970: auditing missing worktree bindings is read-only; retiring one is a
|
||||||
|
# control-plane cleanup mutation and carries the same reconciler-only
|
||||||
|
# authority as any other reconciliation cleanup (review 644 B2).
|
||||||
|
"audit_missing_worktree_bindings": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"gitea_audit_missing_worktree_bindings": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"reconcile_missing_worktree_bindings": {
|
||||||
|
"permission": "gitea.branch.delete",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"gitea_reconcile_missing_worktree_bindings": {
|
||||||
|
"permission": "gitea.branch.delete",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
"work_issue": {
|
"work_issue": {
|
||||||
"permission": "gitea.pr.create",
|
"permission": "gitea.pr.create",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
@@ -632,6 +684,8 @@ ROLE_EXCLUSIVE_TASKS: frozenset[str] = frozenset(
|
|||||||
"delete_branch",
|
"delete_branch",
|
||||||
"cleanup_merged_pr_branch",
|
"cleanup_merged_pr_branch",
|
||||||
"reconciliation_cleanup",
|
"reconciliation_cleanup",
|
||||||
|
"reconcile_missing_worktree_bindings",
|
||||||
|
"gitea_reconcile_missing_worktree_bindings",
|
||||||
"work_issue",
|
"work_issue",
|
||||||
"work-issue",
|
"work-issue",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,8 +175,21 @@ class TestLauncherSnippets(unittest.TestCase):
|
|||||||
def test_only_safe_keys_no_secrets(self):
|
def test_only_safe_keys_no_secrets(self):
|
||||||
entry = gitea_config.launcher_entry("prgs", "/cfg/profiles.json")["gitea-tools"]
|
entry = gitea_config.launcher_entry("prgs", "/cfg/profiles.json")["gitea-tools"]
|
||||||
self.assertEqual(set(entry), {"command", "args", "env"})
|
self.assertEqual(set(entry), {"command", "args", "env"})
|
||||||
self.assertEqual(set(entry["env"]), {"GITEA_MCP_CONFIG", "GITEA_MCP_PROFILE", "GITEA_CLIENT_MANAGED"})
|
# #978 B1: production launcher also injects trusted client identity.
|
||||||
|
required = {
|
||||||
|
"GITEA_MCP_CONFIG",
|
||||||
|
"GITEA_MCP_PROFILE",
|
||||||
|
"GITEA_CLIENT_MANAGED",
|
||||||
|
"GITEA_MCP_CLIENT",
|
||||||
|
"GITEA_MCP_CLIENT_INSTANCE",
|
||||||
|
"GITEA_MCP_INSTANCE_PROVENANCE",
|
||||||
|
}
|
||||||
|
self.assertTrue(required.issubset(set(entry["env"])), entry["env"])
|
||||||
self.assertEqual(entry["env"]["GITEA_MCP_PROFILE"], "prgs")
|
self.assertEqual(entry["env"]["GITEA_MCP_PROFILE"], "prgs")
|
||||||
|
self.assertTrue(
|
||||||
|
entry["env"]["GITEA_MCP_CLIENT_INSTANCE"].startswith("inst-"),
|
||||||
|
entry["env"]["GITEA_MCP_CLIENT_INSTANCE"],
|
||||||
|
)
|
||||||
blob = json.dumps(entry).lower()
|
blob = json.dumps(entry).lower()
|
||||||
for word in ("token", "password", "secret"):
|
for word in ("token", "password", "secret"):
|
||||||
self.assertNotIn(word, blob)
|
self.assertNotIn(word, blob)
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ class TestCanonicalRootGuardBinding(_ServerHarness):
|
|||||||
process_project_root=self.install_root,
|
process_project_root=self.install_root,
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
require_binding=True,
|
require_binding=True,
|
||||||
|
mode="derivation",
|
||||||
)
|
)
|
||||||
self.assertFalse(got.get("block"), got.get("reasons"))
|
self.assertFalse(got.get("block"), got.get("reasons"))
|
||||||
self.assertEqual(got["resolved_slug"], TARGET_SLUG)
|
self.assertEqual(got["resolved_slug"], TARGET_SLUG)
|
||||||
|
|||||||
@@ -112,7 +112,19 @@ class TestIssue686ManualMcpProvenance(unittest.TestCase):
|
|||||||
self.assertTrue(any("All matching profiles for task 'create_issue' (['prgs-author']) are running but stale" in r for r in reasons))
|
self.assertTrue(any("All matching profiles for task 'create_issue' (['prgs-author']) are running but stale" in r for r in reasons))
|
||||||
|
|
||||||
def test_namespace_health_classification_includes_provenance(self):
|
def test_namespace_health_classification_includes_provenance(self):
|
||||||
"""AC 1 & 4: mcp_namespace_health diagnostics include provenance and unconsumed_gitea_env."""
|
"""AC 1 & 4: mcp_namespace_health diagnostics include provenance and unconsumed_gitea_env.
|
||||||
|
|
||||||
|
#948 narrowed the vocabulary here. This process carries no client-managed
|
||||||
|
declaration, so the old code labelled it ``manual_launch`` — asserting a
|
||||||
|
hand-launched terminal process it had no evidence for, and contradicting
|
||||||
|
``gitea_get_runtime_context``, which read the same process and reported
|
||||||
|
``client_managed``. Absence of proof is now reported as ``unproven``.
|
||||||
|
|
||||||
|
The #686 wall itself is unchanged and still asserted below:
|
||||||
|
``is_client_managed`` stays False, so nothing previously refused is now
|
||||||
|
permitted. Only the label on the *reason* changed, so remediation names
|
||||||
|
the proof that is actually missing.
|
||||||
|
"""
|
||||||
process = {
|
process = {
|
||||||
"pid": 5555,
|
"pid": 5555,
|
||||||
"profile": "prgs-author",
|
"profile": "prgs-author",
|
||||||
@@ -129,10 +141,12 @@ class TestIssue686ManualMcpProvenance(unittest.TestCase):
|
|||||||
process=process,
|
process=process,
|
||||||
probe_source="client_namespace",
|
probe_source="client_namespace",
|
||||||
)
|
)
|
||||||
self.assertEqual(res["provenance"], "manual_launch")
|
self.assertEqual(res["provenance"], "unproven")
|
||||||
self.assertFalse(res["is_client_managed"])
|
self.assertFalse(res["is_client_managed"])
|
||||||
|
# The wall is intact: no client-managed proof still fails closed.
|
||||||
|
self.assertTrue(res["provenance_fail_closed"])
|
||||||
self.assertEqual(res["unconsumed_gitea_env"], {"GITEA_DUMMY": "99"})
|
self.assertEqual(res["unconsumed_gitea_env"], {"GITEA_DUMMY": "99"})
|
||||||
self.assertEqual(res["diagnostics"]["provenance"], "manual_launch")
|
self.assertEqual(res["diagnostics"]["provenance"], "unproven")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -241,9 +241,10 @@ class TestNamespaceContextUsesConfiguredRoot(unittest.TestCase):
|
|||||||
process_project_root=self.install,
|
process_project_root=self.install,
|
||||||
env={},
|
env={},
|
||||||
configured_canonical_root=self.target,
|
configured_canonical_root=self.target,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
)
|
)
|
||||||
self.assertEqual(ctx["canonical_repo_root"], self.target)
|
self.assertEqual(ctx["canonical_repo_root"], self.target)
|
||||||
self.assertFalse(ctx["roots_aligned"])
|
self.assertTrue(ctx["roots_aligned"])
|
||||||
|
|
||||||
def test_target_worktree_is_member_of_target_root(self):
|
def test_target_worktree_is_member_of_target_root(self):
|
||||||
got = nwb.amw.assess_workspace_repo_membership(
|
got = nwb.amw.assess_workspace_repo_membership(
|
||||||
@@ -268,6 +269,7 @@ class TestNamespaceContextUsesConfiguredRoot(unittest.TestCase):
|
|||||||
env={},
|
env={},
|
||||||
current_branch="feat/issue-1",
|
current_branch="feat/issue-1",
|
||||||
configured_canonical_root=self.target,
|
configured_canonical_root=self.target,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
)
|
)
|
||||||
self.assertFalse(assessment["block"], assessment.get("reasons"))
|
self.assertFalse(assessment["block"], assessment.get("reasons"))
|
||||||
self.assertEqual(assessment["canonical_repo_root"], self.target)
|
self.assertEqual(assessment["canonical_repo_root"], self.target)
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""Regression tests for Issue #708 wiring: detection must reach a gate, not just exist.
|
||||||
|
|
||||||
|
The prior #708 slice added a pure decision function with no call site, so a session
|
||||||
|
whose namespaces were Connected-but-unattached still passed every mutation gate.
|
||||||
|
These tests pin the parts that make the detection load-bearing:
|
||||||
|
|
||||||
|
* the typed condition is distinct from #672 / #584 / #685,
|
||||||
|
* the session store records a per-namespace attachment verdict,
|
||||||
|
* review/merge mutations fail closed while a required namespace is unattached,
|
||||||
|
* recovery is reconnect-only and never suggests an unsafe fallback,
|
||||||
|
* startup ordering races and discovery-cache telemetry are reported,
|
||||||
|
* a healthy final report cannot be produced without attachment proof.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import mcp_namespace_health
|
||||||
|
|
||||||
|
|
||||||
|
REQUIRED = ["gitea-author", "gitea-reviewer", "gitea-merger", "gitea-tools"]
|
||||||
|
|
||||||
|
|
||||||
|
def _connected_but_unattached():
|
||||||
|
"""The #708 signature: host says Connected, session tool surface is empty."""
|
||||||
|
return mcp_namespace_health.assess_connected_namespace_attachment(
|
||||||
|
connected_servers=REQUIRED,
|
||||||
|
attached_session_namespaces=[],
|
||||||
|
required_namespaces=REQUIRED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- AC1: distinct typed detection -----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_connected_with_empty_session_tool_list_is_typed_distinctly():
|
||||||
|
res = _connected_but_unattached()
|
||||||
|
assert res["attachment_healthy"] is False
|
||||||
|
assert res["discovery_status"] == "connected_but_namespaces_missing"
|
||||||
|
assert res["error_type"] == "mcp_connected_namespaces_missing"
|
||||||
|
assert sorted(res["missing_namespaces"]) == sorted(REQUIRED)
|
||||||
|
# Not misclassified as config drift (#672), transport-closed (#584), or EOF (#685).
|
||||||
|
assert res["error_type"] not in {
|
||||||
|
"mcp_config_drift",
|
||||||
|
"transport_closed",
|
||||||
|
"mcp_client_eof",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_proof_carries_connected_and_attached_per_namespace():
|
||||||
|
res = _connected_but_unattached()
|
||||||
|
proof = res["proof_of_connected_vs_attached"]
|
||||||
|
for ns in REQUIRED:
|
||||||
|
assert proof[ns] == {"connected": True, "attached": False}
|
||||||
|
|
||||||
|
|
||||||
|
# --- AC2: recovery is auto-attach or reconnect-only -------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_next_action_is_reconnect_only():
|
||||||
|
res = _connected_but_unattached()
|
||||||
|
assert res["reconnect_required"] is True
|
||||||
|
action = res["exact_next_action"]
|
||||||
|
assert "Reconnect the IDE/client MCP session" in action
|
||||||
|
for forbidden in ("pkill", "chmod", "curl", ".env", "sys.path"):
|
||||||
|
assert forbidden not in action
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanctioned_recovery_tool_is_named():
|
||||||
|
res = _connected_but_unattached()
|
||||||
|
assert res["sanctioned_recovery_tool"] == "gitea_request_mcp_reconnect"
|
||||||
|
assert "gitea_request_mcp_reconnect" in " ".join(res["remediation"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_auto_attach_reports_recovered_without_operator():
|
||||||
|
res = mcp_namespace_health.assess_connected_namespace_attachment(
|
||||||
|
connected_servers=REQUIRED,
|
||||||
|
attached_session_namespaces=REQUIRED,
|
||||||
|
required_namespaces=REQUIRED,
|
||||||
|
auto_attach_attempted=True,
|
||||||
|
auto_attach_succeeded=True,
|
||||||
|
)
|
||||||
|
assert res["attachment_healthy"] is True
|
||||||
|
assert res["auto_recovered"] is True
|
||||||
|
assert res["reconnect_required"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_auto_attach_still_requires_reconnect():
|
||||||
|
res = mcp_namespace_health.assess_connected_namespace_attachment(
|
||||||
|
connected_servers=REQUIRED,
|
||||||
|
attached_session_namespaces=[],
|
||||||
|
required_namespaces=REQUIRED,
|
||||||
|
auto_attach_attempted=True,
|
||||||
|
auto_attach_succeeded=False,
|
||||||
|
)
|
||||||
|
assert res["auto_recovered"] is False
|
||||||
|
assert res["reconnect_required"] is True
|
||||||
|
assert any("did not succeed" in r for r in res["reasons"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconnect_rediscovery_clears_the_condition():
|
||||||
|
"""Attach state after a reconnect is healthy without any other change."""
|
||||||
|
before = _connected_but_unattached()
|
||||||
|
after = mcp_namespace_health.assess_connected_namespace_attachment(
|
||||||
|
connected_servers=REQUIRED,
|
||||||
|
attached_session_namespaces=REQUIRED,
|
||||||
|
required_namespaces=REQUIRED,
|
||||||
|
discovery_cache_hit=False,
|
||||||
|
discovery_cache_age_seconds=0.0,
|
||||||
|
)
|
||||||
|
assert before["attachment_healthy"] is False
|
||||||
|
assert after["attachment_healthy"] is True
|
||||||
|
assert after["discovery_status"] == "namespaces_attached"
|
||||||
|
assert after["missing_namespaces"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- AC4: startup ordering across multiple role servers ---------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_role_startup_ordering_race_is_reported():
|
||||||
|
res = mcp_namespace_health.assess_connected_namespace_attachment(
|
||||||
|
connected_servers=REQUIRED,
|
||||||
|
attached_session_namespaces=["gitea-author"],
|
||||||
|
required_namespaces=REQUIRED,
|
||||||
|
session_tool_snapshot_at=1000.0,
|
||||||
|
namespace_connected_at={
|
||||||
|
"gitea-author": 990.0,
|
||||||
|
"gitea-reviewer": 1005.0,
|
||||||
|
"gitea-merger": 1007.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert res["startup_ordering_race"] is True
|
||||||
|
assert res["late_attaching_namespaces"] == ["gitea-merger", "gitea-reviewer"]
|
||||||
|
assert res["telemetry"]["startup_ordering_race"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_ordering_race_when_snapshot_follows_connect():
|
||||||
|
res = mcp_namespace_health.assess_connected_namespace_attachment(
|
||||||
|
connected_servers=REQUIRED,
|
||||||
|
attached_session_namespaces=REQUIRED,
|
||||||
|
required_namespaces=REQUIRED,
|
||||||
|
session_tool_snapshot_at=2000.0,
|
||||||
|
namespace_connected_at={ns: 1000.0 for ns in REQUIRED},
|
||||||
|
)
|
||||||
|
assert res["startup_ordering_race"] is False
|
||||||
|
assert res["late_attaching_namespaces"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- AC5: telemetry ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_telemetry_reports_cache_and_recovery_signals():
|
||||||
|
res = mcp_namespace_health.assess_connected_namespace_attachment(
|
||||||
|
connected_servers=REQUIRED,
|
||||||
|
attached_session_namespaces=["gitea-author"],
|
||||||
|
required_namespaces=REQUIRED,
|
||||||
|
discovery_cache_hit=True,
|
||||||
|
discovery_cache_age_seconds=42.5,
|
||||||
|
)
|
||||||
|
tel = res["telemetry"]
|
||||||
|
assert tel["connected_count"] == 4
|
||||||
|
assert tel["attached_count"] == 1
|
||||||
|
assert tel["missing_count"] == 3
|
||||||
|
assert tel["discovery_cache_hit"] is True
|
||||||
|
assert tel["discovery_cache_age_seconds"] == 42.5
|
||||||
|
assert tel["reconnect_required"] is True
|
||||||
|
assert tel["error_type"] == "mcp_connected_namespaces_missing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_telemetry_leaks_no_secrets():
|
||||||
|
res = mcp_namespace_health.assess_connected_namespace_attachment(
|
||||||
|
connected_servers=REQUIRED,
|
||||||
|
attached_session_namespaces=[],
|
||||||
|
required_namespaces=REQUIRED,
|
||||||
|
)
|
||||||
|
blob = repr(res["telemetry"]).lower()
|
||||||
|
for leak in ("token", "authorization", "password", "secret", "/users/", "http"):
|
||||||
|
assert leak not in blob
|
||||||
|
|
||||||
|
|
||||||
|
# --- AC2/AC6: fail-closed mutation gate -------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _session_store_from(assessment):
|
||||||
|
"""Mirror the server-side recorder without importing the MCP server module."""
|
||||||
|
store = {}
|
||||||
|
missing = set(assessment["missing_namespaces"])
|
||||||
|
for ns, state in assessment["proof_of_connected_vs_attached"].items():
|
||||||
|
attached = bool(state["attached"])
|
||||||
|
store[ns] = {
|
||||||
|
"namespace": ns,
|
||||||
|
"connected": bool(state["connected"]),
|
||||||
|
"attached": attached,
|
||||||
|
"attachment_healthy": attached and ns not in missing,
|
||||||
|
"error_type": None if attached else assessment["error_type"],
|
||||||
|
}
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_and_merge_fail_closed_while_unattached():
|
||||||
|
store = _session_store_from(_connected_but_unattached())
|
||||||
|
for task in ("review_pr", "submit_review", "merge_pr", "create_pr", "work_issue"):
|
||||||
|
reasons = mcp_namespace_health.attachment_gate_from_session(task, store)
|
||||||
|
assert reasons, f"{task} must fail closed while its namespace is unattached"
|
||||||
|
assert "fail closed, #708" in reasons[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_offers_only_sanctioned_recovery():
|
||||||
|
store = _session_store_from(_connected_but_unattached())
|
||||||
|
reasons = mcp_namespace_health.attachment_gate_from_session("merge_pr", store)
|
||||||
|
joined = " ".join(reasons)
|
||||||
|
assert "reconnect" in joined.lower()
|
||||||
|
assert "Workflow Safety Hard Stop (#708)" in joined
|
||||||
|
# Unsafe fallbacks appear only inside the prohibition, never as advice.
|
||||||
|
assert "NEVER use" in joined
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_passes_once_namespaces_are_attached():
|
||||||
|
healthy = mcp_namespace_health.assess_connected_namespace_attachment(
|
||||||
|
connected_servers=REQUIRED,
|
||||||
|
attached_session_namespaces=REQUIRED,
|
||||||
|
required_namespaces=REQUIRED,
|
||||||
|
)
|
||||||
|
store = _session_store_from(healthy)
|
||||||
|
for task in ("review_pr", "submit_review", "merge_pr", "create_pr", "work_issue"):
|
||||||
|
assert mcp_namespace_health.attachment_gate_from_session(task, store) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unassessed_session_does_not_gate():
|
||||||
|
"""No recorded assessment must not fabricate a block (matches #543 semantics)."""
|
||||||
|
assert mcp_namespace_health.attachment_gate_from_session("merge_pr", {}) == []
|
||||||
|
assert mcp_namespace_health.attachment_gate_from_session("merge_pr", None) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unmapped_task_is_not_gated():
|
||||||
|
store = _session_store_from(_connected_but_unattached())
|
||||||
|
assert mcp_namespace_health.attachment_gate_from_session("gitea_read", store) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_attachment_gates_only_the_affected_role():
|
||||||
|
"""Author attached, reviewer not: author work proceeds, review fails closed."""
|
||||||
|
res = mcp_namespace_health.assess_connected_namespace_attachment(
|
||||||
|
connected_servers=REQUIRED,
|
||||||
|
attached_session_namespaces=["gitea-author", "gitea-tools"],
|
||||||
|
required_namespaces=REQUIRED,
|
||||||
|
)
|
||||||
|
store = _session_store_from(res)
|
||||||
|
assert mcp_namespace_health.attachment_gate_from_session("work_issue", store) == []
|
||||||
|
assert mcp_namespace_health.attachment_gate_from_session("review_pr", store)
|
||||||
|
assert mcp_namespace_health.attachment_gate_from_session("merge_pr", store)
|
||||||
|
|
||||||
|
|
||||||
|
# --- AC4: no false healthy report without attachment proof ------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_false_healthy_without_attachment_proof():
|
||||||
|
"""Connected alone never yields a healthy verdict."""
|
||||||
|
res = mcp_namespace_health.assess_connected_namespace_attachment(
|
||||||
|
connected_servers=REQUIRED,
|
||||||
|
attached_session_namespaces=None,
|
||||||
|
required_namespaces=REQUIRED,
|
||||||
|
)
|
||||||
|
assert res["success"] is False
|
||||||
|
assert res["attachment_healthy"] is False
|
||||||
|
assert res["telemetry"]["attached_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_gate_maps_each_role_namespace():
|
||||||
|
assert mcp_namespace_health.required_namespace_for_attachment("review_pr") == "gitea-reviewer"
|
||||||
|
assert mcp_namespace_health.required_namespace_for_attachment("merge_pr") == "gitea-merger"
|
||||||
|
assert mcp_namespace_health.required_namespace_for_attachment("create_pr") == "gitea-author"
|
||||||
|
assert mcp_namespace_health.required_namespace_for_attachment("nope") is None
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Unit regression tests for Issue #708: Connected-but-namespaces-missing detection and attachment safety."""
|
||||||
|
|
||||||
|
import mcp_namespace_health
|
||||||
|
|
||||||
|
|
||||||
|
def test_assess_connected_namespace_attachment_success():
|
||||||
|
# required_namespaces is declared explicitly: these three are the namespaces this case
|
||||||
|
# is about. Relying on the default (which also requires gitea-tools) would ask for a
|
||||||
|
# healthy verdict covering a required namespace that was never connected — exactly the
|
||||||
|
# false-healthy classification these tests now forbid.
|
||||||
|
connected = ["gitea-author", "gitea-reviewer", "gitea-merger"]
|
||||||
|
attached = ["gitea-author", "gitea-reviewer", "gitea-merger"]
|
||||||
|
res = mcp_namespace_health.assess_connected_namespace_attachment(
|
||||||
|
connected_servers=connected,
|
||||||
|
attached_session_namespaces=attached,
|
||||||
|
required_namespaces=connected,
|
||||||
|
)
|
||||||
|
assert res["success"] is True
|
||||||
|
assert res["attachment_healthy"] is True
|
||||||
|
assert res["discovery_status"] == "namespaces_attached"
|
||||||
|
assert res["error_type"] is None
|
||||||
|
assert res["missing_namespaces"] == []
|
||||||
|
assert res["exact_next_action"] == "None; session tool namespaces attached."
|
||||||
|
|
||||||
|
|
||||||
|
def test_assess_connected_namespace_attachment_missing():
|
||||||
|
# Every required namespace here *is* connected, so the only condition present is the
|
||||||
|
# #708 one: Connected at the host, absent from the session tool surface.
|
||||||
|
connected = ["gitea-author", "gitea-reviewer", "gitea-merger"]
|
||||||
|
attached = ["gitea-author"]
|
||||||
|
res = mcp_namespace_health.assess_connected_namespace_attachment(
|
||||||
|
connected_servers=connected,
|
||||||
|
attached_session_namespaces=attached,
|
||||||
|
required_namespaces=connected,
|
||||||
|
)
|
||||||
|
assert res["success"] is False
|
||||||
|
assert res["attachment_healthy"] is False
|
||||||
|
assert res["discovery_status"] == "connected_but_namespaces_missing"
|
||||||
|
assert res["error_type"] == "mcp_connected_namespaces_missing"
|
||||||
|
assert "gitea-reviewer" in res["missing_namespaces"]
|
||||||
|
assert "gitea-merger" in res["missing_namespaces"]
|
||||||
|
assert "Reconnect the IDE/client MCP session" in res["exact_next_action"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_assess_connected_namespace_attachment_disconnected():
|
||||||
|
res = mcp_namespace_health.assess_connected_namespace_attachment(
|
||||||
|
connected_servers=[],
|
||||||
|
attached_session_namespaces=[],
|
||||||
|
)
|
||||||
|
assert res["success"] is False
|
||||||
|
assert res["attachment_healthy"] is False
|
||||||
|
assert res["discovery_status"] == "disconnected"
|
||||||
|
|
||||||
|
|
||||||
|
def test_proof_of_connected_vs_attached_mapping():
|
||||||
|
connected = ["gitea-author", "gitea-reviewer"]
|
||||||
|
attached = ["gitea-author"]
|
||||||
|
res = mcp_namespace_health.assess_connected_namespace_attachment(
|
||||||
|
connected_servers=connected,
|
||||||
|
attached_session_namespaces=attached,
|
||||||
|
)
|
||||||
|
proof = res["proof_of_connected_vs_attached"]
|
||||||
|
assert proof["gitea-author"] == {"connected": True, "attached": True}
|
||||||
|
assert proof["gitea-reviewer"] == {"connected": True, "attached": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsafe_fallback_policy_enforcement():
|
||||||
|
res = mcp_namespace_health.assess_connected_namespace_attachment(
|
||||||
|
connected_servers=["gitea-author"],
|
||||||
|
attached_session_namespaces=[],
|
||||||
|
)
|
||||||
|
policy = res["unsafe_fallback_policy"]
|
||||||
|
assert "Workflow Safety Hard Stop (#708)" in policy
|
||||||
|
assert "direct imports" in policy
|
||||||
|
assert "API mutations" in policy
|
||||||
|
assert "profile hopping" in policy
|
||||||
|
assert "session-state overrides" in policy
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
"""Regression tests for Issue #708 B2: not-connected is not Connected-but-unattached.
|
||||||
|
|
||||||
|
The first #708 slice counted a namespace as ``missing`` only when it appeared in
|
||||||
|
``connected_servers``. A *required* namespace absent from that inventory was therefore
|
||||||
|
never counted at all, so the session reported ``attachment_healthy: true`` with
|
||||||
|
``error_type: None`` while holding no attachment proof for it — and the gate text told the
|
||||||
|
operator "the host reports Connected" about a service nothing had reported Connected.
|
||||||
|
|
||||||
|
These tests pin the corrected distinction:
|
||||||
|
|
||||||
|
* required and not connected never yields a healthy verdict,
|
||||||
|
* it is typed ``mcp_required_namespaces_not_connected``, never the #708 condition,
|
||||||
|
* ``mcp_connected_namespaces_missing`` stays reserved for genuinely Connected namespaces,
|
||||||
|
* both categories still fail review and merge closed, with their own reason,
|
||||||
|
* per-namespace ``connected``/``attached`` evidence is reported accurately,
|
||||||
|
* one session's evidence cannot clear another session's block.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import mcp_namespace_health
|
||||||
|
|
||||||
|
|
||||||
|
ROLES = ["gitea-author", "gitea-reviewer", "gitea-merger", "gitea-tools"]
|
||||||
|
|
||||||
|
NOT_CONNECTED = mcp_namespace_health.ERROR_REQUIRED_NAMESPACES_NOT_CONNECTED
|
||||||
|
CONNECTED_MISSING = mcp_namespace_health.ERROR_CONNECTED_NAMESPACES_MISSING
|
||||||
|
|
||||||
|
|
||||||
|
def _assess(connected, attached, required):
|
||||||
|
return mcp_namespace_health.assess_connected_namespace_attachment(
|
||||||
|
connected_servers=connected,
|
||||||
|
attached_session_namespaces=attached,
|
||||||
|
required_namespaces=required,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _store(assessment):
|
||||||
|
"""The recorder contract: per-namespace verdicts feed the gate."""
|
||||||
|
return dict(assessment["namespace_conditions"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- the four evidence combinations ----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_connected_and_attached_is_healthy():
|
||||||
|
res = _assess(ROLES, ROLES, ROLES)
|
||||||
|
assert res["attachment_healthy"] is True
|
||||||
|
assert res["error_type"] is None
|
||||||
|
assert res["missing_namespaces"] == []
|
||||||
|
assert res["not_connected_namespaces"] == []
|
||||||
|
assert res["discovery_status"] == "namespaces_attached"
|
||||||
|
|
||||||
|
|
||||||
|
def test_connected_but_unattached_keeps_the_708_condition():
|
||||||
|
res = _assess(ROLES, ["gitea-author"], ROLES)
|
||||||
|
assert res["attachment_healthy"] is False
|
||||||
|
assert res["error_type"] == CONNECTED_MISSING
|
||||||
|
assert res["not_connected_namespaces"] == []
|
||||||
|
assert sorted(res["missing_namespaces"]) == [
|
||||||
|
"gitea-merger",
|
||||||
|
"gitea-reviewer",
|
||||||
|
"gitea-tools",
|
||||||
|
]
|
||||||
|
assert res["discovery_status"] == "connected_but_namespaces_missing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_required_but_not_connected_is_never_healthy():
|
||||||
|
"""The reviewer's exact reproduction from review 637."""
|
||||||
|
res = _assess(
|
||||||
|
["gitea-reviewer"], ["gitea-reviewer"], ["gitea-reviewer", "gitea-merger"]
|
||||||
|
)
|
||||||
|
assert res["attachment_healthy"] is False
|
||||||
|
assert res["success"] is False
|
||||||
|
assert res["error_type"] is not None
|
||||||
|
assert res["telemetry"]["not_connected_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_required_but_not_connected_is_typed_distinctly():
|
||||||
|
res = _assess(
|
||||||
|
["gitea-reviewer"], ["gitea-reviewer"], ["gitea-reviewer", "gitea-merger"]
|
||||||
|
)
|
||||||
|
assert res["error_type"] == NOT_CONNECTED
|
||||||
|
assert res["discovery_status"] == "required_namespaces_not_connected"
|
||||||
|
assert res["not_connected_namespaces"] == ["gitea-merger"]
|
||||||
|
# Not collapsed into the #708 condition, nor into config drift (#672) or
|
||||||
|
# transport-closed (#584).
|
||||||
|
assert CONNECTED_MISSING not in res["error_types"]
|
||||||
|
assert res["missing_namespaces"] == []
|
||||||
|
assert res["error_type"] not in {"mcp_config_drift", "transport_closed"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_connected_reason_makes_no_connected_claim():
|
||||||
|
res = _assess(
|
||||||
|
["gitea-reviewer"], ["gitea-reviewer"], ["gitea-reviewer", "gitea-merger"]
|
||||||
|
)
|
||||||
|
about_merger = [r for r in res["reasons"] if "gitea-merger" in r]
|
||||||
|
assert about_merger, "the not-connected namespace must be named in the reasons"
|
||||||
|
for reason in about_merger:
|
||||||
|
assert "report Connected at host/CLI layer" not in reason
|
||||||
|
assert "gitea-merger" in res["exact_next_action"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_neither_connected_nor_attached_reports_disconnected():
|
||||||
|
res = _assess([], [], ROLES)
|
||||||
|
assert res["attachment_healthy"] is False
|
||||||
|
assert res["discovery_status"] == "disconnected"
|
||||||
|
assert res["error_type"] == NOT_CONNECTED
|
||||||
|
assert sorted(res["not_connected_namespaces"]) == sorted(ROLES)
|
||||||
|
assert res["missing_namespaces"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- mixed required namespaces ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_connected_attached_and_not_connected():
|
||||||
|
res = _assess(
|
||||||
|
["gitea-author"], ["gitea-author"], ["gitea-author", "gitea-merger"]
|
||||||
|
)
|
||||||
|
assert res["attachment_healthy"] is False
|
||||||
|
assert res["not_connected_namespaces"] == ["gitea-merger"]
|
||||||
|
assert res["missing_namespaces"] == []
|
||||||
|
conditions = res["namespace_conditions"]
|
||||||
|
assert conditions["gitea-author"]["attachment_healthy"] is True
|
||||||
|
assert conditions["gitea-author"]["condition"] is None
|
||||||
|
assert conditions["gitea-merger"]["attachment_healthy"] is False
|
||||||
|
assert conditions["gitea-merger"]["condition"] == NOT_CONNECTED
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_conditions_present_are_both_reported():
|
||||||
|
"""One namespace Connected-but-unattached, another never connected."""
|
||||||
|
res = _assess(
|
||||||
|
["gitea-author", "gitea-reviewer"],
|
||||||
|
["gitea-author"],
|
||||||
|
["gitea-author", "gitea-reviewer", "gitea-merger"],
|
||||||
|
)
|
||||||
|
assert res["missing_namespaces"] == ["gitea-reviewer"]
|
||||||
|
assert res["not_connected_namespaces"] == ["gitea-merger"]
|
||||||
|
# Neither condition is hidden by the other; error_type names the primary one.
|
||||||
|
assert sorted(res["error_types"]) == sorted([NOT_CONNECTED, CONNECTED_MISSING])
|
||||||
|
assert res["error_type"] == NOT_CONNECTED
|
||||||
|
|
||||||
|
|
||||||
|
# --- unknown, partial, malformed, contradictory evidence --------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_attachment_evidence_is_not_healthy():
|
||||||
|
"""No session tool surface reported at all is unproven, not proven good."""
|
||||||
|
res = _assess(ROLES, None, ROLES)
|
||||||
|
assert res["attachment_healthy"] is False
|
||||||
|
assert res["telemetry"]["attached_count"] == 0
|
||||||
|
assert res["error_type"] == CONNECTED_MISSING
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_evidence_gates_only_the_unproven_roles():
|
||||||
|
res = _assess(ROLES, ["gitea-author", "gitea-tools"], ROLES)
|
||||||
|
store = _store(res)
|
||||||
|
assert mcp_namespace_health.attachment_gate_from_session("work_issue", store) == []
|
||||||
|
assert mcp_namespace_health.attachment_gate_from_session("review_pr", store)
|
||||||
|
assert mcp_namespace_health.attachment_gate_from_session("merge_pr", store)
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_namespace_entries_are_discarded_not_trusted():
|
||||||
|
"""Blank and whitespace-only names must not become namespaces or proof."""
|
||||||
|
res = mcp_namespace_health.assess_connected_namespace_attachment(
|
||||||
|
connected_servers=["gitea-author", "", " "],
|
||||||
|
attached_session_namespaces=["gitea-author", ""],
|
||||||
|
required_namespaces=["gitea-author", " "],
|
||||||
|
)
|
||||||
|
assert "" not in res["proof_of_connected_vs_attached"]
|
||||||
|
assert " " not in res["proof_of_connected_vs_attached"]
|
||||||
|
assert res["attachment_healthy"] is True
|
||||||
|
assert res["telemetry"]["required_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_contradictory_attached_without_connected_fails_closed():
|
||||||
|
"""Attached in the session yet absent from the connected inventory."""
|
||||||
|
res = _assess(["gitea-author"], ["gitea-author", "gitea-merger"], ROLES)
|
||||||
|
assert res["attachment_healthy"] is False
|
||||||
|
assert "gitea-merger" in res["contradictory_namespaces"]
|
||||||
|
assert "gitea-merger" in res["not_connected_namespaces"]
|
||||||
|
assert res["namespace_conditions"]["gitea-merger"]["contradictory_evidence"] is True
|
||||||
|
assert res["namespace_conditions"]["gitea-merger"]["attachment_healthy"] is False
|
||||||
|
assert any("contradictory evidence" in r for r in res["reasons"])
|
||||||
|
assert res["telemetry"]["contradictory_count"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_required_entries_are_counted_once():
|
||||||
|
res = _assess(["gitea-author"], ["gitea-author"], ["gitea-author", "gitea-author"])
|
||||||
|
assert res["telemetry"]["required_count"] == 1
|
||||||
|
assert res["attachment_healthy"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# --- review and merge behaviour for both failure categories -----------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_fails_closed_when_required_namespace_never_connected():
|
||||||
|
store = _store(_assess(["gitea-author"], ["gitea-author"], ROLES))
|
||||||
|
reasons = mcp_namespace_health.attachment_gate_from_session("merge_pr", store)
|
||||||
|
assert reasons
|
||||||
|
assert NOT_CONNECTED in reasons[0]
|
||||||
|
assert "fail closed, #708" in reasons[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_fails_closed_when_required_namespace_never_connected():
|
||||||
|
store = _store(_assess(["gitea-author"], ["gitea-author"], ROLES))
|
||||||
|
reasons = mcp_namespace_health.attachment_gate_from_session("review_pr", store)
|
||||||
|
assert reasons
|
||||||
|
assert NOT_CONNECTED in reasons[0]
|
||||||
|
assert "fail closed, #708" in reasons[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_connected_block_never_claims_the_host_reports_connected():
|
||||||
|
store = _store(_assess(["gitea-author"], ["gitea-author"], ROLES))
|
||||||
|
reasons = mcp_namespace_health.attachment_gate_from_session("merge_pr", store)
|
||||||
|
assert "the host reports Connected" not in reasons[0]
|
||||||
|
assert "absent from the connected-service inventory" in reasons[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_connected_but_unattached_block_still_says_connected():
|
||||||
|
store = _store(_assess(ROLES, ["gitea-author"], ROLES))
|
||||||
|
reasons = mcp_namespace_health.attachment_gate_from_session("merge_pr", store)
|
||||||
|
assert CONNECTED_MISSING in reasons[0]
|
||||||
|
assert "the host reports Connected" in reasons[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_categories_offer_only_the_sanctioned_recovery():
|
||||||
|
for store in (
|
||||||
|
_store(_assess(ROLES, [], ROLES)),
|
||||||
|
_store(_assess(["gitea-author"], ["gitea-author"], ROLES)),
|
||||||
|
):
|
||||||
|
reasons = mcp_namespace_health.attachment_gate_from_session("merge_pr", store)
|
||||||
|
joined = " ".join(reasons)
|
||||||
|
assert "reconnect" in joined.lower()
|
||||||
|
assert "Workflow Safety Hard Stop (#708)" in joined
|
||||||
|
for forbidden in ("pkill", "chmod", "curl ", ".env", "sys.path"):
|
||||||
|
assert forbidden not in reasons[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_without_connected_evidence_blocks_without_asserting_either():
|
||||||
|
"""A legacy/partial store entry must fail closed and claim nothing it cannot prove."""
|
||||||
|
store = {"gitea-merger": {"namespace": "gitea-merger", "attached": False}}
|
||||||
|
reasons = mcp_namespace_health.attachment_gate_from_session("merge_pr", store)
|
||||||
|
assert reasons
|
||||||
|
assert "no connected-status evidence" in reasons[0]
|
||||||
|
assert "the host reports Connected" not in reasons[0]
|
||||||
|
assert "fail closed, #708" in reasons[0]
|
||||||
|
|
||||||
|
|
||||||
|
# --- session isolation ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_session_evidence_does_not_clear_another_session_block():
|
||||||
|
"""Attachment evidence is per-session state; it must not travel between sessions."""
|
||||||
|
blocked_session = _store(_assess(["gitea-author"], ["gitea-author"], ROLES))
|
||||||
|
healthy_session = _store(_assess(ROLES, ROLES, ROLES))
|
||||||
|
|
||||||
|
assert (
|
||||||
|
mcp_namespace_health.attachment_gate_from_session("merge_pr", healthy_session)
|
||||||
|
== []
|
||||||
|
)
|
||||||
|
# The healthy session's verdict is not consulted for the blocked session.
|
||||||
|
assert mcp_namespace_health.attachment_gate_from_session("merge_pr", blocked_session)
|
||||||
|
# And the blocked session's store is unchanged by the healthy one existing.
|
||||||
|
assert blocked_session["gitea-merger"]["attachment_healthy"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_reads_only_the_store_it_is_given():
|
||||||
|
healthy_session = _store(_assess(ROLES, ROLES, ROLES))
|
||||||
|
assert mcp_namespace_health.attachment_gate_from_session("merge_pr", {}) == []
|
||||||
|
assert mcp_namespace_health.attachment_gate_from_session("merge_pr", None) == []
|
||||||
|
assert (
|
||||||
|
mcp_namespace_health.attachment_gate_from_session("merge_pr", healthy_session)
|
||||||
|
== []
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- telemetry stays secret-free -------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_connected_telemetry_leaks_no_secrets():
|
||||||
|
res = _assess(["gitea-author"], ["gitea-author"], ROLES)
|
||||||
|
blob = repr(res["telemetry"]).lower()
|
||||||
|
for leak in ("token", "authorization", "password", "secret", "/users/", "http"):
|
||||||
|
assert leak not in blob
|
||||||
@@ -0,0 +1,924 @@
|
|||||||
|
"""Transport-neutral MCP bind seam (#931).
|
||||||
|
|
||||||
|
These tests drive the *real* bind boundary — ``mark_sanctioned_daemon`` followed
|
||||||
|
by ``bind_native_mcp_transport`` from a canonical entrypoint path, with the
|
||||||
|
pytest allowance switched off — rather than mocking the new accessor. The
|
||||||
|
distinction matters here for the same reason it mattered in #941: a suite that
|
||||||
|
only exercises the helper in isolation cannot observe a seam that the live path
|
||||||
|
never reaches.
|
||||||
|
|
||||||
|
Covered:
|
||||||
|
|
||||||
|
1. no configured transport defaults to the local transport
|
||||||
|
2. explicit local transport binds
|
||||||
|
3. the sanctioned remote identifier binds through the seam
|
||||||
|
4. an unregistered identifier is rejected at bind time
|
||||||
|
5. an invalid bind prevents the server reaching tool service
|
||||||
|
6. the unbound state fails closed where a bind is required
|
||||||
|
7. every transport-aware guard reads the same authoritative value
|
||||||
|
8. client-controlled input cannot alter the bound transport
|
||||||
|
9. the durable decision-lock record carries the selected transport
|
||||||
|
10. existing stdio behaviour is unchanged
|
||||||
|
11. repeated / conflicting bind attempts follow one fail-closed contract
|
||||||
|
12. capability, role, repository and provenance protections do not regress
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
import mcp_daemon_guard
|
||||||
|
import mcp_session_state
|
||||||
|
import mcp_transport_config
|
||||||
|
import irrecoverable_provenance
|
||||||
|
|
||||||
|
|
||||||
|
class _ProductionBind:
|
||||||
|
"""Context manager that reaches the real production bind path.
|
||||||
|
|
||||||
|
Patches only the two things a unit test cannot otherwise satisfy: the
|
||||||
|
resolved canonical entrypoint frame, and the pytest allowance that would
|
||||||
|
short-circuit ``mark_sanctioned_daemon``. Everything downstream of those —
|
||||||
|
validation, pinning, the rebind contract — runs unmodified.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, env: dict[str, str] | None = None):
|
||||||
|
self._env = env or {}
|
||||||
|
self._stack: list = []
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||||
|
canonical = str((REPO_ROOT / "mcp_server.py").resolve())
|
||||||
|
self._stack = [
|
||||||
|
patch.object(
|
||||||
|
mcp_daemon_guard,
|
||||||
|
"_caller_official_entrypoint_path",
|
||||||
|
side_effect=lambda: canonical,
|
||||||
|
),
|
||||||
|
patch.object(mcp_daemon_guard, "is_pytest_runtime", return_value=False),
|
||||||
|
patch.dict(os.environ, self._env),
|
||||||
|
]
|
||||||
|
for ctx in self._stack:
|
||||||
|
ctx.__enter__()
|
||||||
|
# Start from a clean configuration unless the test set one.
|
||||||
|
if mcp_transport_config.TRANSPORT_ENV not in self._env:
|
||||||
|
os.environ.pop(mcp_transport_config.TRANSPORT_ENV, None)
|
||||||
|
mcp_daemon_guard.mark_sanctioned_daemon()
|
||||||
|
return mcp_daemon_guard
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
for ctx in reversed(self._stack):
|
||||||
|
ctx.__exit__(*exc)
|
||||||
|
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermittedSetIsSingleSourceOfTruth(unittest.TestCase):
|
||||||
|
"""AC3: identifiers are enumerated once, in the seam."""
|
||||||
|
|
||||||
|
def test_guard_allowlist_is_the_seam_allowlist(self):
|
||||||
|
self.assertIs(
|
||||||
|
mcp_daemon_guard._PRODUCTION_TRANSPORTS,
|
||||||
|
mcp_transport_config.SUPPORTED_TRANSPORTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_default_is_a_member_of_the_permitted_set(self):
|
||||||
|
self.assertIn(
|
||||||
|
mcp_transport_config.DEFAULT_TRANSPORT,
|
||||||
|
mcp_transport_config.SUPPORTED_TRANSPORTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_remote_identifier_is_permitted_and_not_the_default(self):
|
||||||
|
self.assertIn(
|
||||||
|
mcp_transport_config.REMOTE_TRANSPORT,
|
||||||
|
mcp_transport_config.SUPPORTED_TRANSPORTS,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(
|
||||||
|
mcp_transport_config.REMOTE_TRANSPORT,
|
||||||
|
mcp_transport_config.DEFAULT_TRANSPORT,
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
mcp_transport_config.is_remote_transport(
|
||||||
|
mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_default_transport_literal_outside_the_seam(self):
|
||||||
|
"""AC3: no production module reads the literal outside the seam.
|
||||||
|
|
||||||
|
Review 635 flagged that a fixed five-module list cannot catch a *new*
|
||||||
|
module reintroducing the literal. This globs every production module in
|
||||||
|
the repository root instead, so the guarantee holds for code that does
|
||||||
|
not exist yet.
|
||||||
|
"""
|
||||||
|
default = mcp_transport_config.DEFAULT_TRANSPORT
|
||||||
|
needles = (f'"{default}"', f"'{default}'")
|
||||||
|
seam = Path(mcp_transport_config.__file__).name
|
||||||
|
scanned: list[str] = []
|
||||||
|
offenders: list[str] = []
|
||||||
|
for path in sorted(REPO_ROOT.glob("*.py")):
|
||||||
|
if path.name == seam:
|
||||||
|
continue # the seam is the one place the literal may live
|
||||||
|
scanned.append(path.name)
|
||||||
|
for lineno, line in enumerate(
|
||||||
|
path.read_text(encoding="utf-8").splitlines(), start=1
|
||||||
|
):
|
||||||
|
code = line.split("#", 1)[0]
|
||||||
|
if any(needle in code for needle in needles):
|
||||||
|
offenders.append(f"{path.name}:{lineno}: {line.strip()}")
|
||||||
|
# Guard the guard: a glob that silently matched nothing would pass.
|
||||||
|
self.assertGreater(len(scanned), 20, "production glob matched too little")
|
||||||
|
self.assertIn("mcp_daemon_guard.py", scanned)
|
||||||
|
self.assertIn("gitea_mcp_server.py", scanned)
|
||||||
|
self.assertEqual(offenders, [], "\n".join(offenders))
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfiguredTransportResolution(unittest.TestCase):
|
||||||
|
"""AC1: configuration supplies the identifier; unset still yields the default."""
|
||||||
|
|
||||||
|
def test_unset_yields_default(self):
|
||||||
|
res = mcp_transport_config.resolve_configured_transport(env={})
|
||||||
|
self.assertEqual(res["transport"], mcp_transport_config.DEFAULT_TRANSPORT)
|
||||||
|
self.assertFalse(res["configured"])
|
||||||
|
self.assertEqual(res["source"], mcp_transport_config.SOURCE_DEFAULT)
|
||||||
|
self.assertTrue(res["supported"])
|
||||||
|
self.assertEqual(res["reasons"], [])
|
||||||
|
|
||||||
|
def test_blank_and_whitespace_are_treated_as_unset(self):
|
||||||
|
for raw in ("", " ", "\t\n"):
|
||||||
|
res = mcp_transport_config.resolve_configured_transport(
|
||||||
|
env={mcp_transport_config.TRANSPORT_ENV: raw}
|
||||||
|
)
|
||||||
|
self.assertEqual(res["transport"], mcp_transport_config.DEFAULT_TRANSPORT)
|
||||||
|
self.assertFalse(res["configured"])
|
||||||
|
|
||||||
|
def test_explicit_default_is_reported_as_configured(self):
|
||||||
|
res = mcp_transport_config.resolve_configured_transport(
|
||||||
|
env={
|
||||||
|
mcp_transport_config.TRANSPORT_ENV: (
|
||||||
|
mcp_transport_config.DEFAULT_TRANSPORT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(res["transport"], mcp_transport_config.DEFAULT_TRANSPORT)
|
||||||
|
self.assertTrue(res["configured"])
|
||||||
|
self.assertEqual(res["source"], mcp_transport_config.SOURCE_CONFIGURED)
|
||||||
|
|
||||||
|
def test_remote_identifier_resolves_and_is_supported(self):
|
||||||
|
res = mcp_transport_config.resolve_configured_transport(
|
||||||
|
env={
|
||||||
|
mcp_transport_config.TRANSPORT_ENV: (
|
||||||
|
mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(res["transport"], mcp_transport_config.REMOTE_TRANSPORT)
|
||||||
|
self.assertTrue(res["supported"])
|
||||||
|
|
||||||
|
def test_case_and_padding_are_normalized(self):
|
||||||
|
padded = f" {mcp_transport_config.REMOTE_TRANSPORT.upper()} "
|
||||||
|
res = mcp_transport_config.resolve_configured_transport(
|
||||||
|
env={mcp_transport_config.TRANSPORT_ENV: padded}
|
||||||
|
)
|
||||||
|
self.assertEqual(res["transport"], mcp_transport_config.REMOTE_TRANSPORT)
|
||||||
|
self.assertTrue(res["supported"])
|
||||||
|
|
||||||
|
def test_unregistered_identifier_is_not_silently_defaulted(self):
|
||||||
|
res = mcp_transport_config.resolve_configured_transport(
|
||||||
|
env={mcp_transport_config.TRANSPORT_ENV: "carrier-pigeon"}
|
||||||
|
)
|
||||||
|
self.assertFalse(res["supported"])
|
||||||
|
self.assertEqual(res["transport"], "carrier-pigeon")
|
||||||
|
self.assertNotEqual(res["transport"], mcp_transport_config.DEFAULT_TRANSPORT)
|
||||||
|
self.assertTrue(res["reasons"])
|
||||||
|
|
||||||
|
def test_superseded_sse_transport_is_not_registered(self):
|
||||||
|
"""A real MCP transport that this deployment does not sanction."""
|
||||||
|
self.assertFalse(mcp_transport_config.is_supported_transport("sse"))
|
||||||
|
res = mcp_transport_config.resolve_configured_transport(
|
||||||
|
env={mcp_transport_config.TRANSPORT_ENV: "sse"}
|
||||||
|
)
|
||||||
|
self.assertFalse(res["supported"])
|
||||||
|
|
||||||
|
def test_require_configured_transport_raises_on_unregistered(self):
|
||||||
|
with self.assertRaises(mcp_transport_config.TransportConfigurationError):
|
||||||
|
mcp_transport_config.require_configured_transport(
|
||||||
|
env={mcp_transport_config.TRANSPORT_ENV: "carrier-pigeon"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_non_string_configuration_never_matches_permitted_set(self):
|
||||||
|
for value in (object(), 1, None, True, ["stdio"], {"t": "stdio"}):
|
||||||
|
self.assertEqual(mcp_transport_config.normalize_transport(value), "")
|
||||||
|
self.assertFalse(mcp_transport_config.is_supported_transport(value))
|
||||||
|
|
||||||
|
|
||||||
|
class TestBindSeam(unittest.TestCase):
|
||||||
|
"""AC1/AC2: the live bind path resolves, validates, and pins."""
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||||
|
|
||||||
|
def test_1_no_configured_transport_binds_default(self):
|
||||||
|
with _ProductionBind() as guard:
|
||||||
|
status = guard.bind_native_mcp_transport()
|
||||||
|
self.assertEqual(
|
||||||
|
status["transport"], mcp_transport_config.DEFAULT_TRANSPORT
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
guard.bound_transport(), mcp_transport_config.DEFAULT_TRANSPORT
|
||||||
|
)
|
||||||
|
self.assertTrue(status["production_native_mcp_transport"])
|
||||||
|
|
||||||
|
def test_2_explicit_default_transport_binds(self):
|
||||||
|
with _ProductionBind(
|
||||||
|
{
|
||||||
|
mcp_transport_config.TRANSPORT_ENV: (
|
||||||
|
mcp_transport_config.DEFAULT_TRANSPORT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) as guard:
|
||||||
|
status = guard.bind_native_mcp_transport()
|
||||||
|
self.assertEqual(
|
||||||
|
status["transport"], mcp_transport_config.DEFAULT_TRANSPORT
|
||||||
|
)
|
||||||
|
self.assertTrue(guard.is_production_native_mcp_transport())
|
||||||
|
|
||||||
|
def test_2b_explicit_argument_still_binds(self):
|
||||||
|
"""The pre-#931 call form keeps working for launchers and tests."""
|
||||||
|
with _ProductionBind() as guard:
|
||||||
|
status = guard.bind_native_mcp_transport(
|
||||||
|
transport=mcp_transport_config.DEFAULT_TRANSPORT
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
status["transport"], mcp_transport_config.DEFAULT_TRANSPORT
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_3_sanctioned_remote_identifier_binds_through_the_seam(self):
|
||||||
|
with _ProductionBind(
|
||||||
|
{
|
||||||
|
mcp_transport_config.TRANSPORT_ENV: (
|
||||||
|
mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) as guard:
|
||||||
|
status = guard.bind_native_mcp_transport()
|
||||||
|
self.assertEqual(
|
||||||
|
status["transport"], mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
guard.bound_transport(), mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
)
|
||||||
|
# The remote identifier is trusted exactly like the local one; the
|
||||||
|
# listener that serves it is #938 and is not implemented here.
|
||||||
|
self.assertTrue(guard.is_native_mcp_transport())
|
||||||
|
self.assertTrue(guard.is_production_native_mcp_transport())
|
||||||
|
guard.assert_production_mutation_runtime("remote-bind")
|
||||||
|
|
||||||
|
def test_4_unregistered_identifier_rejected_at_bind_time(self):
|
||||||
|
with _ProductionBind(
|
||||||
|
{mcp_transport_config.TRANSPORT_ENV: "carrier-pigeon"}
|
||||||
|
) as guard:
|
||||||
|
with self.assertRaises(guard.UnsanctionedRuntimeError) as ctx:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
self.assertIn("carrier-pigeon", str(ctx.exception))
|
||||||
|
self.assertIn("#931", str(ctx.exception))
|
||||||
|
# Nothing was bound, so nothing may dispatch.
|
||||||
|
self.assertIsNone(guard.bound_transport())
|
||||||
|
self.assertFalse(guard.is_native_mcp_transport())
|
||||||
|
|
||||||
|
def test_4b_unregistered_explicit_argument_rejected(self):
|
||||||
|
with _ProductionBind() as guard:
|
||||||
|
with self.assertRaises(guard.UnsanctionedRuntimeError):
|
||||||
|
guard.bind_native_mcp_transport(transport="carrier-pigeon")
|
||||||
|
self.assertIsNone(guard.bound_transport())
|
||||||
|
|
||||||
|
def test_4c_superseded_sse_rejected_at_bind_time(self):
|
||||||
|
with _ProductionBind({mcp_transport_config.TRANSPORT_ENV: "sse"}) as guard:
|
||||||
|
with self.assertRaises(guard.UnsanctionedRuntimeError):
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
self.assertIsNone(guard.bound_transport())
|
||||||
|
|
||||||
|
def test_5_invalid_bind_prevents_tool_service(self):
|
||||||
|
"""A failed bind must stop the server before it serves tools."""
|
||||||
|
with _ProductionBind(
|
||||||
|
{mcp_transport_config.TRANSPORT_ENV: "carrier-pigeon"}
|
||||||
|
) as guard:
|
||||||
|
with self.assertRaises(guard.UnsanctionedRuntimeError):
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
# This is the exact expression the entrypoint passes to mcp.run.
|
||||||
|
with self.assertRaises(guard.UnsanctionedRuntimeError) as ctx:
|
||||||
|
guard.assert_transport_bound("tool service")
|
||||||
|
self.assertIn("No MCP transport is bound", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_6_unbound_state_fails_closed(self):
|
||||||
|
"""Entrypoint claimed but never bound — the offline-import shape."""
|
||||||
|
with _ProductionBind() as guard:
|
||||||
|
self.assertIsNone(guard.bound_transport())
|
||||||
|
self.assertFalse(guard.is_native_mcp_transport())
|
||||||
|
with self.assertRaises(guard.UnsanctionedRuntimeError):
|
||||||
|
guard.assert_transport_bound("tool service")
|
||||||
|
with self.assertRaises(guard.UnsanctionedRuntimeError):
|
||||||
|
guard.assert_sanctioned_mutation_runtime("gitea_mutation")
|
||||||
|
|
||||||
|
def test_6b_no_runtime_at_all_fails_closed(self):
|
||||||
|
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||||
|
with patch.object(mcp_daemon_guard, "is_pytest_runtime", return_value=False):
|
||||||
|
self.assertIsNone(mcp_daemon_guard.bound_transport())
|
||||||
|
with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError):
|
||||||
|
mcp_daemon_guard.assert_transport_bound("tool service")
|
||||||
|
|
||||||
|
|
||||||
|
class TestOneAuthoritativeValue(unittest.TestCase):
|
||||||
|
"""AC: every transport-aware guard observes the same value."""
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||||
|
|
||||||
|
def test_7_all_guards_read_the_same_bound_value(self):
|
||||||
|
with _ProductionBind(
|
||||||
|
{
|
||||||
|
mcp_transport_config.TRANSPORT_ENV: (
|
||||||
|
mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) as guard:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
expected = mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
|
||||||
|
self.assertEqual(guard.bound_transport(), expected)
|
||||||
|
self.assertEqual(guard.assert_transport_bound(), expected)
|
||||||
|
self.assertEqual(guard.native_runtime_status()["bound_transport"], expected)
|
||||||
|
self.assertEqual(guard.native_runtime_status()["transport"], expected)
|
||||||
|
self.assertEqual(
|
||||||
|
guard.mutation_provenance_fields()["bound_transport"], expected
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
irrecoverable_provenance.assess_transport_for_auth_mint()[
|
||||||
|
"bound_transport"
|
||||||
|
],
|
||||||
|
expected,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_8_environment_change_after_bind_cannot_move_the_value(self):
|
||||||
|
"""Client- or environment-shaped input must not alter a bound transport."""
|
||||||
|
with _ProductionBind() as guard:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
self.assertEqual(
|
||||||
|
guard.bound_transport(), mcp_transport_config.DEFAULT_TRANSPORT
|
||||||
|
)
|
||||||
|
# A stray launcher (or an attacker) rewrites config post-bind.
|
||||||
|
os.environ[mcp_transport_config.TRANSPORT_ENV] = (
|
||||||
|
mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
guard.bound_transport(), mcp_transport_config.DEFAULT_TRANSPORT
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
guard.mutation_provenance_fields()["bound_transport"],
|
||||||
|
mcp_transport_config.DEFAULT_TRANSPORT,
|
||||||
|
)
|
||||||
|
os.environ[mcp_transport_config.TRANSPORT_ENV] = "carrier-pigeon"
|
||||||
|
self.assertEqual(
|
||||||
|
guard.bound_transport(), mcp_transport_config.DEFAULT_TRANSPORT
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_8b_bound_transport_takes_no_caller_argument(self):
|
||||||
|
"""The accessor cannot be steered by a tool parameter."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
list(inspect.signature(mcp_daemon_guard.bound_transport).parameters), []
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_11_rebinding_the_same_transport_is_idempotent(self):
|
||||||
|
with _ProductionBind() as guard:
|
||||||
|
first = guard.bind_native_mcp_transport()
|
||||||
|
second = guard.bind_native_mcp_transport()
|
||||||
|
self.assertEqual(first["transport"], second["transport"])
|
||||||
|
self.assertEqual(
|
||||||
|
guard.bound_transport(), mcp_transport_config.DEFAULT_TRANSPORT
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_11b_rebinding_a_different_transport_fails_closed(self):
|
||||||
|
with _ProductionBind() as guard:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
with self.assertRaises(guard.UnsanctionedRuntimeError) as ctx:
|
||||||
|
guard.bind_native_mcp_transport(
|
||||||
|
transport=mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
)
|
||||||
|
self.assertIn("already bound", str(ctx.exception))
|
||||||
|
# The first value survives the attempt.
|
||||||
|
self.assertEqual(
|
||||||
|
guard.bound_transport(), mcp_transport_config.DEFAULT_TRANSPORT
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_11c_rebinding_an_unregistered_transport_fails_closed(self):
|
||||||
|
with _ProductionBind() as guard:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
with self.assertRaises(guard.UnsanctionedRuntimeError):
|
||||||
|
guard.bind_native_mcp_transport(transport="carrier-pigeon")
|
||||||
|
self.assertEqual(
|
||||||
|
guard.bound_transport(), mcp_transport_config.DEFAULT_TRANSPORT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDurableRecordCarriesTransport(unittest.TestCase):
|
||||||
|
"""AC4: the identifier reaches a durable decision-lock record."""
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||||
|
|
||||||
|
def _save_and_read_decision_lock(self, state_dir: str) -> dict:
|
||||||
|
mcp_session_state.save_state(
|
||||||
|
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||||||
|
payload={"pr_number": 931, "action": "COMMENT"},
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
profile_identity="prgs-author",
|
||||||
|
state_dir=state_dir,
|
||||||
|
)
|
||||||
|
loaded = mcp_session_state.load_state(
|
||||||
|
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
profile_identity="prgs-author",
|
||||||
|
state_dir=state_dir,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(loaded)
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
def test_9_decision_lock_records_the_bound_transport(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||||
|
mcp_daemon_guard.install_test_native_runtime()
|
||||||
|
record = self._save_and_read_decision_lock(tmp)
|
||||||
|
self.assertIn("bound_transport", record)
|
||||||
|
self.assertEqual(
|
||||||
|
record["bound_transport"], mcp_daemon_guard.bound_transport()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_9b_unbound_runtime_records_no_transport_identifier(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||||
|
record = self._save_and_read_decision_lock(tmp)
|
||||||
|
self.assertIn("bound_transport", record)
|
||||||
|
self.assertIsNone(record["bound_transport"])
|
||||||
|
|
||||||
|
def test_9c_provenance_fields_expose_the_identifier(self):
|
||||||
|
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||||
|
fields = mcp_daemon_guard.mutation_provenance_fields()
|
||||||
|
self.assertIn("bound_transport", fields)
|
||||||
|
self.assertIsNone(fields["bound_transport"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestStdioBehaviourUnchanged(unittest.TestCase):
|
||||||
|
"""AC6 / prompt items 10 and 12: no regression on the existing path."""
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||||
|
|
||||||
|
def test_10_trust_class_field_keeps_its_pre_931_values(self):
|
||||||
|
"""``transport`` remains the trust class, not the identifier."""
|
||||||
|
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_daemon_guard.mutation_provenance_fields()["transport"], "untrusted"
|
||||||
|
)
|
||||||
|
mcp_daemon_guard.install_test_native_runtime()
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_daemon_guard.mutation_provenance_fields()["transport"],
|
||||||
|
"test_native_mcp",
|
||||||
|
)
|
||||||
|
with _ProductionBind() as guard:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
self.assertEqual(
|
||||||
|
guard.mutation_provenance_fields()["transport"], "native_mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_10b_default_bind_reproduces_the_pre_931_runtime_record(self):
|
||||||
|
with _ProductionBind() as guard:
|
||||||
|
status = guard.bind_native_mcp_transport()
|
||||||
|
self.assertTrue(status["native_mcp_transport"])
|
||||||
|
self.assertTrue(status["production_native_mcp_transport"])
|
||||||
|
self.assertEqual(status["mode"], "production")
|
||||||
|
self.assertEqual(status["phase"], "transport_bound")
|
||||||
|
self.assertEqual(
|
||||||
|
status["transport"], mcp_transport_config.DEFAULT_TRANSPORT
|
||||||
|
)
|
||||||
|
guard.assert_sanctioned_mutation_runtime("native-ide")
|
||||||
|
guard.assert_production_mutation_runtime("native-ide")
|
||||||
|
|
||||||
|
def test_12_session_state_root_still_pinned_at_bind(self):
|
||||||
|
"""#695 AC2 must survive the seam."""
|
||||||
|
with tempfile.TemporaryDirectory() as legit:
|
||||||
|
with tempfile.TemporaryDirectory() as rogue:
|
||||||
|
with _ProductionBind(
|
||||||
|
{mcp_daemon_guard.SESSION_STATE_DIR_ENV: legit}
|
||||||
|
) as guard:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
self.assertEqual(
|
||||||
|
guard.pinned_session_state_dir(), str(Path(legit).resolve())
|
||||||
|
)
|
||||||
|
os.environ[mcp_daemon_guard.SESSION_STATE_DIR_ENV] = rogue
|
||||||
|
self.assertEqual(
|
||||||
|
guard.pinned_session_state_dir(), str(Path(legit).resolve())
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_12b_test_mode_record_still_cannot_authorize_production(self):
|
||||||
|
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||||
|
mcp_daemon_guard.install_test_native_runtime()
|
||||||
|
self.assertTrue(mcp_daemon_guard.is_native_mcp_transport())
|
||||||
|
self.assertFalse(mcp_daemon_guard.is_production_native_mcp_transport())
|
||||||
|
with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError):
|
||||||
|
mcp_daemon_guard.assert_production_mutation_runtime("prod-endpoint")
|
||||||
|
|
||||||
|
def test_12c_bind_still_requires_the_canonical_entrypoint(self):
|
||||||
|
"""A remote identifier does not relax provenance."""
|
||||||
|
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||||
|
with patch.object(mcp_daemon_guard, "is_pytest_runtime", return_value=False):
|
||||||
|
with patch.object(
|
||||||
|
mcp_daemon_guard,
|
||||||
|
"_caller_official_entrypoint_path",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError):
|
||||||
|
mcp_daemon_guard.bind_native_mcp_transport(
|
||||||
|
transport=mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
)
|
||||||
|
self.assertIsNone(mcp_daemon_guard.bound_transport())
|
||||||
|
|
||||||
|
def test_12d_bind_still_requires_a_prior_entrypoint_claim(self):
|
||||||
|
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||||
|
canonical = str((REPO_ROOT / "mcp_server.py").resolve())
|
||||||
|
with patch.object(mcp_daemon_guard, "is_pytest_runtime", return_value=False):
|
||||||
|
with patch.object(
|
||||||
|
mcp_daemon_guard,
|
||||||
|
"_caller_official_entrypoint_path",
|
||||||
|
side_effect=lambda: canonical,
|
||||||
|
):
|
||||||
|
# No mark_sanctioned_daemon() first.
|
||||||
|
with self.assertRaises(
|
||||||
|
mcp_daemon_guard.UnsanctionedRuntimeError
|
||||||
|
) as ctx:
|
||||||
|
mcp_daemon_guard.bind_native_mcp_transport()
|
||||||
|
self.assertIn("no entrypoint claim", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_12e_auth_mint_verdict_is_unchanged_for_the_default_transport(self):
|
||||||
|
with _ProductionBind() as guard:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
verdict = irrecoverable_provenance.assess_transport_for_auth_mint()
|
||||||
|
self.assertTrue(verdict["allowed"])
|
||||||
|
self.assertTrue(verdict["native_mcp_transport"])
|
||||||
|
self.assertTrue(verdict["production_native_mcp_transport"])
|
||||||
|
self.assertEqual(verdict["reasons"], [])
|
||||||
|
|
||||||
|
def test_12f_auth_mint_still_refuses_an_unbound_runtime(self):
|
||||||
|
with _ProductionBind():
|
||||||
|
# Claimed but never bound.
|
||||||
|
verdict = irrecoverable_provenance.assess_transport_for_auth_mint()
|
||||||
|
self.assertFalse(verdict["allowed"])
|
||||||
|
self.assertTrue(verdict["reasons"])
|
||||||
|
self.assertIsNone(verdict["bound_transport"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecutionBoundary(unittest.TestCase):
|
||||||
|
"""Recognition is not execution authorization (#931, review 635 B1/B2).
|
||||||
|
|
||||||
|
A registered remote identifier must still bind, pin and record — the #931
|
||||||
|
seam — while being refused at the serve boundary, because serving it would
|
||||||
|
start a listener with no authentication or per-request principal. That
|
||||||
|
listener belongs to #938.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||||
|
|
||||||
|
# -- the two sets are distinct, and narrower in the right direction ----
|
||||||
|
|
||||||
|
def test_executable_set_is_a_strict_subset_of_recognized(self):
|
||||||
|
self.assertTrue(
|
||||||
|
mcp_transport_config.EXECUTABLE_TRANSPORTS
|
||||||
|
< mcp_transport_config.SUPPORTED_TRANSPORTS
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_default_transport_is_executable(self):
|
||||||
|
self.assertTrue(
|
||||||
|
mcp_transport_config.is_executable_transport(
|
||||||
|
mcp_transport_config.DEFAULT_TRANSPORT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_remote_transport_is_recognized_but_not_executable(self):
|
||||||
|
remote = mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
self.assertTrue(mcp_transport_config.is_supported_transport(remote))
|
||||||
|
self.assertFalse(mcp_transport_config.is_executable_transport(remote))
|
||||||
|
|
||||||
|
def test_remote_listener_ownership_is_declared(self):
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_transport_config.TRANSPORT_EXECUTION_OWNER[
|
||||||
|
mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
],
|
||||||
|
"#938",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- stdio still reaches the runner, unchanged -------------------------
|
||||||
|
|
||||||
|
def test_default_transport_is_authorized_for_service(self):
|
||||||
|
with _ProductionBind() as guard:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
self.assertEqual(
|
||||||
|
guard.authorize_transport_execution("tool service"),
|
||||||
|
mcp_transport_config.DEFAULT_TRANSPORT,
|
||||||
|
)
|
||||||
|
self.assertTrue(guard.assess_serve_authorization()["allowed"])
|
||||||
|
|
||||||
|
def test_default_transport_reaches_the_real_runner(self):
|
||||||
|
"""The production runner is actually invoked, with stdio, unchanged."""
|
||||||
|
with _ProductionBind() as guard:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
class _Runner:
|
||||||
|
def run(self, transport=None, **kw):
|
||||||
|
seen["transport"] = transport
|
||||||
|
|
||||||
|
_Runner().run(transport=guard.authorize_transport_execution("tool service"))
|
||||||
|
self.assertEqual(
|
||||||
|
seen["transport"], mcp_transport_config.DEFAULT_TRANSPORT
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- streamable-http binds, records, and is refused before serving -----
|
||||||
|
|
||||||
|
def test_remote_transport_binds_and_is_recorded(self):
|
||||||
|
"""The #931 seam is intact: it binds, pins and records."""
|
||||||
|
with _ProductionBind(
|
||||||
|
{
|
||||||
|
mcp_transport_config.TRANSPORT_ENV: (
|
||||||
|
mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) as guard:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
self.assertEqual(
|
||||||
|
guard.bound_transport(), mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
guard.mutation_provenance_fields()["bound_transport"],
|
||||||
|
mcp_transport_config.REMOTE_TRANSPORT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_remote_transport_is_refused_at_the_serve_boundary(self):
|
||||||
|
with _ProductionBind(
|
||||||
|
{
|
||||||
|
mcp_transport_config.TRANSPORT_ENV: (
|
||||||
|
mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) as guard:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
with self.assertRaises(guard.TransportExecutionError) as ctx:
|
||||||
|
guard.authorize_transport_execution("tool service")
|
||||||
|
err = ctx.exception
|
||||||
|
self.assertEqual(
|
||||||
|
err.blocker_kind,
|
||||||
|
mcp_transport_config.BLOCKER_LISTENER_NOT_COMMISSIONED,
|
||||||
|
)
|
||||||
|
self.assertEqual(err.owner_issue, "#938")
|
||||||
|
self.assertEqual(err.transport, mcp_transport_config.REMOTE_TRANSPORT)
|
||||||
|
|
||||||
|
def test_refusal_names_transport_and_unmet_requirement_and_owner(self):
|
||||||
|
with _ProductionBind(
|
||||||
|
{
|
||||||
|
mcp_transport_config.TRANSPORT_ENV: (
|
||||||
|
mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) as guard:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
with self.assertRaises(guard.TransportExecutionError) as ctx:
|
||||||
|
guard.authorize_transport_execution("tool service")
|
||||||
|
text = str(ctx.exception)
|
||||||
|
self.assertIn(mcp_transport_config.REMOTE_TRANSPORT, text)
|
||||||
|
self.assertIn("#938", text)
|
||||||
|
self.assertIn("not commissioned", text)
|
||||||
|
self.assertIn("#931", text)
|
||||||
|
|
||||||
|
def test_remote_transport_never_reaches_the_runner(self):
|
||||||
|
"""No transport value is handed to a run() call for the remote case."""
|
||||||
|
with _ProductionBind(
|
||||||
|
{
|
||||||
|
mcp_transport_config.TRANSPORT_ENV: (
|
||||||
|
mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) as guard:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class _Runner:
|
||||||
|
def run(self, transport=None, **kw):
|
||||||
|
calls.append(transport)
|
||||||
|
|
||||||
|
with self.assertRaises(guard.TransportExecutionError):
|
||||||
|
_Runner().run(
|
||||||
|
transport=guard.authorize_transport_execution("tool service")
|
||||||
|
)
|
||||||
|
self.assertEqual(calls, [], "runner must never be invoked")
|
||||||
|
|
||||||
|
def test_no_http_listener_is_created_for_remote_transport(self):
|
||||||
|
"""Nothing in the refusal path touches uvicorn or a socket bind."""
|
||||||
|
with _ProductionBind(
|
||||||
|
{
|
||||||
|
mcp_transport_config.TRANSPORT_ENV: (
|
||||||
|
mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) as guard:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
import socket
|
||||||
|
|
||||||
|
bound_sockets = []
|
||||||
|
real_bind = socket.socket.bind
|
||||||
|
|
||||||
|
def _tripwire(self, addr): # pragma: no cover - must not run
|
||||||
|
bound_sockets.append(addr)
|
||||||
|
return real_bind(self, addr)
|
||||||
|
|
||||||
|
with patch.object(socket.socket, "bind", _tripwire):
|
||||||
|
with self.assertRaises(guard.TransportExecutionError):
|
||||||
|
guard.authorize_transport_execution("tool service")
|
||||||
|
self.assertEqual(bound_sockets, [], "no socket may be bound")
|
||||||
|
|
||||||
|
def test_no_mutation_is_authorized_after_the_denial(self):
|
||||||
|
"""The denial leaves no partial state that would let a tool dispatch."""
|
||||||
|
with _ProductionBind(
|
||||||
|
{
|
||||||
|
mcp_transport_config.TRANSPORT_ENV: (
|
||||||
|
mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) as guard:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
with self.assertRaises(guard.TransportExecutionError):
|
||||||
|
guard.authorize_transport_execution("tool service")
|
||||||
|
# Serve stays unauthorized on every subsequent query.
|
||||||
|
self.assertFalse(guard.assess_serve_authorization()["allowed"])
|
||||||
|
self.assertFalse(guard.native_runtime_status()["serve_authorized"])
|
||||||
|
with self.assertRaises(guard.TransportExecutionError):
|
||||||
|
guard.authorize_transport_execution("tool service")
|
||||||
|
|
||||||
|
# -- B2: the decision genuinely consumes bound_transport ---------------
|
||||||
|
|
||||||
|
def test_serve_decision_consumes_bound_transport(self):
|
||||||
|
"""Changing only bound_transport flips the verdict."""
|
||||||
|
with _ProductionBind() as guard:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
self.assertTrue(guard.assess_serve_authorization()["allowed"])
|
||||||
|
with patch.object(
|
||||||
|
guard,
|
||||||
|
"bound_transport",
|
||||||
|
return_value=mcp_transport_config.REMOTE_TRANSPORT,
|
||||||
|
):
|
||||||
|
verdict = guard.assess_serve_authorization()
|
||||||
|
self.assertFalse(verdict["allowed"])
|
||||||
|
self.assertEqual(
|
||||||
|
verdict["transport"], mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
)
|
||||||
|
with self.assertRaises(guard.TransportExecutionError):
|
||||||
|
guard.authorize_transport_execution("tool service")
|
||||||
|
|
||||||
|
def test_serve_verdict_reports_the_bound_transport(self):
|
||||||
|
with _ProductionBind(
|
||||||
|
{
|
||||||
|
mcp_transport_config.TRANSPORT_ENV: (
|
||||||
|
mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) as guard:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
verdict = guard.assess_serve_authorization()
|
||||||
|
self.assertEqual(
|
||||||
|
verdict["transport"], mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
)
|
||||||
|
self.assertTrue(verdict["recognized"])
|
||||||
|
self.assertFalse(verdict["executable"])
|
||||||
|
|
||||||
|
# -- earlier and later boundaries are unchanged ------------------------
|
||||||
|
|
||||||
|
def test_unregistered_identifier_still_fails_at_bind_not_at_serve(self):
|
||||||
|
with _ProductionBind(
|
||||||
|
{mcp_transport_config.TRANSPORT_ENV: "carrier-pigeon"}
|
||||||
|
) as guard:
|
||||||
|
with self.assertRaises(guard.UnsanctionedRuntimeError) as ctx:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
self.assertIn("not a registered MCP transport", str(ctx.exception))
|
||||||
|
self.assertIsNone(guard.bound_transport())
|
||||||
|
|
||||||
|
def test_unbound_execution_keeps_the_pre_existing_failure(self):
|
||||||
|
with _ProductionBind() as guard:
|
||||||
|
with self.assertRaises(guard.UnsanctionedRuntimeError) as ctx:
|
||||||
|
guard.authorize_transport_execution("tool service")
|
||||||
|
self.assertIn("No MCP transport is bound", str(ctx.exception))
|
||||||
|
self.assertNotIsInstance(ctx.exception, guard.TransportExecutionError)
|
||||||
|
|
||||||
|
def test_transport_execution_error_is_caught_by_existing_handlers(self):
|
||||||
|
"""Subclassing keeps every pre-existing fail-closed handler correct."""
|
||||||
|
self.assertTrue(
|
||||||
|
issubclass(
|
||||||
|
mcp_daemon_guard.TransportExecutionError,
|
||||||
|
mcp_daemon_guard.UnsanctionedRuntimeError,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_test_mode_runtime_is_not_servable(self):
|
||||||
|
"""The pytest-only record is outside the recognized and executable sets."""
|
||||||
|
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||||
|
mcp_daemon_guard.install_test_native_runtime()
|
||||||
|
self.assertNotIn(
|
||||||
|
mcp_daemon_guard.bound_transport(),
|
||||||
|
mcp_transport_config.SUPPORTED_TRANSPORTS,
|
||||||
|
)
|
||||||
|
self.assertFalse(mcp_daemon_guard.assess_serve_authorization()["allowed"])
|
||||||
|
|
||||||
|
def test_serve_authorization_does_not_leak_across_runtimes(self):
|
||||||
|
"""A later runtime's verdict never reflects an earlier one."""
|
||||||
|
with _ProductionBind(
|
||||||
|
{
|
||||||
|
mcp_transport_config.TRANSPORT_ENV: (
|
||||||
|
mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) as guard:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
self.assertFalse(guard.assess_serve_authorization()["allowed"])
|
||||||
|
with _ProductionBind() as guard:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
self.assertTrue(guard.assess_serve_authorization()["allowed"])
|
||||||
|
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_daemon_guard.assess_serve_authorization()["blocker_kind"],
|
||||||
|
mcp_transport_config.BLOCKER_TRANSPORT_NOT_BOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_refusal_carries_no_credential_material(self):
|
||||||
|
with _ProductionBind(
|
||||||
|
{
|
||||||
|
mcp_transport_config.TRANSPORT_ENV: (
|
||||||
|
mcp_transport_config.REMOTE_TRANSPORT
|
||||||
|
),
|
||||||
|
"GITEA_TOKEN": "super-secret-value",
|
||||||
|
}
|
||||||
|
) as guard:
|
||||||
|
guard.bind_native_mcp_transport()
|
||||||
|
with self.assertRaises(guard.TransportExecutionError) as ctx:
|
||||||
|
guard.authorize_transport_execution("tool service")
|
||||||
|
blob = str(ctx.exception) + repr(ctx.exception.assessment)
|
||||||
|
self.assertNotIn("super-secret-value", blob)
|
||||||
|
self.assertNotIn("GITEA_TOKEN", blob)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEntrypointWiring(unittest.TestCase):
|
||||||
|
"""The live entrypoint must use the seam and the execution guard."""
|
||||||
|
|
||||||
|
def test_entrypoint_binds_without_a_literal_transport(self):
|
||||||
|
text = (REPO_ROOT / "gitea_mcp_server.py").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("mcp_daemon_guard.bind_native_mcp_transport()", text)
|
||||||
|
self.assertNotIn('bind_native_mcp_transport(transport="stdio")', text)
|
||||||
|
|
||||||
|
def test_entrypoint_serves_only_through_the_execution_guard(self):
|
||||||
|
text = (REPO_ROOT / "gitea_mcp_server.py").read_text(encoding="utf-8")
|
||||||
|
self.assertIn(
|
||||||
|
"mcp.run(transport=mcp_daemon_guard.authorize_transport_execution", text
|
||||||
|
)
|
||||||
|
self.assertNotIn('mcp.run(transport="stdio")', text)
|
||||||
|
self.assertNotIn(
|
||||||
|
"mcp.run(transport=mcp_daemon_guard.assert_transport_bound", text
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_every_run_call_in_production_goes_through_the_guard(self):
|
||||||
|
"""Glob the production surface: no serve site may bypass the guard."""
|
||||||
|
offenders: list[str] = []
|
||||||
|
run_sites = 0
|
||||||
|
for path in sorted(REPO_ROOT.glob("*.py")):
|
||||||
|
for lineno, line in enumerate(
|
||||||
|
path.read_text(encoding="utf-8").splitlines(), start=1
|
||||||
|
):
|
||||||
|
code = line.split("#", 1)[0]
|
||||||
|
if "mcp.run(" not in code:
|
||||||
|
continue
|
||||||
|
run_sites += 1
|
||||||
|
if "authorize_transport_execution" not in code:
|
||||||
|
offenders.append(f"{path.name}:{lineno}: {line.strip()}")
|
||||||
|
self.assertEqual(run_sites, 1, "expected exactly one serve site")
|
||||||
|
self.assertEqual(offenders, [], "\n".join(offenders))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,662 @@
|
|||||||
|
"""Client/session-aware runtime ownership and provenance (#948).
|
||||||
|
|
||||||
|
Covers the reproduced contradiction that motivated the issue: one surface
|
||||||
|
reporting ``client_managed`` while another reported ``manual_launch`` for the
|
||||||
|
same process, remediation hardcoded to one vendor, and a profile-wide duplicate
|
||||||
|
wall that could not tell two healthy clients apart.
|
||||||
|
|
||||||
|
All client and session identifiers here are synthetic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import mcp_client_reconnect
|
||||||
|
import mcp_namespace_health
|
||||||
|
import mcp_worker_identity as mwi
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 7, 29, 6, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _registry() -> mwi.WorkerRegistry:
|
||||||
|
"""A registry on a throwaway path; never the operator's real one."""
|
||||||
|
handle, path = tempfile.mkstemp(suffix=".sqlite3")
|
||||||
|
os.close(handle)
|
||||||
|
os.unlink(path)
|
||||||
|
return mwi.WorkerRegistry(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _attach(
|
||||||
|
registry: mwi.WorkerRegistry,
|
||||||
|
*,
|
||||||
|
client: str,
|
||||||
|
session: str,
|
||||||
|
generation: str,
|
||||||
|
profile: str = "prgs-reviewer",
|
||||||
|
role: str = "reviewer",
|
||||||
|
pid: int = 4242,
|
||||||
|
now: datetime = NOW,
|
||||||
|
ttl: float = 900.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Register one synthetic worker and return the outcome."""
|
||||||
|
identity = mwi.generate_worker_identity(client, session, now=now)
|
||||||
|
outcome = registry.register(
|
||||||
|
worker_identity=identity,
|
||||||
|
client_name=client,
|
||||||
|
client_instance_id=f"inst-{session}",
|
||||||
|
session_id=session,
|
||||||
|
generation_id=generation,
|
||||||
|
role=role,
|
||||||
|
profile=profile,
|
||||||
|
pid=pid,
|
||||||
|
heartbeat_ttl_seconds=ttl,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
outcome["identity"] = identity
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityFormatTests(unittest.TestCase):
|
||||||
|
"""AC27-29: collision-resistant `<llm-name>-<UTC-timestamp>-<short-sha>`."""
|
||||||
|
|
||||||
|
def test_identity_matches_required_format(self):
|
||||||
|
identity = mwi.generate_worker_identity("Gemini", "sess-0001", now=NOW)
|
||||||
|
parsed = mwi.parse_worker_identity(identity)
|
||||||
|
self.assertTrue(parsed["valid"], parsed["reasons"])
|
||||||
|
self.assertEqual(parsed["client_name"], "gemini")
|
||||||
|
self.assertEqual(parsed["minted_at"], "20260729T060000Z")
|
||||||
|
self.assertEqual(len(parsed["digest"]), 12)
|
||||||
|
|
||||||
|
def test_digest_varies_with_session_and_nonce(self):
|
||||||
|
base = dict(timestamp_ns=1, now=NOW)
|
||||||
|
a = mwi.generate_worker_identity("codex", "sess-A", nonce="n", **base)
|
||||||
|
b = mwi.generate_worker_identity("codex", "sess-B", nonce="n", **base)
|
||||||
|
c = mwi.generate_worker_identity("codex", "sess-A", nonce="m", **base)
|
||||||
|
self.assertNotEqual(a, b, "session must feed the digest")
|
||||||
|
self.assertNotEqual(a, c, "nonce must feed the digest")
|
||||||
|
|
||||||
|
def test_identity_is_not_role_or_profile(self):
|
||||||
|
"""AC26: identity is independent of role and profile."""
|
||||||
|
args = dict(timestamp_ns=7, nonce="fixed", now=NOW)
|
||||||
|
same = mwi.generate_worker_identity("claude", "sess-1", **args)
|
||||||
|
self.assertEqual(same, mwi.generate_worker_identity("claude", "sess-1", **args))
|
||||||
|
# Nothing role- or profile-derived appears in the identity.
|
||||||
|
self.assertNotIn("reviewer", same)
|
||||||
|
self.assertNotIn("prgs", same)
|
||||||
|
|
||||||
|
def test_malformed_identity_rejected(self):
|
||||||
|
self.assertFalse(mwi.parse_worker_identity("prgs-reviewer")["valid"])
|
||||||
|
self.assertFalse(mwi.parse_worker_identity("")["valid"])
|
||||||
|
self.assertFalse(mwi.parse_worker_identity(None)["valid"])
|
||||||
|
|
||||||
|
|
||||||
|
class PerClientAttachmentTests(unittest.TestCase):
|
||||||
|
"""Every supported client attaches and is reported as itself."""
|
||||||
|
|
||||||
|
def _assert_attached_as(self, client: str, expected_name: str):
|
||||||
|
registry = _registry()
|
||||||
|
outcome = _attach(
|
||||||
|
registry, client=client, session=f"sess-{client}", generation="gen-1"
|
||||||
|
)
|
||||||
|
self.assertTrue(outcome["registered"], outcome["reasons"])
|
||||||
|
|
||||||
|
verdict = mwi.assess_provenance(
|
||||||
|
registry=registry, worker_identity=outcome["identity"], env={}, now=NOW
|
||||||
|
)
|
||||||
|
self.assertEqual(verdict["session_ownership"], mwi.OWNERSHIP_OWNED)
|
||||||
|
self.assertEqual(verdict["provenance"], mwi.PROVENANCE_CLIENT_SESSION)
|
||||||
|
self.assertEqual(verdict["client_name"], expected_name)
|
||||||
|
self.assertTrue(verdict["session_owned"])
|
||||||
|
self.assertFalse(verdict["fail_closed"])
|
||||||
|
return verdict
|
||||||
|
|
||||||
|
def test_codex_attachment(self):
|
||||||
|
self._assert_attached_as("codex", "codex")
|
||||||
|
|
||||||
|
def test_gemini_attachment(self):
|
||||||
|
self._assert_attached_as("gemini", "gemini")
|
||||||
|
|
||||||
|
def test_antigravity_attachment(self):
|
||||||
|
self._assert_attached_as("antigravity", "antigravity")
|
||||||
|
|
||||||
|
def test_claude_attachment(self):
|
||||||
|
self._assert_attached_as("claude", "claude_code")
|
||||||
|
|
||||||
|
def test_unknown_client_is_named_not_guessed(self):
|
||||||
|
verdict = self._assert_attached_as("some_new_llm", "some_new_llm")
|
||||||
|
self.assertNotEqual(verdict["client_name"], "codex")
|
||||||
|
|
||||||
|
|
||||||
|
class SessionLifecycleTests(unittest.TestCase):
|
||||||
|
def test_same_client_new_session_gets_distinct_identity(self):
|
||||||
|
registry = _registry()
|
||||||
|
first = _attach(registry, client="codex", session="sess-1", generation="gen-1")
|
||||||
|
second = _attach(registry, client="codex", session="sess-2", generation="gen-2")
|
||||||
|
self.assertTrue(first["registered"])
|
||||||
|
self.assertTrue(second["registered"])
|
||||||
|
self.assertNotEqual(first["identity"], second["identity"])
|
||||||
|
|
||||||
|
# Both are live and neither blocks the other.
|
||||||
|
cohort = mwi.classify_cohort(registry.list_workers(), now=NOW)
|
||||||
|
self.assertEqual(cohort["live_worker_count"], 2)
|
||||||
|
self.assertFalse(cohort["blocked"], cohort["reasons"])
|
||||||
|
|
||||||
|
def test_different_client_attaches_after_previous_session_ends(self):
|
||||||
|
"""AC14: expiry then takeover with a higher fencing epoch."""
|
||||||
|
registry = _registry()
|
||||||
|
gone = _attach(
|
||||||
|
registry, client="codex", session="sess-old", generation="gen-shared", ttl=60
|
||||||
|
)
|
||||||
|
later = NOW + timedelta(hours=1)
|
||||||
|
self.assertFalse(
|
||||||
|
registry.is_live(registry.get(gone["identity"]), now=later)["live"]
|
||||||
|
)
|
||||||
|
|
||||||
|
arriving = _attach(
|
||||||
|
registry,
|
||||||
|
client="gemini",
|
||||||
|
session="sess-new",
|
||||||
|
generation="gen-other",
|
||||||
|
now=later,
|
||||||
|
)
|
||||||
|
claim = registry.claim_generation(
|
||||||
|
worker_identity=arriving["identity"],
|
||||||
|
generation_id="gen-shared",
|
||||||
|
now=later,
|
||||||
|
)
|
||||||
|
self.assertTrue(claim["claimed"], claim["reasons"])
|
||||||
|
self.assertIn(gone["identity"], claim["superseded_workers"])
|
||||||
|
self.assertGreater(claim["fencing_epoch"], gone["fencing_epoch"])
|
||||||
|
|
||||||
|
def test_superseded_session_is_fenced_on_resume(self):
|
||||||
|
"""AC15/AC16: the prior session cannot heartbeat its way back."""
|
||||||
|
registry = _registry()
|
||||||
|
old = _attach(
|
||||||
|
registry, client="codex", session="sess-old", generation="gen-shared", ttl=60
|
||||||
|
)
|
||||||
|
later = NOW + timedelta(hours=1)
|
||||||
|
new = _attach(
|
||||||
|
registry, client="gemini", session="sess-new", generation="gen-x", now=later
|
||||||
|
)
|
||||||
|
registry.claim_generation(
|
||||||
|
worker_identity=new["identity"], generation_id="gen-shared", now=later
|
||||||
|
)
|
||||||
|
|
||||||
|
resumed = registry.heartbeat(
|
||||||
|
worker_identity=old["identity"],
|
||||||
|
fencing_epoch=old["fencing_epoch"],
|
||||||
|
now=later,
|
||||||
|
)
|
||||||
|
self.assertFalse(resumed["renewed"])
|
||||||
|
self.assertFalse(resumed["mutation_performed"])
|
||||||
|
self.assertEqual(resumed["blocker_kind"], mwi.BLOCKER_FENCED)
|
||||||
|
|
||||||
|
def test_heartbeat_renews_only_the_owning_lease(self):
|
||||||
|
"""AC11: a wrong epoch never renews, and never mutates."""
|
||||||
|
registry = _registry()
|
||||||
|
worker = _attach(registry, client="codex", session="s", generation="g")
|
||||||
|
good = registry.heartbeat(
|
||||||
|
worker_identity=worker["identity"],
|
||||||
|
fencing_epoch=worker["fencing_epoch"],
|
||||||
|
now=NOW + timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
self.assertTrue(good["renewed"])
|
||||||
|
|
||||||
|
bad = registry.heartbeat(
|
||||||
|
worker_identity=worker["identity"],
|
||||||
|
fencing_epoch=worker["fencing_epoch"] + 99,
|
||||||
|
now=NOW + timedelta(minutes=6),
|
||||||
|
)
|
||||||
|
self.assertFalse(bad["renewed"])
|
||||||
|
self.assertFalse(bad["mutation_performed"])
|
||||||
|
self.assertEqual(
|
||||||
|
registry.get(worker["identity"])["last_heartbeat_at"],
|
||||||
|
good["last_heartbeat_at"],
|
||||||
|
"a refused heartbeat must not advance the record",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictAndCollisionTests(unittest.TestCase):
|
||||||
|
def test_two_live_sessions_cannot_claim_one_generation(self):
|
||||||
|
registry = _registry()
|
||||||
|
first = _attach(registry, client="codex", session="s1", generation="gen-shared")
|
||||||
|
second = _attach(registry, client="gemini", session="s2", generation="gen-other")
|
||||||
|
|
||||||
|
claim = registry.claim_generation(
|
||||||
|
worker_identity=second["identity"],
|
||||||
|
generation_id="gen-shared",
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
self.assertFalse(claim["claimed"])
|
||||||
|
self.assertFalse(claim["mutation_performed"])
|
||||||
|
self.assertEqual(claim["blocker_kind"], mwi.BLOCKER_CONFLICTING_SESSIONS)
|
||||||
|
self.assertEqual(
|
||||||
|
claim["conflicting_owners"][0]["worker_identity"], first["identity"]
|
||||||
|
)
|
||||||
|
# The sanctioned recovery must never be "kill the other process".
|
||||||
|
self.assertIn("Do not kill", claim["exact_next_action"])
|
||||||
|
|
||||||
|
def test_contested_generation_fails_closed_in_assessment(self):
|
||||||
|
registry = _registry()
|
||||||
|
first = _attach(registry, client="codex", session="s1", generation="gen-shared")
|
||||||
|
_attach(registry, client="gemini", session="s2", generation="gen-shared")
|
||||||
|
|
||||||
|
verdict = mwi.assess_provenance(
|
||||||
|
registry=registry, worker_identity=first["identity"], env={}, now=NOW
|
||||||
|
)
|
||||||
|
self.assertEqual(verdict["session_ownership"], mwi.OWNERSHIP_CONTESTED)
|
||||||
|
self.assertTrue(verdict["fail_closed"])
|
||||||
|
self.assertEqual(verdict["blocker_kind"], mwi.BLOCKER_CONTRADICTORY)
|
||||||
|
self.assertTrue(verdict["conflicting_live_sessions"])
|
||||||
|
|
||||||
|
def test_identity_collision_is_refused_without_corrupting_existing(self):
|
||||||
|
"""AC31: never replace, adopt, merge with, or corrupt the incumbent."""
|
||||||
|
registry = _registry()
|
||||||
|
incumbent = _attach(registry, client="codex", session="s1", generation="gen-1")
|
||||||
|
before = registry.get(incumbent["identity"])
|
||||||
|
|
||||||
|
collided = registry.register(
|
||||||
|
worker_identity=incumbent["identity"],
|
||||||
|
client_name="gemini",
|
||||||
|
client_instance_id="inst-other",
|
||||||
|
session_id="s2",
|
||||||
|
generation_id="gen-2",
|
||||||
|
pid=9999,
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
self.assertFalse(collided["registered"])
|
||||||
|
self.assertTrue(collided["collision"])
|
||||||
|
self.assertFalse(collided["mutation_performed"])
|
||||||
|
self.assertEqual(collided["blocker_kind"], mwi.BLOCKER_IDENTITY_COLLISION)
|
||||||
|
self.assertEqual(collided["collision_kind"], "active_worker")
|
||||||
|
self.assertEqual(
|
||||||
|
registry.get(incumbent["identity"]), before, "incumbent must be untouched"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_after_collision_a_regenerated_identity_registers(self):
|
||||||
|
"""AC32/AC35: forced collision, safe regeneration, successful replacement."""
|
||||||
|
registry = _registry()
|
||||||
|
fixed = dict(timestamp_ns=99, nonce="deterministic", now=NOW)
|
||||||
|
forced = mwi.generate_worker_identity("codex", "sess-collide", **fixed)
|
||||||
|
first = registry.register(
|
||||||
|
worker_identity=forced,
|
||||||
|
client_name="codex",
|
||||||
|
client_instance_id="inst-1",
|
||||||
|
session_id="sess-collide",
|
||||||
|
generation_id="gen-1",
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
self.assertTrue(first["registered"])
|
||||||
|
|
||||||
|
# A second worker deriving the same inputs collides deterministically.
|
||||||
|
again = mwi.generate_worker_identity("codex", "sess-collide", **fixed)
|
||||||
|
self.assertEqual(again, forced)
|
||||||
|
self.assertTrue(
|
||||||
|
registry.register(
|
||||||
|
worker_identity=again,
|
||||||
|
client_name="codex",
|
||||||
|
client_instance_id="inst-2",
|
||||||
|
session_id="sess-collide",
|
||||||
|
generation_id="gen-2",
|
||||||
|
now=NOW,
|
||||||
|
)["collision"]
|
||||||
|
)
|
||||||
|
|
||||||
|
replacement = mwi.generate_worker_identity(
|
||||||
|
"codex", "sess-collide", timestamp_ns=100, nonce="different", now=NOW
|
||||||
|
)
|
||||||
|
self.assertNotEqual(replacement, forced)
|
||||||
|
self.assertTrue(
|
||||||
|
registry.register(
|
||||||
|
worker_identity=replacement,
|
||||||
|
client_name="codex",
|
||||||
|
client_instance_id="inst-2",
|
||||||
|
session_id="sess-collide",
|
||||||
|
generation_id="gen-2",
|
||||||
|
now=NOW,
|
||||||
|
)["registered"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_restarted_worker_inherits_nothing(self):
|
||||||
|
"""AC33/AC34: a restart mints a new identity and no prior epoch."""
|
||||||
|
registry = _registry()
|
||||||
|
before = _attach(
|
||||||
|
registry, client="codex", session="sess-before", generation="gen-1", ttl=60
|
||||||
|
)
|
||||||
|
later = NOW + timedelta(hours=2)
|
||||||
|
after = _attach(
|
||||||
|
registry, client="codex", session="sess-after", generation="gen-2", now=later
|
||||||
|
)
|
||||||
|
self.assertNotEqual(before["identity"], after["identity"])
|
||||||
|
self.assertNotEqual(
|
||||||
|
registry.get(after["identity"])["generation_id"],
|
||||||
|
registry.get(before["identity"])["generation_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LivenessTests(unittest.TestCase):
|
||||||
|
def test_stale_session_record_is_not_live(self):
|
||||||
|
registry = _registry()
|
||||||
|
worker = _attach(registry, client="codex", session="s", generation="g", ttl=300)
|
||||||
|
stale = registry.is_live(
|
||||||
|
registry.get(worker["identity"]), now=NOW + timedelta(hours=1)
|
||||||
|
)
|
||||||
|
self.assertFalse(stale["live"])
|
||||||
|
self.assertFalse(stale["heartbeat_fresh"])
|
||||||
|
|
||||||
|
def test_liveness_is_not_pid_comparison_alone(self):
|
||||||
|
"""AC7: a live PID does not resurrect an expired registration."""
|
||||||
|
registry = _registry()
|
||||||
|
worker = _attach(registry, client="codex", session="s", generation="g", ttl=60)
|
||||||
|
verdict = registry.is_live(
|
||||||
|
registry.get(worker["identity"]),
|
||||||
|
now=NOW + timedelta(hours=1),
|
||||||
|
pid_alive=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
verdict["live"], "a live PID must not override a dead heartbeat"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dead_pid_withdraws_liveness_from_a_fresh_heartbeat(self):
|
||||||
|
registry = _registry()
|
||||||
|
worker = _attach(registry, client="codex", session="s", generation="g")
|
||||||
|
verdict = registry.is_live(
|
||||||
|
registry.get(worker["identity"]), now=NOW, pid_alive=False
|
||||||
|
)
|
||||||
|
self.assertFalse(verdict["live"])
|
||||||
|
|
||||||
|
def test_stale_ownership_does_not_permanently_strand_a_daemon(self):
|
||||||
|
registry = _registry()
|
||||||
|
stranded = _attach(
|
||||||
|
registry, client="codex", session="s-old", generation="gen-daemon", ttl=60
|
||||||
|
)
|
||||||
|
later = NOW + timedelta(hours=3)
|
||||||
|
rescuer = _attach(
|
||||||
|
registry, client="claude", session="s-new", generation="gen-tmp", now=later
|
||||||
|
)
|
||||||
|
claim = registry.claim_generation(
|
||||||
|
worker_identity=rescuer["identity"],
|
||||||
|
generation_id="gen-daemon",
|
||||||
|
now=later,
|
||||||
|
)
|
||||||
|
self.assertTrue(claim["claimed"], claim["reasons"])
|
||||||
|
self.assertIn(stranded["identity"], claim["superseded_workers"])
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceTests(unittest.TestCase):
|
||||||
|
def test_env_flag_alone_does_not_prove_session_ownership(self):
|
||||||
|
verdict = mwi.assess_provenance(
|
||||||
|
registry=None,
|
||||||
|
worker_identity=None,
|
||||||
|
env={"GITEA_CLIENT_MANAGED": "1", "GITEA_MCP_SANCTIONED_DAEMON": "1"},
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
self.assertFalse(verdict["session_owned"])
|
||||||
|
self.assertEqual(verdict["session_ownership"], mwi.OWNERSHIP_UNOWNED)
|
||||||
|
self.assertTrue(verdict["env_flag_only"])
|
||||||
|
self.assertTrue(verdict["fail_closed"])
|
||||||
|
self.assertNotIn(mwi.EVIDENCE_ATTACHMENT_RECORD, verdict["evidence"])
|
||||||
|
self.assertFalse(verdict["env_signal"]["proves_session_ownership"])
|
||||||
|
|
||||||
|
def test_env_flag_still_answers_the_launch_question(self):
|
||||||
|
"""The #686 wall is preserved: env decides launch, not ownership."""
|
||||||
|
self.assertTrue(
|
||||||
|
mwi.assess_launch_provenance({"GITEA_CLIENT_MANAGED": "1"})["client_managed"]
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
mwi.assess_launch_provenance({"GITEA_CLIENT_MANAGED": "0"})["client_managed"]
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
mwi.assess_launch_provenance({}, stdin_is_tty=True)["client_managed"]
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
mwi.assess_launch_provenance({"GITEA_MCP_PROFILE": "prgs-author"})[
|
||||||
|
"client_managed"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_evidence_is_unproven_not_manual(self):
|
||||||
|
"""A missing proof must not be reported as a hand-launched process."""
|
||||||
|
verdict = mwi.assess_provenance(
|
||||||
|
registry=None, worker_identity=None, env={}, now=NOW
|
||||||
|
)
|
||||||
|
self.assertEqual(verdict["provenance"], mwi.PROVENANCE_UNPROVEN)
|
||||||
|
self.assertNotEqual(verdict["provenance"], mwi.PROVENANCE_MANUAL)
|
||||||
|
self.assertTrue(verdict["fail_closed"])
|
||||||
|
|
||||||
|
def test_declared_manual_launch_is_reported_as_manual(self):
|
||||||
|
verdict = mwi.assess_provenance(
|
||||||
|
registry=None,
|
||||||
|
worker_identity=None,
|
||||||
|
env={"GITEA_CLIENT_MANAGED": "0"},
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
self.assertEqual(verdict["provenance"], mwi.PROVENANCE_MANUAL)
|
||||||
|
|
||||||
|
def test_fail_closed_refusal_names_its_scope_not_the_profile(self):
|
||||||
|
"""AC17/AC41: no refusal is profile-wide."""
|
||||||
|
verdict = mwi.assess_provenance(
|
||||||
|
registry=None,
|
||||||
|
worker_identity=None,
|
||||||
|
env={},
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
role="reviewer",
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
self.assertFalse(verdict["scope"]["profile_wide"])
|
||||||
|
self.assertEqual(verdict["blocker_kind"], mwi.BLOCKER_NO_ATTACHMENT)
|
||||||
|
|
||||||
|
|
||||||
|
class CohortScopingTests(unittest.TestCase):
|
||||||
|
def test_shared_profile_with_distinct_identities_does_not_block(self):
|
||||||
|
"""AC40: profile is not a singleton identity."""
|
||||||
|
registry = _registry()
|
||||||
|
_attach(
|
||||||
|
registry,
|
||||||
|
client="codex",
|
||||||
|
session="s1",
|
||||||
|
generation="g1",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
)
|
||||||
|
_attach(
|
||||||
|
registry,
|
||||||
|
client="gemini",
|
||||||
|
session="s2",
|
||||||
|
generation="g2",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
)
|
||||||
|
|
||||||
|
cohort = mwi.classify_cohort(registry.list_workers(), now=NOW)
|
||||||
|
self.assertFalse(cohort["blocked"], cohort["reasons"])
|
||||||
|
self.assertEqual(cohort["blocker_kind"], mwi.BLOCKER_NONE)
|
||||||
|
self.assertIn("prgs-reviewer", cohort["shared_profiles"])
|
||||||
|
self.assertTrue(cohort["profile_sharing_permitted"])
|
||||||
|
self.assertEqual(cohort["blocked_worker_identities"], [])
|
||||||
|
|
||||||
|
def test_duplicate_cohort_records_block_only_the_offenders(self):
|
||||||
|
registry = _registry()
|
||||||
|
_attach(registry, client="codex", session="s1", generation="gen-contested")
|
||||||
|
_attach(registry, client="gemini", session="s2", generation="gen-contested")
|
||||||
|
_attach(registry, client="claude", session="s3", generation="gen-fine")
|
||||||
|
|
||||||
|
cohort = mwi.classify_cohort(registry.list_workers(), now=NOW)
|
||||||
|
self.assertTrue(cohort["blocked"])
|
||||||
|
self.assertEqual(cohort["contested_generations"], ["gen-contested"])
|
||||||
|
self.assertEqual(len(cohort["blocked_worker_identities"]), 2)
|
||||||
|
|
||||||
|
def test_mixed_runtime_generations_are_scoped_independently(self):
|
||||||
|
"""AC17: one stale generation does not wall unrelated healthy ones."""
|
||||||
|
registry = _registry()
|
||||||
|
stale = _attach(
|
||||||
|
registry, client="codex", session="s1", generation="gen-stale", ttl=60
|
||||||
|
)
|
||||||
|
healthy_a = _attach(registry, client="gemini", session="s2", generation="gen-a")
|
||||||
|
healthy_b = _attach(registry, client="claude", session="s3", generation="gen-b")
|
||||||
|
|
||||||
|
scoped = mwi.scope_runtime_failure(
|
||||||
|
failure_kind="stale-runtime",
|
||||||
|
worker_identity=stale["identity"],
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
all_live_workers=registry.list_workers(),
|
||||||
|
)
|
||||||
|
self.assertFalse(scoped["profile_wide"])
|
||||||
|
self.assertFalse(scoped["fleet_wide"])
|
||||||
|
self.assertEqual(len(scoped["affected_workers"]), 1)
|
||||||
|
self.assertEqual(scoped["unaffected_worker_count"], 2)
|
||||||
|
unaffected = {w["worker_identity"] for w in scoped["unaffected_workers"]}
|
||||||
|
self.assertEqual(unaffected, {healthy_a["identity"], healthy_b["identity"]})
|
||||||
|
|
||||||
|
|
||||||
|
class HardcodedClientRegressionTests(unittest.TestCase):
|
||||||
|
def test_unknown_client_does_not_resolve_to_codex(self):
|
||||||
|
for name in ("gemini", "antigravity", "grok", "some_new_llm", "", None):
|
||||||
|
with self.subTest(client=name):
|
||||||
|
self.assertNotEqual(
|
||||||
|
mcp_client_reconnect.normalize_client(name),
|
||||||
|
"codex",
|
||||||
|
"an unidentified client must never be handed Codex UI steps",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_known_clients_still_get_their_own_steps(self):
|
||||||
|
self.assertEqual(mcp_client_reconnect.normalize_client("codex"), "codex")
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_client_reconnect.normalize_client("claude_code"), "claude_code"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_generic_steps_do_not_name_a_specific_vendor(self):
|
||||||
|
steps = " ".join(mcp_client_reconnect.operator_ui_steps("gemini"))
|
||||||
|
self.assertNotIn("Codex", steps)
|
||||||
|
|
||||||
|
def test_reconnect_client_is_derived_from_the_attachment_record(self):
|
||||||
|
registry = _registry()
|
||||||
|
worker = _attach(registry, client="antigravity", session="s", generation="g")
|
||||||
|
verdict = mwi.assess_provenance(
|
||||||
|
registry=registry, worker_identity=worker["identity"], env={}, now=NOW
|
||||||
|
)
|
||||||
|
self.assertEqual(mwi.reconnect_client_for(verdict), "antigravity")
|
||||||
|
|
||||||
|
def test_reconnect_client_is_unknown_rather_than_guessed(self):
|
||||||
|
self.assertEqual(mwi.reconnect_client_for({}), mwi.UNKNOWN_CLIENT)
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteBindingTests(unittest.TestCase):
|
||||||
|
def test_explicit_prgs_selection_is_honoured(self):
|
||||||
|
resolved = mwi.resolve_bound_remote(
|
||||||
|
requested_remote="prgs", bound_remote="prgs", default_remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertEqual(resolved["remote"], "prgs")
|
||||||
|
self.assertFalse(resolved["drifted"])
|
||||||
|
|
||||||
|
def test_omitted_remote_uses_the_binding_not_the_library_default(self):
|
||||||
|
"""The reported dadeschools host drift."""
|
||||||
|
resolved = mwi.resolve_bound_remote(
|
||||||
|
requested_remote=None, bound_remote="prgs", default_remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertEqual(resolved["remote"], "prgs")
|
||||||
|
self.assertNotEqual(resolved["remote"], "dadeschools")
|
||||||
|
self.assertEqual(resolved["resolved_from"], "session_binding")
|
||||||
|
|
||||||
|
def test_contradicting_the_binding_is_refused(self):
|
||||||
|
resolved = mwi.resolve_bound_remote(
|
||||||
|
requested_remote="dadeschools",
|
||||||
|
bound_remote="prgs",
|
||||||
|
default_remote="dadeschools",
|
||||||
|
)
|
||||||
|
self.assertEqual(resolved["remote"], "prgs")
|
||||||
|
self.assertTrue(resolved["drifted"])
|
||||||
|
self.assertFalse(resolved["honoured_request"])
|
||||||
|
|
||||||
|
def test_unbound_session_falls_back_and_says_so(self):
|
||||||
|
resolved = mwi.resolve_bound_remote(
|
||||||
|
requested_remote=None, bound_remote=None, default_remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertEqual(resolved["remote"], "dadeschools")
|
||||||
|
self.assertEqual(resolved["resolved_from"], "library_default")
|
||||||
|
self.assertTrue(resolved["reasons"])
|
||||||
|
|
||||||
|
|
||||||
|
class SurfaceAgreementTests(unittest.TestCase):
|
||||||
|
"""The reproduced contradiction: two surfaces, one process, two answers."""
|
||||||
|
|
||||||
|
def test_namespace_health_and_direct_assessment_agree(self):
|
||||||
|
registry = _registry()
|
||||||
|
worker = _attach(
|
||||||
|
registry,
|
||||||
|
client="gemini",
|
||||||
|
session="sess-agree",
|
||||||
|
generation="gen-agree",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
)
|
||||||
|
env = {"GITEA_MCP_PROFILE": "prgs-reviewer", "GITEA_CLIENT_MANAGED": "1"}
|
||||||
|
|
||||||
|
direct = mwi.assess_provenance(
|
||||||
|
registry=registry,
|
||||||
|
worker_identity=worker["identity"],
|
||||||
|
env=env,
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
)
|
||||||
|
health = mcp_namespace_health.classify_namespace_probe(
|
||||||
|
"gitea-reviewer",
|
||||||
|
configured=True,
|
||||||
|
registered_tools=["gitea_whoami"],
|
||||||
|
probe_result={"success": True},
|
||||||
|
probe_source="client_namespace",
|
||||||
|
process={"pid": 4242, "profile": "prgs-reviewer", "env": env},
|
||||||
|
registry=registry,
|
||||||
|
worker_identity=worker["identity"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(health["provenance"], direct["provenance"])
|
||||||
|
self.assertEqual(health["is_client_managed"], direct["is_client_managed"])
|
||||||
|
self.assertEqual(health["worker_identity"], direct["worker_identity"])
|
||||||
|
self.assertEqual(health["session_id"], "sess-agree")
|
||||||
|
self.assertEqual(health["client_name"], "gemini")
|
||||||
|
|
||||||
|
def test_namespace_health_can_report_client_managed_at_all(self):
|
||||||
|
"""The old derivation was structurally incapable of this."""
|
||||||
|
env = {"GITEA_CLIENT_MANAGED": "1", "GITEA_MCP_PROFILE": "prgs-author"}
|
||||||
|
health = mcp_namespace_health.classify_namespace_probe(
|
||||||
|
"gitea-author",
|
||||||
|
configured=True,
|
||||||
|
registered_tools=["gitea_whoami"],
|
||||||
|
probe_result={"success": True},
|
||||||
|
probe_source="client_namespace",
|
||||||
|
process={"pid": 1234, "profile": "prgs-author", "env": env},
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
health["is_client_managed"],
|
||||||
|
"a client-managed launch must be reportable as client-managed",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_namespace_health_without_attachment_fails_closed(self):
|
||||||
|
health = mcp_namespace_health.classify_namespace_probe(
|
||||||
|
"gitea-author",
|
||||||
|
configured=True,
|
||||||
|
registered_tools=["gitea_whoami"],
|
||||||
|
probe_result={"success": True},
|
||||||
|
probe_source="client_namespace",
|
||||||
|
process={"pid": 1234, "profile": "prgs-author", "env": {}},
|
||||||
|
)
|
||||||
|
self.assertTrue(health["provenance_fail_closed"])
|
||||||
|
self.assertEqual(health["provenance"], mwi.PROVENANCE_UNPROVEN)
|
||||||
|
self.assertIsNone(health["session_id"])
|
||||||
|
|
||||||
|
def test_no_false_reconnect_loop_for_an_owned_session(self):
|
||||||
|
"""A proven owner must not be told to reconnect."""
|
||||||
|
registry = _registry()
|
||||||
|
worker = _attach(registry, client="claude", session="s", generation="g")
|
||||||
|
verdict = mwi.assess_provenance(
|
||||||
|
registry=registry, worker_identity=worker["identity"], env={}, now=NOW
|
||||||
|
)
|
||||||
|
self.assertFalse(verdict["fail_closed"])
|
||||||
|
self.assertEqual(verdict["blocker_kind"], mwi.BLOCKER_NONE)
|
||||||
|
self.assertEqual(verdict["reasons"], [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,323 @@
|
|||||||
|
"""Validation tooling for the remote-MCP threat model (#956).
|
||||||
|
|
||||||
|
#956 requires that "every boundary claim [is] traceable to a file and line
|
||||||
|
anchor that resolves at the reviewed commit". A prose document cannot enforce
|
||||||
|
that about itself, and #930 demonstrated the failure mode: its inventory cited
|
||||||
|
``gitea_mcp_server.py`` anchors generated at ``7bf4f125`` which no longer point
|
||||||
|
at the described code at ``aad5c8b4``. Nothing failed, because nothing checked.
|
||||||
|
|
||||||
|
These tests are that check. They enforce, in both directions:
|
||||||
|
|
||||||
|
* every ``file.py:NNN`` anchor cited in the prose is declared in the fixture;
|
||||||
|
* every declared anchor resolves — the file exists, the line exists, and the
|
||||||
|
source line actually contains the substring the fixture claims for it;
|
||||||
|
* the document's structural obligations (assets, adversaries, boundaries,
|
||||||
|
credential rows, the co-residency ruling, and the child mapping) are present
|
||||||
|
and internally consistent.
|
||||||
|
|
||||||
|
A refactor that shifts a line number therefore breaks the suite instead of
|
||||||
|
silently rotting the security documentation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
DOC_PATH = os.path.join(REPO_ROOT, "docs", "remote-mcp", "threat-model.md")
|
||||||
|
FIXTURE_PATH = os.path.join(
|
||||||
|
REPO_ROOT, "docs", "remote-mcp", "threat-model-anchors.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ``module.py:123`` as it appears inside markdown inline code spans.
|
||||||
|
ANCHOR_RE = re.compile(r"`([A-Za-z0-9_./-]+\.py):(\d+)`")
|
||||||
|
|
||||||
|
# The epic children this document must map to a boundary (#929 children 2-10).
|
||||||
|
REQUIRED_CHILDREN = [931, 932, 933, 934, 935, 936, 937, 938, 939]
|
||||||
|
|
||||||
|
# The adversaries #956 names explicitly.
|
||||||
|
REQUIRED_ADVERSARIES = [
|
||||||
|
"compromised LLM client",
|
||||||
|
"prompt injection",
|
||||||
|
"malicious tool arguments",
|
||||||
|
"network attacker",
|
||||||
|
"curious operator",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path):
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
return fh.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _heading_re(title):
|
||||||
|
"""Match a level-2 heading by title, with or without section numbering.
|
||||||
|
|
||||||
|
The document numbers its sections ('## 6. Decomposition ruling'), so an
|
||||||
|
exact-substring assertion would break on renumbering without the document
|
||||||
|
having actually lost anything.
|
||||||
|
"""
|
||||||
|
return re.compile(
|
||||||
|
r"^##\s+(?:\d+\.\s+)?" + re.escape(title), re.MULTILINE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _section_body(doc, title):
|
||||||
|
"""Return the text of section *title*, bounded by the next level-2 heading.
|
||||||
|
|
||||||
|
Bounding matters: an unbounded slice runs to end-of-document, so the
|
||||||
|
walkthrough tables in a later section leak into the child-to-boundary
|
||||||
|
mapping and satisfy its coverage check with rows that assign no owner.
|
||||||
|
"""
|
||||||
|
match = _heading_re(title).search(doc)
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
rest = doc[match.end():]
|
||||||
|
nxt = re.search(r"^##\s", rest, re.MULTILINE)
|
||||||
|
return rest[: nxt.start()] if nxt else rest
|
||||||
|
|
||||||
|
|
||||||
|
def _source_line(rel_path, lineno):
|
||||||
|
"""Return the 1-based *lineno* of *rel_path*, or None if out of range."""
|
||||||
|
abs_path = os.path.join(REPO_ROOT, rel_path)
|
||||||
|
if not os.path.exists(abs_path):
|
||||||
|
return None
|
||||||
|
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
|
||||||
|
for idx, line in enumerate(fh, start=1):
|
||||||
|
if idx == lineno:
|
||||||
|
return line
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ThreatModelFixtureTests(unittest.TestCase):
|
||||||
|
"""The fixture itself must be well-formed before it can prove anything."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.fixture = json.loads(_read(FIXTURE_PATH))
|
||||||
|
|
||||||
|
def test_fixture_declares_a_generation_commit(self):
|
||||||
|
sha = self.fixture.get("generated_against_commit") or ""
|
||||||
|
self.assertRegex(
|
||||||
|
sha,
|
||||||
|
r"^[0-9a-f]{40}$",
|
||||||
|
"the fixture must record the full commit its anchors were taken at",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_fixture_anchors_are_unique_and_well_formed(self):
|
||||||
|
seen = set()
|
||||||
|
for entry in self.fixture["anchors"]:
|
||||||
|
anchor = entry["anchor"]
|
||||||
|
self.assertNotIn(anchor, seen, f"duplicate anchor entry: {anchor}")
|
||||||
|
seen.add(anchor)
|
||||||
|
self.assertRegex(anchor, r"^[A-Za-z0-9_./-]+\.py:[1-9]\d*$", anchor)
|
||||||
|
self.assertTrue(
|
||||||
|
(entry.get("expect") or "").strip(),
|
||||||
|
f"anchor {anchor} declares no 'expect' substring, so it proves nothing",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ThreatModelAnchorResolutionTests(unittest.TestCase):
|
||||||
|
"""#956 required positive test: every anchor resolves at the reviewed commit."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.fixture = json.loads(_read(FIXTURE_PATH))
|
||||||
|
self.doc = _read(DOC_PATH)
|
||||||
|
|
||||||
|
def test_every_declared_anchor_resolves_to_the_claimed_source_line(self):
|
||||||
|
failures = []
|
||||||
|
for entry in self.fixture["anchors"]:
|
||||||
|
rel_path, _, raw_lineno = entry["anchor"].partition(":")
|
||||||
|
lineno = int(raw_lineno)
|
||||||
|
line = _source_line(rel_path, lineno)
|
||||||
|
if line is None:
|
||||||
|
failures.append(f"{entry['anchor']}: file or line does not exist")
|
||||||
|
continue
|
||||||
|
if entry["expect"] not in line:
|
||||||
|
failures.append(
|
||||||
|
f"{entry['anchor']}: expected {entry['expect']!r}, "
|
||||||
|
f"found {line.strip()!r}"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[], failures, "unresolved threat-model anchors:\n" + "\n".join(failures)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_every_anchor_cited_in_the_document_is_declared_in_the_fixture(self):
|
||||||
|
declared = {e["anchor"] for e in self.fixture["anchors"]}
|
||||||
|
cited = {f"{m.group(1)}:{m.group(2)}" for m in ANCHOR_RE.finditer(self.doc)}
|
||||||
|
undeclared = sorted(cited - declared)
|
||||||
|
self.assertEqual(
|
||||||
|
[],
|
||||||
|
undeclared,
|
||||||
|
"document cites anchors that no test verifies: " + ", ".join(undeclared),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_document_actually_cites_anchors(self):
|
||||||
|
cited = {f"{m.group(1)}:{m.group(2)}" for m in ANCHOR_RE.finditer(self.doc)}
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
len(cited),
|
||||||
|
30,
|
||||||
|
"a boundary document with almost no anchors is not traceable",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unresolvable_anchor_is_detected(self):
|
||||||
|
"""Negative control: the checker must fail on a deliberately bad anchor.
|
||||||
|
|
||||||
|
Without this, a checker that silently passed everything would look
|
||||||
|
identical to a correct one.
|
||||||
|
"""
|
||||||
|
self.assertIsNone(_source_line("gitea_config.py", 10**9))
|
||||||
|
self.assertIsNone(_source_line("no_such_module_for_956.py", 1))
|
||||||
|
real = _source_line("gitea_config.py", 54)
|
||||||
|
self.assertIsNotNone(real)
|
||||||
|
self.assertNotIn("this substring is not on that line", real)
|
||||||
|
|
||||||
|
|
||||||
|
class ThreatModelStructureTests(unittest.TestCase):
|
||||||
|
"""The document must contain what #956's acceptance criteria demand."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.doc = _read(DOC_PATH)
|
||||||
|
|
||||||
|
def test_records_the_commit_it_was_generated_against(self):
|
||||||
|
fixture = json.loads(_read(FIXTURE_PATH))
|
||||||
|
self.assertIn(
|
||||||
|
fixture["generated_against_commit"],
|
||||||
|
self.doc,
|
||||||
|
"the document must state the commit its anchors resolve at",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_names_every_required_adversary(self):
|
||||||
|
low = self.doc.lower()
|
||||||
|
for adversary in REQUIRED_ADVERSARIES:
|
||||||
|
self.assertIn(adversary.lower(), low, f"adversary not covered: {adversary}")
|
||||||
|
|
||||||
|
def test_maps_every_epic_child_from_two_through_ten(self):
|
||||||
|
for number in REQUIRED_CHILDREN:
|
||||||
|
self.assertIn(
|
||||||
|
f"#{number}",
|
||||||
|
self.doc,
|
||||||
|
f"epic child #{number} is not mapped to a boundary",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_credential_rows_declare_holder_boundary_and_blast_radius(self):
|
||||||
|
for column in ("Holder", "Boundary", "Blast radius"):
|
||||||
|
self.assertIn(
|
||||||
|
column,
|
||||||
|
self.doc,
|
||||||
|
f"the credential inventory must state each credential's {column.lower()}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_states_an_explicit_co_residency_ruling(self):
|
||||||
|
"""AC3/AC5: an explicit ruling, not an implication."""
|
||||||
|
self.assertIsNotNone(
|
||||||
|
_heading_re("Decomposition ruling").search(self.doc),
|
||||||
|
"the document must contain an explicit decomposition-ruling section",
|
||||||
|
)
|
||||||
|
for service in ("Jenkins", "GlitchTip", "Sentry", "database"):
|
||||||
|
self.assertIn(service, self.doc, f"ruling does not address {service}")
|
||||||
|
self.assertRegex(
|
||||||
|
self.doc,
|
||||||
|
r"D1\b.*must not",
|
||||||
|
"the ruling must state the prohibition, not merely discuss it",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_contains_the_compromised_client_walkthrough(self):
|
||||||
|
"""#956 required negative/adversarial test."""
|
||||||
|
self.assertIsNotNone(
|
||||||
|
_heading_re("Adversarial walkthrough").search(self.doc),
|
||||||
|
"the required compromised-client walkthrough is missing",
|
||||||
|
)
|
||||||
|
self.assertIn("Before the migration", self.doc)
|
||||||
|
self.assertIn("After the migration", self.doc)
|
||||||
|
|
||||||
|
def test_every_boundary_states_what_it_protects_and_what_crossing_requires(self):
|
||||||
|
boundary_ids = set(re.findall(r"\bB(\d+)\b", self.doc))
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
len(boundary_ids), 5, "too few trust boundaries to be a decomposition"
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"Protects",
|
||||||
|
"Crossing requires today",
|
||||||
|
"Crossing must require remotely",
|
||||||
|
):
|
||||||
|
self.assertIn(column, self.doc, f"boundary table is missing '{column}'")
|
||||||
|
|
||||||
|
def test_declares_itself_documentation_only(self):
|
||||||
|
self.assertIn("documentation only", self.doc.lower())
|
||||||
|
|
||||||
|
|
||||||
|
class ThreatModelConsistencyTests(unittest.TestCase):
|
||||||
|
"""Counts stated in prose must match the rows actually present."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.doc = _read(DOC_PATH)
|
||||||
|
|
||||||
|
def _declared_ids(self, prefix):
|
||||||
|
# Table rows begin '| CR1 |' / '| B3 |' / '| A2 |'.
|
||||||
|
return sorted(
|
||||||
|
{
|
||||||
|
int(m)
|
||||||
|
for m in re.findall(
|
||||||
|
r"^\|\s*%s(\d+)\s*\|" % prefix, self.doc, re.MULTILINE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_identifier_sequences_have_no_gaps(self):
|
||||||
|
for prefix, label in (
|
||||||
|
("A", "assets"),
|
||||||
|
("B", "boundaries"),
|
||||||
|
("CR", "credentials"),
|
||||||
|
):
|
||||||
|
ids = self._declared_ids(prefix)
|
||||||
|
self.assertTrue(ids, f"no {label} declared")
|
||||||
|
self.assertEqual(
|
||||||
|
list(range(1, len(ids) + 1)),
|
||||||
|
ids,
|
||||||
|
f"{label} identifiers must run 1..n with no gaps; got {ids}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stated_credential_count_matches_the_rows(self):
|
||||||
|
ids = self._declared_ids("CR")
|
||||||
|
match = re.search(r"(\d+)\s+credential(?:s)? in total", self.doc)
|
||||||
|
self.assertIsNotNone(match, "the credential inventory must state its own total")
|
||||||
|
self.assertEqual(
|
||||||
|
len(ids),
|
||||||
|
int(match.group(1)),
|
||||||
|
"stated credential total disagrees with the number of rows",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_every_boundary_is_owned_by_at_least_one_child(self):
|
||||||
|
"""Each boundary must be owned by a child *in the mapping table*.
|
||||||
|
|
||||||
|
Scanning the whole section would let a prose summary line ("Boundary
|
||||||
|
coverage: ... B5 (#936)") satisfy the assertion while the table row
|
||||||
|
that actually assigns the owner had been emptied — verified by
|
||||||
|
deliberately blanking a row and watching a whole-section check still
|
||||||
|
pass. Only table rows count.
|
||||||
|
"""
|
||||||
|
mapping_section = _section_body(self.doc, "Child-to-boundary mapping")
|
||||||
|
self.assertIsNotNone(
|
||||||
|
mapping_section, "child-to-boundary mapping section is missing"
|
||||||
|
)
|
||||||
|
rows = [
|
||||||
|
line
|
||||||
|
for line in mapping_section.splitlines()
|
||||||
|
if line.lstrip().startswith("|") and re.search(r"#93\d", line)
|
||||||
|
]
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
len(rows), len(REQUIRED_CHILDREN), "mapping table has too few child rows"
|
||||||
|
)
|
||||||
|
mapped = set(re.findall(r"\bB(\d+)\b", "\n".join(rows)))
|
||||||
|
declared = {str(i) for i in self._declared_ids("B")}
|
||||||
|
unmapped = sorted(declared - mapped, key=int)
|
||||||
|
self.assertEqual(
|
||||||
|
[],
|
||||||
|
unmapped,
|
||||||
|
"boundaries with no owning child: " + ", ".join("B" + u for u in unmapped),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,739 @@
|
|||||||
|
"""Regression tests for Issue #973 blocker B10: repository-authority mode contract.
|
||||||
|
|
||||||
|
Before this repair ``assess_canonical_repository_root`` never checked its
|
||||||
|
``mode`` argument against an allowlist. Both dispatch points were permissive:
|
||||||
|
|
||||||
|
* the configured-root path tested ``mode == "derivation"`` and sent every other
|
||||||
|
value into a catch-all ``else``, so an unsupported mode silently received
|
||||||
|
*validation* semantics, and
|
||||||
|
* the single-repository default path tested ``mode == "validation"``, so an
|
||||||
|
unsupported mode skipped the identity comparison entirely and was strictly
|
||||||
|
*weaker* than validation.
|
||||||
|
|
||||||
|
The measured consequence was that ``mode="invalid_mode"`` with matching expected
|
||||||
|
and observed identities returned ``proven: True`` / ``block: False`` with no
|
||||||
|
reasons, and that an unsupported mode passed on the default path where
|
||||||
|
``"validation"`` correctly blocked.
|
||||||
|
|
||||||
|
These tests exercise the production module directly with real git repositories —
|
||||||
|
no patched stand-in for the function under test — and drive the production
|
||||||
|
enforcement and mutation-context consumers rather than only the intermediate
|
||||||
|
assessment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import inspect
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import canonical_repository_root as crr
|
||||||
|
import gitea_config
|
||||||
|
import gitea_mcp_server as mcp_server
|
||||||
|
import namespace_workspace_binding as nwb
|
||||||
|
import stable_control_runtime
|
||||||
|
|
||||||
|
|
||||||
|
INSTALL_SLUG = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||||
|
TARGET_SLUG = "Scaled-Tech-Consulting/mcp-control-plane"
|
||||||
|
FOREIGN_SLUG = "Someone-Else/Evil-Repo"
|
||||||
|
|
||||||
|
# Explicitly supplied values that must all be refused. Omission is *not* in this
|
||||||
|
# list: omitting the argument keeps the documented ``"validation"`` default.
|
||||||
|
UNSUPPORTED_STRING_MODES = (
|
||||||
|
"invalid_mode",
|
||||||
|
"",
|
||||||
|
"validaton", # misspelling
|
||||||
|
"derivaton", # misspelling
|
||||||
|
"Validation", # case variant
|
||||||
|
"DERIVATION", # case variant
|
||||||
|
" validation", # leading whitespace
|
||||||
|
"validation ", # trailing whitespace
|
||||||
|
"derivation\n", # trailing newline
|
||||||
|
"validation,derivation",
|
||||||
|
)
|
||||||
|
|
||||||
|
UNSUPPORTED_NON_STRING_MODES = (
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
3.14,
|
||||||
|
[],
|
||||||
|
["validation"],
|
||||||
|
{},
|
||||||
|
{"mode": "validation"},
|
||||||
|
("validation",),
|
||||||
|
object(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _init_repo(path: str, remote_url: str, *, user: str = "Test User") -> None:
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
subprocess.run(["git", "init", "-b", "master"], cwd=path, check=True,
|
||||||
|
capture_output=True)
|
||||||
|
subprocess.run(["git", "config", "user.email", "[email protected]"], cwd=path,
|
||||||
|
check=True, capture_output=True)
|
||||||
|
subprocess.run(["git", "config", "user.name", user], cwd=path, check=True,
|
||||||
|
capture_output=True)
|
||||||
|
with open(os.path.join(path, "README.md"), "w") as handle:
|
||||||
|
handle.write(f"{os.path.basename(path)}\n")
|
||||||
|
subprocess.run(["git", "add", "README.md"], cwd=path, check=True,
|
||||||
|
capture_output=True)
|
||||||
|
subprocess.run(["git", "commit", "-m", "initial"], cwd=path, check=True,
|
||||||
|
capture_output=True)
|
||||||
|
subprocess.run(["git", "remote", "add", "prgs", remote_url], cwd=path,
|
||||||
|
check=True, capture_output=True)
|
||||||
|
|
||||||
|
|
||||||
|
class _CanonicalRootFixture(unittest.TestCase):
|
||||||
|
"""Real install / target / foreign git repositories, as in the #973 suite."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.tmp_dir = os.path.realpath(self._tmp.name)
|
||||||
|
|
||||||
|
self.install_root = os.path.join(self.tmp_dir, "Gitea-Tools")
|
||||||
|
_init_repo(
|
||||||
|
self.install_root,
|
||||||
|
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.target_root = os.path.join(self.tmp_dir, "mcp-control-plane")
|
||||||
|
_init_repo(
|
||||||
|
self.target_root,
|
||||||
|
"https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane.git",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.evil_root = os.path.join(self.tmp_dir, "Evil-Repo")
|
||||||
|
_init_repo(
|
||||||
|
self.evil_root,
|
||||||
|
"https://gitea.prgs.cc/Someone-Else/Evil-Repo.git",
|
||||||
|
user="Evil User",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.target_branches = os.path.join(self.target_root, "branches")
|
||||||
|
self.target_worktree = os.path.join(self.target_branches, "rev-pr-99")
|
||||||
|
subprocess.run(
|
||||||
|
["git", "worktree", "add", "-b", "rev-pr-99", self.target_worktree],
|
||||||
|
cwd=self.target_root, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def assertRefusedForMode(self, assessment: dict, mode) -> None:
|
||||||
|
"""Assert a fail-closed refusal attributable to *mode* and nothing else."""
|
||||||
|
self.assertFalse(assessment["proven"], assessment)
|
||||||
|
self.assertTrue(assessment["block"], assessment)
|
||||||
|
self.assertEqual(assessment["reason_code"], crr.DENY_UNKNOWN_MODE, assessment)
|
||||||
|
self.assertEqual(len(assessment["reasons"]), 1, assessment)
|
||||||
|
reason = assessment["reasons"][0]
|
||||||
|
self.assertIn("unsupported repository-authority mode", reason)
|
||||||
|
self.assertIn(repr(mode), reason)
|
||||||
|
# No trusted repository identity may be derived through an invalid mode.
|
||||||
|
self.assertIsNone(assessment["resolved_slug"], assessment)
|
||||||
|
self.assertIsNone(assessment["canonical_repo_root"], assessment)
|
||||||
|
|
||||||
|
|
||||||
|
class TestB10UnsupportedModeIsRejected(_CanonicalRootFixture):
|
||||||
|
"""Direct assessment tests for invalid-mode parsing."""
|
||||||
|
|
||||||
|
def test_invalid_string_with_missing_expected_identity(self):
|
||||||
|
"""G1: blocks for the mode, not incidentally for a missing identity."""
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
mode="invalid_mode",
|
||||||
|
)
|
||||||
|
self.assertRefusedForMode(assessment, "invalid_mode")
|
||||||
|
self.assertFalse(
|
||||||
|
any("unprovable or missing" in r for r in assessment["reasons"]),
|
||||||
|
"must block because the mode is unsupported, not because the expected "
|
||||||
|
"identity happened to be missing",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid_string_with_matching_identities(self):
|
||||||
|
"""G2: the contract violation — matching identities used to return proven."""
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug=TARGET_SLUG,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
mode="invalid_mode",
|
||||||
|
)
|
||||||
|
self.assertRefusedForMode(assessment, "invalid_mode")
|
||||||
|
|
||||||
|
def test_invalid_string_with_conflicting_identities(self):
|
||||||
|
"""G3: refused for the mode, not for the incidental identity mismatch."""
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.evil_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug=INSTALL_SLUG,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
mode="invalid_mode",
|
||||||
|
)
|
||||||
|
self.assertRefusedForMode(assessment, "invalid_mode")
|
||||||
|
self.assertFalse(
|
||||||
|
any("identity mismatch" in r for r in assessment["reasons"]),
|
||||||
|
"the identity comparison must not have run at all",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_string_mode(self):
|
||||||
|
"""G4a: an explicitly supplied empty string is an unsupported value."""
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug=TARGET_SLUG,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
mode="",
|
||||||
|
)
|
||||||
|
self.assertRefusedForMode(assessment, "")
|
||||||
|
|
||||||
|
def test_explicit_none_mode(self):
|
||||||
|
"""G4b: explicit None is refused; it is not treated as omission."""
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug=TARGET_SLUG,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
mode=None,
|
||||||
|
)
|
||||||
|
self.assertRefusedForMode(assessment, None)
|
||||||
|
self.assertIn("of type NoneType", assessment["reasons"][0])
|
||||||
|
|
||||||
|
def test_representative_non_string_modes(self):
|
||||||
|
for mode in UNSUPPORTED_NON_STRING_MODES:
|
||||||
|
with self.subTest(mode=repr(mode)):
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug=TARGET_SLUG,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
mode=mode,
|
||||||
|
)
|
||||||
|
self.assertRefusedForMode(assessment, mode)
|
||||||
|
self.assertIn(
|
||||||
|
f"of type {type(mode).__name__}", assessment["reasons"][0]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_strings_misspellings_and_whitespace_variants(self):
|
||||||
|
for mode in UNSUPPORTED_STRING_MODES:
|
||||||
|
with self.subTest(mode=repr(mode)):
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug=TARGET_SLUG,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
mode=mode,
|
||||||
|
)
|
||||||
|
self.assertRefusedForMode(assessment, mode)
|
||||||
|
|
||||||
|
def test_supported_modes_are_exactly_two(self):
|
||||||
|
self.assertEqual(
|
||||||
|
crr.SUPPORTED_MODES, ("validation", "derivation")
|
||||||
|
)
|
||||||
|
self.assertIsNone(crr.unsupported_mode_reason("validation"))
|
||||||
|
self.assertIsNone(crr.unsupported_mode_reason("derivation"))
|
||||||
|
self.assertIsNotNone(crr.unsupported_mode_reason("invalid_mode"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestB10SupportedModesUnchanged(_CanonicalRootFixture):
|
||||||
|
"""The repair must not disturb the two documented modes."""
|
||||||
|
|
||||||
|
def test_omitted_mode_defaults_to_validation(self):
|
||||||
|
"""G4c/G4d: omission still selects validation, proven by both outcomes."""
|
||||||
|
matching = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug=TARGET_SLUG,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(matching["proven"], matching)
|
||||||
|
self.assertFalse(matching["block"], matching)
|
||||||
|
self.assertIsNone(matching["reason_code"], matching)
|
||||||
|
self.assertEqual(matching["resolved_slug"], TARGET_SLUG)
|
||||||
|
|
||||||
|
conflicting = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.evil_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug=INSTALL_SLUG,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(conflicting["proven"], conflicting)
|
||||||
|
self.assertTrue(conflicting["block"], conflicting)
|
||||||
|
self.assertIsNone(conflicting["reason_code"], conflicting)
|
||||||
|
self.assertTrue(
|
||||||
|
any("identity mismatch" in r for r in conflicting["reasons"]),
|
||||||
|
"omission must behave exactly like explicit validation",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_explicit_validation_retains_strict_behavior(self):
|
||||||
|
"""G5a/G5b/G5c."""
|
||||||
|
ok = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root, source="env",
|
||||||
|
expected_slug=TARGET_SLUG, process_project_root=self.install_root,
|
||||||
|
remote="prgs", require_binding=True, mode="validation",
|
||||||
|
)
|
||||||
|
self.assertTrue(ok["proven"], ok)
|
||||||
|
|
||||||
|
mismatch = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.evil_root, source="env",
|
||||||
|
expected_slug=INSTALL_SLUG, process_project_root=self.install_root,
|
||||||
|
remote="prgs", require_binding=True, mode="validation",
|
||||||
|
)
|
||||||
|
self.assertTrue(mismatch["block"], mismatch)
|
||||||
|
self.assertTrue(any("identity mismatch" in r for r in mismatch["reasons"]))
|
||||||
|
|
||||||
|
unprovable = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root, source="env",
|
||||||
|
expected_slug=None, process_project_root=self.install_root,
|
||||||
|
remote="prgs", require_binding=True, mode="validation",
|
||||||
|
)
|
||||||
|
self.assertTrue(unprovable["block"], unprovable)
|
||||||
|
self.assertTrue(
|
||||||
|
any("unprovable or missing" in r for r in unprovable["reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_explicit_derivation_retains_trusted_derivation(self):
|
||||||
|
"""G5d: derivation still resolves identity with no expected slug."""
|
||||||
|
derived = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root, source="env",
|
||||||
|
expected_slug=None, process_project_root=self.install_root,
|
||||||
|
remote="prgs", require_binding=True, mode="derivation",
|
||||||
|
)
|
||||||
|
self.assertTrue(derived["proven"], derived)
|
||||||
|
self.assertFalse(derived["block"], derived)
|
||||||
|
self.assertIsNone(derived["reason_code"], derived)
|
||||||
|
self.assertEqual(derived["resolved_slug"], TARGET_SLUG)
|
||||||
|
|
||||||
|
def test_derivation_without_resolvable_remote_still_fails_closed(self):
|
||||||
|
no_remote = os.path.join(self.tmp_dir, "no-remote-target")
|
||||||
|
os.makedirs(no_remote)
|
||||||
|
subprocess.run(["git", "init", "-b", "master"], cwd=no_remote, check=True,
|
||||||
|
capture_output=True)
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=no_remote, source="env", expected_slug=None,
|
||||||
|
process_project_root=self.install_root, remote="prgs",
|
||||||
|
require_binding=True, mode="derivation",
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["block"], assessment)
|
||||||
|
self.assertTrue(
|
||||||
|
any("no resolvable" in r for r in assessment["reasons"]), assessment
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestB10SingleRepositoryDefaultPath(_CanonicalRootFixture):
|
||||||
|
"""G6: on the unconfigured path an invalid mode used to be weaker than validation."""
|
||||||
|
|
||||||
|
def _assess(self, mode_kwargs: dict) -> dict:
|
||||||
|
return crr.assess_canonical_repository_root(
|
||||||
|
configured_value=None,
|
||||||
|
source=None,
|
||||||
|
expected_slug=FOREIGN_SLUG,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=False,
|
||||||
|
**mode_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_validation_blocks_a_foreign_expected_identity(self):
|
||||||
|
"""G6b: the reference behaviour the invalid mode must not undercut."""
|
||||||
|
got = self._assess({"mode": "validation"})
|
||||||
|
self.assertFalse(got["proven"], got)
|
||||||
|
self.assertTrue(got["block"], got)
|
||||||
|
self.assertTrue(any("identity mismatch" in r for r in got["reasons"]))
|
||||||
|
|
||||||
|
def test_invalid_mode_no_longer_passes_where_validation_blocks(self):
|
||||||
|
"""G6a: identical inputs, only the mode differs — must not fail open."""
|
||||||
|
invalid = self._assess({"mode": "invalid_mode"})
|
||||||
|
self.assertRefusedForMode(invalid, "invalid_mode")
|
||||||
|
|
||||||
|
validation = self._assess({"mode": "validation"})
|
||||||
|
self.assertEqual(
|
||||||
|
invalid["proven"], validation["proven"],
|
||||||
|
"an unsupported mode must never be more permissive than validation",
|
||||||
|
)
|
||||||
|
self.assertTrue(invalid["block"] and validation["block"])
|
||||||
|
|
||||||
|
def test_empty_string_mode_on_default_path(self):
|
||||||
|
"""G6c."""
|
||||||
|
self.assertRefusedForMode(self._assess({"mode": ""}), "")
|
||||||
|
|
||||||
|
def test_omitted_mode_on_default_path_still_validates(self):
|
||||||
|
got = self._assess({})
|
||||||
|
self.assertFalse(got["proven"], got)
|
||||||
|
self.assertTrue(any("identity mismatch" in r for r in got["reasons"]), got)
|
||||||
|
|
||||||
|
|
||||||
|
class TestB10RejectionOrdering(_CanonicalRootFixture):
|
||||||
|
"""Refusal must precede every form of candidate-root or Git inspection."""
|
||||||
|
|
||||||
|
def test_no_git_or_identity_discovery_runs_for_an_unsupported_mode(self):
|
||||||
|
with patch.object(crr, "resolve_repo_toplevel") as toplevel, \
|
||||||
|
patch.object(crr, "repository_identity_slug") as identity:
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug=TARGET_SLUG,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
mode="invalid_mode",
|
||||||
|
)
|
||||||
|
self.assertRefusedForMode(assessment, "invalid_mode")
|
||||||
|
toplevel.assert_not_called()
|
||||||
|
identity.assert_not_called()
|
||||||
|
|
||||||
|
def test_the_same_spies_do_fire_for_a_supported_mode(self):
|
||||||
|
"""Control: proves the previous test's assertions are not vacuous."""
|
||||||
|
with patch.object(crr, "resolve_repo_toplevel",
|
||||||
|
wraps=crr.resolve_repo_toplevel) as toplevel, \
|
||||||
|
patch.object(crr, "repository_identity_slug",
|
||||||
|
wraps=crr.repository_identity_slug) as identity:
|
||||||
|
crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug=TARGET_SLUG,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
mode="validation",
|
||||||
|
)
|
||||||
|
toplevel.assert_called()
|
||||||
|
identity.assert_called()
|
||||||
|
|
||||||
|
def test_nonexistent_candidate_root_still_reports_the_mode_refusal(self):
|
||||||
|
"""No mocks: the existence check cannot have run before the refusal."""
|
||||||
|
nonexistent = os.path.join(self.tmp_dir, "no-such-repository")
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=nonexistent,
|
||||||
|
source="env",
|
||||||
|
expected_slug=TARGET_SLUG,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
mode="invalid_mode",
|
||||||
|
)
|
||||||
|
self.assertRefusedForMode(assessment, "invalid_mode")
|
||||||
|
self.assertFalse(
|
||||||
|
any("does not exist" in r for r in assessment["reasons"]), assessment
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_symlinked_candidate_root_is_not_resolved_for_an_unsupported_mode(self):
|
||||||
|
link = os.path.join(self.tmp_dir, "target-alias")
|
||||||
|
os.symlink(self.target_root, link)
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=link,
|
||||||
|
source="env",
|
||||||
|
expected_slug=TARGET_SLUG,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
mode="invalid_mode",
|
||||||
|
)
|
||||||
|
self.assertRefusedForMode(assessment, "invalid_mode")
|
||||||
|
# The refusal payload must not leak a resolved path for the candidate.
|
||||||
|
self.assertIsNone(assessment["canonical_repo_root"], assessment)
|
||||||
|
|
||||||
|
|
||||||
|
class TestB10NoModeInjectionSurface(unittest.TestCase):
|
||||||
|
"""``mode`` must not be reachable from requests, environment, or config."""
|
||||||
|
|
||||||
|
PRODUCTION_MODULES = ("gitea_mcp_server.py", "namespace_workspace_binding.py")
|
||||||
|
|
||||||
|
def _repo_root(self) -> str:
|
||||||
|
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
def test_production_call_sites_only_pass_allowlisted_literal_modes(self):
|
||||||
|
"""Valid hardcoded modes reach the correct path; nothing else is passed."""
|
||||||
|
seen: list[tuple[str, str | None]] = []
|
||||||
|
for name in self.PRODUCTION_MODULES:
|
||||||
|
path = os.path.join(self._repo_root(), name)
|
||||||
|
with open(path) as handle:
|
||||||
|
tree = ast.parse(handle.read())
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.Call):
|
||||||
|
continue
|
||||||
|
func = node.func
|
||||||
|
called = (
|
||||||
|
func.attr if isinstance(func, ast.Attribute)
|
||||||
|
else getattr(func, "id", None)
|
||||||
|
)
|
||||||
|
if called != "assess_canonical_repository_root":
|
||||||
|
continue
|
||||||
|
supplied = [k for k in node.keywords if k.arg == "mode"]
|
||||||
|
if not supplied:
|
||||||
|
seen.append((name, None))
|
||||||
|
continue
|
||||||
|
value = supplied[0].value
|
||||||
|
self.assertIsInstance(
|
||||||
|
value, ast.Constant,
|
||||||
|
f"{name}: mode must be a literal, never a variable or expression",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
value.value, crr.SUPPORTED_MODES,
|
||||||
|
f"{name}: unsupported mode literal {value.value!r}",
|
||||||
|
)
|
||||||
|
seen.append((name, value.value))
|
||||||
|
self.assertTrue(seen, "expected production call sites to be found")
|
||||||
|
# Both documented modes are exercised by production, and omission is used.
|
||||||
|
self.assertIn(None, [mode for _, mode in seen])
|
||||||
|
self.assertIn("derivation", [mode for _, mode in seen])
|
||||||
|
|
||||||
|
def test_no_public_entry_point_exposes_a_mode_parameter(self):
|
||||||
|
for func in (
|
||||||
|
nwb.resolve_namespace_mutation_context,
|
||||||
|
nwb.assess_namespace_mutation_workspace,
|
||||||
|
mcp_server._resolve_namespace_mutation_context,
|
||||||
|
mcp_server._enforce_canonical_repository_root,
|
||||||
|
mcp_server._canonical_repository_slug,
|
||||||
|
mcp_server._trusted_session_repository,
|
||||||
|
mcp_server._resolve_expected_repository_slug,
|
||||||
|
):
|
||||||
|
with self.subTest(func=func.__name__):
|
||||||
|
self.assertNotIn("mode", inspect.signature(func).parameters)
|
||||||
|
|
||||||
|
def test_no_environment_key_selects_a_repository_authority_mode(self):
|
||||||
|
for key in gitea_config.RECOGNIZED_GITEA_ENV_KEYS:
|
||||||
|
self.assertNotIn(
|
||||||
|
"CANONICAL_REPOSITORY_MODE", key.upper(),
|
||||||
|
f"{key} would expose a repository-authority mode selector",
|
||||||
|
)
|
||||||
|
# An invented mode-ish variable is simply not consumed by crr.
|
||||||
|
env = {
|
||||||
|
"GITEA_CANONICAL_REPOSITORY_ROOT": "/some/path",
|
||||||
|
"GITEA_CANONICAL_REPOSITORY_MODE": "invalid_mode",
|
||||||
|
}
|
||||||
|
value, source = crr.configured_canonical_root(None, env)
|
||||||
|
self.assertEqual(value, "/some/path")
|
||||||
|
self.assertNotIn("mode", (source or "").lower())
|
||||||
|
self.assertIn(
|
||||||
|
"GITEA_CANONICAL_REPOSITORY_MODE",
|
||||||
|
gitea_config.get_unconsumed_gitea_env_overrides(env),
|
||||||
|
"an unknown GITEA_* key must still be rejected as unrecognised",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_repository_configuration_carries_no_mode_field(self):
|
||||||
|
profile = {
|
||||||
|
"canonical_repository_root": "/some/path",
|
||||||
|
"mode": "invalid_mode",
|
||||||
|
}
|
||||||
|
value, source = crr.configured_canonical_root(profile, {})
|
||||||
|
self.assertEqual(value, "/some/path")
|
||||||
|
self.assertEqual(source, "profile canonical_repository_root")
|
||||||
|
|
||||||
|
|
||||||
|
class TestB10ProductionEnforcementPaths(_CanonicalRootFixture):
|
||||||
|
"""Production enforcement, mutation-context, reviewer and merger consumers."""
|
||||||
|
|
||||||
|
def _force_invalid_mode(self):
|
||||||
|
"""Simulate a future call site threading an unsupported mode.
|
||||||
|
|
||||||
|
The real ``assess_canonical_repository_root`` still executes — only the
|
||||||
|
caller-side argument is substituted — so the refusal under test is
|
||||||
|
produced by production code, not by a stand-in. No caller-controlled
|
||||||
|
``mode`` parameter is added to any production signature to achieve this.
|
||||||
|
"""
|
||||||
|
real = crr.assess_canonical_repository_root
|
||||||
|
|
||||||
|
def _wrapper(**kwargs):
|
||||||
|
kwargs["mode"] = "invalid_mode"
|
||||||
|
return real(**kwargs)
|
||||||
|
|
||||||
|
return patch.object(crr, "assess_canonical_repository_root", _wrapper)
|
||||||
|
|
||||||
|
def test_mutation_context_fails_closed_under_a_refused_mode(self):
|
||||||
|
with self._force_invalid_mode():
|
||||||
|
ctx = nwb.resolve_namespace_mutation_context(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=self.target_worktree,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug=TARGET_SLUG,
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
assessment = ctx["canonical_root_assessment"]
|
||||||
|
self.assertFalse(ctx["roots_aligned"], ctx)
|
||||||
|
self.assertTrue(assessment["block"], assessment)
|
||||||
|
self.assertEqual(assessment["reason_code"], crr.DENY_UNKNOWN_MODE)
|
||||||
|
self.assertIsNone(assessment["resolved_slug"])
|
||||||
|
# The refused mode must not yield the candidate root as canonical.
|
||||||
|
self.assertNotEqual(ctx["canonical_repo_root"], self.target_root)
|
||||||
|
self.assertEqual(ctx["canonical_repo_root"], self.install_root)
|
||||||
|
|
||||||
|
def test_mutation_context_unchanged_for_the_supported_default(self):
|
||||||
|
ctx = nwb.resolve_namespace_mutation_context(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=self.target_worktree,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug=TARGET_SLUG,
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertTrue(ctx["roots_aligned"], ctx)
|
||||||
|
self.assertEqual(ctx["canonical_repo_root"], self.target_root)
|
||||||
|
self.assertIsNone(ctx["canonical_root_assessment"]["reason_code"])
|
||||||
|
|
||||||
|
def test_reviewer_and_merger_authorization_fails_closed_under_a_refused_mode(self):
|
||||||
|
for role in ("reviewer", "merger"):
|
||||||
|
with self.subTest(role=role), self._force_invalid_mode():
|
||||||
|
assessment = nwb.assess_namespace_mutation_workspace(
|
||||||
|
role_kind=role,
|
||||||
|
worktree_path=self.target_worktree,
|
||||||
|
worktree=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug=TARGET_SLUG,
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["block"], assessment)
|
||||||
|
self.assertTrue(
|
||||||
|
any("unsupported repository-authority mode" in r
|
||||||
|
for r in assessment["reasons"]),
|
||||||
|
assessment,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reviewer_and_merger_authorization_unchanged_for_supported_modes(self):
|
||||||
|
for role in ("reviewer", "merger"):
|
||||||
|
with self.subTest(role=role):
|
||||||
|
assessment = nwb.assess_namespace_mutation_workspace(
|
||||||
|
role_kind=role,
|
||||||
|
worktree_path=self.target_worktree,
|
||||||
|
worktree=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug=TARGET_SLUG,
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["block"], assessment)
|
||||||
|
|
||||||
|
def test_final_mutation_gate_blocks_when_the_mode_refusal_unaligns_roots(self):
|
||||||
|
with self._force_invalid_mode():
|
||||||
|
ctx = nwb.resolve_namespace_mutation_context(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=self.target_worktree,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug=TARGET_SLUG,
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
report = stable_control_runtime.build_runtime_report(
|
||||||
|
process_root=self.install_root,
|
||||||
|
checkout_branch="master",
|
||||||
|
runtime_head="abcdef123456",
|
||||||
|
active_task_workspace=ctx["workspace_path"],
|
||||||
|
canonical_repository_root=ctx["canonical_repo_root"],
|
||||||
|
workspace_roots_aligned=ctx["roots_aligned"],
|
||||||
|
)
|
||||||
|
gate = stable_control_runtime.assess_runtime_mutation_gate(report)
|
||||||
|
self.assertTrue(gate["block"], gate)
|
||||||
|
|
||||||
|
def test_enforce_canonical_repository_root_uses_the_validation_default(self):
|
||||||
|
"""B8 boundary intact: a foreign configured root raises through production."""
|
||||||
|
with patch.object(mcp_server, "PROJECT_ROOT", self.install_root), \
|
||||||
|
patch.object(mcp_server, "_configured_canonical_root",
|
||||||
|
return_value=(self.evil_root, "env")), \
|
||||||
|
patch.object(mcp_server.session_ctx, "get_session_context",
|
||||||
|
return_value=None):
|
||||||
|
with self.assertRaises(RuntimeError) as raised:
|
||||||
|
mcp_server._enforce_canonical_repository_root(remote="prgs")
|
||||||
|
self.assertIn("identity mismatch", str(raised.exception))
|
||||||
|
|
||||||
|
def test_enforce_canonical_repository_root_refuses_a_threaded_invalid_mode(self):
|
||||||
|
bound = {"org": "Scaled-Tech-Consulting",
|
||||||
|
"repository": "mcp-control-plane", "remote": "prgs"}
|
||||||
|
with patch.object(mcp_server, "PROJECT_ROOT", self.install_root), \
|
||||||
|
patch.object(mcp_server, "_configured_canonical_root",
|
||||||
|
return_value=(self.target_root, "env")), \
|
||||||
|
patch.object(mcp_server.session_ctx, "get_session_context",
|
||||||
|
return_value=bound), \
|
||||||
|
self._force_invalid_mode():
|
||||||
|
with self.assertRaises(RuntimeError) as raised:
|
||||||
|
mcp_server._enforce_canonical_repository_root(remote="prgs")
|
||||||
|
self.assertIn("unsupported repository-authority mode", str(raised.exception))
|
||||||
|
|
||||||
|
def test_enforce_canonical_repository_root_passes_for_a_valid_binding(self):
|
||||||
|
bound = {"org": "Scaled-Tech-Consulting",
|
||||||
|
"repository": "mcp-control-plane", "remote": "prgs"}
|
||||||
|
with patch.object(mcp_server, "PROJECT_ROOT", self.install_root), \
|
||||||
|
patch.object(mcp_server, "_configured_canonical_root",
|
||||||
|
return_value=(self.target_root, "env")), \
|
||||||
|
patch.object(mcp_server.session_ctx, "get_session_context",
|
||||||
|
return_value=bound), \
|
||||||
|
patch.object(mcp_server.session_ctx, "assess_session_context",
|
||||||
|
return_value={"block": False, "reasons": []}), \
|
||||||
|
patch.object(mcp_server, "get_profile",
|
||||||
|
return_value={"profile_name": "prgs-reviewer"}):
|
||||||
|
mcp_server._enforce_canonical_repository_root(remote="prgs")
|
||||||
|
|
||||||
|
def test_canonical_repository_slug_derivation_unbroken(self):
|
||||||
|
"""B9 boundary intact: legitimate cross-repository derivation still works."""
|
||||||
|
profile = {
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"allowed_repositories": [TARGET_SLUG],
|
||||||
|
"canonical_repository_root": self.target_root,
|
||||||
|
}
|
||||||
|
with patch.object(mcp_server, "PROJECT_ROOT", self.install_root), \
|
||||||
|
patch.object(mcp_server.session_ctx, "get_session_context",
|
||||||
|
return_value=None):
|
||||||
|
slug, reasons = mcp_server._canonical_repository_slug(profile, "prgs")
|
||||||
|
self.assertEqual(slug, TARGET_SLUG, reasons)
|
||||||
|
self.assertEqual(reasons, [])
|
||||||
|
|
||||||
|
def test_canonical_repository_slug_fails_closed_under_a_refused_mode(self):
|
||||||
|
profile = {
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"allowed_repositories": [TARGET_SLUG],
|
||||||
|
"canonical_repository_root": self.target_root,
|
||||||
|
}
|
||||||
|
with patch.object(mcp_server, "PROJECT_ROOT", self.install_root), \
|
||||||
|
patch.object(mcp_server.session_ctx, "get_session_context",
|
||||||
|
return_value=None), \
|
||||||
|
self._force_invalid_mode():
|
||||||
|
slug, reasons = mcp_server._canonical_repository_slug(profile, "prgs")
|
||||||
|
self.assertIsNone(slug)
|
||||||
|
self.assertTrue(
|
||||||
|
any("unsupported repository-authority mode" in r for r in reasons),
|
||||||
|
reasons,
|
||||||
|
)
|
||||||
|
result = mcp_server._trusted_session_repository(
|
||||||
|
profile, "prgs", for_mutation=True
|
||||||
|
)
|
||||||
|
self.assertIsNone(result["org"])
|
||||||
|
self.assertIsNone(result["repository"])
|
||||||
|
self.assertTrue(result["reasons"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
"""Regression tests for Issue #973: validated cross-repository canonical roots."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import gitea_config
|
||||||
|
import namespace_workspace_binding as nwb
|
||||||
|
import canonical_repository_root as crr
|
||||||
|
import stable_control_runtime
|
||||||
|
import gitea_mcp_server as mcp_server
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue973RecognizedEnvKeys(unittest.TestCase):
|
||||||
|
"""Test recognized environment variable keys under #973."""
|
||||||
|
|
||||||
|
def test_recognized_gitea_env_keys(self):
|
||||||
|
for key in (
|
||||||
|
"GITEA_CANONICAL_REPOSITORY_ROOT",
|
||||||
|
"GITEA_REVIEWER_WORKTREE",
|
||||||
|
"GITEA_MERGER_WORKTREE",
|
||||||
|
"GITEA_MCP_SESSION_STATE_TTL_HOURS",
|
||||||
|
):
|
||||||
|
self.assertIn(key, gitea_config.RECOGNIZED_GITEA_ENV_KEYS)
|
||||||
|
|
||||||
|
def test_get_unconsumed_gitea_env_overrides_ignores_recognized(self):
|
||||||
|
env = {
|
||||||
|
"GITEA_CANONICAL_REPOSITORY_ROOT": "/some/path",
|
||||||
|
"GITEA_REVIEWER_WORKTREE": "/some/reviewer/path",
|
||||||
|
"GITEA_MERGER_WORKTREE": "/some/merger/path",
|
||||||
|
"GITEA_MCP_SESSION_STATE_TTL_HOURS": "24",
|
||||||
|
"GITEA_UNRECOGNIZED_FOO_VAR": "bar",
|
||||||
|
}
|
||||||
|
unconsumed = gitea_config.get_unconsumed_gitea_env_overrides(env)
|
||||||
|
self.assertNotIn("GITEA_CANONICAL_REPOSITORY_ROOT", unconsumed)
|
||||||
|
self.assertNotIn("GITEA_REVIEWER_WORKTREE", unconsumed)
|
||||||
|
self.assertNotIn("GITEA_MERGER_WORKTREE", unconsumed)
|
||||||
|
self.assertNotIn("GITEA_MCP_SESSION_STATE_TTL_HOURS", unconsumed)
|
||||||
|
self.assertIn("GITEA_UNRECOGNIZED_FOO_VAR", unconsumed)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue973CrossRepoCanonicalRoots(unittest.TestCase):
|
||||||
|
"""Test workspace binding and canonical root validation for cross-repo namespaces."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.tmp_dir = os.path.realpath(self._tmp.name)
|
||||||
|
|
||||||
|
# Create simulated installation root
|
||||||
|
self.install_root = os.path.join(self.tmp_dir, "Gitea-Tools")
|
||||||
|
os.makedirs(self.install_root)
|
||||||
|
subprocess.run(["git", "init", "-b", "master"], cwd=self.install_root, check=True)
|
||||||
|
subprocess.run(["git", "config", "user.email", "[email protected]"], cwd=self.install_root, check=True)
|
||||||
|
subprocess.run(["git", "config", "user.name", "Test User"], cwd=self.install_root, check=True)
|
||||||
|
with open(os.path.join(self.install_root, "README.md"), "w") as f:
|
||||||
|
f.write("install\n")
|
||||||
|
subprocess.run(["git", "add", "README.md"], cwd=self.install_root, check=True)
|
||||||
|
subprocess.run(["git", "commit", "-m", "initial"], cwd=self.install_root, check=True)
|
||||||
|
|
||||||
|
# Create simulated target repository root
|
||||||
|
self.target_root = os.path.join(self.tmp_dir, "mcp-control-plane")
|
||||||
|
os.makedirs(self.target_root)
|
||||||
|
subprocess.run(["git", "init", "-b", "master"], cwd=self.target_root, check=True)
|
||||||
|
subprocess.run(["git", "config", "user.email", "[email protected]"], cwd=self.target_root, check=True)
|
||||||
|
subprocess.run(["git", "config", "user.name", "Test User"], cwd=self.target_root, check=True)
|
||||||
|
with open(os.path.join(self.target_root, "README.md"), "w") as f:
|
||||||
|
f.write("target\n")
|
||||||
|
subprocess.run(["git", "add", "README.md"], cwd=self.target_root, check=True)
|
||||||
|
subprocess.run(["git", "commit", "-m", "initial"], cwd=self.target_root, check=True)
|
||||||
|
|
||||||
|
# Add remotes to simulate real git repositories with identities
|
||||||
|
subprocess.run(["git", "remote", "add", "prgs", "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"], cwd=self.install_root, check=True)
|
||||||
|
subprocess.run(["git", "remote", "add", "prgs", "https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane.git"], cwd=self.target_root, check=True)
|
||||||
|
|
||||||
|
# Create simulated foreign repository root
|
||||||
|
self.evil_root = os.path.join(self.tmp_dir, "Evil-Repo")
|
||||||
|
os.makedirs(self.evil_root)
|
||||||
|
subprocess.run(["git", "init", "-b", "master"], cwd=self.evil_root, check=True)
|
||||||
|
subprocess.run(["git", "config", "user.email", "[email protected]"], cwd=self.evil_root, check=True)
|
||||||
|
subprocess.run(["git", "config", "user.name", "Evil User"], cwd=self.evil_root, check=True)
|
||||||
|
with open(os.path.join(self.evil_root, "README.md"), "w") as f:
|
||||||
|
f.write("evil\n")
|
||||||
|
subprocess.run(["git", "add", "README.md"], cwd=self.evil_root, check=True)
|
||||||
|
subprocess.run(["git", "commit", "-m", "initial"], cwd=self.evil_root, check=True)
|
||||||
|
subprocess.run(["git", "remote", "add", "prgs", "https://gitea.prgs.cc/Someone-Else/Evil-Repo.git"], cwd=self.evil_root, check=True)
|
||||||
|
|
||||||
|
# Create branches/ directory and a valid registered worktree in target repository
|
||||||
|
self.target_branches = os.path.join(self.target_root, "branches")
|
||||||
|
self.target_worktree = os.path.join(self.target_branches, "rev-pr-99")
|
||||||
|
subprocess.run(["git", "worktree", "add", "-b", "rev-pr-99", self.target_worktree], cwd=self.target_root, check=True)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def test_valid_same_repository_configuration(self):
|
||||||
|
ctx = nwb.resolve_namespace_mutation_context(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=None,
|
||||||
|
)
|
||||||
|
self.assertEqual(ctx["canonical_repo_root"], self.install_root)
|
||||||
|
self.assertTrue(ctx["roots_aligned"])
|
||||||
|
self.assertTrue(ctx["canonical_root_assessment"]["proven"])
|
||||||
|
|
||||||
|
def test_valid_cross_repo_canonical_root(self):
|
||||||
|
ctx = nwb.resolve_namespace_mutation_context(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=self.target_worktree,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertEqual(ctx["canonical_repo_root"], self.target_root)
|
||||||
|
self.assertTrue(ctx["roots_aligned"])
|
||||||
|
self.assertTrue(ctx["canonical_root_assessment"]["proven"])
|
||||||
|
|
||||||
|
def test_expected_repository_identity_match(self):
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root,
|
||||||
|
source="test",
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["proven"])
|
||||||
|
self.assertFalse(assessment["block"])
|
||||||
|
|
||||||
|
def test_foreign_repository_identity_mismatch(self):
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.evil_root,
|
||||||
|
source="test",
|
||||||
|
expected_slug="Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["proven"])
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertTrue(any("identity mismatch" in r for r in assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_native_repository_binding_mismatch(self):
|
||||||
|
ctx = nwb.resolve_namespace_mutation_context(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=self.target_worktree,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.evil_root,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertFalse(ctx["roots_aligned"])
|
||||||
|
self.assertFalse(ctx["canonical_root_assessment"]["proven"])
|
||||||
|
self.assertTrue(any("identity mismatch" in r for r in ctx["canonical_root_assessment"]["reasons"]))
|
||||||
|
|
||||||
|
def test_unpatched_foreign_configured_root_derives_expected_from_process_root_and_blocks(self):
|
||||||
|
"""B8: Production path test where foreign configured root cannot self-authorize."""
|
||||||
|
with patch.object(mcp_server, "PROJECT_ROOT", self.install_root), \
|
||||||
|
patch.object(mcp_server, "_configured_canonical_root", return_value=(self.evil_root, "env")):
|
||||||
|
expected_slug = mcp_server._resolve_expected_repository_slug("prgs")
|
||||||
|
self.assertEqual(expected_slug, "Scaled-Tech-Consulting/Gitea-Tools")
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.evil_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug=expected_slug,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["proven"])
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertEqual(assessment["resolved_slug"], "Someone-Else/Evil-Repo")
|
||||||
|
self.assertTrue(any("identity mismatch" in r for r in assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_unpatched_valid_cross_repo_matching_session_context(self):
|
||||||
|
"""B8: Valid cross-repo namespace matches when session context is bound to target repo."""
|
||||||
|
bound_ctx = {"org": "Scaled-Tech-Consulting", "repository": "mcp-control-plane", "remote": "prgs"}
|
||||||
|
with patch.object(mcp_server, "PROJECT_ROOT", self.install_root), \
|
||||||
|
patch.object(mcp_server.session_ctx, "get_session_context", return_value=bound_ctx):
|
||||||
|
expected_slug = mcp_server._resolve_expected_repository_slug("prgs")
|
||||||
|
self.assertEqual(expected_slug, "Scaled-Tech-Consulting/mcp-control-plane")
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug=expected_slug,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["proven"])
|
||||||
|
self.assertFalse(assessment["block"])
|
||||||
|
self.assertEqual(assessment["resolved_slug"], "Scaled-Tech-Consulting/mcp-control-plane")
|
||||||
|
|
||||||
|
def test_unprovable_expected_identity_fails_closed(self):
|
||||||
|
"""B8: If expected repository identity is unprovable for a configured root, fail closed."""
|
||||||
|
no_remote_root = os.path.join(self.tmp_dir, "no-remote-process-root")
|
||||||
|
os.makedirs(no_remote_root)
|
||||||
|
subprocess.run(["git", "init", "-b", "master"], cwd=no_remote_root, check=True)
|
||||||
|
with patch.object(mcp_server, "PROJECT_ROOT", no_remote_root), \
|
||||||
|
patch.object(mcp_server.session_ctx, "get_session_context", return_value=None):
|
||||||
|
expected_slug = mcp_server._resolve_expected_repository_slug("prgs")
|
||||||
|
self.assertIsNone(expected_slug)
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug=expected_slug,
|
||||||
|
process_project_root=no_remote_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["proven"])
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertTrue(any("unprovable or missing" in r for r in assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_missing_canonical_root(self):
|
||||||
|
ctx = nwb.resolve_namespace_mutation_context(
|
||||||
|
role_kind="author",
|
||||||
|
worktree_path=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root="",
|
||||||
|
)
|
||||||
|
self.assertEqual(ctx["canonical_repo_root"], self.install_root)
|
||||||
|
self.assertTrue(ctx["roots_aligned"])
|
||||||
|
|
||||||
|
def test_nonexistent_configured_canonical_root(self):
|
||||||
|
nonexistent = os.path.join(self.tmp_dir, "nonexistent-repo")
|
||||||
|
ctx = nwb.resolve_namespace_mutation_context(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=self.target_worktree,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=nonexistent,
|
||||||
|
)
|
||||||
|
self.assertFalse(ctx["roots_aligned"])
|
||||||
|
self.assertFalse(ctx["canonical_root_assessment"]["proven"])
|
||||||
|
self.assertTrue(any("does not exist" in r for r in ctx["canonical_root_assessment"]["reasons"]))
|
||||||
|
|
||||||
|
def test_non_git_configured_canonical_root(self):
|
||||||
|
non_git = os.path.join(self.tmp_dir, "non-git-dir")
|
||||||
|
os.makedirs(non_git)
|
||||||
|
ctx = nwb.resolve_namespace_mutation_context(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=self.target_worktree,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=non_git,
|
||||||
|
)
|
||||||
|
self.assertFalse(ctx["roots_aligned"])
|
||||||
|
self.assertFalse(ctx["canonical_root_assessment"]["proven"])
|
||||||
|
self.assertTrue(any("not a git repository" in r for r in ctx["canonical_root_assessment"]["reasons"]))
|
||||||
|
|
||||||
|
def test_git_common_directory_membership_matching(self):
|
||||||
|
valid, err = nwb.verify_git_common_directory_membership(
|
||||||
|
self.target_worktree, self.target_root
|
||||||
|
)
|
||||||
|
self.assertTrue(valid, err)
|
||||||
|
self.assertIsNone(err)
|
||||||
|
|
||||||
|
def test_foreign_git_common_directory(self):
|
||||||
|
# Foreign worktree created under install_root
|
||||||
|
install_branches = os.path.join(self.install_root, "branches")
|
||||||
|
foreign_wt = os.path.join(install_branches, "foreign-wt")
|
||||||
|
subprocess.run(["git", "worktree", "add", "-b", "foreign-wt", foreign_wt], cwd=self.install_root, check=True)
|
||||||
|
|
||||||
|
valid, err = nwb.verify_git_common_directory_membership(
|
||||||
|
foreign_wt, self.target_root
|
||||||
|
)
|
||||||
|
self.assertFalse(valid)
|
||||||
|
self.assertIn("does not match", err)
|
||||||
|
|
||||||
|
def test_normalized_path_aliases(self):
|
||||||
|
alias_path = self.target_worktree + "/../rev-pr-99/./"
|
||||||
|
valid, err = nwb.verify_git_common_directory_membership(
|
||||||
|
alias_path, self.target_root
|
||||||
|
)
|
||||||
|
self.assertTrue(valid, err)
|
||||||
|
|
||||||
|
def test_safe_symlink_identity(self):
|
||||||
|
link_path = os.path.join(self.target_branches, "symlink-rev-99")
|
||||||
|
try:
|
||||||
|
os.symlink(self.target_worktree, link_path)
|
||||||
|
valid, err = nwb.verify_git_common_directory_membership(
|
||||||
|
link_path, self.target_root
|
||||||
|
)
|
||||||
|
self.assertTrue(valid, err)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(link_path):
|
||||||
|
os.unlink(link_path)
|
||||||
|
|
||||||
|
def test_symlink_escape_or_foreign_alias(self):
|
||||||
|
outside_dir = os.path.join(self.tmp_dir, "outside-target")
|
||||||
|
os.makedirs(outside_dir)
|
||||||
|
link_escape = os.path.join(self.target_branches, "escape-link")
|
||||||
|
try:
|
||||||
|
os.symlink(outside_dir, link_escape)
|
||||||
|
assessment = nwb.assess_namespace_mutation_workspace(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=link_escape,
|
||||||
|
worktree=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
finally:
|
||||||
|
if os.path.exists(link_escape):
|
||||||
|
os.unlink(link_escape)
|
||||||
|
|
||||||
|
def test_reviewer_worktree_registered_and_valid(self):
|
||||||
|
assessment = nwb.assess_namespace_mutation_workspace(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=self.target_worktree,
|
||||||
|
worktree=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["block"])
|
||||||
|
|
||||||
|
def test_reviewer_worktree_unregistered_blocks(self):
|
||||||
|
unreg_wt = os.path.join(self.target_branches, "unregistered-reviewer")
|
||||||
|
os.makedirs(unreg_wt)
|
||||||
|
assessment = nwb.assess_namespace_mutation_workspace(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=unreg_wt,
|
||||||
|
worktree=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertTrue(any("is not registered in git worktree list" in r for r in assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_merger_worktree_registered_and_valid(self):
|
||||||
|
assessment = nwb.assess_namespace_mutation_workspace(
|
||||||
|
role_kind="merger",
|
||||||
|
worktree_path=self.target_worktree,
|
||||||
|
worktree=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["block"])
|
||||||
|
|
||||||
|
def test_merger_worktree_unregistered_blocks(self):
|
||||||
|
unreg_wt = os.path.join(self.target_branches, "unregistered-merger")
|
||||||
|
os.makedirs(unreg_wt)
|
||||||
|
assessment = nwb.assess_namespace_mutation_workspace(
|
||||||
|
role_kind="merger",
|
||||||
|
worktree_path=unreg_wt,
|
||||||
|
worktree=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertTrue(any("is not registered in git worktree list" in r for r in assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_reviewer_or_merger_worktree_outside_branches_blocks(self):
|
||||||
|
outside_wt = os.path.join(self.target_root, "outside_branches_wt")
|
||||||
|
os.makedirs(outside_wt)
|
||||||
|
for r_kind in ("reviewer", "merger"):
|
||||||
|
assessment = nwb.assess_namespace_mutation_workspace(
|
||||||
|
role_kind=r_kind,
|
||||||
|
worktree_path=outside_wt,
|
||||||
|
worktree=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertTrue(any("is not under" in r for r in assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_nonexistent_reviewer_or_merger_worktree_blocks(self):
|
||||||
|
nonexistent_wt = os.path.join(self.target_branches, "nonexistent-wt")
|
||||||
|
for r_kind in ("reviewer", "merger"):
|
||||||
|
assessment = nwb.assess_namespace_mutation_workspace(
|
||||||
|
role_kind=r_kind,
|
||||||
|
worktree_path=nonexistent_wt,
|
||||||
|
worktree=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertTrue(any("does not exist" in r for r in assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_safe_and_unsafe_mutation_alignment_outcomes(self):
|
||||||
|
# Safe alignment (same repo)
|
||||||
|
report_safe = stable_control_runtime.build_runtime_report(
|
||||||
|
process_root=self.install_root,
|
||||||
|
checkout_branch="master",
|
||||||
|
runtime_head="abcdef123456",
|
||||||
|
active_task_workspace=self.install_root,
|
||||||
|
canonical_repository_root=self.install_root,
|
||||||
|
workspace_roots_aligned=True,
|
||||||
|
)
|
||||||
|
gate_safe = stable_control_runtime.assess_runtime_mutation_gate(report_safe)
|
||||||
|
self.assertFalse(gate_safe["block"])
|
||||||
|
|
||||||
|
# Unsafe alignment
|
||||||
|
report_unsafe = stable_control_runtime.build_runtime_report(
|
||||||
|
process_root=self.install_root,
|
||||||
|
checkout_branch="master",
|
||||||
|
runtime_head="abcdef123456",
|
||||||
|
active_task_workspace=self.target_worktree,
|
||||||
|
canonical_repository_root=self.target_root,
|
||||||
|
workspace_roots_aligned=False,
|
||||||
|
)
|
||||||
|
gate_unsafe = stable_control_runtime.assess_runtime_mutation_gate(report_unsafe)
|
||||||
|
self.assertTrue(gate_unsafe["block"])
|
||||||
|
|
||||||
|
def test_reviewer_lease_lifecycle_production_path(self):
|
||||||
|
"""B5: Automated regression for reviewer lease acquire and release through production path."""
|
||||||
|
mock_whoami = {
|
||||||
|
"authenticated": True,
|
||||||
|
"username": "sysadmin",
|
||||||
|
"remote": "prgs",
|
||||||
|
"profile": {
|
||||||
|
"profile_name": "prgs-reviewer",
|
||||||
|
"role": "reviewer",
|
||||||
|
"role_kind": "reviewer",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.pr.comment", "gitea.pr.approve", "gitea.pr.request_changes"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def mock_api_request(method, url, auth=None, json_data=None):
|
||||||
|
if method == "GET":
|
||||||
|
return {"number": 99, "head": {"sha": "abc1234"}, "state": "open", "merged": False, "merged_at": None}
|
||||||
|
elif method == "POST":
|
||||||
|
return {"id": 9999, "body": (json_data or {}).get("body", "")}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with patch.object(mcp_server, "gitea_whoami", return_value=mock_whoami), \
|
||||||
|
patch.object(mcp_server, "get_profile", return_value=mock_whoami["profile"]), \
|
||||||
|
patch.object(mcp_server, "_effective_workspace_role", return_value="reviewer"), \
|
||||||
|
patch.object(mcp_server, "_configured_canonical_root", return_value=(self.target_root, "env")), \
|
||||||
|
patch.object(mcp_server, "_reviewer_session_worktree", return_value=self.target_worktree), \
|
||||||
|
patch.object(mcp_server, "_auth", return_value="token mock-token"), \
|
||||||
|
patch.object(mcp_server, "_fetch_pr_comments", return_value=[]), \
|
||||||
|
patch.object(mcp_server, "api_request", side_effect=mock_api_request), \
|
||||||
|
patch("reviewer_pr_lease.assess_acquire_lease", return_value={"acquire_allowed": True, "reasons": [], "lease_body": "<!-- LEASE -->"}), \
|
||||||
|
patch("reviewer_pr_lease.find_active_reviewer_lease", return_value={"session_id": "sid-123", "reviewer": "sysadmin"}), \
|
||||||
|
patch("reviewer_pr_lease.get_session_lease", return_value={"session_id": "sid-123", "reviewer": "sysadmin"}), \
|
||||||
|
patch("reviewer_pr_lease.clear_session_lease") as mock_clear:
|
||||||
|
|
||||||
|
acq_res = mcp_server.gitea_acquire_reviewer_pr_lease(
|
||||||
|
pr_number=99,
|
||||||
|
remote="prgs",
|
||||||
|
worktree=self.target_worktree,
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertTrue(acq_res.get("success"), acq_res)
|
||||||
|
|
||||||
|
rel_res = mcp_server.gitea_release_reviewer_pr_lease(
|
||||||
|
pr_number=99,
|
||||||
|
worktree=self.target_worktree,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertTrue(rel_res.get("success"), rel_res)
|
||||||
|
mock_clear.assert_called_once()
|
||||||
|
|
||||||
|
def test_unbound_session_derives_and_retains_configured_target_repository(self):
|
||||||
|
"""B9: Unbound session with a legitimate configured target root derives and retains target repository."""
|
||||||
|
prof = {
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/mcp-control-plane"],
|
||||||
|
"canonical_repository_root": self.target_root,
|
||||||
|
}
|
||||||
|
with patch.object(mcp_server, "PROJECT_ROOT", self.install_root), \
|
||||||
|
patch.object(mcp_server.session_ctx, "get_session_context", return_value=None):
|
||||||
|
slug, reasons = mcp_server._canonical_repository_slug(prof, "prgs")
|
||||||
|
self.assertEqual(slug, "Scaled-Tech-Consulting/mcp-control-plane", f"reasons: {reasons}")
|
||||||
|
self.assertEqual(reasons, [])
|
||||||
|
res = mcp_server._trusted_session_repository(prof, "prgs")
|
||||||
|
self.assertEqual(res["org"], "Scaled-Tech-Consulting")
|
||||||
|
self.assertEqual(res["repository"], "mcp-control-plane")
|
||||||
|
|
||||||
|
def test_process_root_a_configured_target_b_retains_b(self):
|
||||||
|
"""B9: Process root A (Gitea-Tools) and configured target B (mcp-control-plane) keep B as expected identity when bound."""
|
||||||
|
bound_ctx = {"org": "Scaled-Tech-Consulting", "repository": "mcp-control-plane", "remote": "prgs"}
|
||||||
|
with patch.object(mcp_server, "PROJECT_ROOT", self.install_root), \
|
||||||
|
patch.object(mcp_server.session_ctx, "get_session_context", return_value=bound_ctx):
|
||||||
|
expected = mcp_server._resolve_expected_repository_slug("prgs")
|
||||||
|
self.assertEqual(expected, "Scaled-Tech-Consulting/mcp-control-plane")
|
||||||
|
|
||||||
|
def test_request_supplied_repository_c_cannot_replace_derived_or_bound_repository_b(self):
|
||||||
|
"""B9: Request-supplied repository C (Timesheet) cannot replace bound repository B (mcp-control-plane)."""
|
||||||
|
bound_ctx = {"org": "Scaled-Tech-Consulting", "repository": "mcp-control-plane", "remote": "prgs"}
|
||||||
|
with patch.object(mcp_server, "PROJECT_ROOT", self.install_root), \
|
||||||
|
patch.object(mcp_server.session_ctx, "get_session_context", return_value=bound_ctx):
|
||||||
|
expected = mcp_server._resolve_expected_repository_slug("prgs", org="Scaled-Tech-Consulting", repo="Timesheet")
|
||||||
|
self.assertEqual(expected, "Scaled-Tech-Consulting/mcp-control-plane")
|
||||||
|
|
||||||
|
def test_known_expected_repo_a_plus_candidate_root_b_fails_closed(self):
|
||||||
|
"""B9: Known expected repository A plus candidate root B fails closed in validation mode."""
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug="Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
mode="validation",
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["proven"])
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertTrue(any("identity mismatch" in r for r in assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_validation_mode_with_unprovable_expected_identity_fails_closed(self):
|
||||||
|
"""B9: Validation mode with expected_slug=None and require_binding=True fails closed."""
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
mode="validation",
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["proven"])
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertTrue(any("unprovable or missing" in r for r in assessment["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,692 @@
|
|||||||
|
"""Regression tests for Issue #983: derived target base ref for cross-repository checkouts.
|
||||||
|
|
||||||
|
The mutation guard previously assumed ``prgs/master`` and the parity report
|
||||||
|
assumed ``origin/master``. Any repository using neither — for example remote
|
||||||
|
``MDCPS`` on integration branch ``dev`` — could not prove base equivalence, so
|
||||||
|
every gated mutation failed closed with no reachable remedy.
|
||||||
|
|
||||||
|
Two further defects were found by review at head ``2d5d5c9d`` and are covered
|
||||||
|
here:
|
||||||
|
|
||||||
|
* **B1** — the first fix derived the integration branch from
|
||||||
|
``refs/remotes/<remote>/HEAD``. That symref is a *local cache* written at clone
|
||||||
|
time and never refreshed by fetch, so on the real Weekly Briefings target it
|
||||||
|
still named ``main`` while the checkout tracked and sat exactly on ``dev``. The
|
||||||
|
authoritative signal is the checkout's own configured upstream. The fixtures
|
||||||
|
below therefore reproduce the **disagreement**: the cache says ``main``, the
|
||||||
|
configured upstream says ``dev``, and ``dev`` must win.
|
||||||
|
* **B2** — the parity report passed its internally inferred identity remote back
|
||||||
|
into the resolver, which reads a caller-supplied remote as "the caller already
|
||||||
|
disambiguated" and skips its ambiguity gate. Gating and reporting could then
|
||||||
|
evaluate different repositories. An inferred remote is now never laundered into
|
||||||
|
explicit caller intent, and ambiguity fails closed on both sides.
|
||||||
|
|
||||||
|
These tests build hermetic git repositories on disk (no network, no fetch) and
|
||||||
|
assert the derived target end to end: identity remote, integration branch,
|
||||||
|
tracking ref, fail-closed refusals, and agreement between the mutation guard and
|
||||||
|
the parity report.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import anti_stomp_preflight
|
||||||
|
import canonical_repository_root as crr
|
||||||
|
import master_parity_gate
|
||||||
|
import root_checkout_guard
|
||||||
|
|
||||||
|
MDCPS_URL = "https://gitea.example.net/MDCPS/WeeklyBriefings-Meta.git"
|
||||||
|
MDCPS_SLUG = "MDCPS/WeeklyBriefings-Meta"
|
||||||
|
PRGS_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||||
|
PRGS_SLUG = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||||
|
|
||||||
|
|
||||||
|
def _git(root: str, *args: str) -> str:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", root, *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return (res.stdout or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_repo(root: str, *, remote: str | None, url: str | None) -> str:
|
||||||
|
"""Initialise a repository with one commit and an optional named remote."""
|
||||||
|
os.makedirs(root, exist_ok=True)
|
||||||
|
_git(root, "init", "--quiet")
|
||||||
|
_git(root, "config", "user.email", "[email protected]")
|
||||||
|
_git(root, "config", "user.name", "Issue983 Test")
|
||||||
|
_git(root, "config", "commit.gpgsign", "false")
|
||||||
|
with open(os.path.join(root, "seed.txt"), "w", encoding="utf-8") as fh:
|
||||||
|
fh.write("seed\n")
|
||||||
|
_git(root, "add", "seed.txt")
|
||||||
|
_git(root, "commit", "--quiet", "-m", "seed")
|
||||||
|
if remote and url:
|
||||||
|
_git(root, "remote", "add", remote, url)
|
||||||
|
return _git(root, "rev-parse", "HEAD")
|
||||||
|
|
||||||
|
|
||||||
|
def _set_remote_branch(root: str, remote: str, branch: str, sha: str) -> None:
|
||||||
|
"""Create refs/remotes/<remote>/<branch> without contacting a network."""
|
||||||
|
_git(root, "update-ref", f"refs/remotes/{remote}/{branch}", sha)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_remote_head(root: str, remote: str, branch: str) -> None:
|
||||||
|
"""Write the *cached* refs/remotes/<remote>/HEAD symref.
|
||||||
|
|
||||||
|
This is the signal B1 proved untrustworthy. Fixtures use it to reproduce a
|
||||||
|
stale cache, never to manufacture the answer under test.
|
||||||
|
"""
|
||||||
|
_git(
|
||||||
|
root,
|
||||||
|
"symbolic-ref",
|
||||||
|
f"refs/remotes/{remote}/HEAD",
|
||||||
|
f"refs/remotes/{remote}/{branch}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_upstream(root: str, remote: str, branch: str) -> None:
|
||||||
|
"""Configure the current branch's upstream, exactly as git tracking does.
|
||||||
|
|
||||||
|
Writes ``branch.<current>.remote`` / ``branch.<current>.merge`` directly
|
||||||
|
rather than via ``--set-upstream-to`` so no ref is required to pre-exist and
|
||||||
|
no network is touched.
|
||||||
|
"""
|
||||||
|
current = _git(root, "symbolic-ref", "--short", "HEAD")
|
||||||
|
_git(root, "config", f"branch.{current}.remote", remote)
|
||||||
|
_git(root, "config", f"branch.{current}.merge", f"refs/heads/{branch}")
|
||||||
|
|
||||||
|
|
||||||
|
def _checkout_new_branch(root: str, branch: str) -> None:
|
||||||
|
_git(root, "checkout", "--quiet", "-b", branch)
|
||||||
|
|
||||||
|
|
||||||
|
def _advance(root: str, message: str) -> str:
|
||||||
|
with open(os.path.join(root, "seed.txt"), "a", encoding="utf-8") as fh:
|
||||||
|
fh.write(message + "\n")
|
||||||
|
_git(root, "add", "seed.txt")
|
||||||
|
_git(root, "commit", "--quiet", "-m", message)
|
||||||
|
return _git(root, "rev-parse", "HEAD")
|
||||||
|
|
||||||
|
|
||||||
|
class _RepoCase(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self._tmp.cleanup)
|
||||||
|
self.root = os.path.join(self._tmp.name, "repo")
|
||||||
|
|
||||||
|
def _weekly_briefings_shape(self) -> str:
|
||||||
|
"""The real Weekly Briefings target, including its stale cache.
|
||||||
|
|
||||||
|
Remote ``MDCPS``; checked out on ``dev``; upstream configured to
|
||||||
|
``MDCPS/dev``; both ``dev`` and ``main`` present as tracking refs; and
|
||||||
|
``refs/remotes/MDCPS/HEAD`` still cached at ``main`` from clone time.
|
||||||
|
"""
|
||||||
|
head = _make_repo(self.root, remote="MDCPS", url=MDCPS_URL)
|
||||||
|
_checkout_new_branch(self.root, "dev")
|
||||||
|
_set_remote_branch(self.root, "MDCPS", "dev", head)
|
||||||
|
stale = _advance(self.root, "main diverged long ago")
|
||||||
|
_set_remote_branch(self.root, "MDCPS", "main", stale)
|
||||||
|
_git(self.root, "reset", "--hard", "--quiet", head)
|
||||||
|
_set_remote_head(self.root, "MDCPS", "main") # stale clone-time cache
|
||||||
|
_set_upstream(self.root, "MDCPS", "dev") # authoritative
|
||||||
|
return head
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrgsBehaviourPreserved(_RepoCase):
|
||||||
|
"""Required coverage 1: PRGS prgs/master compatibility."""
|
||||||
|
|
||||||
|
def _prgs(self) -> str:
|
||||||
|
head = _make_repo(self.root, remote="prgs", url=PRGS_URL)
|
||||||
|
_set_remote_branch(self.root, "prgs", "master", head)
|
||||||
|
_set_remote_head(self.root, "prgs", "master")
|
||||||
|
_set_upstream(self.root, "prgs", "master")
|
||||||
|
return head
|
||||||
|
|
||||||
|
def test_prgs_master_resolves_unchanged(self):
|
||||||
|
head = self._prgs()
|
||||||
|
|
||||||
|
got = crr.resolve_target_base_ref(self.root)
|
||||||
|
self.assertTrue(got["proven"], got["reasons"])
|
||||||
|
self.assertEqual(got["remote"], "prgs")
|
||||||
|
self.assertEqual(got["branch"], "master")
|
||||||
|
self.assertEqual(got["tracking_ref"], "refs/remotes/prgs/master")
|
||||||
|
self.assertEqual(got["repository_slug"], PRGS_SLUG)
|
||||||
|
self.assertEqual(got["source"], crr.BASE_REF_SOURCE_CONFIGURED_UPSTREAM)
|
||||||
|
# Cache and upstream agree here, which is the ordinary PRGS state.
|
||||||
|
self.assertFalse(got["cached_remote_head_conflicts"])
|
||||||
|
self.assertEqual(root_checkout_guard.resolve_remote_master_sha(self.root), head)
|
||||||
|
|
||||||
|
def test_prgs_resolves_without_a_configured_upstream(self):
|
||||||
|
"""A PRGS checkout with no tracking config still resolves master."""
|
||||||
|
head = _make_repo(self.root, remote="prgs", url=PRGS_URL)
|
||||||
|
_set_remote_branch(self.root, "prgs", "master", head)
|
||||||
|
|
||||||
|
got = crr.resolve_target_base_ref(self.root)
|
||||||
|
self.assertTrue(got["proven"], got["reasons"])
|
||||||
|
self.assertEqual(got["tracking_ref"], "refs/remotes/prgs/master")
|
||||||
|
self.assertEqual(got["source"], crr.BASE_REF_SOURCE_UNIQUE_CANDIDATE)
|
||||||
|
|
||||||
|
def test_legacy_explicit_remote_refs_path_is_untouched(self):
|
||||||
|
"""An explicit remote_refs override still short-circuits derivation."""
|
||||||
|
head = _make_repo(self.root, remote="prgs", url=PRGS_URL)
|
||||||
|
_set_remote_branch(self.root, "prgs", "master", head)
|
||||||
|
|
||||||
|
state = root_checkout_guard.resolve_remote_master_ref_state(
|
||||||
|
self.root, remote_refs=root_checkout_guard.REMOTE_MASTER_REFS
|
||||||
|
)
|
||||||
|
self.assertEqual(state["sha"], head)
|
||||||
|
self.assertEqual(state["source"], "explicit_remote_refs")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStaleCachedRemoteHead(_RepoCase):
|
||||||
|
"""Required coverage 3: configured upstream MDCPS/dev vs stale cache -> main.
|
||||||
|
|
||||||
|
This is B1. The fixture deliberately does **not** point
|
||||||
|
``refs/remotes/MDCPS/HEAD`` at ``dev``; it reproduces the disagreement that
|
||||||
|
was live on ``/Users/jasonwalker/Development/weekly-briefings``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_configured_upstream_beats_stale_cached_remote_head(self):
|
||||||
|
head = self._weekly_briefings_shape()
|
||||||
|
|
||||||
|
# Precondition: the fixture really is in the defective state.
|
||||||
|
self.assertEqual(
|
||||||
|
_git(self.root, "symbolic-ref", "refs/remotes/MDCPS/HEAD"),
|
||||||
|
"refs/remotes/MDCPS/main",
|
||||||
|
)
|
||||||
|
self.assertEqual(_git(self.root, "config", "--get", "branch.dev.merge"), "refs/heads/dev")
|
||||||
|
self.assertNotEqual(
|
||||||
|
_git(self.root, "rev-parse", "refs/remotes/MDCPS/main"),
|
||||||
|
_git(self.root, "rev-parse", "refs/remotes/MDCPS/dev"),
|
||||||
|
)
|
||||||
|
|
||||||
|
got = crr.resolve_target_base_ref(self.root)
|
||||||
|
self.assertTrue(got["proven"], got["reasons"])
|
||||||
|
self.assertEqual(got["branch"], "dev")
|
||||||
|
self.assertEqual(got["tracking_ref"], "refs/remotes/MDCPS/dev")
|
||||||
|
self.assertEqual(got["source"], crr.BASE_REF_SOURCE_CONFIGURED_UPSTREAM)
|
||||||
|
# The stale cache is reported, never obeyed.
|
||||||
|
self.assertEqual(got["cached_remote_head_branch"], "main")
|
||||||
|
self.assertTrue(got["cached_remote_head_conflicts"])
|
||||||
|
self.assertEqual(root_checkout_guard.resolve_remote_master_sha(self.root), head)
|
||||||
|
|
||||||
|
def test_checkout_on_its_integration_tip_is_not_blocked(self):
|
||||||
|
"""The live symptom: a checkout exactly on its tip was reported stale."""
|
||||||
|
head = self._weekly_briefings_shape()
|
||||||
|
|
||||||
|
state = root_checkout_guard.resolve_remote_master_ref_state(self.root)
|
||||||
|
self.assertEqual(state["sha"], head)
|
||||||
|
self.assertTrue(state["cached_remote_head_conflicts"])
|
||||||
|
|
||||||
|
assessment = root_checkout_guard.assess_root_checkout_guard(
|
||||||
|
workspace_path=self.root,
|
||||||
|
canonical_repo_root=self.root,
|
||||||
|
current_branch="dev",
|
||||||
|
head_sha=head,
|
||||||
|
porcelain_status="",
|
||||||
|
remote_master_sha=state["sha"],
|
||||||
|
remote_master_ref=state["ref"],
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["proven"], assessment["reasons"])
|
||||||
|
|
||||||
|
report = master_parity_gate.assess_target_repository_parity(
|
||||||
|
canonical_root=self.root, source="test"
|
||||||
|
)
|
||||||
|
self.assertFalse(report["stale"])
|
||||||
|
self.assertEqual(report["base_branch"], "dev")
|
||||||
|
self.assertEqual(report["reasons"], [])
|
||||||
|
|
||||||
|
def test_cached_remote_head_alone_never_proves_a_target(self):
|
||||||
|
"""With no configured upstream, the cache cannot supply the branch."""
|
||||||
|
head = _make_repo(self.root, remote="MDCPS", url=MDCPS_URL)
|
||||||
|
# Only a non-candidate branch exists, and only the cache names it.
|
||||||
|
_set_remote_branch(self.root, "MDCPS", "trunk", head)
|
||||||
|
_set_remote_head(self.root, "MDCPS", "trunk")
|
||||||
|
|
||||||
|
got = crr.resolve_target_base_ref(self.root)
|
||||||
|
self.assertFalse(got["proven"])
|
||||||
|
self.assertEqual(got["reason_code"], crr.DENY_NO_BASE_BRANCH)
|
||||||
|
self.assertEqual(got["tracking_refs"], ())
|
||||||
|
self.assertEqual(got["cached_remote_head_branch"], "trunk")
|
||||||
|
# The refusal names the misleading signal so an operator is not sent
|
||||||
|
# chasing a ref that looks authoritative.
|
||||||
|
self.assertIn("trunk", " ".join(got["reasons"]))
|
||||||
|
|
||||||
|
def test_cached_remote_head_never_breaks_a_tie(self):
|
||||||
|
"""Two candidates, no upstream: the cache must not decide."""
|
||||||
|
head = _make_repo(self.root, remote="MDCPS", url=MDCPS_URL)
|
||||||
|
_set_remote_branch(self.root, "MDCPS", "dev", head)
|
||||||
|
_set_remote_branch(self.root, "MDCPS", "main", head)
|
||||||
|
_set_remote_head(self.root, "MDCPS", "dev")
|
||||||
|
|
||||||
|
got = crr.resolve_target_base_ref(self.root)
|
||||||
|
self.assertFalse(got["proven"])
|
||||||
|
self.assertEqual(got["reason_code"], crr.DENY_AMBIGUOUS_BASE_BRANCH)
|
||||||
|
self.assertIsNone(root_checkout_guard.resolve_remote_master_sha(self.root))
|
||||||
|
|
||||||
|
def test_no_proven_source_is_the_remote_head_cache(self):
|
||||||
|
"""Structural guard: the cache is not in the set of proving sources."""
|
||||||
|
sources = {
|
||||||
|
crr.BASE_REF_SOURCE_CONFIGURED_UPSTREAM,
|
||||||
|
crr.BASE_REF_SOURCE_UNIQUE_CANDIDATE,
|
||||||
|
}
|
||||||
|
self.assertNotIn("remote_head_symref", sources)
|
||||||
|
self.assertFalse(hasattr(crr, "BASE_REF_SOURCE_REMOTE_HEAD"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrossRepositoryTarget(_RepoCase):
|
||||||
|
"""Required coverage 2/4/5: MDCPS/dev, no origin remote, exact case."""
|
||||||
|
|
||||||
|
def test_mdcps_dev_resolves(self):
|
||||||
|
head = self._weekly_briefings_shape()
|
||||||
|
got = crr.resolve_target_base_ref(self.root)
|
||||||
|
self.assertTrue(got["proven"], got["reasons"])
|
||||||
|
self.assertEqual(got["remote"], "MDCPS")
|
||||||
|
self.assertEqual(got["branch"], "dev")
|
||||||
|
self.assertEqual(got["tracking_ref"], "refs/remotes/MDCPS/dev")
|
||||||
|
self.assertEqual(got["repository_slug"], MDCPS_SLUG)
|
||||||
|
self.assertEqual(root_checkout_guard.resolve_remote_master_sha(self.root), head)
|
||||||
|
|
||||||
|
def test_no_remote_named_origin(self):
|
||||||
|
self._weekly_briefings_shape()
|
||||||
|
self.assertEqual(_git(self.root, "remote"), "MDCPS")
|
||||||
|
got = crr.resolve_target_base_ref(self.root)
|
||||||
|
self.assertTrue(got["proven"], got["reasons"])
|
||||||
|
self.assertNotIn("origin", got["tracking_ref"])
|
||||||
|
|
||||||
|
def test_remote_name_case_is_preserved_exactly(self):
|
||||||
|
self._weekly_briefings_shape()
|
||||||
|
got = crr.resolve_target_base_ref(self.root)
|
||||||
|
self.assertEqual(got["remote"], "MDCPS")
|
||||||
|
self.assertNotEqual(got["remote"], "mdcps")
|
||||||
|
# The tracking ref must address the real ref, which is case-sensitive.
|
||||||
|
self.assertEqual(got["tracking_ref"], "refs/remotes/MDCPS/dev")
|
||||||
|
self.assertTrue(
|
||||||
|
_git(self.root, "rev-parse", "--verify", got["tracking_ref"]),
|
||||||
|
"case-preserved tracking ref must resolve",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_lowercase_candidate_never_supplies_the_remote_name(self):
|
||||||
|
"""Guards against silently case-folding MDCPS to the candidate 'mdcps'.
|
||||||
|
|
||||||
|
``_IDENTITY_REMOTE_CANDIDATES`` contains a lowercase ``mdcps`` entry and
|
||||||
|
is probed *before* the repository's own remote listing. Git remote names
|
||||||
|
live in case-sensitive config subsections on every platform, so the
|
||||||
|
lowercase probe cannot resolve and the exact-case name must arrive from
|
||||||
|
``git remote``. Asserted through config rather than ref lookup because a
|
||||||
|
case-insensitive filesystem (macOS) resolves loose refs either way, which
|
||||||
|
would make a ref-based assertion test the filesystem instead of the code.
|
||||||
|
"""
|
||||||
|
self._weekly_briefings_shape()
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", self.root, "remote", "get-url", "mdcps"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(res.returncode, 0, "git remote names are case-sensitive")
|
||||||
|
|
||||||
|
# A caller naming the wrong case cannot disambiguate, but the target is
|
||||||
|
# unambiguous anyway, so the exact-case name is still resolved.
|
||||||
|
got = crr.resolve_target_base_ref(self.root, explicit_remote="mdcps")
|
||||||
|
self.assertTrue(got["proven"], got["reasons"])
|
||||||
|
self.assertEqual(got["remote"], "MDCPS")
|
||||||
|
self.assertEqual(got["tracking_ref"], "refs/remotes/MDCPS/dev")
|
||||||
|
self.assertFalse(got["identity_explicit"], "a non-matching name is not explicit intent")
|
||||||
|
|
||||||
|
name, slug = crr.resolve_identity_remote(self.root)
|
||||||
|
self.assertEqual(name, "MDCPS")
|
||||||
|
self.assertEqual(slug, MDCPS_SLUG)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTargetStaleness(_RepoCase):
|
||||||
|
"""Required coverage 6/7: matching tip, and behind or divergent checkout."""
|
||||||
|
|
||||||
|
def test_local_equal_to_resolved_tip_is_not_stale(self):
|
||||||
|
self._weekly_briefings_shape()
|
||||||
|
got = master_parity_gate.assess_target_repository_parity(
|
||||||
|
canonical_root=self.root, source="test"
|
||||||
|
)
|
||||||
|
self.assertTrue(got["determinable"])
|
||||||
|
self.assertFalse(got["stale"])
|
||||||
|
self.assertEqual(got["tracking_ref"], "refs/remotes/MDCPS/dev")
|
||||||
|
self.assertEqual(got["base_remote"], "MDCPS")
|
||||||
|
self.assertEqual(got["base_branch"], "dev")
|
||||||
|
self.assertIsNone(got["reason_code"])
|
||||||
|
|
||||||
|
def test_local_behind_resolved_tip_is_stale(self):
|
||||||
|
head = self._weekly_briefings_shape()
|
||||||
|
advanced = _advance(self.root, "remote moved on")
|
||||||
|
_set_remote_branch(self.root, "MDCPS", "dev", advanced)
|
||||||
|
_git(self.root, "reset", "--hard", "--quiet", head)
|
||||||
|
|
||||||
|
got = master_parity_gate.assess_target_repository_parity(
|
||||||
|
canonical_root=self.root, source="test"
|
||||||
|
)
|
||||||
|
self.assertTrue(got["determinable"])
|
||||||
|
self.assertTrue(got["stale"])
|
||||||
|
self.assertEqual(got["checkout_head"], head)
|
||||||
|
self.assertEqual(got["remote_tracking_head"], advanced)
|
||||||
|
|
||||||
|
def test_local_divergent_from_resolved_tip_is_stale(self):
|
||||||
|
head = self._weekly_briefings_shape()
|
||||||
|
remote_side = _advance(self.root, "remote side")
|
||||||
|
_set_remote_branch(self.root, "MDCPS", "dev", remote_side)
|
||||||
|
_git(self.root, "reset", "--hard", "--quiet", head)
|
||||||
|
local_side = _advance(self.root, "local side")
|
||||||
|
|
||||||
|
got = master_parity_gate.assess_target_repository_parity(
|
||||||
|
canonical_root=self.root, source="test"
|
||||||
|
)
|
||||||
|
self.assertTrue(got["stale"])
|
||||||
|
self.assertEqual(got["checkout_head"], local_side)
|
||||||
|
self.assertNotEqual(local_side, remote_side)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFailClosed(_RepoCase):
|
||||||
|
"""Required coverage 8/9: missing remote or ref, and ambiguous targets."""
|
||||||
|
|
||||||
|
def test_missing_remote_fails_closed(self):
|
||||||
|
_make_repo(self.root, remote=None, url=None)
|
||||||
|
got = crr.resolve_target_base_ref(self.root)
|
||||||
|
self.assertFalse(got["proven"])
|
||||||
|
self.assertEqual(got["reason_code"], crr.DENY_NO_IDENTITY_REMOTE)
|
||||||
|
self.assertEqual(got["tracking_refs"], ())
|
||||||
|
self.assertIsNone(root_checkout_guard.resolve_remote_master_sha(self.root))
|
||||||
|
|
||||||
|
def test_missing_tracking_ref_fails_closed(self):
|
||||||
|
_make_repo(self.root, remote="MDCPS", url=MDCPS_URL)
|
||||||
|
# Remote configured, but nothing has ever been fetched.
|
||||||
|
got = crr.resolve_target_base_ref(self.root)
|
||||||
|
self.assertFalse(got["proven"])
|
||||||
|
self.assertEqual(got["reason_code"], crr.DENY_NO_BASE_BRANCH)
|
||||||
|
self.assertIsNone(root_checkout_guard.resolve_remote_master_sha(self.root))
|
||||||
|
|
||||||
|
def test_configured_upstream_without_a_tracking_ref_fails_closed(self):
|
||||||
|
"""A proven target always names a ref that resolves."""
|
||||||
|
_make_repo(self.root, remote="MDCPS", url=MDCPS_URL)
|
||||||
|
_set_upstream(self.root, "MDCPS", "dev") # declared, never fetched
|
||||||
|
|
||||||
|
got = crr.resolve_target_base_ref(self.root)
|
||||||
|
self.assertFalse(got["proven"])
|
||||||
|
self.assertEqual(got["reason_code"], crr.DENY_NO_BASE_BRANCH)
|
||||||
|
self.assertIsNone(got["tracking_ref"])
|
||||||
|
|
||||||
|
def test_every_proven_target_resolves(self):
|
||||||
|
"""No 'proven' result may name an unresolvable tracking ref."""
|
||||||
|
head = self._weekly_briefings_shape()
|
||||||
|
got = crr.resolve_target_base_ref(self.root)
|
||||||
|
self.assertTrue(got["proven"])
|
||||||
|
self.assertEqual(_git(self.root, "rev-parse", got["tracking_ref"]), head)
|
||||||
|
|
||||||
|
def test_ambiguous_integration_branch_fails_closed(self):
|
||||||
|
head = _make_repo(self.root, remote="MDCPS", url=MDCPS_URL)
|
||||||
|
# Two candidate integration branches and no configured upstream.
|
||||||
|
_set_remote_branch(self.root, "MDCPS", "dev", head)
|
||||||
|
_set_remote_branch(self.root, "MDCPS", "main", head)
|
||||||
|
|
||||||
|
got = crr.resolve_target_base_ref(self.root)
|
||||||
|
self.assertFalse(got["proven"])
|
||||||
|
self.assertEqual(got["reason_code"], crr.DENY_AMBIGUOUS_BASE_BRANCH)
|
||||||
|
self.assertIsNone(root_checkout_guard.resolve_remote_master_sha(self.root))
|
||||||
|
|
||||||
|
def test_ambiguous_identity_remote_fails_closed(self):
|
||||||
|
head = _make_repo(self.root, remote="MDCPS", url=MDCPS_URL)
|
||||||
|
_git(self.root, "remote", "add", "prgs", PRGS_URL)
|
||||||
|
_set_remote_branch(self.root, "MDCPS", "dev", head)
|
||||||
|
_set_remote_branch(self.root, "prgs", "master", head)
|
||||||
|
|
||||||
|
got = crr.resolve_target_base_ref(self.root)
|
||||||
|
self.assertFalse(got["proven"])
|
||||||
|
self.assertEqual(got["reason_code"], crr.DENY_AMBIGUOUS_REMOTE)
|
||||||
|
self.assertEqual(got["tracking_refs"], ())
|
||||||
|
|
||||||
|
def test_orphan_tracking_ref_from_removed_remote_is_ignored(self):
|
||||||
|
"""The live Gitea-Tools symptom: refs/remotes/origin/* outlives its remote."""
|
||||||
|
head = _make_repo(self.root, remote="prgs", url=PRGS_URL)
|
||||||
|
_set_remote_branch(self.root, "prgs", "master", head)
|
||||||
|
_set_upstream(self.root, "prgs", "master")
|
||||||
|
# An abandoned ref left behind by a remote that no longer exists.
|
||||||
|
_set_remote_branch(self.root, "origin", "master", head)
|
||||||
|
_advance(self.root, "orphan must not be consulted")
|
||||||
|
|
||||||
|
got = master_parity_gate.assess_target_repository_parity(
|
||||||
|
canonical_root=self.root, source="test"
|
||||||
|
)
|
||||||
|
self.assertEqual(got["tracking_ref"], "refs/remotes/prgs/master")
|
||||||
|
self.assertEqual(got["repository_slug"], PRGS_SLUG)
|
||||||
|
self.assertNotIn(
|
||||||
|
"target repository identity could not be derived from its git remote",
|
||||||
|
got["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExplicitVersusInferredRemote(_RepoCase):
|
||||||
|
"""Required coverage 11: explicit disambiguation stays distinct from inference.
|
||||||
|
|
||||||
|
This is B2. A remote the module inferred while probing must never re-enter
|
||||||
|
the resolver as though an operator had named it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _two_remotes(self) -> str:
|
||||||
|
head = _make_repo(self.root, remote="MDCPS", url=MDCPS_URL)
|
||||||
|
_git(self.root, "remote", "add", "prgs", PRGS_URL)
|
||||||
|
_set_remote_branch(self.root, "MDCPS", "dev", head)
|
||||||
|
_set_remote_branch(self.root, "prgs", "master", head)
|
||||||
|
return head
|
||||||
|
|
||||||
|
def test_explicit_remote_disambiguates(self):
|
||||||
|
self._two_remotes()
|
||||||
|
got = crr.resolve_target_base_ref(self.root, explicit_remote="MDCPS")
|
||||||
|
self.assertTrue(got["proven"], got["reasons"])
|
||||||
|
self.assertEqual(got["remote"], "MDCPS")
|
||||||
|
self.assertEqual(got["branch"], "dev")
|
||||||
|
self.assertTrue(got["identity_explicit"])
|
||||||
|
|
||||||
|
def test_inferred_remote_does_not_disambiguate(self):
|
||||||
|
"""Feeding the inferred remote back in must not unlock the target."""
|
||||||
|
self._two_remotes()
|
||||||
|
inferred, _ = crr.resolve_identity_remote(self.root)
|
||||||
|
self.assertIsNotNone(inferred, "the first-wins probe still returns a name")
|
||||||
|
|
||||||
|
# The report infers internally and must still refuse.
|
||||||
|
report = master_parity_gate.assess_target_repository_parity(
|
||||||
|
canonical_root=self.root, source="test"
|
||||||
|
)
|
||||||
|
self.assertEqual(report["reason_code"], crr.DENY_AMBIGUOUS_REMOTE)
|
||||||
|
self.assertIsNone(report["repository_slug"])
|
||||||
|
self.assertIsNone(report["tracking_ref"])
|
||||||
|
self.assertFalse(report["stale"])
|
||||||
|
self.assertTrue(report["reasons"])
|
||||||
|
|
||||||
|
def test_report_never_passes_a_remote_into_the_resolver(self):
|
||||||
|
"""Structural guard against the exact B2 regression."""
|
||||||
|
src = inspect.getsource(master_parity_gate.assess_target_repository_parity)
|
||||||
|
self.assertIn("resolve_target_base_ref(canonical_root)", src)
|
||||||
|
self.assertNotIn("resolve_target_base_ref(canonical_root, remote=", src)
|
||||||
|
self.assertNotIn("explicit_remote=identity", src)
|
||||||
|
# Reporting must use the ambiguity-aware identity resolver.
|
||||||
|
self.assertIn("assess_identity_remote(canonical_root)", src)
|
||||||
|
|
||||||
|
def test_explicit_parameter_is_named_for_its_meaning(self):
|
||||||
|
for fn in (
|
||||||
|
crr.resolve_target_base_ref,
|
||||||
|
root_checkout_guard.resolve_remote_master_sha,
|
||||||
|
root_checkout_guard.resolve_remote_master_ref_state,
|
||||||
|
):
|
||||||
|
params = inspect.signature(fn).parameters
|
||||||
|
self.assertIn("explicit_remote", params, fn.__name__)
|
||||||
|
self.assertNotIn("remote", params, fn.__name__)
|
||||||
|
|
||||||
|
def test_unmatched_explicit_remote_cannot_unlock_an_ambiguous_target(self):
|
||||||
|
self._two_remotes()
|
||||||
|
got = crr.resolve_target_base_ref(self.root, explicit_remote="nonexistent")
|
||||||
|
self.assertFalse(got["proven"])
|
||||||
|
self.assertEqual(got["reason_code"], crr.DENY_AMBIGUOUS_REMOTE)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGatingAndReportingAgree(_RepoCase):
|
||||||
|
"""Required coverage 10: guard and parity report make identical decisions."""
|
||||||
|
|
||||||
|
def test_same_resolved_target_for_gate_and_report(self):
|
||||||
|
self._weekly_briefings_shape()
|
||||||
|
|
||||||
|
gate = root_checkout_guard.resolve_remote_master_ref_state(self.root)
|
||||||
|
report = master_parity_gate.assess_target_repository_parity(
|
||||||
|
canonical_root=self.root, source="test"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(gate["remote"], report["base_remote"])
|
||||||
|
self.assertEqual(gate["branch"], report["base_branch"])
|
||||||
|
self.assertEqual(gate["sha"], report["remote_tracking_head"])
|
||||||
|
self.assertIn(
|
||||||
|
gate["ref"],
|
||||||
|
(report["tracking_ref"], f"{gate['remote']}/{gate['branch']}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_both_sides_refuse_the_same_unresolvable_target(self):
|
||||||
|
_make_repo(self.root, remote=None, url=None)
|
||||||
|
|
||||||
|
gate = root_checkout_guard.resolve_remote_master_ref_state(self.root)
|
||||||
|
report = master_parity_gate.assess_target_repository_parity(
|
||||||
|
canonical_root=self.root, source="test"
|
||||||
|
)
|
||||||
|
self.assertIsNone(gate["sha"])
|
||||||
|
self.assertIsNone(report["remote_tracking_head"])
|
||||||
|
self.assertFalse(report["stale"])
|
||||||
|
self.assertTrue(report["reasons"])
|
||||||
|
self.assertEqual(gate["reason_code"], report["reason_code"])
|
||||||
|
|
||||||
|
def test_both_sides_refuse_the_same_ambiguous_target(self):
|
||||||
|
head = _make_repo(self.root, remote="MDCPS", url=MDCPS_URL)
|
||||||
|
_git(self.root, "remote", "add", "prgs", PRGS_URL)
|
||||||
|
_set_remote_branch(self.root, "MDCPS", "dev", head)
|
||||||
|
_set_remote_branch(self.root, "prgs", "master", head)
|
||||||
|
|
||||||
|
gate = root_checkout_guard.resolve_remote_master_ref_state(self.root)
|
||||||
|
report = master_parity_gate.assess_target_repository_parity(
|
||||||
|
canonical_root=self.root, source="test"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNone(gate["sha"])
|
||||||
|
self.assertEqual(gate["reason_code"], crr.DENY_AMBIGUOUS_REMOTE)
|
||||||
|
# The report must not name a repository the gate refuses to act on.
|
||||||
|
self.assertEqual(report["reason_code"], gate["reason_code"])
|
||||||
|
self.assertIsNone(report["repository_slug"])
|
||||||
|
self.assertIsNone(report["base_remote"])
|
||||||
|
self.assertIsNone(report["base_branch"])
|
||||||
|
self.assertFalse(report["stale"])
|
||||||
|
|
||||||
|
def test_both_sides_refuse_the_same_ambiguous_branch(self):
|
||||||
|
head = _make_repo(self.root, remote="MDCPS", url=MDCPS_URL)
|
||||||
|
_set_remote_branch(self.root, "MDCPS", "dev", head)
|
||||||
|
_set_remote_branch(self.root, "MDCPS", "main", head)
|
||||||
|
|
||||||
|
gate = root_checkout_guard.resolve_remote_master_ref_state(self.root)
|
||||||
|
report = master_parity_gate.assess_target_repository_parity(
|
||||||
|
canonical_root=self.root, source="test"
|
||||||
|
)
|
||||||
|
self.assertEqual(gate["reason_code"], crr.DENY_AMBIGUOUS_BASE_BRANCH)
|
||||||
|
self.assertEqual(report["reason_code"], gate["reason_code"])
|
||||||
|
self.assertIsNone(report["tracking_ref"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductionCallers(unittest.TestCase):
|
||||||
|
"""Required coverage 13: every production caller consumes the same target."""
|
||||||
|
|
||||||
|
def test_guard_reports_the_ref_it_actually_compared(self):
|
||||||
|
assessment = root_checkout_guard.assess_root_checkout_guard(
|
||||||
|
workspace_path="/tmp/nonexistent-workspace-983",
|
||||||
|
canonical_repo_root="/tmp/nonexistent-root-983",
|
||||||
|
current_branch="dev",
|
||||||
|
head_sha="a" * 40,
|
||||||
|
porcelain_status="",
|
||||||
|
remote_master_sha="b" * 40,
|
||||||
|
remote_master_ref="refs/remotes/MDCPS/dev",
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
joined = " ".join(assessment["reasons"])
|
||||||
|
self.assertIn("refs/remotes/MDCPS/dev", joined)
|
||||||
|
self.assertNotIn("prgs/master", joined)
|
||||||
|
|
||||||
|
def test_guard_message_without_a_ref_stays_generic(self):
|
||||||
|
assessment = root_checkout_guard.assess_root_checkout_guard(
|
||||||
|
workspace_path="/tmp/nonexistent-workspace-983",
|
||||||
|
canonical_repo_root="/tmp/nonexistent-root-983",
|
||||||
|
current_branch="master",
|
||||||
|
head_sha="a" * 40,
|
||||||
|
porcelain_status="",
|
||||||
|
remote_master_sha="b" * 40,
|
||||||
|
)
|
||||||
|
joined = " ".join(assessment["reasons"])
|
||||||
|
self.assertIn("the tracking integration ref", joined)
|
||||||
|
self.assertNotIn("prgs/master", joined)
|
||||||
|
|
||||||
|
def test_anti_stomp_preflight_forwards_the_resolved_ref(self):
|
||||||
|
sig = inspect.signature(anti_stomp_preflight.assess_anti_stomp_preflight)
|
||||||
|
self.assertIn("remote_master_ref", sig.parameters)
|
||||||
|
src = inspect.getsource(anti_stomp_preflight.assess_anti_stomp_preflight)
|
||||||
|
self.assertIn("remote_master_ref=remote_master_ref", src)
|
||||||
|
|
||||||
|
def test_no_production_caller_inherits_the_prgs_default(self):
|
||||||
|
"""Every resolve site must derive, or pass remote_refs explicitly."""
|
||||||
|
import gitea_mcp_server
|
||||||
|
|
||||||
|
src = inspect.getsource(gitea_mcp_server)
|
||||||
|
# The four historical call sites now consume the resolved-target state.
|
||||||
|
self.assertGreaterEqual(src.count("resolve_remote_master_ref_state("), 4)
|
||||||
|
self.assertNotIn("resolve_remote_master_sha(canonical_root)", src)
|
||||||
|
|
||||||
|
def test_no_production_caller_supplies_an_explicit_remote(self):
|
||||||
|
"""Nothing in production may suppress the ambiguity gate (#983 B2)."""
|
||||||
|
import gitea_mcp_server
|
||||||
|
|
||||||
|
for module in (gitea_mcp_server, anti_stomp_preflight, master_parity_gate):
|
||||||
|
src = inspect.getsource(module)
|
||||||
|
self.assertNotIn("explicit_remote=", src, module.__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryStructureUntouched(_RepoCase):
|
||||||
|
"""Required coverage 12: resolution mutates no ref, remote, config, or checkout."""
|
||||||
|
|
||||||
|
def test_resolution_creates_no_refs_or_branches(self):
|
||||||
|
self._weekly_briefings_shape()
|
||||||
|
|
||||||
|
fmt = "--format=%(refname) %(objectname)"
|
||||||
|
before_refs = _git(self.root, "for-each-ref", fmt)
|
||||||
|
before_remotes = _git(self.root, "remote")
|
||||||
|
before_config = _git(self.root, "config", "--local", "--list")
|
||||||
|
before_head = _git(self.root, "rev-parse", "HEAD")
|
||||||
|
before_branch = _git(self.root, "symbolic-ref", "--short", "HEAD")
|
||||||
|
before_status = _git(self.root, "status", "--porcelain", "--untracked-files=all")
|
||||||
|
before_symref = _git(self.root, "symbolic-ref", "refs/remotes/MDCPS/HEAD")
|
||||||
|
|
||||||
|
crr.resolve_target_base_ref(self.root)
|
||||||
|
crr.assess_identity_remote(self.root)
|
||||||
|
root_checkout_guard.resolve_remote_master_sha(self.root)
|
||||||
|
root_checkout_guard.resolve_remote_master_ref_state(self.root)
|
||||||
|
master_parity_gate.assess_target_repository_parity(
|
||||||
|
canonical_root=self.root, source="test"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(_git(self.root, "for-each-ref", fmt), before_refs)
|
||||||
|
self.assertEqual(_git(self.root, "remote"), before_remotes)
|
||||||
|
self.assertEqual(_git(self.root, "config", "--local", "--list"), before_config)
|
||||||
|
self.assertEqual(_git(self.root, "rev-parse", "HEAD"), before_head)
|
||||||
|
self.assertEqual(_git(self.root, "symbolic-ref", "--short", "HEAD"), before_branch)
|
||||||
|
self.assertEqual(
|
||||||
|
_git(self.root, "status", "--porcelain", "--untracked-files=all"), before_status
|
||||||
|
)
|
||||||
|
# The stale cache is specifically NOT repaired: that would be a mutation.
|
||||||
|
self.assertEqual(_git(self.root, "symbolic-ref", "refs/remotes/MDCPS/HEAD"), before_symref)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -245,10 +245,15 @@ class TestNamespaceWorkspaceIntegration(unittest.TestCase):
|
|||||||
def test_pr487_style_merge_binds_clean_merger_workspace(
|
def test_pr487_style_merge_binds_clean_merger_workspace(
|
||||||
self, _exists, _isdir, mock_run
|
self, _exists, _isdir, mock_run
|
||||||
):
|
):
|
||||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
|
def mock_git(cmd, *args, **kwargs):
|
||||||
|
if "rev-parse" in cmd:
|
||||||
|
return MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
|
||||||
|
return MagicMock(returncode=0, stdout=f"worktree {CONTROL_ROOT}\nworktree {MERGER_CLEAN}\n")
|
||||||
|
mock_run.side_effect = mock_git
|
||||||
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
|
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
|
||||||
|
os.environ[nwb.MERGER_WORKTREE_ENV] = MERGER_CLEAN
|
||||||
srv._preflight_resolved_role = "reviewer"
|
srv._preflight_resolved_role = "reviewer"
|
||||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||||
with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()):
|
with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()):
|
||||||
resolved = srv._verify_role_mutation_workspace("prgs")
|
resolved = srv._verify_role_mutation_workspace("prgs")
|
||||||
self.assertEqual(resolved, os.path.realpath(MCP_PROCESS_ROOT))
|
self.assertEqual(resolved, os.path.realpath(MERGER_CLEAN))
|
||||||
@@ -87,13 +87,27 @@ class TestAssessRootCheckoutGuard(unittest.TestCase):
|
|||||||
self.assertTrue(result["block"])
|
self.assertTrue(result["block"])
|
||||||
self.assertIn("tracked local edits", result["reasons"][0])
|
self.assertIn("tracked local edits", result["reasons"][0])
|
||||||
|
|
||||||
def test_head_behind_prgs_master_blocked(self):
|
def test_head_behind_tracking_base_ref_blocked(self):
|
||||||
|
"""#983: the base ref is derived, so the message no longer hardcodes PRGS."""
|
||||||
result = self._assess(
|
result = self._assess(
|
||||||
head_sha=OTHER_SHA,
|
head_sha=OTHER_SHA,
|
||||||
remote_master_sha=MASTER_SHA,
|
remote_master_sha=MASTER_SHA,
|
||||||
)
|
)
|
||||||
self.assertTrue(result["block"])
|
self.assertTrue(result["block"])
|
||||||
self.assertIn("does not match prgs/master", result["reasons"][0])
|
self.assertIn(
|
||||||
|
"does not match the tracking integration ref", result["reasons"][0]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_head_behind_named_base_ref_reports_that_ref(self):
|
||||||
|
"""The resolved ref is named, so a non-PRGS target is reported accurately."""
|
||||||
|
result = self._assess(
|
||||||
|
head_sha=OTHER_SHA,
|
||||||
|
remote_master_sha=MASTER_SHA,
|
||||||
|
remote_master_ref="refs/remotes/MDCPS/dev",
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertIn("does not match refs/remotes/MDCPS/dev", result["reasons"][0])
|
||||||
|
self.assertNotIn("prgs/master", result["reasons"][0])
|
||||||
|
|
||||||
def test_merger_requires_clean_control_checkout(self):
|
def test_merger_requires_clean_control_checkout(self):
|
||||||
result = self._assess(
|
result = self._assess(
|
||||||
|
|||||||
@@ -153,6 +153,12 @@ EXPECTED_ROLE_EXCLUSIVE_TASKS = frozenset(
|
|||||||
"delete_branch",
|
"delete_branch",
|
||||||
"cleanup_merged_pr_branch",
|
"cleanup_merged_pr_branch",
|
||||||
"reconciliation_cleanup",
|
"reconciliation_cleanup",
|
||||||
|
# #970 review 644 B2: retiring a missing worktree binding is a
|
||||||
|
# control-plane cleanup mutation, so it carries the same reconciler-only
|
||||||
|
# authority as every other reconciliation cleanup. Permission alone must
|
||||||
|
# not authorize it.
|
||||||
|
"reconcile_missing_worktree_bindings",
|
||||||
|
"gitea_reconcile_missing_worktree_bindings",
|
||||||
"work_issue",
|
"work_issue",
|
||||||
"work-issue",
|
"work-issue",
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user