Compare commits

..
Author SHA1 Message Date
jcwalker3 a633db6186 Merge branch 'master' into fix/issue-690-review-profile-switch-guard 2026-07-28 08:38:08 -05:00
sysadmin 2b3f5baaeb fix(mcp): invalidate review session state on cross-profile activation (Closes #690)
A mid-run profile switch (reviewer -> author -> reviewer) left workflow-load
proof, reviewer lease binding, the review decision lock, live namespace
health, and preflight identity/capability stamps intact, so a formal verdict
could be recorded under contaminated session state.

- gitea_activate_profile now invalidates all review-critical session state
  on a cross-profile switch, in memory and in durable state keyed by either
  profile identity, and reports the invalidation + re-preflight requirement.
- Full reviewer preflight (whoami, load_review_workflow,
  resolve_task_capability(review_pr), head re-pin, lease re-acquire) is
  required before any formal verdict after a switch; switching back cannot
  resurrect the stale run.
- Namespace provenance: optional launcher-declared GITEA_MCP_NAMESPACE is
  reported by whoami/runtime context/capability resolution, and a declared
  namespace that disagrees with a task's required namespace fails closed.
- Docs: supported pattern is separate session/namespace per role, not
  in-process profile hopping mid-review.
2026-07-25 19:17:19 -04:00
46 changed files with 742 additions and 19060 deletions
+21 -136
View File
@@ -37,48 +37,6 @@ CANONICAL_ROOT_ENV = "GITEA_CANONICAL_REPOSITORY_ROOT"
# Candidate git remote names probed when deriving repository identity.
_IDENTITY_REMOTE_CANDIDATES = ("prgs", "origin", "dadeschools", "mdcps")
# 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(
profile: Mapping | None,
@@ -174,7 +132,6 @@ def assess_canonical_repository_root(
process_project_root: str,
remote: str | None = None,
require_binding: bool = False,
mode: str = "validation",
) -> dict:
"""Validate the canonical repository root binding, failing closed on forgery.
@@ -183,41 +140,15 @@ def assess_canonical_repository_root(
``configured`` (whether a cross-repo binding was declared),
``resolved_slug`` and ``source``.
*mode* accepts exactly the two values in :data:`SUPPORTED_MODES`:
- ``"validation"`` (default): an independently trusted expected repository
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.
Without a configured binding the single-repo default is preserved: the
canonical root is derived from *process_project_root* and never blocks
(unless *require_binding* explicitly demands one).
Every other explicitly supplied value — unknown strings, misspellings, the
empty string, ``None``, and non-strings — is refused with ``proven`` False,
``block`` True, and ``reason_code`` :data:`DENY_UNKNOWN_MODE` (#973 B10).
Omitting *mode* entirely keeps the documented ``"validation"`` default.
With a configured binding the path must exist, be a git repository, and —
when *expected_slug* is known — carry a matching repository identity. A
mismatched or (when *require_binding*) unprovable identity is a forged or
conflicting binding and fails closed.
"""
# #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)
declared = (configured_value or "").strip()
@@ -237,31 +168,12 @@ def assess_canonical_repository_root(
)
# Single-repo default: canonical root follows the install checkout.
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(
proven=not reasons,
reasons=reasons,
proven=True,
reasons=[],
configured=False,
canonical_repo_root=derived,
resolved_slug=resolved_slug,
resolved_slug=None,
source=None,
)
@@ -295,36 +207,19 @@ def assess_canonical_repository_root(
resolved_slug = repository_identity_slug(toplevel, remote=remote)
reasons: list[str] = []
if mode == MODE_DERIVATION:
if not resolved_slug:
expected = (expected_slug or "").strip() or None
if expected:
if resolved_slug and resolved_slug.lower() != expected.lower():
reasons.append(
f"configured canonical repository root '{toplevel}' has no resolvable "
"git remote identity (fail closed)"
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)"
)
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:
elif not resolved_slug and require_binding:
reasons.append(
f"canonical repository root '{toplevel}' has configured value "
f"'{configured_value}' but authoritative expected repository identity "
"is unprovable or missing (fail closed)"
f"canonical repository root '{toplevel}' has no resolvable git "
f"remote identity to confirm authorization for '{expected}' "
"(fail closed)"
)
return _assessment(
@@ -357,19 +252,10 @@ def _assessment(
proven: bool,
reasons: list[str],
configured: bool,
canonical_repo_root: str | None,
canonical_repo_root: str,
resolved_slug: str | None,
source: str | None,
reason_code: str | None = None,
) -> 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 {
"proven": proven,
"block": not proven,
@@ -378,5 +264,4 @@ def _assessment(
"canonical_repo_root": canonical_repo_root,
"resolved_slug": resolved_slug,
"source": source,
"reason_code": reason_code,
}
-298
View File
@@ -280,22 +280,6 @@ def _ts(dt: datetime | None = None) -> str:
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:
if not value:
return None
@@ -1929,168 +1913,6 @@ 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(
self,
*,
@@ -3229,123 +3051,3 @@ class ControlPlaneDB:
"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",
}
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,
},
}
+41
View File
@@ -589,6 +589,47 @@ When dynamic profile switching is enabled and a profile is activated via `gitea_
2. Call `gitea_whoami` with the target remote to prove and verify the fresh Gitea authenticated identity.
This guarantees the active profile operations align with the actual Gitea authenticated user credential.
### 4. Review-State Invalidation on Profile Switch (#690)
A cross-profile activation (e.g. reviewer → author → reviewer) is a session
boundary for formal review state. On any switch where the activated profile
differs from the previous one, `gitea_activate_profile` invalidates, in
memory **and** in durable session state (for both the old and new profile
identities):
- preflight identity/capability stamps (`gitea_whoami` / `gitea_resolve_task_capability` proof),
- review workflow-load proof (`gitea_load_review_workflow`),
- the review decision lock (including `final_review_decision_ready` markers),
- the reviewer PR session lease binding,
- live namespace-health assessments.
Before any formal verdict (`gitea_mark_final_review_decision` /
`gitea_submit_pr_review`) the full reviewer preflight must be re-established
under the new profile: `gitea_whoami`, `gitea_load_review_workflow`,
`gitea_resolve_task_capability(review_pr)`, live head re-pin, and lease
re-acquire/adopt. Switching back to the earlier profile does **not**
resurrect the prior run — durable state keyed by either profile identity is
cleared at switch time.
The supported pattern remains **separate session/namespace per role**
(dual-namespace, §2): file author-side follow-ups from an author session,
not by hopping profiles inside a formal review run. Runtime profile
switching is the operator-approved exception and always carries the
re-preflight cost above.
### 5. Namespace Provenance (#690)
The server cannot derive its own client-managed MCP namespace name, so a
launcher may declare it via the `GITEA_MCP_NAMESPACE` environment variable
(e.g. `gitea-reviewer`). `gitea_whoami`, `gitea_get_runtime_context`, and
`gitea_resolve_task_capability` report `namespace_provenance` — the
configured client namespace, the active execution profile, and, for tasks
with a required namespace (`review_pr` → `gitea-reviewer`, `merge_pr` →
`gitea-merger`), a mismatch verdict. A declared namespace that disagrees
with the requested task's required namespace **fails closed**. An
undeclared namespace is reported as `unknown` and is never treated as
proof either way.
## Gitea MCP Runtime Isolation and Worktree Safety
To ensure high availability and prevent broken feature worktrees from disabling essential security/identity controls, the Gitea MCP server implements runtime isolation:
-203
View File
@@ -1,203 +0,0 @@
# 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).
* [stale-worker-retirement.md](stale-worker-retirement.md) — CAS-protected
retirement of conclusively stale registrations (#980). Note that the
`registry_revision` this snapshot returns is **time-seeded** and unsuitable
for compare-and-swap; retirement derives its own stable
`registry_fingerprint` from registry content alone.
## 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.
+17 -67
View File
@@ -87,73 +87,23 @@ When a namespace returns EOF, follow
When blocked, repair the IDE namespace and re-record a healthy
`client_namespace` assessment before retrying the mutation.
## Connected vs Attached Tool Surface (#708)
## Namespace provenance (#690)
MCP servers can report **Connected** at the CLI / host inventory layer while the **active LLM session exposes none of their tool namespaces**.
A server process cannot derive the name of the client-managed namespace it is
registered under, so the launcher may declare it with the
`GITEA_MCP_NAMESPACE` environment variable (e.g. `GITEA_MCP_NAMESPACE=gitea-reviewer`).
### 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.
- `gitea_whoami`, `gitea_get_runtime_context`, and
`gitea_resolve_task_capability` report `namespace_provenance`: the declared
client namespace, the active execution profile, and — for tasks with a
required namespace (`review_pr`/`submit_review``gitea-reviewer`,
`merge_pr``gitea-merger`) — a `mismatch` verdict.
- A declared namespace that disagrees with the requested task's required
namespace **fails closed** (`allowed_in_current_session=false` with a STOP
guidance entry).
- An undeclared namespace is reported as `namespace_source="unknown"` and is
never treated as proof either way.
- A profile switch via `gitea_activate_profile` clears all recorded live
namespace-health assessments; re-probe through the client before further
review/merge mutations.
-9
View File
@@ -51,13 +51,11 @@ that gates each call, not which tools exist.
- `gitea_adopt_merger_pr_lease`
- `gitea_adopt_workflow_lease`
- `gitea_allocate_next_work`
- `gitea_apply_stale_worker_retirement`
- `gitea_assess_already_landed_reconciliation`
- `gitea_assess_conflict_fix_classification`
- `gitea_assess_conflict_fix_push`
- `gitea_assess_gitea_operation_path`
- `gitea_assess_master_parity`
- `gitea_assess_mcp_namespace_attachment`
- `gitea_assess_mcp_namespace_health`
- `gitea_assess_pr_sync_status`
- `gitea_assess_review_merge_state_machine`
@@ -66,7 +64,6 @@ that gates each call, not which tools exist.
- `gitea_assess_work_issue_duplicate`
- `gitea_assess_worktree_cleanup_integrity`
- `gitea_audit_config`
- `gitea_audit_missing_worktree_bindings`
- `gitea_audit_runtime_recovery_contamination`
- `gitea_audit_stable_branch_contamination`
- `gitea_audit_worktree_cleanup`
@@ -125,24 +122,19 @@ that gates each call, not which tools exist.
- `gitea_observability_link_issue`
- `gitea_observability_list_projects`
- `gitea_observability_reconcile_incident`
- `gitea_plan_stale_worker_retirement`
- `gitea_post_heartbeat`
- `gitea_publish_unpublished_issue_branch`
- `gitea_quarantine_contaminated_review`
- `gitea_rebind_dirty_same_claimant_author_session`
- `gitea_reclaim_expired_workflow_lease`
- `gitea_reconcile_after_restart`
- `gitea_reconcile_already_landed_pr`
- `gitea_reconcile_issue_claims`
- `gitea_reconcile_merged_cleanups`
- `gitea_reconcile_missing_worktree_bindings`
- `gitea_reconcile_superseded_by_merged_pr`
- `gitea_record_daemon_process_kill_attempt`
- `gitea_record_irrecoverable_decision_lock_provenance`
- `gitea_record_pre_review_command`
- `gitea_record_shell_spawn_outcome`
- `gitea_record_stable_branch_push_attempt`
- `gitea_recover_dirty_orphaned_issue_worktree`
- `gitea_recover_incomplete_bootstrap_lock`
- `gitea_release_merger_pr_lease`
- `gitea_release_reviewer_pr_lease`
@@ -161,7 +153,6 @@ that gates each call, not which tools exist.
- `gitea_sentry_reconcile_issue`
- `gitea_sentry_watchdog`
- `gitea_set_issue_labels`
- `gitea_snapshot_instance_fleet`
- `gitea_submit_pr_review`
- `gitea_update_pr_branch_by_merge`
- `gitea_validate_review_final_report`
+67 -233
View File
@@ -8,239 +8,73 @@
"document. #930's inventory had no such guard and its gitea_mcp_server.py",
"anchors drifted between 7bf4f125 and aad5c8b4."
],
"generated_against_commit": "1dd30ecb1508b559868c2d5d94367bc055d5138e",
"generated_against_commit": "aad5c8b42361d380a8eeb07b94b90815e594c2c5",
"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"
}
{"anchor": "gitea_mcp_server.py:24721", "expect": "bind_native_mcp_transport(transport=\"stdio\")"},
{"anchor": "mcp_daemon_guard.py:45", "expect": "_PRODUCTION_TRANSPORTS = frozenset({\"stdio\"})"},
{"anchor": "mcp_daemon_guard.py:174", "expect": "def bind_native_mcp_transport"},
{"anchor": "irrecoverable_provenance.py:497", "expect": "def assess_transport_for_auth_mint"},
{"anchor": "gitea_mcp_server.py:9129", "expect": "assess_transport_for_auth_mint()"},
{"anchor": "gitea_mcp_server.py:9378", "expect": "assess_transport_for_auth_mint()"},
{"anchor": "mcp_server.py:4", "expect": "Runs over stdio."},
{"anchor": "gitea_mcp_server.py:15412", "expect": "def _is_client_managed_process"},
{"anchor": "gitea_mcp_server.py:15442", "expect": "def _provenance_mutation_block"},
{"anchor": "gitea_mcp_server.py:15450", "expect": "unsupported_manual_launch"},
{"anchor": "gitea_mcp_server.py:19001", "expect": "server_provenance"},
{"anchor": "gitea_mcp_server.py:21442", "expect": "def _check_mcp_runtimes_diagnostics"},
{"anchor": "gitea_mcp_server.py:21462", "expect": "\"ps\", \"-o\", \"pid,lstart,command\""},
{"anchor": "gitea_mcp_server.py:21506", "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:440", "expect": "def assert_keychain_access_allowed"},
{"anchor": "gitea_mcp_server.py:19258", "expect": "def gitea_list_profiles"},
{"anchor": "gitea_mcp_server.py:19309", "expect": "gitea_config.resolve_token(p)"},
{"anchor": "gitea_mcp_server.py:19552", "expect": "def gitea_audit_config"},
{"anchor": "gitea_mcp_server.py:19574", "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:17707", "expect": "\"jenkins-mcp\""},
{"anchor": "gitea_mcp_server.py:17713", "expect": "external-mcp"},
{"anchor": "gitea_mcp_server.py:17734", "expect": "\"glitchtip-mcp\""},
{"anchor": "gitea_mcp_server.py:17739", "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:19102", "expect": "def gitea_assess_master_parity"},
{"anchor": "gitea_mcp_server.py:190", "expect": "ACTIVE_WORKTREE_ENV"},
{"anchor": "gitea_mcp_server.py:191", "expect": "AUTHOR_WORKTREE_ENV"},
{"anchor": "gitea_mcp_server.py:2348", "expect": "/tmp/gitea_issue_lock.json"},
{"anchor": "gitea_mcp_server.py:10894", "expect": "def gitea_bootstrap_author_issue_worktree"},
{"anchor": "mcp_server.py:10", "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:12801", "expect": "owner_pid_alive"}
]
}
+29 -35
View File
@@ -5,11 +5,7 @@ What the adversary is, what each boundary protects, and which services may share
- **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.
- **Generated against commit:** `aad5c8b42361d380a8eeb07b94b90815e594c2c5` (`master`).
- **Scope:** documentation only. This child changes no server behavior. It adds one
document, one anchor fixture, and the test that enforces them.
@@ -31,7 +27,7 @@ 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
bind it cited at line 23750 now lives at `gitea_mcp_server.py:24721`, 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
@@ -75,19 +71,19 @@ 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.
`aad5c8b42361d380a8eeb07b94b90815e594c2c5`, 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. |
| B1 | LLM client ↔ MCP server session | A1, A3, A10 — that a mutating session was established through the sanctioned client path | A literal `stdio` bind (`gitea_mcp_server.py:24721`) inside a closed allowlist (`mcp_daemon_guard.py:45`, `mcp_daemon_guard.py:174`); client-managed provenance (`gitea_mcp_server.py:15412`) or a refusal (`gitea_mcp_server.py:15450`); production transport before recovery-authorization mint (`irrecoverable_provenance.py:497`, consumed at `gitea_mcp_server.py:9129` and `gitea_mcp_server.py:9378`) | 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. |
| 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:440`). 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. |
| 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:10894`); the active workspace is process-global (`gitea_mcp_server.py:190`, `gitea_mcp_server.py:191`) | 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:12801`). A legacy global slot still exists at `gitea_mcp_server.py:2348`, 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. |
| 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:19102` | Freshness defined against the deployed build identity, with an explicit fail-closed verdict when undeterminable. |
### What no boundary constrains
@@ -132,10 +128,10 @@ 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`).
(`gitea_mcp_server.py:19258`) reports each profile's credential status by calling
`resolve_token` on it (`gitea_mcp_server.py:19309`), and `gitea_audit_config`
(`gitea_mcp_server.py:19552`) reports service credential status through
`service_summaries` (`gitea_mcp_server.py:19574`).
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.
@@ -160,7 +156,7 @@ an attacker holding a token calls the API, not our tools.
| 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. |
| CR13 | macOS login keychain access | The operator's login session; gated by `assert_keychain_access_allowed` (`mcp_daemon_guard.py:440`) | 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
@@ -180,16 +176,16 @@ 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
(`gitea_mcp_server.py:19258`) 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
(`gitea_mcp_server.py:19309`). 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`,
(`gitea_mcp_server.py:19552`) reports `MDCPS Jenkins: enabled, read-only, authenticated`.
That word `authenticated` is produced by `service_summaries` (`gitea_mcp_server.py:19574`,
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
@@ -197,8 +193,8 @@ 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`,
(`gitea_mcp_server.py:17707`, `gitea_mcp_server.py:17713`, `gitea_mcp_server.py:17734`,
`gitea_mcp_server.py:17739`) 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
@@ -209,8 +205,8 @@ GlitchTip, the Sentry bridge runs *inside* the Gitea server, resolving its token
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`.
server spawns — including the `ps` invocations at `gitea_mcp_server.py:21462` and
`gitea_mcp_server.py:21506`, reached from `gitea_mcp_server.py:21442`.
**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,
@@ -219,15 +215,13 @@ assumes. Every other asset costs an attacker a credential; this one costs nothin
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
reported as exactly `client_managed` or `manual_launch` (`gitea_mcp_server.py:19001`),
derived from environment inspection (`gitea_mcp_server.py:15412`) 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.
of the process, and the stdio contract that underwrites it is stated as a property of the
server itself (`mcp_server.py:4`).
## 6. Decomposition ruling
@@ -259,7 +253,7 @@ 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
behavior behind `gitea_mcp_server.py:19309` and `gitea_mcp_server.py:19574` 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
@@ -336,11 +330,11 @@ 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. |
| B1 | Everything the fleet serves. The client *is* the sanctioned launcher: it satisfies the client-managed check (`gitea_mcp_server.py:15412`) 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 CR1CR10 via CR13, with no additional prompt — the daemon is already sanctioned, so `assert_keychain_access_allowed` (`mcp_daemon_guard.py:583`) returns immediately. | Nothing. |
| B3 | Every credential in CR1CR10 via CR13, with no additional prompt — the daemon is already sanctioned, so `assert_keychain_access_allowed` (`mcp_daemon_guard.py:440`) 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. |
| B5 | The operator's checkout and every worktree, through the author tools (`gitea_mcp_server.py:10894`), plus the shared stderr path at `mcp_server.py:10`. | 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. |
-282
View File
@@ -1,282 +0,0 @@
# Stale worker retirement (#980)
The #978 instance-fleet snapshot made registry accuracy observable but
deliberately read-only: a registry full of rows whose owning processes are long
gone stays full. This document describes the sanctioned way to retire those
rows — a dry-run-first, compare-and-swap-protected workflow available only to
controller and reconciler namespaces.
Related: [instance-fleet-identity.md](instance-fleet-identity.md) (#978),
[post-restart-reconcile.md](post-restart-reconcile.md) (#662).
## Why a dedicated registry token
`mcp_fleet_snapshot._consistency_token` seeds its digest with `snapshot_at`,
formatted at second precision. Its output — surfaced as `registry_revision` and
`consistency_token` on the snapshot — therefore changes on **every call**, even
when no registry row changed. Any compare-and-swap gated on it can never pass:
a dry-run/apply cycle spanning more than one second aborts unconditionally.
That token remains useful as an observation stamp and is unchanged. #980 adds a
separate, *stable* token instead:
| Token | Module | Derived from | Stable across time? |
| --- | --- | --- | --- |
| `registry_revision` / `consistency_token` | `mcp_fleet_snapshot` | `snapshot_at` + a subset of row fields | **No** — moves every second |
| `registry_fingerprint` | `mcp_fleet_retirement` | canonical retirement-relevant row content only | **Yes** |
| `candidate_fingerprint` | `mcp_fleet_retirement` | canonical content of the selected candidate rows | **Yes** |
`registry_fingerprint` guarantees:
* identical canonical registry contents always produce the same token, whenever
they are observed;
* row iteration order never affects the token (serialized rows are sorted);
* any retirement-relevant change moves it — row creation or deletion, identity
change, heartbeat or TTL change, ownership change, registration-state change,
PID change, or repository-binding change.
The exact field set is `mcp_fleet_retirement.FINGERPRINT_FIELDS`. Deliberately
excluded: `token_fingerprint` (credential-adjacent, never a retirement input),
the four `*_revision` columns (revision drift is an independent restart concern
and is not part of the eligibility conjunction), and the `retired_*` bookkeeping
columns this feature adds. Numeric values are canonicalised, so a TTL that
round-trips through SQLite as `900.0` hashes identically to `900`.
## Eligibility — the conjunction
A registration is retired only when **every** one of these holds. Any missing
or contradictory evidence preserves the row.
| Requirement | Preserve reason code when it fails |
| --- | --- |
| `status` is `active` | `already_terminal_registration` |
| Every field the conjunction reads is present (`REQUIRED_IDENTITY_FIELDS`) | `incomplete_registry_identity` |
| `last_heartbeat_at` parses as a UTC stamp | `unparsable_heartbeat` |
| Worker is not live | `worker_live` |
| PID probe returns a definite answer | `pid_liveness_unknown` |
| PID probe says the process is gone | `pid_alive` |
| Heartbeat has expired under the canonical TTL | `heartbeat_not_expired` |
| `ownership_state` is exactly `stale` | `ambiguous_ownership_state` |
| Repository binding present and canonical | `repository_binding_ambiguous` |
| No identity evidence shared with a live or unprobeable worker | `conflicting_identity_evidence` |
| No other active row claims the same (instance, namespace) while one may be live | `client_instance_conflict` |
| Not an active workflow-lease owner | `protected_active_workflow_owner` |
| Instance identity is launcher-minted (`inst-…`) | `untrusted_identity_provenance` |
| Row's `host_id` matches the host running retirement | `host_binding_unproven` |
| Boot identity is known on both sides | `boot_identity_unknown` |
| The pid number is not occupied by a different incarnation | `pid_reuse_detected` |
Eligible rows carry `eligible_stale_orphan`.
Three properties are worth stating explicitly:
* **`pid_alive` can only withdraw liveness, never grant it**
(`WorkerRegistry.is_live`, #948 AC7). A heartbeat-lapsed but still-running
process therefore classifies as `stale` in the snapshot, yet #980's added
`pid_alive is False` requirement preserves it. An unprobeable PID (`None`)
also fails closed.
* **Affirmative identity proof is required (review 657 B2).** An earlier
revision required only that the pre-existing registry columns were non-null —
which a legacy `legacy-pid-…` row satisfies trivially, so a row that proved
nothing about *which* process it described was retireable. Retirement now
needs both halves of a positive proof:
* **Attribution** — a launcher-minted `inst-…` `client_instance_id`, so the
row is known to belong to one specific application launch rather than
having been inferred from pid proximity.
* **Fencing** — `host_id`, `boot_id`, and `process_start_time`, which turn a
bare pid into a statement about one process: which machine it ran on, which
boot of that machine, and which incarnation of that pid number.
**Consequence, stated plainly:** registrations written before these columns
existed, and any row on a legacy instance identity, are preserved
*permanently*. They are retired only after their worker re-registers under a
trusted identity — never on weaker evidence. That the alternative would leave
legacy rows outstanding indefinitely is not a reason to relax the proof.
* **A live pid is an absolute block.** Even across a boot boundary, where the
number provably cannot belong to the registered process, an occupied pid
preserves the row rather than being argued away by the fencing proof.
Multiple processes belonging to one legitimate worker cohort are not treated as
multiple independent workers: the fleet model from #948/#978 is preserved
unchanged, and sharing a role or profile is never a duplicate.
## Tools
### `gitea_plan_stale_worker_retirement`
Read-only. Controller and reconciler only.
| Parameter | Meaning |
| --- | --- |
| `remote` | `dadeschools` or `prgs` |
| `host`, `org`, `repo` | Optional overrides (audit context) |
| `canonical_repository` | Expected repository binding; defaults to the process root |
Returns `registry_fingerprint`, `candidate_fingerprint`,
`candidate_worker_identities`, per-worker `candidates` and `preserved` entries
(each with `reason_code`, `detail`, and structured `evidence`),
`preserved_reason_counts`, `assessed_count`, `candidate_count`,
`preserved_count`, and `protected_active_workflow_owners`.
`mutation_performed` is always `false` and `read_only` is always `true`.
Planning is deterministic: the same authoritative registry contents produce the
same plan and the same tokens regardless of when they are observed.
### `gitea_apply_stale_worker_retirement`
Mutating. Controller and reconciler only.
| Parameter | Meaning |
| --- | --- |
| `registry_fingerprint` | The exact stable token the plan returned |
| `candidate_fingerprint` | The exact candidate-set token the plan returned |
| `worker_identities` | The exact candidate identities (list, or JSON / comma-separated string) |
| `remote`, `host`, `org`, `repo` | As above |
| `canonical_repository` | Must match the value the plan used |
Before touching the registry, apply fails closed on: profile permission, role
kind, master parity (`mutation_safe`), stable-runtime mode, capability
resolution refreshed immediately before mutation, worker-registry availability,
workflow-lease enumeration failure, and daemon-cohort uniqueness
(`classify_cohort`).
A matching token is necessary but never sufficient. Inside one
`BEGIN IMMEDIATE` transaction (`WorkerRegistry.retire_stale_workers`) the
server:
1. re-reads the authoritative rows;
2. recomputes `registry_fingerprint` from *those* rows and compares — a mismatch
returns `registry_revision_moved` with `retired_count: 0` and no write;
3. recomputes the eligibility plan from *those* rows — re-reading the active
workflow leases rather than reusing the set captured before the transaction
opened, so a lease acquired after planning still preserves its worker — and
compares `candidate_fingerprint`. A mismatch returns `candidate_set_moved`
with `retired_count: 0` and no write; a lease-enumeration failure raises and
rolls the transaction back;
4. revalidates every requested identity against that fresh plan;
5. retires each survivor with a guarded `UPDATE` that additionally asserts
`status`, `last_heartbeat_at`, `generation_id`, `session_id`,
`fencing_epoch`, and `pid` are unchanged. A guard that matches no row
preserves the worker with `row_changed_since_plan`.
There is no window between a safety check and its matching write, so a worker
that comes back to life, changes ownership, or is retired concurrently cannot be
removed on the strength of a stale observation. Any exception — including a
commit failure — rolls the whole transaction back and returns
`transaction_failed` with `success: false`, `retired_count: 0`, and
`mutation_performed: false`; a partial write can never be reported as success.
### Outcomes
| `outcome` | Meaning | `mutation_performed` |
| --- | --- | --- |
| `planned` | Dry-run result | `false` |
| `applied` | Transaction ran; see `retired` / `preserved` | `true` only if something was retired |
| `registry_revision_moved` | Registry changed between plan and apply | `false` |
| `candidate_set_moved` | Eligibility verdict changed between plan and apply | `false` |
| `already_retired` | Every requested row is already retired (idempotent replay) | `false` |
| `nothing_requested` | Empty target list | `false` |
| `transaction_failed` | Rolled back; nothing retired | `false` |
## What retirement does to the fleet snapshot
A retired row keeps its history: `status` moves to `retired` and `retired_at`,
`retired_by`, `retirement_reason` are recorded. Nothing is deleted. Because
`retired` is not `active`, the #978 snapshot counts the row as **historical**,
not stale, so `stale_worker_count` falls and historical rows never make the live
fleet unsafe by themselves.
**Retirement does not repair untrusted live identity.** Live workers registered
under legacy `pid-`/`proc-` instance identities are preserved untouched and
keep their `legacy_incomplete_identity` blockers. Retiring every stale row can
therefore legitimately produce:
* `stale_worker_count: 0`
* residual live `legacy_incomplete_identity` blockers
* `live_fleet_safe: false`
That is a truthful result, and the `post_apply` block reports the remaining
blockers rather than claiming the fleet became safe. Trusted
`client_instance_id` propagation through launchers is a separate enrollment
problem.
## Permissions
Plan and apply are authorized differently, and deliberately so (review 657 B1).
| | Plan | Apply |
| --- | --- | --- |
| Capability | `gitea.read` | `gitea.worker_registry.retire` |
| Nature | observational; opens no transaction, writes nothing | mutation |
| Roles | `controller`, `reconciler` | `controller`, `reconciler` |
An earlier revision authorized apply with `gitea.read` alone, reasoning that
the mutation lands in the local control-plane registry rather than in Gitea.
That the write is local makes it **no less a mutation**: sharing an
observational permission class with plan meant any profile that could *look*
could also *destroy*. Apply now requires its own capability.
* **Denied:** author, reviewer, merger, and every ordinary read-only profile —
they lack the capability, so they fail closed on the permission itself rather
than on the role check alone. The role restriction remains as defence in
depth: a profile mistakenly granted the capability still cannot reach apply
from an author, reviewer, or merger role.
* The capability is checked at entry **and** re-resolved immediately before the
registry mutation, so a profile change mid-call cannot be outrun.
* **No new Gitea write permission is introduced.**
`gitea.worker_registry.retire` authorizes exactly one local control-plane
transition (`worker_registrations.status -> retired`) and grants no branch,
issue, PR, review, merge, or restart authority. No author permission is
broadened.
* The fleet snapshot remains observational: nothing here turns it into a gate on
ordinary author work.
### Operator step
No profile holds `gitea.worker_registry.retire` by default, so apply is inert
until an operator adds it to the `allowed_operations` of the controller or
reconciler profile in `profiles.json`. Removing it again immediately and
completely revokes apply, while leaving plan and every other capability
untouched. That grant is a configuration change and is outside the scope of the
code that implements this feature.
## External-state fencing
`BEGIN IMMEDIATE` locks the worker registry and nothing else, so two inputs the
decision depends on sit outside the transaction's isolation domain: the
workflow-lease table in a separate control-plane database, and OS process
liveness. Re-reading them once during revalidation is not sufficient — the
per-target loop runs afterwards, so a lease acquired (or a pid revived) after
revalidation but before a given row's `UPDATE` would go unnoticed, and the
registry-column guard cannot catch it because no registry column changed.
Two mechanisms close that window, both applied per target immediately before
its own write:
* **`external_fence_fn`** — a version token over active leases
(`external_state_fingerprint`), captured inside the transaction *before* the
authoritative read and re-compared before every guarded `UPDATE`. Any movement
raises, rolling back the whole transaction: once the world has changed, every
remaining per-row decision was computed against a world that no longer exists.
An unreadable lease store raises rather than returning a token, because
"unreadable" must not silently compare equal to "unchanged".
* **`liveness_fn`** — a re-probe of process liveness and fencing identity that
must affirmatively re-establish that this exact process is gone. It compares
`process_start_time`, so a pid number reused since the plan is refused rather
than accepted.
A caller that supplies no `liveness_fn` retires nothing
(`liveness_reprobe_unavailable`) rather than proceeding unfenced. The guarded
`UPDATE` additionally asserts `host_id`, `boot_id`, and `process_start_time` are
unchanged, and all three participate in the CAS token, so fencing movement
alone is enough to abort.
## Non-goals
* Killing or restarting processes.
* Editing session files or configuration.
* Direct database cleanup outside the sanctioned transaction.
* Retiring live workers.
* Backfilling trusted identity for legacy workers.
* Rewriting worker ownership.
* Cleaning unrelated workflow-lease or issue-claim registries.
+15 -90
View File
@@ -1180,10 +1180,6 @@ RECOGNIZED_GITEA_ENV_KEYS = frozenset({
"GITEA_SERVER_PROVENANCE",
"GITEA_AUTHOR_WORKTREE",
"GITEA_ACTIVE_WORKTREE",
"GITEA_REVIEWER_WORKTREE",
"GITEA_MERGER_WORKTREE",
"GITEA_CANONICAL_REPOSITORY_ROOT",
"GITEA_MCP_SESSION_STATE_TTL_HOURS",
"GITEA_DISABLE_KEYCHAIN",
"GITEA_CONTROL_PLANE_DB",
"GITEA_DB_PATH",
@@ -1193,31 +1189,6 @@ RECOGNIZED_GITEA_ENV_KEYS = frozenset({
"GITEA_IRRECOVERABLE_HMAC_SECRET",
"GITEA_FORCE_MCP_RUNTIME_CHECK",
"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 = (
@@ -1245,70 +1216,24 @@ def get_unconsumed_gitea_env_overrides(env=None) -> dict[str, str]:
return unconsumed
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,
):
def launcher_entry(profile_name, config_path=None):
"""Return a thin MCP launcher entry for *profile_name*.
Contains command/args and the GITEA_MCP_* / GITEA_CLIENT_MANAGED env vars —
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).
Contains command/args and the GITEA_MCP_* / GITEA_CLIENT_MANAGED env vars — never a token
or password. Suitable for Claude / Gemini / Codex ``mcpServers`` blocks.
"""
import mcp_application_launcher as app_launcher
built = app_launcher.launcher_entry_for_profile(
profile_name,
client_type=client_type or "unknown",
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,
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,
)
command, args = server_command()
return {
"gitea-tools": {
"command": command,
"args": args,
"env": {
"GITEA_MCP_CONFIG": config_path or DEFAULT_CONFIG_PATH,
"GITEA_MCP_PROFILE": profile_name,
"GITEA_CLIENT_MANAGED": "1",
},
}
}
+176 -1767
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -500,11 +500,6 @@ def assess_transport_for_auth_mint() -> dict[str, Any]:
native = mcp_daemon_guard.is_native_mcp_transport()
pytest = mcp_daemon_guard.is_pytest_runtime()
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:
reasons.append(
"irrecoverable provenance authorization requires production native "
@@ -515,7 +510,6 @@ def assess_transport_for_auth_mint() -> dict[str, Any]:
"allowed": not reasons,
"native_mcp_transport": native,
"production_native_mcp_transport": production,
"bound_transport": bound,
"pytest": pytest,
"reasons": reasons,
}
-393
View File
@@ -1,393 +0,0 @@
"""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
+9 -21
View File
@@ -92,15 +92,7 @@ OPERATOR_UI_STEPS: dict[str, tuple[str, ...]] = {
),
}
#: 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"
DEFAULT_CLIENT = "codex"
def normalize_reason(reason: str | None) -> str:
@@ -136,18 +128,14 @@ def normalize_reason(reason: str | None) -> str:
def normalize_client(client: str | None) -> str:
"""Return the UI-step key for a client.
#948: alias resolution is shared with ``mcp_worker_identity`` so a client
name means the same thing wherever it is read. A name we recognise but have
no bespoke steps for — Gemini, Antigravity, Grok — resolves to the generic
host-agnostic steps rather than to another vendor's panel.
"""
import mcp_worker_identity
canonical = mcp_worker_identity.normalize_client_name(client)
if canonical in OPERATOR_UI_STEPS:
return canonical
"""Return a known client key for operator UI steps."""
text = (client or "").strip().lower().replace(" ", "_").replace("-", "_")
if text in ("codex", "openai_codex", "openai"):
return "codex"
if text in ("claude", "claude_code", "claude_desktop", "anthropic"):
return "claude_code"
if text in OPERATOR_UI_STEPS:
return text
return DEFAULT_CLIENT
+13 -176
View File
@@ -32,8 +32,6 @@ import time
from pathlib import Path
from typing import Any
import mcp_transport_config
SANCTIONED_DAEMON_ENV = "GITEA_MCP_SANCTIONED_DAEMON"
ALLOW_DIRECT_IMPORT_ENV = "GITEA_ALLOW_DIRECT_MCP_IMPORT"
ALLOW_KEYCHAIN_CLI_ENV = "GITEA_ALLOW_KEYCHAIN_CLI"
@@ -44,9 +42,7 @@ FORCE_PROVENANCE_FAIL_ENV = "GITEA_TEST_FORCE_UNSANCTIONED"
_NATIVE_RUNTIME: dict[str, Any] | None = None
# Production transport identifiers accepted by bind_native_mcp_transport.
# #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
_PRODUCTION_TRANSPORTS = frozenset({"stdio"})
_RUNTIME_MODE_PRODUCTION = "production"
_RUNTIME_MODE_TEST = "test"
_PHASE_ENTRYPOINT_CLAIMED = "entrypoint_claimed"
@@ -62,23 +58,6 @@ class UnsanctionedRuntimeError(RuntimeError):
"""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:
if (os.environ.get(FORCE_PROVENANCE_FAIL_ENV) or "").strip() in {
"1",
@@ -192,46 +171,23 @@ def mark_sanctioned_daemon() -> dict[str, Any]:
return native_runtime_status()
def bind_native_mcp_transport(*, transport: str | None = None) -> dict[str, Any]:
"""Bind the live native MCP transport lifecycle (#695 / #931).
def bind_native_mcp_transport(*, transport: str) -> dict[str, Any]:
"""Bind the live native MCP transport lifecycle (#695).
Must be called from the resolved canonical entrypoint immediately before
the real MCP server transport loop (``mcp.run``). Requires a prior
successful :func:`mark_sanctioned_daemon` claim in this process.
Import-only or offline launch without this bind leaves
the real MCP server transport loop (e.g. ``mcp.run(transport=\"stdio\")``).
Requires a prior successful :func:`mark_sanctioned_daemon` claim in this
process. Import-only or offline launch without this bind leaves
: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
if transport is None:
resolution = mcp_transport_config.resolve_configured_transport()
transport_name = str(resolution["transport"])
if not resolution["supported"]:
raise UnsanctionedRuntimeError(
"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)}."
)
transport_name = (transport or "").strip().lower()
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()
if entrypoint_path is None:
@@ -260,23 +216,6 @@ def bind_native_mcp_transport(*, transport: str | None = None) -> dict[str, Any]
"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).
# Changing GITEA_MCP_SESSION_STATE_DIR after bind must not manufacture a
# second authority domain for decision locks / workflow proofs.
@@ -414,88 +353,6 @@ def is_production_native_mcp_transport() -> bool:
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:
"""Backward-compatible name; #695 requires native transport, not env alone."""
if is_production_native_mcp_transport():
@@ -619,21 +476,6 @@ def native_runtime_status() -> dict[str, Any]:
"entrypoint_path": rt.get("entrypoint_path"),
"phase": rt.get("phase"),
"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"),
"session_state_dir": pinned_session_state_dir() or rt.get("session_state_dir"),
"session_state_dir_pinned": pinned_session_state_dir() is not None,
@@ -660,12 +502,7 @@ def mutation_provenance_fields() -> dict[str, Any]:
if st.get("mode") == _RUNTIME_MODE_TEST and st["native_mcp_transport"]:
transport = "test_native_mcp"
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,
"bound_transport": st.get("bound_transport"),
"native_mcp_transport": bool(st["native_mcp_transport"]),
"production_native_mcp_transport": bool(
st.get("production_native_mcp_transport")
-776
View File
@@ -1,776 +0,0 @@
"""CAS-protected retirement planning for stale worker registrations (#980).
#978 (merged PR #979) made the fleet observable: every registered namespace
worker, its instance attribution, its heartbeat freshness, and a structured
classification. It deliberately stopped there — the snapshot is read-only and
the control plane still had no sanctioned way to retire registry rows whose
owning process is conclusively gone.
This module is the *decision layer* for that retirement. It is pure: callers
supply registry rows, a clock, and a PID probe; nothing here opens SQLite,
scans process tables, or mutates state. The transactional apply lives in
:meth:`mcp_worker_identity.WorkerRegistry.retire_stale_workers`, which calls
back into these same pure functions so plan and apply can never disagree about
what "the registry looks like" or "which rows are eligible".
Why a separate token
--------------------
``mcp_fleet_snapshot._consistency_token`` seeds its digest with ``snapshot_at``
at second precision, so ``registry_revision`` changes on every call even when
no registry row changed. A compare-and-swap gated on it can never pass — a
dry-run/apply cycle spanning more than one second aborts unconditionally. That
token is still useful as an observation stamp, so it is left exactly as it is;
#980 gets its own :func:`registry_fingerprint`, derived *only* from canonical
retirement-relevant row content:
* identical registry contents observed at any two times produce the same token,
* row order never affects the token (serialized rows are sorted),
* any create/delete/identity/liveness/ownership/registration-state change to a
retirement-relevant field changes the token.
Fail-closed posture
-------------------
A worker is retired only when the control plane *conclusively* establishes it
is a stale orphan. Missing evidence is never read as permission: an unprobeable
PID, an unparsable heartbeat, a row that shares identity evidence with a live
or unprobeable worker, a foreign or absent repository binding, or a worker that
still owns an active workflow lease all preserve the row.
Trusted launcher identity (``inst-…`` provenance) is deliberately *not* part of
the conjunction. #980 places trusted ``client_instance_id`` propagation out of
scope and lists "backfilling trusted identity for legacy workers" as a non-goal;
requiring it here would preserve every legacy row forever and make the feature
inert. What *is* required is that the registry fields the conjunction reads are
actually present — see :data:`REQUIRED_IDENTITY_FIELDS`.
"""
from __future__ import annotations
import hashlib
from datetime import datetime
from typing import Any, Callable, Iterable, Mapping, Sequence
import mcp_fleet_snapshot as fleet
import mcp_worker_identity as mwi
# --- Outcomes -------------------------------------------------------------
OUTCOME_PLANNED = "planned"
OUTCOME_APPLIED = "applied"
OUTCOME_REGISTRY_MOVED = "registry_revision_moved"
OUTCOME_CANDIDATES_MOVED = "candidate_set_moved"
OUTCOME_ALREADY_RETIRED = "already_retired"
OUTCOME_NOTHING_REQUESTED = "nothing_requested"
# --- Reason codes ---------------------------------------------------------
#: The only reason code that authorizes retirement.
REASON_ELIGIBLE = "eligible_stale_orphan"
REASON_ALREADY_TERMINAL = "already_terminal_registration"
REASON_AMBIGUOUS_OWNERSHIP = "ambiguous_ownership_state"
REASON_CONFLICTING_IDENTITY = "conflicting_identity_evidence"
REASON_FOREIGN_REPOSITORY = "repository_binding_ambiguous"
REASON_HEARTBEAT_FRESH = "heartbeat_not_expired"
REASON_INCOMPLETE_IDENTITY = "incomplete_registry_identity"
REASON_NOT_IN_PLAN = "not_in_current_plan"
REASON_PID_ALIVE = "pid_alive"
REASON_PID_UNKNOWN = "pid_liveness_unknown"
REASON_PROTECTED_OWNER = "protected_active_workflow_owner"
REASON_ROW_CHANGED = "row_changed_since_plan"
REASON_ROW_MISSING = "registration_missing"
REASON_UNPARSABLE_HEARTBEAT = "unparsable_heartbeat"
REASON_WORKER_LIVE = "worker_live"
# --- #980 review 657 B2: affirmative identity/liveness proof ---------------
#: The registration's instance identity is not launcher-minted (``inst-…``),
#: so nothing proves which application launch this row belongs to.
REASON_UNTRUSTED_PROVENANCE = "untrusted_identity_provenance"
#: The row does not record which host its pid belongs to, or records a
#: different host than the one probing. A local pid probe cannot speak for a
#: process on another machine.
REASON_HOST_UNPROVEN = "host_binding_unproven"
#: Boot identity is missing on the row or unobtainable here, so a recorded pid
#: cannot be compared against a live pid at all.
REASON_BOOT_UNKNOWN = "boot_identity_unknown"
#: The pid is alive but belongs to a different process incarnation than the one
#: registered — reported distinctly from a plain live worker.
REASON_PID_REUSED = "pid_reuse_detected"
#: Two active registrations claim one client instance within one namespace.
REASON_INSTANCE_CONFLICT = "client_instance_conflict"
#: The immediate pre-write re-probe could not re-establish death (#980 B3).
REASON_LIVENESS_REPROBE = "liveness_reprobe_refused"
#: Instance-identity prefix minted by the trusted launcher. Kept in sync with
#: ``mcp_fleet_snapshot._TRUSTED_INSTANCE_PREFIX`` through
#: :func:`mcp_fleet_snapshot.assess_instance_identity`, which stays the single
#: authority on what "trusted" means — this module never re-implements it.
TRUSTED_INSTANCE_PREFIX = "inst-"
#: Registry columns that must carry a usable value before the eligibility
#: conjunction can even be evaluated. Absence is ambiguity, not permission.
#:
#: #980 review 657 B2 added the fencing triple. Before it, "complete identity"
#: meant only that the pre-existing columns were non-null, which a legacy
#: ``legacy-pid-…`` row satisfies trivially — so a row that proved nothing about
#: *which* process it described was retireable. The triple is what makes a
#: recorded pid interpretable: which machine it ran on, which boot of that
#: machine, and which incarnation of that pid number. A registration written
#: before these columns existed carries NULL and is therefore preserved
#: permanently, which is the intended fail-closed outcome.
REQUIRED_IDENTITY_FIELDS: tuple[str, ...] = (
"worker_identity",
"client_instance_id",
"session_id",
"generation_id",
"status",
"started_at",
"last_heartbeat_at",
"heartbeat_ttl_seconds",
"pid",
"host_id",
"boot_id",
"process_start_time",
)
#: Canonical retirement-relevant content. Ordering here is fixed and part of
#: the token contract; adding a field changes every fingerprint, so a change
#: here is a deliberate contract revision.
#:
#: Deliberately excluded: ``token_fingerprint`` (credential-adjacent, never a
#: retirement input), the four ``*_revision`` columns (revision drift is an
#: independent restart concern and is not part of the eligibility conjunction),
#: and the ``retired_*`` bookkeeping columns this feature adds.
FINGERPRINT_FIELDS: tuple[str, ...] = (
"worker_identity",
"client_name",
"client_instance_id",
"session_id",
"generation_id",
"role",
"profile",
"namespace",
"remote",
"repository_binding",
"pid",
"process_identity",
"transport",
"started_at",
"last_heartbeat_at",
"heartbeat_ttl_seconds",
"fencing_epoch",
"status",
"fleet_run_id",
"authenticated_account",
"instance_id_provenance",
# #980 review 657 B3: fencing evidence is a retirement input, so moving it
# must move the CAS token. Without these, a row whose host, boot, or
# process incarnation changed would hash identically to the row the plan
# approved.
"host_id",
"boot_id",
"process_start_time",
)
_FINGERPRINT_VERSION = "registryfp-v1"
_CANDIDATE_VERSION = "candidatefp-v1"
_UNIT = "\x1f"
_RECORD = "\x1e"
def _canon(value: Any) -> str:
"""Stable text for one field value, independent of Python/SQLite typing.
``900`` and ``900.0`` are the same TTL and must hash the same; a value that
round-trips through SQLite as REAL must not produce a different token than
the same value supplied by a caller as ``int``.
"""
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, float):
if value != value or value in (float("inf"), float("-inf")):
return repr(value)
if value.is_integer():
return str(int(value))
return repr(value)
if isinstance(value, int):
return str(value)
return str(value)
def _serialize_row(row: Mapping[str, Any]) -> str:
return _UNIT.join(f"{name}={_canon(row.get(name))}" for name in FINGERPRINT_FIELDS)
def _digest(version: str, serialized: Sequence[str], prefix: str) -> str:
ordered = sorted(serialized)
material = _RECORD.join([version, str(len(ordered)), *ordered])
return f"{prefix}-{hashlib.sha256(material.encode('utf-8')).hexdigest()[:32]}"
def registry_fingerprint(rows: Iterable[Mapping[str, Any]]) -> str:
"""Content-derived compare-and-swap token for the worker registry (#980).
Derived exclusively from :data:`FINGERPRINT_FIELDS` across every row. It
contains no ``snapshot_at``, wall-clock, request, or report-generation
time, so two observations of an unchanged registry always agree, and the
serialized rows are sorted so iteration order cannot perturb the digest.
"""
return _digest(
_FINGERPRINT_VERSION,
[_serialize_row(row) for row in rows],
"registryfp",
)
def candidate_fingerprint(candidate_rows: Iterable[Mapping[str, Any]]) -> str:
"""Exact-candidate-set token over the selected rows' canonical content.
A matching :func:`registry_fingerprint` already implies these rows are
unchanged; this second token additionally pins *which* rows the operator
approved, so an apply can never widen or narrow the approved set.
"""
return _digest(
_CANDIDATE_VERSION,
[_serialize_row(row) for row in candidate_rows],
"candidatefp",
)
def _probe_pid(
pid: Any, pid_alive_probe: Callable[[int | None], bool | None] | None
) -> bool | None:
if pid_alive_probe is None or pid is None:
return None
try:
result = pid_alive_probe(pid)
except Exception:
return None
return None if result is None else bool(result)
def _reuse_detected(
row: Mapping[str, Any],
live_start_time: str | None,
current_host_id: str | None,
current_boot_id: str | None,
) -> bool:
"""Is the pid occupied by a *different* incarnation than the one recorded?
Only meaningful when the recorded pid is comparable to the live one — same
machine, same boot. Across hosts or boots the number is unrelated by
construction and reuse is not the interesting question.
"""
recorded_host = (row.get("host_id") or "").strip()
recorded_boot = (row.get("boot_id") or "").strip()
if not current_host_id or recorded_host != current_host_id:
return False
if not current_boot_id or recorded_boot != current_boot_id:
return False
recorded_start = (row.get("process_start_time") or "").strip()
return bool(live_start_time) and live_start_time != recorded_start
def _instance_key(row: Mapping[str, Any]) -> tuple[str, str] | None:
"""The (instance, namespace) pair #978 requires to be unique among live rows."""
instance = _canon(row.get("client_instance_id"))
namespace = _canon(row.get("namespace"))
if not instance or not namespace:
return None
return (instance, namespace)
def _probe_start_time(
pid: Any, start_time_probe: Callable[[Any], str | None] | None
) -> str | None:
if start_time_probe is None or pid is None:
return None
try:
return start_time_probe(pid)
except Exception:
return None
def _evidence(
row: Mapping[str, Any],
snapshot_row: Mapping[str, Any],
pid_alive: bool | None,
) -> dict[str, Any]:
liveness = snapshot_row.get("liveness") or {}
return {
"worker_identity": row.get("worker_identity"),
"client_type": snapshot_row.get("client_type"),
"client_instance_id": row.get("client_instance_id"),
"fleet_run_id": row.get("fleet_run_id"),
"namespace": row.get("namespace"),
"profile": row.get("profile"),
"declared_role": row.get("role"),
"session_id": row.get("session_id"),
"generation_id": row.get("generation_id"),
"fencing_epoch": row.get("fencing_epoch"),
"process_identity": snapshot_row.get("process_identity"),
"pid": row.get("pid"),
"pid_alive": pid_alive,
"host_id": row.get("host_id"),
"boot_id": row.get("boot_id"),
"process_start_time": row.get("process_start_time"),
"repository_binding": row.get("repository_binding"),
"foreign_repository": bool(snapshot_row.get("foreign_repository")),
"status": row.get("status"),
"started_at": row.get("started_at"),
"last_heartbeat_at": row.get("last_heartbeat_at"),
"heartbeat_ttl_seconds": row.get("heartbeat_ttl_seconds"),
"heartbeat_age_seconds": liveness.get("heartbeat_age_seconds"),
"heartbeat_fresh": liveness.get("heartbeat_fresh"),
"live": bool(snapshot_row.get("live")),
"ownership_state": snapshot_row.get("ownership_state"),
"instance_id_provenance": snapshot_row.get("instance_id_provenance"),
"instance_identity_trusted": bool(
snapshot_row.get("instance_identity_trusted")
),
}
def _conflict_keys(
row: Mapping[str, Any], snapshot_row: Mapping[str, Any]
) -> list[tuple[str, str]]:
keys: list[tuple[str, str]] = []
for name, value in (
("session_id", row.get("session_id")),
("generation_id", row.get("generation_id")),
("process_identity", snapshot_row.get("process_identity")),
("pid", row.get("pid")),
):
text = _canon(value)
if text:
keys.append((name, text))
return keys
def external_state_fingerprint(
leases: Iterable[Mapping[str, Any]],
*,
liveness: Iterable[tuple[Any, Any]] = (),
) -> str:
"""Version token over the external state a retirement decision consumed.
#980 review 657 B3: ``BEGIN IMMEDIATE`` on the worker registry does not
cover the control-plane lease table or the OS process table, so those
inputs can move while the transaction is open. This token lets the
transaction detect that movement: it is captured before the authoritative
read and re-compared immediately before every guarded write, and any
difference aborts rather than retiring against evidence that has changed.
Only ownership-relevant lease fields participate, so unrelated churn (a
heartbeat timestamp advancing on an unrelated lease) does not cause
spurious aborts, while an acquire, release, or owner change always does.
"""
lease_units: list[str] = []
for lease in leases:
lease_units.append(
_UNIT.join(
f"{name}={_canon(lease.get(name))}"
for name in (
"lease_id",
"role",
"target",
"status",
"session_id",
"owner_session_id",
"owner_pid",
"session_pid",
"generation",
)
)
)
for pid, alive in liveness:
lease_units.append(f"liveness{_UNIT}pid={_canon(pid)}{_UNIT}alive={_canon(alive)}")
return _digest("externalfp-v1", lease_units, "externalfp")
def assess_retirement_identity_proof(
row: Mapping[str, Any],
snapshot_row: Mapping[str, Any],
*,
current_host_id: str | None,
current_boot_id: str | None,
live_start_time: str | None,
pid_alive: bool | None,
) -> tuple[str, str] | None:
"""Affirmative proof that this row names one specific, now-dead process.
Returns ``None`` when the proof holds, or ``(reason_code, detail)`` naming
the first thing that could not be established. #980 review 657 B2: absence
of evidence is never read as staleness, so every branch here refuses on
*missing* information exactly as firmly as on contradictory information.
The proof has two independent halves and needs both:
* **Attribution** — a launcher-minted ``inst-…`` instance identity, so the
row is known to belong to one specific application launch rather than
having been inferred from pid proximity.
* **Fencing** — the row's host matches the host doing the probing, boot
identity is known on both sides, and the recorded process incarnation
agrees with whatever currently occupies that pid number.
Requiring trusted attribution means pre-#978 ``legacy-pid-…`` rows are
preserved permanently. That is deliberate. The reviewer specifically
rejected the argument that legacy rows "would remain forever" as grounds
for a weaker proof, and #980 lists backfilling trusted identity for legacy
workers as a non-goal — so those rows are retired only after their worker
re-registers under a trusted identity, never on weaker evidence.
"""
if not snapshot_row.get("instance_identity_trusted"):
return (
REASON_UNTRUSTED_PROVENANCE,
"client_instance_id "
f"{row.get('client_instance_id')!r} is not launcher-minted "
f"({snapshot_row.get('instance_id_provenance')!r}); nothing proves "
"which application launch this registration belongs to",
)
recorded_host = (row.get("host_id") or "").strip()
if not current_host_id:
return (
REASON_HOST_UNPROVEN,
"this process cannot establish its own host identity, so a local "
"pid probe cannot be attributed to any machine",
)
if recorded_host != current_host_id:
return (
REASON_HOST_UNPROVEN,
f"registration is bound to host {recorded_host!r} but retirement is "
f"running on {current_host_id!r}; a local pid probe says nothing "
"about a process on another machine",
)
recorded_boot = (row.get("boot_id") or "").strip()
if not current_boot_id:
return (
REASON_BOOT_UNKNOWN,
"the current boot identity could not be determined, so a recorded "
"pid cannot be compared against a live pid",
)
recorded_start = (row.get("process_start_time") or "").strip()
if recorded_boot != current_boot_id:
# A different boot is the strongest possible death evidence: every pid
# from a previous boot is gone, and pid numbers restart, so whatever
# occupies this number now is unrelated by construction.
return None
# Same boot: the pid number is directly comparable, so the recorded
# incarnation must still agree with whatever holds that number.
if pid_alive and live_start_time and live_start_time != recorded_start:
return (
REASON_PID_REUSED,
f"pid {row.get('pid')!r} is alive but started at "
f"{live_start_time!r}, not the registered {recorded_start!r}; the "
"number was reused by an unrelated process and this registration's "
"own liveness is therefore unproven",
)
if pid_alive:
return (
REASON_PID_ALIVE,
f"recorded pid {row.get('pid')!r} is still running on this host and "
"boot",
)
return None
def _missing_identity_fields(row: Mapping[str, Any]) -> list[str]:
missing: list[str] = []
for name in REQUIRED_IDENTITY_FIELDS:
value = row.get(name)
if value is None or (isinstance(value, str) and not value.strip()):
missing.append(name)
return missing
def plan_stale_worker_retirement(
rows: Iterable[Mapping[str, Any]],
*,
now: datetime | None = None,
pid_alive_probe: Callable[[int | None], bool | None] | None = None,
canonical_repository: str | None = None,
protected_worker_identities: Iterable[str] | None = None,
protected_session_ids: Iterable[str] | None = None,
protected_pids: Iterable[Any] | None = None,
current_host_id: str | None = None,
current_boot_id: str | None = None,
start_time_probe: Callable[[Any], str | None] | None = None,
) -> dict[str, Any]:
"""Decide, without mutating anything, which registrations may be retired.
Every row lands in exactly one of ``candidates`` (eligible) or
``preserved`` (with the reason code that stopped it), so the output
explains the whole registry rather than only the interesting part.
"""
all_rows = [dict(row) for row in rows]
protected_ids = {str(w) for w in (protected_worker_identities or []) if w}
protected_sessions = {str(s) for s in (protected_session_ids or []) if s}
protected_pid_set = {_canon(p) for p in (protected_pids or []) if p is not None}
snapshots: dict[int, dict[str, Any]] = {}
pid_alive_by_index: dict[int, bool | None] = {}
start_time_by_index: dict[int, str | None] = {}
for index, row in enumerate(all_rows):
pid_alive = _probe_pid(row.get("pid"), pid_alive_probe)
pid_alive_by_index[index] = pid_alive
start_time_by_index[index] = _probe_start_time(
row.get("pid"), start_time_probe
)
snapshots[index] = fleet.build_worker_snapshot_row(
row,
now=now,
pid_alive_probe=(lambda _pid, _value=pid_alive: _value),
canonical_repository=canonical_repository,
)
# Identity evidence owned by a worker that is live, or whose liveness could
# not be established, is ambiguous: anything sharing it is preserved.
ambiguous_keys: set[tuple[str, str]] = set()
identity_counts: dict[str, int] = {}
for index, row in enumerate(all_rows):
identity = _canon(row.get("worker_identity"))
if identity:
identity_counts[identity] = identity_counts.get(identity, 0) + 1
snapshot_row = snapshots[index]
liveness = snapshot_row.get("liveness") or {}
unresolved = (
pid_alive_by_index[index] is None
or liveness.get("heartbeat_fresh") is None
)
if snapshot_row.get("live") or (
str(row.get("status") or "") == mwi.STATUS_ACTIVE and unresolved
):
ambiguous_keys.update(_conflict_keys(row, snapshot_row))
# Two active registrations claiming one (client_instance_id, namespace)
# violate the #978 uniqueness invariant — but only when one of them might
# still be running. Several *dead* rows accumulating on one slot across
# restarts is ordinary history and every one of them is safely retirable;
# a slot shared with a live or unprobeable worker is genuinely ambiguous,
# because which row that process belongs to cannot be settled from the
# registry alone.
#
# The key is deliberately the (instance, namespace) pair, not the instance
# alone: one legitimate cohort is exactly one instance spread across
# distinct namespaces, so keying on the instance would make every cohort
# look self-conflicting and preserve the whole fleet forever.
instance_members: dict[tuple[str, str], list[int]] = {}
for index, row in enumerate(all_rows):
if str(row.get("status") or "") != mwi.STATUS_ACTIVE:
continue
key = _instance_key(row)
if key is None:
continue
instance_members.setdefault(key, []).append(index)
instance_conflicts: dict[tuple[str, str], bool] = {}
for key, members in instance_members.items():
if len(members) < 2:
continue
contested = any(
snapshots[i].get("live") or pid_alive_by_index[i] is None
for i in members
)
if contested:
instance_conflicts[key] = True
candidates: list[dict[str, Any]] = []
candidate_rows: list[Mapping[str, Any]] = []
preserved: list[dict[str, Any]] = []
for index, row in enumerate(all_rows):
snapshot_row = snapshots[index]
pid_alive = pid_alive_by_index[index]
liveness = snapshot_row.get("liveness") or {}
evidence = _evidence(row, snapshot_row, pid_alive)
blocked: tuple[str, str] | None = None
identity = _canon(row.get("worker_identity"))
missing = _missing_identity_fields(row)
shared = sorted(
f"{name}={value}"
for name, value in _conflict_keys(row, snapshot_row)
if (name, value) in ambiguous_keys
)
binding = (row.get("repository_binding") or "").strip()
protected_hits: list[str] = []
if identity and identity in protected_ids:
protected_hits.append(f"worker_identity={identity}")
if _canon(row.get("session_id")) in protected_sessions:
protected_hits.append(f"session_id={_canon(row.get('session_id'))}")
if _canon(row.get("pid")) in protected_pid_set:
protected_hits.append(f"pid={_canon(row.get('pid'))}")
if identity and identity_counts.get(identity, 0) > 1:
blocked = (
REASON_CONFLICTING_IDENTITY,
f"worker identity {identity!r} appears on more than one registry row",
)
elif str(row.get("status") or "") != mwi.STATUS_ACTIVE:
blocked = (
REASON_ALREADY_TERMINAL,
f"registration status is {row.get('status')!r}; nothing to retire",
)
elif missing:
blocked = (
REASON_INCOMPLETE_IDENTITY,
"registry row is missing field(s) the retirement conjunction "
f"reads: {missing}",
)
elif mwi._parse_ts(row.get("last_heartbeat_at")) is None:
blocked = (
REASON_UNPARSABLE_HEARTBEAT,
"last_heartbeat_at is not a parsable UTC stamp; liveness is unknown",
)
elif snapshot_row.get("live"):
blocked = (REASON_WORKER_LIVE, "worker is live and must not be retired")
elif pid_alive is None:
blocked = (
REASON_PID_UNKNOWN,
f"pid {row.get('pid')!r} could not be probed; liveness is unproven",
)
elif pid_alive and _reuse_detected(
row, start_time_by_index[index], current_host_id, current_boot_id
):
# Reported before the generic live-pid branch so the operator sees
# *why* the number is occupied: an unrelated process inherited it,
# which means this registration's own liveness is unproven rather
# than positively established.
blocked = (
REASON_PID_REUSED,
f"pid {row.get('pid')!r} is alive but started at "
f"{start_time_by_index[index]!r}, not the registered "
f"{row.get('process_start_time')!r}; the number was reused",
)
elif pid_alive:
blocked = (
REASON_PID_ALIVE,
f"recorded pid {row.get('pid')!r} is still running",
)
elif liveness.get("heartbeat_fresh") is not False:
blocked = (
REASON_HEARTBEAT_FRESH,
"heartbeat has not expired under the canonical TTL policy",
)
elif snapshot_row.get("ownership_state") != "stale":
blocked = (
REASON_AMBIGUOUS_OWNERSHIP,
"ownership_state is "
f"{snapshot_row.get('ownership_state')!r}, not 'stale'",
)
elif not binding or snapshot_row.get("foreign_repository"):
blocked = (
REASON_FOREIGN_REPOSITORY,
"repository binding is absent or does not match the canonical "
"repository; retirement scope is ambiguous",
)
elif shared:
blocked = (
REASON_CONFLICTING_IDENTITY,
"identity evidence is shared with a live or unprobeable worker: "
f"{shared}",
)
elif protected_hits:
blocked = (
REASON_PROTECTED_OWNER,
"worker still owns active workflow state requiring separate "
f"reconciliation: {protected_hits}",
)
elif instance_conflicts.get(_instance_key(row)):
blocked = (
REASON_INSTANCE_CONFLICT,
"another active registration claims client_instance_id "
f"{row.get('client_instance_id')!r} in namespace "
f"{row.get('namespace')!r}; instance ownership is ambiguous",
)
else:
# Affirmative identity + fencing proof runs last: everything above
# establishes the row is *inert*, and this establishes it is
# unambiguously *this* worker (#980 review 657 B2).
blocked = assess_retirement_identity_proof(
row,
snapshot_row,
current_host_id=current_host_id,
current_boot_id=current_boot_id,
live_start_time=start_time_by_index[index],
pid_alive=pid_alive,
)
if blocked is not None:
preserved.append(
{
"worker_identity": row.get("worker_identity"),
"reason_code": blocked[0],
"detail": blocked[1],
"evidence": evidence,
}
)
continue
candidates.append(
{
"worker_identity": row.get("worker_identity"),
"reason_code": REASON_ELIGIBLE,
"detail": (
"dead pid, expired heartbeat, stale ownership, unambiguous "
"identity, canonical repository binding, no active workflow "
"ownership"
),
"evidence": evidence,
}
)
candidate_rows.append(row)
counts: dict[str, int] = {}
for entry in preserved:
counts[entry["reason_code"]] = counts.get(entry["reason_code"], 0) + 1
return {
"success": True,
"read_only": True,
"mutation_performed": False,
"outcome": OUTCOME_PLANNED,
"registry_fingerprint": registry_fingerprint(all_rows),
"candidate_fingerprint": candidate_fingerprint(candidate_rows),
"assessed_count": len(all_rows),
"candidate_count": len(candidates),
"preserved_count": len(preserved),
"candidates": candidates,
"candidate_worker_identities": [c["worker_identity"] for c in candidates],
"preserved": preserved,
"preserved_reason_counts": counts,
"protected_inputs": {
"worker_identities": sorted(protected_ids),
"session_ids": sorted(protected_sessions),
"pids": sorted(protected_pid_set),
},
"canonical_repository": canonical_repository,
"fencing_context": {
"current_host_id": current_host_id,
"current_boot_id": current_boot_id,
"start_time_probe_available": start_time_probe is not None,
},
}
def summarize_plan(plan: Mapping[str, Any]) -> dict[str, Any]:
"""Compact, log-safe view of a plan or apply result."""
return {
"outcome": plan.get("outcome"),
"registry_fingerprint": plan.get("registry_fingerprint"),
"candidate_fingerprint": plan.get("candidate_fingerprint"),
"assessed_count": plan.get("assessed_count"),
"candidate_count": plan.get("candidate_count"),
"retired_count": plan.get("retired_count"),
"preserved_count": plan.get("preserved_count"),
"mutation_performed": plan.get("mutation_performed"),
}
-869
View File
@@ -1,869 +0,0 @@
"""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)),
}
+41 -384
View File
@@ -16,6 +16,7 @@ Probe sources
from __future__ import annotations
import os
from typing import Any
@@ -51,363 +52,63 @@ EOF_PATTERNS = (
"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 = (
"GITEA_MCP_PROFILE",
"GITEA_PROFILE_NAME",
"GITEA_SERVICE",
"GITEA_EXECUTION_ROLE",
"GITEA_MCP_CONFIG",
"GITEA_MCP_NAMESPACE",
)
# #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.
# Optional launcher-provided env declaring the client-managed MCP namespace
# this process is registered under (e.g. ``gitea-reviewer``). The server
# cannot derive its own IDE namespace name, so the launcher declares it; when
# declared, reviewers/mergers can fail closed on a namespace/task mismatch
# (#690 AC4). Absence means "unknown" — reported, never guessed.
NAMESPACE_ENV = "GITEA_MCP_NAMESPACE"
def assess_connected_namespace_attachment(
def configured_client_namespace(env: dict[str, str] | None = None) -> str | None:
"""Return the launcher-declared client namespace, or None when unknown."""
source = os.environ if env is None else env
value = (source.get(NAMESPACE_ENV) or "").strip()
return value or None
def namespace_provenance(
task: str | None = None,
*,
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,
active_profile: str | None = None,
env: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Assess whether host-connected MCP servers have attached tool namespaces in the active session (#708).
"""Report configured client namespace vs active execution profile (#690).
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.
When *task* carries a required namespace (``TASK_REQUIRED_NAMESPACES``)
and the launcher declared a different one, ``mismatch`` is True and the
caller must fail closed for that task. An undeclared namespace is
reported as unknown — never treated as proof either way.
"""
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
configured = configured_client_namespace(env)
required = TASK_REQUIRED_NAMESPACES.get(task or "")
mismatch = bool(configured and required and configured != required)
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:
if mismatch:
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)."
f"configured client namespace '{configured}' does not match "
f"required namespace '{required}' for task '{task}' (fail closed)"
)
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,
"configured_namespace": configured,
"namespace_source": NAMESPACE_ENV if configured else "unknown",
"active_profile": active_profile,
"requested_task": task,
"required_namespace": required,
"mismatch": mismatch,
"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:
if value is None:
return None
@@ -452,23 +153,12 @@ def classify_namespace_probe(
profile: str | None = None,
configured: bool = True,
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]:
"""Classify whether a required tool is callable through a live namespace.
``registered_tools`` is static/server-side evidence. ``probe_result`` is
live invocation evidence. Only ``probe_source=client_namespace`` proves the
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()
tool = required_tool or REQUIRED_NAMESPACE_TOOLS.get(ns) or "gitea_whoami"
@@ -585,31 +275,14 @@ def classify_namespace_probe(
blocks = namespace_health_blocks_task("merge_pr", healthy)
import gitea_config
import mcp_worker_identity
raw_env = process.get("env") if isinstance(process, dict) else None
unconsumed_env = gitea_config.get_unconsumed_gitea_env_overrides(raw_env)
# #948: one authority, shared with gitea_get_runtime_context. The env is
# passed whole rather than through SAFE_ENV_KEYS — the allowlist exists to
# 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,
is_client_managed = bool(
env_summary.get("GITEA_CLIENT_MANAGED") in ("1", "true", "yes", "client_managed")
or env_summary.get("GITEA_MCP_CLIENT_MANAGED") in ("1", "true", "yes", "client_managed")
or env_summary.get("GITEA_SERVER_PROVENANCE") == "client_managed"
)
provenance = provenance_verdict["provenance"]
is_client_managed = provenance_verdict["is_client_managed"]
provenance = "client_managed" if is_client_managed else "manual_launch"
return {
"success": healthy,
@@ -628,15 +301,6 @@ def classify_namespace_probe(
"remediation": remediation,
"provenance": provenance,
"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,
"diagnostics": {
"namespace": ns,
@@ -648,13 +312,6 @@ def classify_namespace_probe(
"probe_source": source,
"provenance": provenance,
"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,
},
"blocks_merge_workflow": blocks,
-205
View File
@@ -1,205 +0,0 @@
"""Host, boot, and process-start fencing evidence for retirement safety (#980).
Review 657 B2/B3 established that a local ``os.kill(pid, 0)`` probe is not, on
its own, evidence that a *particular registered worker* is gone:
* **Host ambiguity.** A registry row written on host A records pid 1234. Probing
pid 1234 on host B answers a question nobody asked. "Not running here" is not
"not running".
* **PID reuse.** Pid 1234 may be alive and belong to an unrelated process that
the kernel handed the number to after the original exited. The naive probe
reads that as "the worker is live" (safe, over-preserving) — but the converse
matters too: evidence captured about pid 1234 at time T must not be honoured
at time T+n if the process behind that number changed in between.
* **Boot boundaries.** Every pid from a previous boot is conclusively gone, and
pid numbers restart, so a recorded pid is only comparable to a live pid when
both belong to the same boot.
This module supplies the three pieces of evidence that turn a bare pid into a
statement about one specific process:
``host_id``
Which machine the pid belongs to.
``boot_id``
Which boot of that machine the pid belongs to. Pids are only comparable
within a single boot.
``process_start_time``
Which *incarnation* of that pid number. Two processes on the same host and
boot sharing a pid number cannot share a start time, so comparing start
times defeats reuse.
Every probe here is read-only, never raises, and returns ``None`` when the
evidence cannot be established. ``None`` means *unknown*, and the retirement
conjunction is required to treat unknown as "preserve", never as "safe".
"""
from __future__ import annotations
import os
import platform
import subprocess
# --- Host identity --------------------------------------------------------
def current_host_id() -> str | None:
"""Stable identifier for the machine this process runs on.
Deliberately the kernel node name rather than anything network-derived: it
does not change when an interface goes down or a VPN reassigns an address,
and retirement must not become unsafe because DNS moved.
"""
try:
node = (platform.node() or "").strip()
except Exception:
return None
return node or None
# --- Boot identity --------------------------------------------------------
def _linux_boot_id() -> str | None:
try:
with open("/proc/sys/kernel/random/boot_id", encoding="utf-8") as handle:
value = handle.read().strip()
except Exception:
return None
return value or None
def _darwin_boot_id() -> str | None:
"""macOS boot identity, derived from ``kern.boottime``.
``sysctl`` prints e.g. ``{ sec = 1785400000, usec = 123456 } Wed Jul 30 ...``.
Only the integer seconds are kept: the trailing human-readable date is
locale-dependent and would make the identifier unstable across environments
for the very same boot.
"""
try:
completed = subprocess.run(
["/usr/sbin/sysctl", "-n", "kern.boottime"],
capture_output=True,
text=True,
timeout=5,
check=False,
)
except Exception:
return None
if completed.returncode != 0:
return None
text = (completed.stdout or "").strip()
marker = "sec = "
start = text.find(marker)
if start < 0:
return None
tail = text[start + len(marker) :]
digits = ""
for char in tail:
if char.isdigit():
digits += char
else:
break
return f"boot-{digits}" if digits else None
def current_boot_id() -> str | None:
"""Identifier for the current boot, or ``None`` when it cannot be proven."""
system = ""
try:
system = (platform.system() or "").strip().lower()
except Exception:
system = ""
if system == "linux":
return _linux_boot_id()
if system == "darwin":
return _darwin_boot_id()
# An unrecognised platform yields no boot evidence rather than a guess.
return None
# --- Process start time ---------------------------------------------------
def _linux_process_start_time(pid: int) -> str | None:
"""Field 22 of ``/proc/<pid>/stat`` — start time in clock ticks since boot.
The executable name in field 2 is parenthesised and may itself contain
spaces and parentheses, so the fields are located from the *last* ``)``
rather than by splitting the whole line.
"""
try:
with open(f"/proc/{pid}/stat", encoding="utf-8") as handle:
raw = handle.read()
except Exception:
return None
close = raw.rfind(")")
if close < 0:
return None
fields = raw[close + 1 :].split()
# After the ')' the next field is state (field 3), so field 22 is index 19.
if len(fields) < 20:
return None
value = fields[19].strip()
return f"ticks-{value}" if value else None
def _darwin_process_start_time(pid: int) -> str | None:
"""macOS process start, from ``ps -o lstart=``.
``lstart`` is the absolute wall-clock start of that pid's current
incarnation. Two processes reusing one pid number report different values,
which is exactly the discrimination reuse detection needs.
"""
try:
completed = subprocess.run(
["/bin/ps", "-o", "lstart=", "-p", str(int(pid))],
capture_output=True,
text=True,
timeout=5,
check=False,
)
except Exception:
return None
if completed.returncode != 0:
return None
value = " ".join((completed.stdout or "").split())
return f"lstart-{value}" if value else None
def process_start_time(pid: int | None) -> str | None:
"""Start-time token for *pid*'s current incarnation, or ``None``.
``None`` is returned both when the pid does not exist and when the platform
cannot answer. Callers must not read either case as evidence of death — a
dead pid is established by the liveness probe, and this value only ever
*withdraws* a retirement that pid-level evidence would otherwise allow.
"""
if pid is None:
return None
try:
numeric = int(pid)
except (TypeError, ValueError):
return None
if numeric <= 0:
return None
system = ""
try:
system = (platform.system() or "").strip().lower()
except Exception:
system = ""
if system == "linux":
return _linux_process_start_time(numeric)
if system == "darwin":
return _darwin_process_start_time(numeric)
return None
def current_process_fencing(pid: int | None = None) -> dict[str, str | None]:
"""The full fencing triple for *pid* (defaults to this process)."""
target = os.getpid() if pid is None else pid
return {
"host_id": current_host_id(),
"boot_id": current_boot_id(),
"process_start_time": process_start_time(target),
}
+3 -7
View File
@@ -1,10 +1,7 @@
#!/usr/bin/env python3
"""Gitea MCP Server — exposes Gitea operations as MCP tools.
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).
Runs over stdio. All tools authenticate via macOS keychain (git credential fill).
"""
import os
import sys
@@ -46,9 +43,8 @@ check_conflict_markers()
# #558 / #695: claim the official entrypoint before loading mutation modules.
# This alone does NOT authorize mutations — gitea_mcp_server binds the live
# native MCP transport immediately before mcp.run, over the configured
# transport (#931). Import-only or offline launch without that bind fails
# closed on mutations.
# native MCP transport (stdio) immediately before mcp.run. Import-only or
# offline launch without that bind fails closed on mutations.
try:
import mcp_daemon_guard
-4
View File
@@ -588,13 +588,9 @@ def save_state(
bool(prov.get("production_native_mcp_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:
body.setdefault("native_mcp_transport", False)
body.setdefault("transport", "untrusted")
body.setdefault("bound_transport", None)
envelope = {
"kind": kind,
-292
View File
@@ -1,292 +0,0 @@
"""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
+27 -153
View File
@@ -8,10 +8,8 @@ poison workspace purity checks in another namespace.
from __future__ import annotations
import os
import subprocess
import author_mutation_worktree as amw
import canonical_repository_root as crr
ACTIVE_WORKTREE_ENV = amw.ACTIVE_WORKTREE_ENV
AUTHOR_WORKTREE_ENV = amw.AUTHOR_WORKTREE_ENV
@@ -154,60 +152,6 @@ def resolve_namespace_workspace(
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(
*,
role_kind: str,
@@ -219,8 +163,6 @@ def resolve_namespace_mutation_context(
worktree: str | None = None,
profile_name: str | None = None,
configured_canonical_root: str | None = None,
expected_slug: str | None = None,
remote: str | None = None,
) -> dict:
"""Shared workspace resolution for runtime_context and mutation guards.
@@ -238,41 +180,11 @@ def resolve_namespace_mutation_context(
env_map = env if env is not None else os.environ
process_root = os.path.realpath(process_project_root)
role = normalize_role_kind(role_kind, profile_name=profile_name)
configured_val = (configured_canonical_root or "").strip()
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"]
configured = (configured_canonical_root or "").strip()
if configured:
canonical_root = os.path.realpath(configured)
else:
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)
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
durable: dict | None = None
if role == "author":
@@ -322,10 +234,7 @@ def resolve_namespace_mutation_context(
"ignored_bindings": demotions + (pollution.get("ignored_bindings") or []),
"process_project_root": process_root,
"canonical_repo_root": canonical_root,
"roots_aligned": roots_aligned,
"canonical_root_assessment": crr_assessment,
"expected_slug": expected_slug,
"remote": remote,
"roots_aligned": canonical_root == process_root,
}
if durable is not None:
result["author_worktree_resolution"] = durable
@@ -337,15 +246,6 @@ def resolve_namespace_mutation_context(
result["author_worktree_reasons"] = list(durable.get("reasons") or [])
result["author_worktree_blocker_kind"] = durable.get("blocker_kind")
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
@@ -478,8 +378,8 @@ def format_namespace_workspace_binding_error(
def assess_namespace_mutation_workspace(
*,
role_kind: str,
worktree_path: str | None = None,
worktree: str | None = None,
worktree_path: str | None,
worktree: str | None,
process_project_root: str,
env: dict[str, str] | os._Environ | None = None,
session_lease_worktree: str | None = None,
@@ -487,8 +387,6 @@ def assess_namespace_mutation_workspace(
profile_name: str | None = None,
current_branch: str | None = None,
configured_canonical_root: str | None = None,
expected_slug: str | None = None,
remote: str | None = None,
) -> dict:
"""Evaluate namespace workspace binding before preflight/mutation."""
ctx = resolve_namespace_mutation_context(
@@ -501,8 +399,6 @@ def assess_namespace_mutation_workspace(
session_lock_worktree=session_lock_worktree,
profile_name=profile_name,
configured_canonical_root=configured_canonical_root,
expected_slug=expected_slug,
remote=remote,
)
mutation_workspace = ctx["workspace_path"]
binding_source = ctx["workspace_binding_source"]
@@ -526,27 +422,6 @@ def assess_namespace_mutation_workspace(
reasons = list(metadata.get("reasons") or [])
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":
# #618 durable resolution already validated existence, membership,
# branches/, lock ownership, and traversal safety when present.
@@ -563,27 +438,26 @@ def assess_namespace_mutation_workspace(
)
if branches["block"]:
reasons.extend(branches["reasons"])
elif role in {"reviewer", "merger"}:
if mutation_workspace == process_root and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"]):
reasons.append(
f"{role} mutation blocked: workspace is the stable control checkout; "
f"create or reconnect to a session-owned worktree under branches/ "
f"or set {ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} / "
f"{ACTIVE_WORKTREE_ENV}"
)
elif mutation_workspace != process_root and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"]):
reasons.append(
f"{role} mutation blocked: workspace '{mutation_workspace}' is not under "
f"'{ctx['canonical_repo_root']}/branches/'"
)
if path_exists and amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"]):
in_list = ctx.get("in_git_worktree_list")
if in_list is False:
reasons.append(
f"{role} mutation blocked: workspace '{mutation_workspace}' is under branches/ "
f"but is not registered in git worktree list for '{ctx['canonical_repo_root']}'"
)
elif (
role == "reviewer"
and mutation_workspace == process_root
and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"])
):
reasons.append(
f"{role} mutation blocked: workspace is the stable control checkout; "
f"create or reconnect to a session-owned worktree under branches/ "
f"or set {ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} / "
f"{ACTIVE_WORKTREE_ENV}"
)
elif (
role in {"reviewer", "merger"}
and mutation_workspace != process_root
and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"])
):
reasons.append(
f"{role} mutation blocked: workspace '{mutation_workspace}' is not under "
f"'{ctx['canonical_repo_root']}/branches/'"
)
block = bool(reasons)
return {
-22
View File
@@ -204,28 +204,6 @@ proposed command before running it; `gitea_audit_runtime_recovery_contamination`
to inspect or (reconciler-only) clear the marker. Full contrast in
`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
`exit_code: -1` with empty stdout/stderr means the shell failed to spawn — not a
-71
View File
@@ -163,56 +163,6 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.read",
"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",
},
# #980: CAS-protected retirement of conclusively stale worker
# registrations.
#
# Planning is observational and stays on ``gitea.read``: it opens no
# transaction, writes nothing, and returns only what a fleet snapshot
# already exposes to the same roles.
#
# Applying is a mutation and review 657 B1 established that ``gitea.read``
# cannot authorize it. The mutation landing in the local control-plane
# registry rather than in Gitea makes it *no less* a mutation, and sharing
# an observational permission class with plan meant any profile that could
# look could also destroy. It now requires its own permission,
# ``gitea.worker_registry.retire``, which no profile holds by default — so
# author, reviewer, merger, and ordinary read-only profiles fail closed on
# the permission itself rather than relying on the role check alone. The
# role restriction (controller/reconciler), runtime parity, cohort
# uniqueness, and the exact registry + candidate fingerprints all remain,
# and are now defence in depth behind the capability rather than a
# substitute for it.
#
# Granting the permission is a deliberate operator act in profiles.json;
# removing it from a profile immediately and completely revokes apply.
"plan_stale_worker_retirement": {
"permission": "gitea.read",
"role": "controller",
},
"gitea_plan_stale_worker_retirement": {
"permission": "gitea.read",
"role": "controller",
},
"apply_stale_worker_retirement": {
"permission": "gitea.worker_registry.retire",
"role": "controller",
},
"gitea_apply_stale_worker_retirement": {
"permission": "gitea.worker_registry.retire",
"role": "controller",
},
# #644: Phase 2 Web Console recovery tasks.
"clear_stale_binding": {
"permission": "gitea.read",
@@ -436,25 +386,6 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.branch.delete",
"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": {
"permission": "gitea.pr.create",
"role": "author",
@@ -722,8 +653,6 @@ ROLE_EXCLUSIVE_TASKS: frozenset[str] = frozenset(
"delete_branch",
"cleanup_merged_pr_branch",
"reconciliation_cleanup",
"reconcile_missing_worktree_bindings",
"gitea_reconcile_missing_worktree_bindings",
"work_issue",
"work-issue",
}
+2
View File
@@ -41,6 +41,7 @@ def _reset_mutation_authority(monkeypatch):
"GITEA_REVIEWER_WORKTREE",
"GITEA_MERGER_WORKTREE",
"GITEA_RECONCILER_WORKTREE",
"GITEA_MCP_NAMESPACE",
]:
monkeypatch.delenv(env_key, raising=False)
@@ -117,6 +118,7 @@ def _reset_mutation_authority(monkeypatch):
monkeypatch.setattr(mcp_server, "_ACTOR_IDENTITY_CACHE", {})
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
monkeypatch.setattr(mcp_server, "_LIVE_NAMESPACE_HEALTH", {})
monkeypatch.setattr(mcp_server, "_PROFILE_SWITCH_INVALIDATION", None)
monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False)
monkeypatch.setattr(mcp_server, "_preflight_capability_called", False)
monkeypatch.setattr(mcp_server, "_preflight_resolved_role", None)
+1 -14
View File
@@ -175,21 +175,8 @@ class TestLauncherSnippets(unittest.TestCase):
def test_only_safe_keys_no_secrets(self):
entry = gitea_config.launcher_entry("prgs", "/cfg/profiles.json")["gitea-tools"]
self.assertEqual(set(entry), {"command", "args", "env"})
# #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(set(entry["env"]), {"GITEA_MCP_CONFIG", "GITEA_MCP_PROFILE", "GITEA_CLIENT_MANAGED"})
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()
for word in ("token", "password", "secret"):
self.assertNotIn(word, blob)
@@ -313,7 +313,6 @@ class TestCanonicalRootGuardBinding(_ServerHarness):
process_project_root=self.install_root,
remote="prgs",
require_binding=True,
mode="derivation",
)
self.assertFalse(got.get("block"), got.get("reasons"))
self.assertEqual(got["resolved_slug"], TARGET_SLUG)
+3 -17
View File
@@ -112,19 +112,7 @@ 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))
def test_namespace_health_classification_includes_provenance(self):
"""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.
"""
"""AC 1 & 4: mcp_namespace_health diagnostics include provenance and unconsumed_gitea_env."""
process = {
"pid": 5555,
"profile": "prgs-author",
@@ -141,12 +129,10 @@ class TestIssue686ManualMcpProvenance(unittest.TestCase):
process=process,
probe_source="client_namespace",
)
self.assertEqual(res["provenance"], "unproven")
self.assertEqual(res["provenance"], "manual_launch")
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["diagnostics"]["provenance"], "unproven")
self.assertEqual(res["diagnostics"]["provenance"], "manual_launch")
if __name__ == "__main__":
@@ -0,0 +1,274 @@
"""Regression coverage for #690: cross-role profile activation invalidation.
A mid-run profile switch (e.g. reviewer author reviewer) must invalidate
workflow-load proof, reviewer lease binding, review decision lock, live
namespace health, and preflight identity/capability stamps, and must require
a full reviewer preflight before any formal verdict. Namespace provenance
must be reported and fail closed on task/namespace mismatch.
"""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import gitea_config
import mcp_namespace_health
import mcp_server
import mcp_session_state
import review_workflow_load
import reviewer_pr_lease
from tests.test_runtime_clarity import CONFIG_SWITCHING_ENABLED
class TestProfileSwitchReviewGuard(unittest.TestCase):
def setUp(self):
self._remotes_patch = patch.dict(mcp_server.REMOTES, {
"dadeschools": {"host": "gitea.example.com", "org": "Example-Org", "repo": "Example-Repo"},
"prgs": {"host": "gitea.example.com", "org": "Example-Org", "repo": "Example-Repo"},
})
self._remotes_patch.start()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG_SWITCHING_ENABLED))
def tearDown(self):
self._remotes_patch.stop()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
self._dir.cleanup()
def _env(self, profile="reviewer-profile"):
return {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
"GITEA_TOKEN_MERGER": "merger-pass",
}
def _seed_contaminated_review_state(self):
"""Simulate an in-flight reviewer run under reviewer-profile."""
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
mcp_server._preflight_resolved_role = "reviewer"
mcp_server._preflight_resolved_task = "review_pr"
review_workflow_load._REVIEW_WORKFLOW_LOAD = {"loaded": True}
mcp_server._REVIEW_DECISION_LOCK = {
"session_profile": "reviewer-profile",
"final_review_decision_ready": True,
"ready_pr_number": 688,
}
reviewer_pr_lease.record_session_lease(
{"session_id": "lease-session-1", "pr_number": 688}
)
mcp_server._LIVE_NAMESPACE_HEALTH["gitea-reviewer"] = {
"namespace": "gitea-reviewer",
"healthy": True,
"ide_namespace_proven": True,
}
# Durable records keyed by the reviewer identity must also be cleared.
mcp_session_state.save_state(
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
payload={"loaded": True},
profile_identity="reviewer-profile",
)
mcp_session_state.save_state(
kind=mcp_session_state.KIND_DECISION_LOCK,
payload={"final_review_decision_ready": True, "ready_pr_number": 688},
profile_identity="reviewer-profile",
)
def _activate(self, target, logins):
with patch.object(
mcp_server, "get_auth_header", side_effect=[f"token p" for _ in logins]
), patch.object(
mcp_server, "api_request", side_effect=[{"login": l} for l in logins]
), patch.object(
mcp_server,
"_workspace_repository_slug",
return_value="Example-Org/Example-Repo",
), patch.object(
mcp_server, "_canonical_repository_slug", return_value=(None, [])
):
return mcp_server.gitea_activate_profile(profile_name=target)
# -----------------------------------------------------------------
# AC1/AC2/AC3: switch invalidates review state; re-preflight required
# -----------------------------------------------------------------
def test_switch_invalidates_review_state_and_blocks_verdict(self):
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
self._seed_contaminated_review_state()
res = self._activate("author-profile", ["reviewer-user", "author-user"])
self.assertTrue(res["success"])
self.assertTrue(res["re_preflight_required"])
inv = res["review_state_invalidation"]
self.assertEqual(inv["from_profile"], "reviewer-profile")
self.assertEqual(inv["to_profile"], "author-profile")
for item in (
"preflight_identity_capability",
"review_workflow_load",
"review_decision_lock",
"reviewer_session_lease",
"live_namespace_health",
):
self.assertIn(item, inv["invalidated"])
# In-memory state cleared.
self.assertFalse(mcp_server._preflight_whoami_called)
self.assertFalse(mcp_server._preflight_capability_called)
self.assertIsNone(mcp_server._preflight_resolved_task)
self.assertIsNone(review_workflow_load._REVIEW_WORKFLOW_LOAD)
self.assertIsNone(mcp_server._REVIEW_DECISION_LOCK)
self.assertIsNone(reviewer_pr_lease.get_session_lease())
self.assertEqual(mcp_server._LIVE_NAMESPACE_HEALTH, {})
self.assertIsNotNone(mcp_server._PROFILE_SWITCH_INVALIDATION)
# Durable records keyed by the reviewer identity are gone.
self.assertIsNone(
mcp_session_state.load_state(
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
profile_identity="reviewer-profile",
)
)
self.assertIsNone(
mcp_session_state.load_state(
kind=mcp_session_state.KIND_DECISION_LOCK,
profile_identity="reviewer-profile",
)
)
# A formal verdict without re-preflight fails closed.
reasons = mcp_server.check_review_decision_gate(
688, "APPROVE", final_review_decision_ready=True
)
self.assertTrue(reasons)
def test_switch_back_cannot_resurrect_stale_review_run(self):
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
self._seed_contaminated_review_state()
self._activate("author-profile", ["reviewer-user", "author-user"])
res = self._activate("reviewer-profile", ["author-user", "reviewer-user"])
self.assertTrue(res["success"])
# The pre-switch review run must not reappear.
self.assertIsNone(mcp_server._REVIEW_DECISION_LOCK)
self.assertIsNone(review_workflow_load._REVIEW_WORKFLOW_LOAD)
self.assertIsNone(reviewer_pr_lease.get_session_lease())
status = review_workflow_load.workflow_load_status()
self.assertFalse(status["workflow_load_valid"])
reasons = mcp_server.check_review_decision_gate(
688, "APPROVE", final_review_decision_ready=True
)
self.assertTrue(reasons)
def test_same_profile_reactivation_keeps_state(self):
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
self._seed_contaminated_review_state()
res = self._activate("reviewer-profile", ["reviewer-user", "reviewer-user"])
self.assertTrue(res["success"], res)
self.assertNotIn("review_state_invalidation", res)
self.assertIsNotNone(mcp_server._REVIEW_DECISION_LOCK)
self.assertTrue(mcp_server._preflight_whoami_called)
def test_clean_repreflight_after_switch_allows_gate(self):
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
self._seed_contaminated_review_state()
self._activate("author-profile", ["reviewer-user", "author-user"])
self._activate("reviewer-profile", ["author-user", "reviewer-user"])
# Re-establish the full reviewer preflight under the new profile.
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="reviewer", resolved_task="review_pr"
)
mcp_server.init_review_decision_lock("dadeschools", "review_pr")
lock = mcp_server._load_review_decision_lock()
self.assertIsNotNone(lock)
lock.update(
{
"final_review_decision_ready": True,
"ready_pr_number": 688,
"ready_action": "APPROVE",
"ready_remote": "dadeschools",
"ready_org": "Example-Org",
"ready_repo": "Example-Repo",
}
)
mcp_server._save_review_decision_lock(lock)
with patch.object(
mcp_server, "_review_workflow_load_gate_reasons", return_value=[]
):
reasons = mcp_server.check_review_decision_gate(
688,
"APPROVE",
final_review_decision_ready=True,
remote="dadeschools",
)
self.assertEqual(reasons, [])
# -----------------------------------------------------------------
# AC4: namespace provenance reporting + fail-closed mismatch
# -----------------------------------------------------------------
def test_namespace_provenance_mismatch_detection(self):
prov = mcp_namespace_health.namespace_provenance(
task="review_pr",
active_profile="reviewer-profile",
env={"GITEA_MCP_NAMESPACE": "gitea-author"},
)
self.assertTrue(prov["mismatch"])
self.assertEqual(prov["required_namespace"], "gitea-reviewer")
prov_ok = mcp_namespace_health.namespace_provenance(
task="review_pr",
active_profile="reviewer-profile",
env={"GITEA_MCP_NAMESPACE": "gitea-reviewer"},
)
self.assertFalse(prov_ok["mismatch"])
prov_unknown = mcp_namespace_health.namespace_provenance(
task="review_pr", active_profile="reviewer-profile", env={}
)
self.assertIsNone(prov_unknown["configured_namespace"])
self.assertFalse(prov_unknown["mismatch"])
self.assertEqual(prov_unknown["namespace_source"], "unknown")
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_whoami_reports_namespace_provenance(self, _auth, _api):
env = self._env("reviewer-profile")
env["GITEA_MCP_NAMESPACE"] = "gitea-reviewer"
with patch.dict(os.environ, env, clear=True):
res = mcp_server.gitea_whoami(remote="dadeschools")
prov = res["namespace_provenance"]
self.assertEqual(prov["configured_namespace"], "gitea-reviewer")
self.assertEqual(prov["active_profile"], "reviewer-profile")
self.assertFalse(prov["mismatch"])
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_resolve_fails_closed_on_namespace_mismatch(self, _auth, _api):
env = self._env("reviewer-profile")
env["GITEA_MCP_NAMESPACE"] = "gitea-author"
with patch.dict(os.environ, env, clear=True):
res = mcp_server.gitea_resolve_task_capability(
task="review_pr", kwargs="{}", remote="dadeschools"
)
self.assertFalse(res["allowed_in_current_session"])
self.assertTrue(res["namespace_provenance"]["mismatch"])
self.assertTrue(
any("namespace" in g for g in res["task_role_guidance"])
)
if __name__ == "__main__":
unittest.main()
@@ -241,10 +241,9 @@ class TestNamespaceContextUsesConfiguredRoot(unittest.TestCase):
process_project_root=self.install,
env={},
configured_canonical_root=self.target,
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
)
self.assertEqual(ctx["canonical_repo_root"], self.target)
self.assertTrue(ctx["roots_aligned"])
self.assertFalse(ctx["roots_aligned"])
def test_target_worktree_is_member_of_target_root(self):
got = nwb.amw.assess_workspace_repo_membership(
@@ -269,7 +268,6 @@ class TestNamespaceContextUsesConfiguredRoot(unittest.TestCase):
env={},
current_branch="feat/issue-1",
configured_canonical_root=self.target,
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
)
self.assertFalse(assessment["block"], assessment.get("reasons"))
self.assertEqual(assessment["canonical_repo_root"], self.target)
-269
View File
@@ -1,269 +0,0 @@
"""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
@@ -1,77 +0,0 @@
"""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
@@ -1,283 +0,0 @@
"""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
-924
View File
@@ -1,924 +0,0 @@
"""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()
@@ -1,662 +0,0 @@
"""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
-739
View File
@@ -1,739 +0,0 @@
"""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()
@@ -1,546 +0,0 @@
"""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
File diff suppressed because it is too large Load Diff
+2 -7
View File
@@ -245,15 +245,10 @@ class TestNamespaceWorkspaceIntegration(unittest.TestCase):
def test_pr487_style_merge_binds_clean_merger_workspace(
self, _exists, _isdir, mock_run
):
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
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
os.environ[nwb.MERGER_WORKTREE_ENV] = MERGER_CLEAN
srv._preflight_resolved_role = "reviewer"
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()):
resolved = srv._verify_role_mutation_workspace("prgs")
self.assertEqual(resolved, os.path.realpath(MERGER_CLEAN))
self.assertEqual(resolved, os.path.realpath(MCP_PROCESS_ROOT))
@@ -153,12 +153,6 @@ EXPECTED_ROLE_EXCLUSIVE_TASKS = frozenset(
"delete_branch",
"cleanup_merged_pr_branch",
"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",
}