Compare commits

..
17 changed files with 103 additions and 1377 deletions
+46 -157
View File
@@ -386,68 +386,6 @@ def run_compensating_recovery(
return recovery_info return recovery_info
def _normalize_sha(value: str | None) -> str | None:
"""Normalize a Git object id for comparison, or ``None`` when unknown."""
normalized = (value or "").strip().lower()
return normalized or None
def _author_bootstrap_assessment(
*,
not_applicable: bool,
allowed: bool,
block: bool,
reasons: list[str],
workspace: str,
root: str,
branch: str | None,
dirty: list[str],
under_branches: bool,
bootstrap_path: str | None = None,
local_head_sha: str | None = None,
remote_master_sha: str | None = None,
exact_next_action: str | None = None,
) -> dict[str, Any]:
"""Structured author-bootstrap assessment consumable by bootstrap_permits (#892).
Field shape mirrors :func:`create_issue_bootstrap._result` so the shared
``bootstrap_permits_control_checkout`` predicate can prove control-checkout
eligibility for ``gitea_bootstrap_author_issue_worktree`` the same way it
does for ``create_issue``. Allowed control assessments must use empty
``reasons`` — narrative belongs in other fields, not the refusal list.
"""
local_tip = _normalize_sha(local_head_sha)
remote_tip = _normalize_sha(remote_master_sha)
base_tips_verified = bool(local_tip and remote_tip and local_tip == remote_tip)
return {
"not_applicable": not_applicable,
"allowed": allowed,
"block": block,
"proven": bool(allowed and not block and not not_applicable),
"reasons": list(reasons),
"workspace_path": workspace,
"canonical_repo_root": root,
"current_branch": branch,
"dirty_files": list(dirty),
"under_branches": under_branches,
"exact_next_action": exact_next_action,
"bootstrap_path": bootstrap_path,
"task_scope": "author_issue_bootstrap",
"local_head_sha": local_tip,
"remote_master_sha": remote_tip,
"base_tips_verified": base_tips_verified,
}
EXACT_NEXT_ACTION_AUTHOR_BOOTSTRAP = (
"Restore the canonical control checkout to a clean accepted base branch "
"(master/main/dev) that matches live master, with no tracked local edits. "
"Re-resolve bootstrap_author_issue_worktree, then re-run "
"gitea_bootstrap_author_issue_worktree from that clean control checkout. "
"Do not use shell git worktree add as the primary path once bootstrap is healthy."
)
def assess_author_issue_bootstrap( def assess_author_issue_bootstrap(
*, *,
workspace_path: str, workspace_path: str,
@@ -459,13 +397,7 @@ def assess_author_issue_bootstrap(
remote_master_sha_error: str | None = None, remote_master_sha_error: str | None = None,
task: str | None = None, task: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Assess whether author issue worktree bootstrap may proceed from control or worktree root. """Assess whether author issue worktree bootstrap may proceed from control or worktree root."""
#892: control-checkout successes emit the full field set required by
``create_issue_bootstrap.bootstrap_permits_control_checkout`` (empty reasons,
task_scope, base tip proof, binding paths) so the #274/#604 guards can
waive control-checkout for this one sanctioned bootstrap task.
"""
root = os.path.realpath(canonical_repo_root or "") root = os.path.realpath(canonical_repo_root or "")
workspace = os.path.realpath(workspace_path or root or ".") workspace = os.path.realpath(workspace_path or root or ".")
branch = (current_branch or "").strip() branch = (current_branch or "").strip()
@@ -475,50 +407,34 @@ def assess_author_issue_bootstrap(
if root if root
else False else False
) )
local_tip = _normalize_sha(head_sha)
remote_tip = _normalize_sha(remote_master_sha)
if not is_author_issue_bootstrap_task(task): if not is_author_issue_bootstrap_task(task):
return _author_bootstrap_assessment( return {
not_applicable=True, "not_applicable": True,
allowed=False, "allowed": False,
block=False, "block": False,
reasons=["task is not author_issue_bootstrap"], "proven": False,
workspace=workspace, "reasons": ["task is not author_issue_bootstrap"],
root=root, }
branch=branch or None,
dirty=dirty,
under_branches=under_branches,
)
# Already under branches/: ordinary #274 path applies; not a control waiver.
if under_branches: if under_branches:
return _author_bootstrap_assessment( return {
not_applicable=True, "not_applicable": False,
allowed=False, "allowed": True,
block=False, "block": False,
reasons=["workspace is under branches/; ordinary #274 path applies"], "proven": True,
workspace=workspace, "bootstrap_path": "existing_branches_worktree",
root=root, "reasons": [
branch=branch or None, "workspace is already a registered worktree under branches/"
dirty=dirty, ],
under_branches=True, }
bootstrap_path="existing_branches_worktree",
local_head_sha=local_tip,
remote_master_sha=remote_tip,
)
reasons: list[str] = [] reasons: list[str] = []
if not root or workspace != root: if workspace != root:
reasons.append( reasons.append(
"bootstrap requires workspace to be canonical control checkout or branches/ worktree" "bootstrap requires workspace to be canonical control checkout or branches/ worktree"
) )
if not branch: if branch not in author_mutation_worktree.BASE_BRANCHES:
reasons.append(
"control checkout is detached HEAD; expected an accepted base branch "
f"({', '.join(sorted(author_mutation_worktree.BASE_BRANCHES))})"
)
elif branch not in author_mutation_worktree.BASE_BRANCHES:
reasons.append( reasons.append(
f"control checkout branch '{branch}' is not an accepted base branch " f"control checkout branch '{branch}' is not an accepted base branch "
f"({', '.join(sorted(author_mutation_worktree.BASE_BRANCHES))})" f"({', '.join(sorted(author_mutation_worktree.BASE_BRANCHES))})"
@@ -528,64 +444,37 @@ def assess_author_issue_bootstrap(
f"control checkout has tracked local edits: {', '.join(dirty[:5])}" f"control checkout has tracked local edits: {', '.join(dirty[:5])}"
) )
# Fail closed on missing tip proof (same bar as create_issue bootstrap #757). if remote_master_sha_error:
if not local_tip:
reasons.append( reasons.append(
"control checkout HEAD SHA is unknown; base equivalence to live " f"could not verify live master tip: {remote_master_sha_error}"
"master cannot be proven (fail closed)"
)
resolver_error = (remote_master_sha_error or "").strip() or None
if resolver_error:
reasons.append(
f"live master tip could not be resolved ({resolver_error}); "
"base equivalence cannot be proven (fail closed)"
)
elif not remote_tip:
reasons.append(
"live master tip is unknown; base equivalence cannot be proven "
"(fail closed)"
)
elif local_tip and remote_tip and local_tip != remote_tip:
reasons.append(
f"control checkout HEAD ({local_tip[:12]}) != live master tip "
f"({remote_tip[:12]})"
) )
elif remote_master_sha and head_sha:
h = head_sha.strip().lower()
rm = remote_master_sha.strip().lower()
if h != rm:
reasons.append(
f"control checkout HEAD ({h[:12]}) != live master tip ({rm[:12]})"
)
if reasons: if reasons:
return _author_bootstrap_assessment( return {
not_applicable=False, "not_applicable": False,
allowed=False, "allowed": False,
block=True, "block": True,
reasons=reasons, "proven": False,
workspace=workspace, "reasons": reasons,
root=root, }
branch=branch or None,
dirty=dirty,
under_branches=False,
local_head_sha=local_tip,
remote_master_sha=remote_tip,
exact_next_action=EXACT_NEXT_ACTION_AUTHOR_BOOTSTRAP,
)
# Allowed: empty reasons so bootstrap_permits_control_checkout can pass. return {
return _author_bootstrap_assessment( "not_applicable": False,
not_applicable=False, "allowed": True,
allowed=True, "block": False,
block=False, "proven": True,
reasons=[], "bootstrap_path": "clean_canonical_control_checkout",
workspace=workspace, "reasons": [
root=root, "control checkout is clean on accepted base branch matching live master"
branch=branch or None, ],
dirty=dirty, }
under_branches=False,
bootstrap_path="clean_canonical_control_checkout",
local_head_sha=local_tip,
remote_master_sha=remote_tip,
exact_next_action=(
"Call gitea_bootstrap_author_issue_worktree with the allocated "
"issue/lease pins; it will create the branches/ worktree and lock."
),
)
import fcntl import fcntl
+6 -18
View File
@@ -241,14 +241,9 @@ def bootstrap_permits_control_checkout(
caller's ordinary block in force. caller's ordinary block in force.
``assessment`` is server-derived only: it is produced by ``assessment`` is server-derived only: it is produced by
:func:`assess_create_issue_bootstrap` or :func:`assess_create_issue_bootstrap` from inspected repository state. It is
:func:`author_issue_bootstrap.assess_author_issue_bootstrap` from inspected never accepted from an MCP tool argument, so no caller can assert
repository state. It is never accepted from an MCP tool argument, so no eligibility it has not proven.
caller can assert eligibility it has not proven.
#892: author issue worktree bootstrap uses the same predicate with
``task_scope='author_issue_bootstrap'`` so a clean control checkout can
create the first ``branches/`` worktree without the lock↔worktree cycle.
""" """
if not isinstance(assessment, dict): if not isinstance(assessment, dict):
return False return False
@@ -269,16 +264,9 @@ def bootstrap_permits_control_checkout(
if assessment.get("reasons"): if assessment.get("reasons"):
return False return False
# Scope proof: create_issue (#749) or author issue bootstrap (#850/#892), # Scope proof: only the create_issue bootstrap, only via the clean
# only via the clean canonical control checkout path. # canonical control checkout path.
task_scope = assessment.get("task_scope") if assessment.get("task_scope") != "create_issue_only":
if is_create_issue_task(task):
if task_scope != "create_issue_only":
return False
elif author_issue_bootstrap.is_author_issue_bootstrap_task(task):
if task_scope != "author_issue_bootstrap":
return False
else:
return False return False
if assessment.get("bootstrap_path") != "clean_canonical_control_checkout": if assessment.get("bootstrap_path") != "clean_canonical_control_checkout":
return False return False
-12
View File
@@ -153,19 +153,7 @@ not a tool argument: a session must never be able to authorize itself.
## Related ## Related
- #630 — manual daemon killing as contaminated recovery (this contrast, enforced). - #630 — manual daemon killing as contaminated recovery (this contrast, enforced).
- #657 — restart-path inventory and daemon classification.
- #686 — manual server launch detection & fail-closed provenance gate.
- #531 / #544 — stale-runtime detection (`ps`-based); sibling failure mode. - #531 / #544 — stale-runtime detection (`ps`-based); sibling failure mode.
- #558 / `docs/mcp-daemon-import-guard.md` — why shell imports are not a repair. - #558 / `docs/mcp-daemon-import-guard.md` — why shell imports are not a repair.
- `docs/mcp-client-registration.md` — per-server registration contract. - `docs/mcp-client-registration.md` — per-server registration contract.
- `docs/mcp-namespace-health.md` — probe sources and mutation enforcement. - `docs/mcp-namespace-health.md` — probe sources and mutation enforcement.
## Sanctioned reconnect vs forbidden manual launch (#686)
In addition to manual process killing (#630), manually launching a duplicate role server from an ad hoc shell (`python3 mcp_server.py`) is forbidden and fail-closed:
- **Why manual launches are unsupported:** A terminal-launched `mcp_server.py` holds its own stdio transport; it can never bind to the IDE client's stdio pipes. It cannot restore a dropped IDE namespace, and a manual duplicate process masks stale client-managed runtimes for that profile, defeating stale-runtime gates.
- **Sanctioned path:** Supported recovery is IDE/client-managed reconnect only (`/mcp reconnect`, IDE restart, or sanctioned reconnect exposure).
- **Fail-closed enforcement (#686):** Mutating tools on a server lacking client-managed launch provenance (`GITEA_CLIENT_MANAGED=1`) refuse execution fail-closed with typed blocker `unsupported_manual_launch` and an exact next action. Unsupported `GITEA_*` env overrides (e.g. `GITEA_DUMMY`) are surfaced in diagnostics rather than silently ignored.
- **Inventory & staleness:** Staleness diagnostics ignore non-client-managed duplicates when evaluating runtime freshness and inventory duplicate processes per profile (#657, #686).
-230
View File
@@ -1,230 +0,0 @@
# Remote-MCP coupling inventory
Every place the Gitea MCP server depends on being a local, client-spawned, stdio-attached
process on the operator's machine.
- **Issue:** #930 (Remote-MCP 01), child 1 of epic #929.
- **Generated against commit:** `7bf4f1258451823a55b36d2157e74f8457165088` (`master`).
- **Anchors:** every `file:line` below resolves at the commit above and at the commit that
adds this document. This change adds one new file and edits no existing file, so no
existing line number shifts between the two.
- **Scope:** documentation only. No server behavior changes in this child.
## How to read an entry
| Field | Meaning |
| ----- | ------- |
| **Anchor** | `file:line` at the commit under review. |
| **Assumes today** | What the code takes for granted while running as a local stdio process. |
| **Observes remotely** | What the same code would actually see on a shared remote host. |
| **Class** | One of: *portable as written*, *needs a seam*, *needs a replacement*, *cannot be remote*. |
| **Owner** | Exactly one epic child (#931#939) responsible for the fix. |
Classification meanings:
- **portable as written** — the code is already transport-, host-, and principal-neutral; it
moves unchanged once its inputs are supplied by a remote-aware caller.
- **needs a seam** — the logic is correct but is wired to a hard-coded local source. It needs
an injection point, not new semantics.
- **needs a replacement** — the semantics themselves are local-only. A remote deployment
needs a differently-defined mechanism, not the same mechanism relocated.
- **cannot be remote** — the operation is inherently about the operator's own machine
(its process table, its keychain, its checkout). It must either stay local behind an
explicit boundary or be deleted from the remote surface.
---
## 1. Transport bind
The transport is bound literally, once, at process start, and the bound value is the root of
the mutation-authorization chain.
| ID | Anchor | Assumes today | Observes remotely | Class | Owner |
| -- | ------ | ------------- | ----------------- | ----- | ----- |
| T1 | `gitea_mcp_server.py:23750` | The single production bind call passes the literal `transport="stdio"` immediately before the server loop. | The literal is wrong for any non-stdio deployment; there is no parameter to change it. | needs a seam | #931 |
| T2 | `mcp_daemon_guard.py:45` | `_PRODUCTION_TRANSPORTS = frozenset({"stdio"})` is the closed allowlist of production transports. | A remote transport name is rejected by the allowlist before any other check runs. | needs a seam | #931 |
| T3 | `mcp_daemon_guard.py:174` | `bind_native_mcp_transport` raises `UnsanctionedRuntimeError` for any transport outside `_PRODUCTION_TRANSPORTS` (raise at `mcp_daemon_guard.py:187`). | The remote server fails to start rather than degrading; the failure is correct, but the allowlist is the only thing that must change. | needs a seam | #931 |
| T4 | `mcp_daemon_guard.py:328` | `is_native_mcp_transport()` asserts a process-local runtime record whose `pid` matches `os.getpid()` and whose phase is `transport_bound`. The predicate itself names no transport. | Unchanged semantics: one server process that bound one transport. It stays true on a remote host. | portable as written | #931 |
| T5 | `mcp_daemon_guard.py:349` | `is_production_native_mcp_transport()` adds only a `mode == production` check on top of T4. | Unchanged. | portable as written | #931 |
| T6 | `irrecoverable_provenance.py:497` | `assess_transport_for_auth_mint()` requires production native transport before minting non-forgeable recovery authorization (#709 F1). | The gate is transport-agnostic in form, but its guarantee — "an ordinary Python process cannot reach this" — is currently underwritten by the stdio bind. Under a remote transport the guarantee must be re-derived from the authenticated session, not from the bind. | needs a seam | #931 |
| T7 | `gitea_mcp_server.py:8375` | Consumer: refuses to proceed unless `assess_transport_for_auth_mint()` allows. | Unchanged given a corrected T6. | portable as written | #931 |
| T8 | `gitea_mcp_server.py:8624` | Second consumer of the same gate on the confirmation path. | Unchanged given a corrected T6. | portable as written | #931 |
| T9 | `mcp_server.py:4` | Module docstring asserts "Runs over stdio." as a property of the server. | The stated contract becomes false on the remote deployment and is load-bearing documentation for operators. | needs a replacement | #931 |
## 2. Launch provenance
Mutations fail closed unless the process can prove a client launched it with real stdio pipes
and `GITEA_CLIENT_MANAGED` provenance. Every proof in this section is a statement about the
local operating system.
| ID | Anchor | Assumes today | Observes remotely | Class | Owner |
| -- | ------ | ------------- | ----------------- | ----- | ----- |
| P1 | `gitea_mcp_server.py:14588` | `_is_client_managed_process()` derives provenance from `GITEA_CLIENT_MANAGED` / `GITEA_MCP_CLIENT_MANAGED` / `GITEA_SERVER_PROVENANCE` / `GITEA_FORCE_CLIENT_MANAGED` on this process's own environment. | A long-lived remote process has one environment for all callers, so a per-process env var can no longer say anything about the caller that issued a request. | needs a replacement | #934 |
| P2 | `gitea_mcp_server.py:14606` | Falls back to `sys.stdin.isatty()`: an active TTY on stdin means a human launched it from a terminal, so refuse. | A remote server has no meaningful stdin. The signal is absent, not merely different. | cannot be remote | #934 |
| P3 | `gitea_mcp_server.py:14618` | `_provenance_mutation_block()` emits `blocker_kind: "unsupported_manual_launch"` and a "reconnect the IDE/client-managed MCP namespace" remediation. | The block shape is reusable; its predicate and its remediation text are both stdio-specific. | needs a seam | #934 |
| P4 | `gitea_mcp_server.py:20599` | `_check_mcp_runtimes_diagnostics()` shells `ps -o pid,lstart,command -ax` and greps for `mcp_server.py` to find peer role servers. | On a shared host the process table lists unrelated tenants' processes, or none at all under a container. Peer discovery by `ps` has no remote meaning. | cannot be remote | #934 |
| P5 | `gitea_mcp_server.py:20702` | More than one process per `GITEA_MCP_PROFILE` in the local process table is reported as a duplicate-launch fault. | A remote endpoint is expected to serve many concurrent sessions per role. "Two processes for one role" becomes the normal case, so the check inverts from a safety net into a false wall. | cannot be remote | #934 |
| P6 | `gitea_mcp_server.py:20715` | Processes lacking client-managed provenance are ignored for runtime freshness and reported as manual launches. | Same defect as P5: correctness depends on enumerating local peers. | cannot be remote | #934 |
| P7 | `gitea_config.py:1172` | `RECOGNIZED_GITEA_ENV_KEYS` is the allowlist of `GITEA_*` env vars a legitimately launched server may carry; anything else is contamination. | Configuration on a remote host arrives from deployment tooling, not from a client-authored env block. The allowlist keeps working mechanically but stops proving anything about provenance. | needs a replacement | #934 |
| P8 | `gitea_mcp_server.py:20683` | The unsupported-env scan applies `RECOGNIZED_GITEA_ENV_KEYS` to *other* processes' environments harvested via `ps eww <pid>`. | Reading another process's environment is unavailable or prohibited across tenants, and is not exposed in this form outside macOS/BSD `ps`. | cannot be remote | #934 |
| P9 | `mcp_daemon_guard.py:126` | `mark_sanctioned_daemon()` requires the claiming stack frame's resolved absolute path to be the canonical `mcp_server.py` / `gitea_mcp_server.py` next to the guard module; basename spoofing is rejected. | Entrypoint-path identity still exists on a remote host, but it authenticates the *deployment*, not the *caller*. It must be kept and demoted from "authorizes mutations" to "authorizes the process". | needs a seam | #934 |
| P10 | `gitea_config.py:1233` | The client-config generator emits `"GITEA_CLIENT_MANAGED": "1"` into each generated MCP client entry, alongside `GITEA_MCP_CONFIG` / `GITEA_MCP_PROFILE`. | A remote endpoint is addressed by URL and credential, not by a spawn command with an env block. This generator produces the wrong artifact entirely. | needs a replacement | #938 |
| P11 | `mcp_namespace_health.py:232` | Namespace health classifies a namespace as `client_managed` or `manual_launch` from the reported env summary. | During dual-run, local and remote namespaces coexist and must both be classifiable; a two-valued local/manual axis cannot express "remote endpoint, authenticated session". | needs a replacement | #939 |
| P12 | `gitea_mcp_server.py:18161` | The diagnostics payload reports `server_provenance` as exactly `"client_managed"` or `"manual_launch"`. | This is the field a cutover operator reads to confirm which deployment served a call. It must gain a remote value before dual-run parity can be validated. | needs a replacement | #939 |
## 3. Role binding
Role separation is currently enforced by *which process a call reaches*. The process is pinned
to one role for its lifetime by an environment variable.
| ID | Anchor | Assumes today | Observes remotely | Class | Owner |
| -- | ------ | ------------- | ----------------- | ----- | ----- |
| R1 | `gitea_config.py:54` | `ENV_PROFILE = "GITEA_MCP_PROFILE"` is the single source of the active profile, read from the process environment. | One shared process serves several principals; a process-wide profile cannot answer "who is calling now". This is the root of the coupling. | needs a replacement | #932 |
| R2 | `review_workflow_load.py:95` | Reads `GITEA_MCP_PROFILE` directly to decide the reviewer workflow binding. | Reads the deployment's profile, not the caller's, silently granting or denying the wrong role. | needs a replacement | #932 |
| R3 | `mcp_discoverability.py:152` | Reads `GITEA_MCP_PROFILE` to describe the namespace to the client. | Correct logic, wrong input source; it needs the request principal injected. | needs a seam | #932 |
| R4 | `webui/deployment_boundary.py:115` | Reads `GITEA_MCP_PROFILE` to classify the deployment boundary for the console. | Same as R3. | needs a seam | #932 |
| R5 | `gitea_mcp_server.py:21106` | Remediation text instructs the operator to "Relaunch the server with `GITEA_MCP_PROFILE` set to a profile that has the required permission". | Relaunching a shared remote endpoint to change one caller's role is not a valid instruction; it would re-role every other session. | needs a replacement | #932 |
| R6 | `native_mcp_preference.py:93` | Detects shell commands that override `GITEA_MCP_PROFILE` away from the session (`native_mcp_preference.py:223`) and flags them as CLI auth divergence. | The divergence check is genuinely useful and survives, but its notion of "the session's profile" must come from the request principal. | needs a seam | #932 |
| R7 | `gitea_mcp_server.py:20671` | Recovers a peer server's role by regexing `GITEA_MCP_PROFILE=` out of that process's environment. | Depends on P4/P8 process-table access; role discovery by peer-env scraping has no remote analogue. | cannot be remote | #932 |
## 4. Credentials
Every token resolves, directly or indirectly, from one human's macOS keychain.
| ID | Anchor | Assumes today | Observes remotely | Class | Owner |
| -- | ------ | ------------- | ----------------- | ----- | ----- |
| C1 | `gitea_config.py:956` | `_keychain_token()` shells `security find-generic-password -s <item> -w`. | `security(1)` is a macOS binary reading the calling user's login keychain. It does not exist on a Linux host and would be the wrong identity even on a shared Mac. | cannot be remote | #933 |
| C2 | `gitea_config.py:974` | `resolve_token(profile, keychain_lookup=_keychain_token)` dispatches on `auth.type` of `env` or `keychain`, defaulting the lookup to C1. | The injectable `keychain_lookup` parameter is the existing seam; a remote credential provider plugs in here without changing the dispatch. | needs a seam | #933 |
| C3 | `gitea_config.py:1015` | `keychain_auth(item_id)` constructs the `{"type": "keychain", "id": ...}` reference stored in profiles. | The reference type itself encodes "macOS keychain" into persisted config. A remote provider needs a new auth reference type, not a new value of this one. | needs a replacement | #933 |
| C4 | `mcp_daemon_guard.py:440` | `assert_keychain_access_allowed()` fails closed for git-credential keychain fill outside a sanctioned daemon, with an operator opt-out env var. | The gate protects a mechanism that will not exist remotely. Its replacement must gate the *credential provider* call, not the keychain call, or the protection silently lapses. | needs a replacement | #933 |
| C5 | `sentry_incident_bridge.py:190` | `resolve_token(env)` resolves the Sentry token from an injected env mapping with no keychain path. | Already host-neutral; it is the shape the Gitea credential path should converge on. | portable as written | #933 |
| C6 | `gitea_mcp_server.py:18469` | The profile-audit tool calls `gitea_config.resolve_token(p)` for every configured profile to report "credentials present" without networking. | On a remote host this would materialize every principal's credential inside one process — an audit surface that becomes a credential-aggregation risk. | needs a seam | #933 |
## 5. Runtime freshness
The mutation gate is defined as "the commit this process started at matches the checkout on
this disk, and both match live master". Two of those three terms are local-disk facts.
| ID | Anchor | Assumes today | Observes remotely | Class | Owner |
| -- | ------ | ------------- | ----------------- | ----- | ----- |
| F1 | `master_parity_gate.py:168` | `capture_startup_parity(root)` reads git `HEAD` from the server's own root once at startup and returns it as the baseline. | A remote host carries a deployed artifact, not the operator's checkout. Its `HEAD` says nothing about the operator's working tree, which is the thing the gate exists to protect. | cannot be remote | #935 |
| F2 | `master_parity_gate.py:255` | `mutation_safe = determinable and in_parity and live_known and not live_stale` — a conjunction of two local-HEAD comparisons and one live-remote comparison. | Two of the three conjuncts lose meaning, so the whole verdict does. A remote deployment needs a redefined, testable freshness predicate rather than this one relocated. | needs a replacement | #935 |
| F3 | `master_parity_gate.py:164` | The live-remote head is probed and cached per `(root, remote, branch)`, keyed on the local root. | The live-remote probe is the one conjunct that survives; it needs a key that is not the operator's filesystem path. | needs a seam | #935 |
| F4 | `gitea_mcp_server.py:18262` | `gitea_assess_master_parity` publishes `startup_head` / `local_head` / `live_remote_head` / `mutation_safe` as the authoritative mutation-safety verdict. | The tool's contract is consumed by every mutation caller and by the operator; it must keep its shape while its semantics are redefined, or every consumer breaks at once. | needs a replacement | #935 |
| F5 | `gitea_mcp_server.py:23054` | Falls back to `_process_boot_head_sha` — the commit this process booted at — when the parity payload has no `startup_head`. | Same defect as F1, in a fallback path that is easy to miss when F1 is fixed. | needs a seam | #935 |
| F6 | `gitea_mcp_server.py:20615` | Staleness is also inferred from `os.path.getmtime()` of `gitea_mcp_server.py` under `PROJECT_ROOT` (`gitea_mcp_server.py:20611`), compared against peer process start times. | File mtime on a deployed artifact tracks the deploy, not the operator's edits, and the peer start times it is compared against come from the unavailable process table (P4). | cannot be remote | #935 |
## 6. Local filesystem
Author and reviewer tools act directly on the operator's checkout.
| ID | Anchor | Assumes today | Observes remotely | Class | Owner |
| -- | ------ | ------------- | ----------------- | ----- | ----- |
| L1 | `gitea_mcp_server.py:10122` | `gitea_bootstrap_author_issue_worktree` creates and binds a git worktree on the server's own disk. | The remote host has no operator checkout to add a worktree to. Executing this remotely would act on the wrong disk while reporting success. | cannot be remote | #936 |
| L2 | `gitea_mcp_server.py:190` | `ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"` and `AUTHOR_WORKTREE_ENV` (`gitea_mcp_server.py:191`) carry the active workspace as process-wide environment. | Process-wide workspace state cannot represent per-session workspaces on a shared endpoint. | needs a replacement | #936 |
| L3 | `gitea_mcp_server.py:9801` | Binding a worktree writes `os.environ["GITEA_AUTHOR_WORKTREE"]` and `os.environ["GITEA_ACTIVE_WORKTREE"]` (`gitea_mcp_server.py:9802`), mutating global process state. | One session's bind would silently retarget every other concurrent session in the same process. This is a correctness bug the moment concurrency is real. | needs a replacement | #936 |
| L4 | `reviewer_inventory_worktree.py:48` | `_BRANCHES_WORKTREE_RE = re.compile(r"\bbranches/", re.I)` requires review worktree paths to sit under `branches/`. | A path convention on the operator's machine, asserted as a validation rule. It needs to become a property of a declared workspace, not a substring test. | needs a seam | #936 |
| L5 | `stable_control_runtime.py:54` | `DEV_WORKTREE_SEGMENT = "branches"` classifies a process root as a development worktree by path segment. | Same class of assumption as L4, on the runtime-classification side. | needs a seam | #936 |
| L6 | `mcp_server.py:42` | `check_conflict_markers()` runs at import and `os.walk`s the install directory for unresolved conflict markers, `sys.exit(1)` on a hit. | On a remote host it scans a deployed artifact, which by construction never has conflict markers — so the guard passes trivially and stops protecting the thing it was written to protect. | needs a replacement | #936 |
| L7 | `role_session_router.py:487` | `check_mid_merge()` reports infra-stop from `.git/MERGE_HEAD`, `rebase-merge`, `rebase-apply` and a source conflict scan under the server's project root. | Same inversion as L6: it would report the deployment's git state, not the operator's. | needs a replacement | #936 |
| L8 | `author_issue_bootstrap.py:996` | Enumerates worktrees with `git -C <root> worktree list --porcelain`. | Requires a real local clone with real worktrees; there is nothing equivalent to enumerate remotely. | cannot be remote | #936 |
| L9 | `mcp_server.py:10` | Redirects `sys.stderr` to the fixed path `/tmp/mcp_server_stderr.log` outside pytest. | A single fixed `/tmp` path is shared by every concurrent server on a host and is not a deployment's logging surface. | needs a replacement | #938 |
| L10 | `gitea_mcp_server.py:2314` | `ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"` — the legacy single global lock slot. | One global `/tmp` slot per host cannot represent concurrent remote sessions and is world-visible on a shared machine. | needs a replacement | #937 |
| L11 | `issue_lock_provenance.py:14` | `ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json")` keeps the same `/tmp` default in the provenance path. | Same as L10; the env override is a local escape hatch, not a remote design. | needs a replacement | #937 |
## 7. Durable state
Locks, leases, session state, and the control-plane database live in the operator's home
directory and are keyed on local PIDs.
| ID | Anchor | Assumes today | Observes remotely | Class | Owner |
| -- | ------ | ------------- | ----------------- | ----- | ----- |
| S1 | `issue_lock_store.py:26` | `DEFAULT_LOCK_DIR = ~/.cache/gitea-tools/issue-locks` — per-issue lock files under one user's home. | A shared endpoint has no single operator home; per-user paths make locks invisible across sessions and hosts. | needs a replacement | #937 |
| S2 | `issue_lock_store.py:83` | `session_pointer_path()` names the session pointer file `session-<os.getpid()>.json`. | Many sessions share one PID on a remote server, so the pointer collapses to a single slot and sessions overwrite each other. | cannot be remote | #937 |
| S3 | `issue_lock_store.py:98` | `is_process_alive(pid)` decides lock liveness by probing the local process table. | A PID recorded by one host is meaningless on another, and may coincidentally match a live unrelated process. | cannot be remote | #937 |
| S4 | `issue_lock_store.py:213` | Lock records stamp `session_pid` and `pid` from `os.getpid()`. | The recorded identity no longer distinguishes sessions; ownership checks silently pass for the wrong caller. | needs a replacement | #937 |
| S5 | `mcp_session_state.py:27` | `DEFAULT_STATE_DIR = ~/.cache/gitea-tools/session-state`, mode `0o700`. | Same home-directory coupling as S1, for review decision locks and workflow proofs. | needs a replacement | #937 |
| S6 | `mcp_session_state.py:559` | Session bodies stamp `session_pid` and `writer_pid` from `os.getpid()` (`mcp_session_state.py:560`). | Writer attribution collapses across concurrent sessions in one process. | needs a replacement | #937 |
| S7 | `control_plane_db.py:47` | `DEFAULT_DB_PATH = ~/.cache/gitea-tools/control-plane/control_plane.sqlite3`. | A per-user SQLite file is not reachable by, or safe for, multiple remote sessions or multiple hosts. | needs a replacement | #937 |
| S8 | `control_plane_db.py:386` | `sqlite3.connect(self.db_path, timeout=30)` — single-writer file locking tuned for one local process. | SQLite's write lock does not extend across hosts and degrades sharply under real concurrency; the store needs a concurrency-safe backend. | needs a replacement | #937 |
| S9 | `control_plane_db.py:1145` | Lease rows record `owner_pid` defaulting to `os.getpid()` (also `control_plane_db.py:2039`). | PID-keyed lease ownership is unusable across hosts and ambiguous within one shared process. | cannot be remote | #937 |
| S10 | `mcp_daemon_guard.py:53` | `_DEFAULT_SESSION_STATE_DIR` is pinned once at transport bind so a later `GITEA_MCP_SESSION_STATE_DIR` change cannot manufacture a second authority domain (#695 AC2). | The single-authority-domain invariant is exactly right and must be preserved; only its backing location needs to move. | needs a seam | #937 |
| S11 | `gitea_mcp_server.py:11875` | Reviewer-lease reclaim reads `owner_pid_alive` from the lease freshness record to decide whether an owner is dead. | Consumes S3/S9; a false "owner alive" or "owner dead" here reclaims or refuses a live lease. This is the highest-consequence consumer of PID liveness. | cannot be remote | #937 |
---
## Summary
### Entries per category
| Category | Entries |
| -------- | ------: |
| 1. Transport bind | 9 |
| 2. Launch provenance | 12 |
| 3. Role binding | 7 |
| 4. Credentials | 6 |
| 5. Runtime freshness | 6 |
| 6. Local filesystem | 11 |
| 7. Durable state | 11 |
| **Total** | **62** |
No category is empty, so no "this category has no coupling" justification is required.
### Entries per classification
| Classification | Entries |
| -------------- | ------: |
| portable as written | 5 |
| needs a seam | 16 |
| needs a replacement | 26 |
| cannot be remote | 15 |
| **Total** | **62** |
### Category × classification
| Category | portable | seam | replacement | cannot | Total |
| -------- | -------: | ---: | ----------: | -----: | ----: |
| 1. Transport bind | 4 | 4 | 1 | 0 | 9 |
| 2. Launch provenance | 0 | 2 | 5 | 5 | 12 |
| 3. Role binding | 0 | 3 | 3 | 1 | 7 |
| 4. Credentials | 1 | 2 | 2 | 1 | 6 |
| 5. Runtime freshness | 0 | 2 | 2 | 2 | 6 |
| 6. Local filesystem | 0 | 2 | 7 | 2 | 11 |
| 7. Durable state | 0 | 1 | 6 | 4 | 11 |
| **Total** | **5** | **16** | **26** | **15** | **62** |
### Entries per epic child
Every child from 2 through 10 is named by at least one entry, and every entry names exactly
one child.
| Child | Issue | Title | Entries | IDs |
| ----: | ----- | ----- | ------: | --- |
| 2 | #931 | Transport-neutral bind seam | 9 | T1T9 |
| 3 | #932 | Per-request principal resolution | 7 | R1R7 |
| 4 | #933 | Server-side credential provider | 6 | C1C6 |
| 5 | #934 | Remote-session provenance | 9 | P1P9 |
| 6 | #935 | Redefined master-parity gate | 6 | F1F6 |
| 7 | #936 | Local-filesystem vs remotable tool split | 8 | L1L8 |
| 8 | #937 | Concurrency-safe session, lock, and lease state | 13 | L10, L11, S1S11 |
| 9 | #938 | Authenticated remote MCP endpoint | 2 | P10, L9 |
| 10 | #939 | Dual-run cutover and rollback | 2 | P11, P12 |
| | | **Total** | **62** | |
## Notes for downstream children
- **The three highest-risk entries are P5, F2, and S11.** Each is a guard that does not
merely stop working remotely — it inverts. P5 turns concurrency into a reported fault,
F2 returns a verdict computed from terms that no longer mean anything, and S11 reclaims
or refuses leases on a PID-liveness answer that is wrong rather than unknown. A gate that
fails open while still reporting green is worse than one that fails to start.
- **T4, T5, T7, T8, and C5 are the portable core.** They show the target shape: predicates
over injected inputs, with no reference to the host, the process table, or the operator's
disk.
- **The keychain seam already exists** at C2 (`resolve_token`'s injectable `keychain_lookup`).
#933 should widen that seam rather than introduce a parallel path, and must remember C4 —
the guard protecting the old mechanism has to be re-pointed, or the protection lapses
silently when the mechanism is replaced.
- **`branches/` appears as a validation rule in at least two independent places** (L4, L5).
Path-substring conventions tend to have more copies than expected; #936 should re-grep
rather than trust this list to be exhaustive for that specific pattern.
+1 -50
View File
@@ -1169,57 +1169,10 @@ def server_command():
return python, [os.path.join(root, "mcp_server.py")] return python, [os.path.join(root, "mcp_server.py")]
RECOGNIZED_GITEA_ENV_KEYS = frozenset({
"GITEA_MCP_CONFIG",
"GITEA_MCP_PROFILE",
"GITEA_PROFILE_NAME",
"GITEA_SERVICE",
"GITEA_EXECUTION_ROLE",
"GITEA_CLIENT_MANAGED",
"GITEA_MCP_CLIENT_MANAGED",
"GITEA_SERVER_PROVENANCE",
"GITEA_AUTHOR_WORKTREE",
"GITEA_ACTIVE_WORKTREE",
"GITEA_DISABLE_KEYCHAIN",
"GITEA_CONTROL_PLANE_DB",
"GITEA_DB_PATH",
"GITEA_LOG_LEVEL",
"GITEA_DEBUG",
"GITEA_HMAC_SECRET",
"GITEA_IRRECOVERABLE_HMAC_SECRET",
"GITEA_FORCE_MCP_RUNTIME_CHECK",
"GITEA_FORCE_CLIENT_MANAGED",
})
RECOGNIZED_GITEA_ENV_PREFIXES = (
"GITEA_TOKEN_",
"GITEA_PASS_",
"GITEA_USER_",
"GITEA_URL_",
"GITEA_HOST_",
"GITEA_REMOTE_",
"GITEA_HTTP_HEADER_",
)
def get_unconsumed_gitea_env_overrides(env=None) -> dict[str, str]:
"""Find unsupported GITEA_* env vars present in *env* (defaults to os.environ)."""
target = os.environ if env is None else env
unconsumed = {}
for key, value in target.items():
if key.startswith("GITEA_"):
if key in RECOGNIZED_GITEA_ENV_KEYS:
continue
if any(key.startswith(p) for p in RECOGNIZED_GITEA_ENV_PREFIXES):
continue
unconsumed[key] = str(value)
return unconsumed
def launcher_entry(profile_name, config_path=None): def launcher_entry(profile_name, config_path=None):
"""Return a thin MCP launcher entry for *profile_name*. """Return a thin MCP launcher entry for *profile_name*.
Contains command/args and the GITEA_MCP_* / GITEA_CLIENT_MANAGED env vars — never a token Contains only command/args and the two GITEA_MCP_* env vars — never a token
or password. Suitable for Claude / Gemini / Codex ``mcpServers`` blocks. or password. Suitable for Claude / Gemini / Codex ``mcpServers`` blocks.
""" """
command, args = server_command() command, args = server_command()
@@ -1230,13 +1183,11 @@ def launcher_entry(profile_name, config_path=None):
"env": { "env": {
"GITEA_MCP_CONFIG": config_path or DEFAULT_CONFIG_PATH, "GITEA_MCP_CONFIG": config_path or DEFAULT_CONFIG_PATH,
"GITEA_MCP_PROFILE": profile_name, "GITEA_MCP_PROFILE": profile_name,
"GITEA_CLIENT_MANAGED": "1",
}, },
} }
} }
def keychain_set(item_id, token, account=None, runner=subprocess.run): def keychain_set(item_id, token, account=None, runner=subprocess.run):
"""Store *token* in the macOS keychain under service *item_id*. """Store *token* in the macOS keychain under service *item_id*.
+10 -154
View File
@@ -577,10 +577,10 @@ def _get_workspace_porcelain(worktree_path: str | None = None) -> str:
def _parse_porcelain_entries(porcelain: str) -> dict[str, str]: def _parse_porcelain_entries(porcelain: str) -> dict[str, str]:
"""Map tracked path -> full porcelain line (untracked ``??`` ignored).""" """Map path -> full porcelain line (including untracked ``??``)."""
entries: dict[str, str] = {} entries: dict[str, str] = {}
for line in (porcelain or "").splitlines(): for line in (porcelain or "").splitlines():
if not line or len(line) < 4 or line.startswith("??"): if not line or len(line) < 4:
continue continue
path = line[3:].strip() path = line[3:].strip()
if " -> " in path: if " -> " in path:
@@ -1546,7 +1546,6 @@ def verify_preflight_purity(
task=task, task=task,
target_issue_number=target_issue_number, target_issue_number=target_issue_number,
require_author_lock=require_author_lock, require_author_lock=require_author_lock,
bootstrap_assessment=bootstrap_assessment,
) )
# #604: common anti-stomp preflight after legacy + #683 enforcers. # #604: common anti-stomp preflight after legacy + #683 enforcers.
_run_anti_stomp_preflight( _run_anti_stomp_preflight(
@@ -1586,7 +1585,6 @@ def verify_preflight_purity(
task=task, task=task,
target_issue_number=target_issue_number, target_issue_number=target_issue_number,
require_author_lock=require_author_lock, require_author_lock=require_author_lock,
bootstrap_assessment=bootstrap_assessment,
) )
if force_anti_stomp: if force_anti_stomp:
_run_anti_stomp_preflight( _run_anti_stomp_preflight(
@@ -1654,15 +1652,8 @@ def _enforce_issue_scope_guard(
task: str | None = None, task: str | None = None,
target_issue_number: int | None = None, target_issue_number: int | None = None,
require_author_lock: bool = False, require_author_lock: bool = False,
bootstrap_assessment: object = _BOOTSTRAP_UNSET,
) -> None: ) -> None:
"""#683: fail closed on missing/out-of-scope issue ownership for mutations. """#683: fail closed on missing/out-of-scope issue ownership for mutations."""
#941: the shared bootstrap assessment is threaded in so this guard judges
the author issue-worktree bootstrap on the same server-derived evidence as
the #274 branches-only and #604 anti-stomp guards. Callers that supply
none fall back to computing it here, which preserves behaviour.
"""
ctx = _resolve_namespace_mutation_context(worktree_path) ctx = _resolve_namespace_mutation_context(worktree_path)
workspace = ctx["workspace_path"] workspace = ctx["workspace_path"]
git_state = issue_lock_worktree.read_worktree_git_state(workspace) git_state = issue_lock_worktree.read_worktree_git_state(workspace)
@@ -1712,32 +1703,11 @@ def _enforce_issue_scope_guard(
import create_issue_bootstrap as _cib import create_issue_bootstrap as _cib
is_create_issue = _cib.is_create_issue_task(task) is_create_issue = _cib.is_create_issue_task(task)
# #941: consume the caller-computed bootstrap assessment when one was
# threaded in, so this guard and the #274/#604 guards judge identical
# evidence. Falling back preserves behaviour for callers that supply none.
bootstrap = (
_create_issue_bootstrap_assessment(task, worktree_path)
if bootstrap_assessment is _BOOTSTRAP_UNSET
else bootstrap_assessment
)
# #941: the author issue-worktree bootstrap is pre-ownership for the same
# reason create_issue is — it exists to break the lock<->worktree cycle, so
# no owning lock can exist yet. The exemption is granted by the canonical
# shared decision over server-derived evidence, never by a task-name list,
# and fails closed on missing, malformed, cross-scope, dirty, drifted, or
# wrongly bound evidence.
bootstrap_waives_ownership = _cib.bootstrap_permits_control_checkout(
bootstrap,
task=task,
workspace_path=workspace,
canonical_repo_root=ctx["canonical_repo_root"],
)
require_lock = bool(require_author_lock) or ( require_lock = bool(require_author_lock) or (
authorish authorish
and workflow_scope_guard.production_guards_forced() and workflow_scope_guard.production_guards_forced()
and role == "author" and role == "author"
and not is_create_issue and not is_create_issue
and not bootstrap_waives_ownership
) )
assessment = workflow_scope_guard.assess_production_mutation_guards( assessment = workflow_scope_guard.assess_production_mutation_guards(
workspace_path=workspace, workspace_path=workspace,
@@ -1750,7 +1720,6 @@ def _enforce_issue_scope_guard(
require_author_lock=require_lock, require_author_lock=require_lock,
in_test_mode=_preflight_in_test_mode(), in_test_mode=_preflight_in_test_mode(),
mutation_task=task, mutation_task=task,
bootstrap_assessment=bootstrap,
) )
workflow_scope_guard.raise_if_blocked(assessment) workflow_scope_guard.raise_if_blocked(assessment)
@@ -14616,56 +14585,6 @@ def _session_context_mutation_block(
return blocked return blocked
def _is_client_managed_process() -> bool:
"""Check whether the current MCP server process has client-managed launch provenance (#686)."""
val = (
os.environ.get("GITEA_CLIENT_MANAGED")
or os.environ.get("GITEA_MCP_CLIENT_MANAGED")
or os.environ.get("GITEA_SERVER_PROVENANCE")
or os.environ.get("GITEA_FORCE_CLIENT_MANAGED")
or ""
).strip().lower()
if val in ("0", "false", "no", "manual", "manual_launch"):
return False
if val in ("1", "true", "yes", "client_managed"):
return True
# A terminal launch has an active TTY on stdin
try:
if sys.stdin and sys.stdin.isatty():
return False
except Exception:
pass
# Standard client launch or test runner with stdio pipe and profile env
if "GITEA_MCP_CONFIG" in os.environ or "GITEA_MCP_PROFILE" in os.environ or "GITEA_PROFILE_NAME" in os.environ:
return True
return False
def _provenance_mutation_block(**extra_fields) -> dict | None:
"""Refuse mutating tool calls on processes lacking client-managed launch provenance (#686)."""
if _is_client_managed_process():
return None
unconsumed = gitea_config.get_unconsumed_gitea_env_overrides()
blocked = {
"success": False,
"performed": False,
"blocker_kind": "unsupported_manual_launch",
"reasons": [
"mutation denied: server process was launched manually from a terminal without client-managed provenance (fail closed). Manually launched mcp_server.py processes cannot receive IDE stdio or serve workflow mutations."
],
"exact_next_action": "BLOCKED + RECONNECT: Reconnect the IDE/client-managed MCP server namespace instead of an ad hoc terminal launch. Hand-launched processes and mcp_config.json hand-edits are classified as workflow contamination.",
"provenance": "manual_launch",
"unconsumed_gitea_env": unconsumed,
}
blocked.update(extra_fields)
return blocked
def _profile_permission_block(required_operation: str, **extra_fields) -> dict | None: def _profile_permission_block(required_operation: str, **extra_fields) -> dict | None:
"""Structured operation-gate denial for gated tools (#69, #142, #897). """Structured operation-gate denial for gated tools (#69, #142, #897).
@@ -14682,10 +14601,6 @@ def _profile_permission_block(required_operation: str, **extra_fields) -> dict |
# #714: evaluate active profile only — never auto-switch. # #714: evaluate active profile only — never auto-switch.
_ensure_matching_profile(required_operation, req_role, extra_fields.get("remote")) _ensure_matching_profile(required_operation, req_role, extra_fields.get("remote"))
prov_block = _provenance_mutation_block(**extra_fields)
if prov_block is not None:
return prov_block
reasons = _profile_operation_gate(required_operation) reasons = _profile_operation_gate(required_operation)
if reasons: if reasons:
return _build_operation_gate_refusal( return _build_operation_gate_refusal(
@@ -14719,10 +14634,6 @@ def _namespace_mutation_block(mutation_task: str, **extra_fields) -> dict | None
# #714: evaluate active profile only — never auto-switch. # #714: evaluate active profile only — never auto-switch.
_ensure_matching_profile(required_permission, required_role, extra_fields.get("remote")) _ensure_matching_profile(required_permission, required_role, extra_fields.get("remote"))
prov_block = _provenance_mutation_block(**extra_fields)
if prov_block is not None:
return prov_block
try: try:
profile = get_profile() profile = get_profile()
except Exception as exc: except Exception as exc:
@@ -18170,9 +18081,6 @@ def gitea_get_runtime_context(
source="gitea_get_runtime_context", source="gitea_get_runtime_context",
) )
is_client_managed = _is_client_managed_process()
unconsumed_env = gitea_config.get_unconsumed_gitea_env_overrides()
result = { result = {
"active_profile": profile["profile_name"], "active_profile": profile["profile_name"],
"authenticated_username": username, "authenticated_username": username,
@@ -18189,9 +18097,6 @@ def gitea_get_runtime_context(
"review_merge_blocked_reasons": blocked_reasons, "review_merge_blocked_reasons": blocked_reasons,
"suggested_fix": suggested_fix, "suggested_fix": suggested_fix,
"safe_next_action": safe_next_action, "safe_next_action": safe_next_action,
"server_provenance": "client_managed" if is_client_managed else "manual_launch",
"is_client_managed": is_client_managed,
"unconsumed_gitea_env": unconsumed_env,
"preflight_ready": preflight["preflight_ready"], "preflight_ready": preflight["preflight_ready"],
"preflight_block_reasons": preflight["preflight_block_reasons"], "preflight_block_reasons": preflight["preflight_block_reasons"],
"preflight_workspace": preflight.get("preflight_workspace"), "preflight_workspace": preflight.get("preflight_workspace"),
@@ -18205,13 +18110,6 @@ def gitea_get_runtime_context(
PROJECT_ROOT), PROJECT_ROOT),
} }
if not is_client_managed:
result["safe_next_action"] = (
"BLOCKED + RECONNECT: Serving process lacks client-managed launch provenance (manual launch). "
"Reconnect the IDE/client-managed MCP server namespace instead of an ad hoc terminal launch."
)
# #702: read-only visibility into the inherited GITEA_ACTIVE_WORKTREE # #702: read-only visibility into the inherited GITEA_ACTIVE_WORKTREE
# binding; recovery itself runs during capability resolution. # binding; recovery itself runs during capability resolution.
try: try:
@@ -20669,9 +20567,7 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) ->
self_pid = os.getpid() self_pid = os.getpid()
self_stale = False self_stale = False
all_profile_procs: dict[str, list[dict]] = {} running_profiles = {}
unsupported_env_found = set()
for line in proc.stdout.splitlines()[1:]: for line in proc.stdout.splitlines()[1:]:
line = line.strip() line = line.strip()
if not line or "mcp_server.py" not in line: if not line or "mcp_server.py" not in line:
@@ -20703,55 +20599,16 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) ->
if match: if match:
profile = match.group(1) profile = match.group(1)
is_client_managed = bool(
re.search(r'\bGITEA_CLIENT_MANAGED=(1|true|yes|client_managed)\b', env_out, re.IGNORECASE)
or re.search(r'\bGITEA_MCP_CLIENT_MANAGED=(1|true|yes|client_managed)\b', env_out, re.IGNORECASE)
or re.search(r'\bGITEA_SERVER_PROVENANCE=client_managed\b', env_out, re.IGNORECASE)
)
for env_match in re.finditer(r'\b(GITEA_[A-Z0-9_]+)=([^\s]+)', env_out):
k, v = env_match.group(1), env_match.group(2)
if k not in gitea_config.RECOGNIZED_GITEA_ENV_KEYS and not any(k.startswith(p) for p in gitea_config.RECOGNIZED_GITEA_ENV_PREFIXES):
unsupported_env_found.add(f"{k}={v}")
is_stale = (start_time < code_mtime) or git_stale is_stale = (start_time < code_mtime) or git_stale
if pid == self_pid and is_stale: if pid == self_pid and is_stale:
self_stale = True self_stale = True
proc_info = { if profile not in running_profiles or start_time > running_profiles[profile]["start_time"]:
"pid": pid, running_profiles[profile] = {
"start_time": start_time, "pid": pid,
"is_stale": is_stale, "start_time": start_time,
"is_client_managed": is_client_managed, "is_stale": is_stale
} }
if profile not in all_profile_procs:
all_profile_procs[profile] = []
all_profile_procs[profile].append(proc_info)
running_profiles = {}
for profile, procs in all_profile_procs.items():
if len(procs) > 1:
pids_str = ", ".join(str(p["pid"]) for p in procs)
reasons.append(
f"stale-runtime: Duplicate MCP server process(es) detected for profile '{profile}' (PIDs: {pids_str}). "
"Manual or duplicate launches defeat staleness detection and cannot receive client stdio."
)
client_procs = [p for p in procs if p["is_client_managed"]]
if client_procs:
client_procs.sort(key=lambda p: p["start_time"], reverse=True)
running_profiles[profile] = client_procs[0]
else:
pids_str = ", ".join(str(p["pid"]) for p in procs)
reasons.append(
f"stale-runtime: Manually launched MCP process(es) detected without client-managed provenance for profile '{profile}' (PIDs: {pids_str}). "
"Manual launches cannot serve client stdio and are ignored for runtime freshness."
)
if unsupported_env_found:
reasons.append(
f"unsupported-env: Unsupported GITEA_* environment variable override(s) detected: {', '.join(sorted(unsupported_env_found))}. "
"Unknown env overrides are unsupported."
)
if self_stale: if self_stale:
# #685: report-only — no config utime, no thread, no os._exit. # #685: report-only — no config utime, no thread, no os._exit.
@@ -20786,7 +20643,6 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) ->
return reasons return reasons
@mcp.tool() @mcp.tool()
def gitea_resolve_task_capability( def gitea_resolve_task_capability(
task: str, task: str,
-16
View File
@@ -225,16 +225,6 @@ def classify_namespace_probe(
# on bad data without treating success as IDE proof). # on bad data without treating success as IDE proof).
blocks = namespace_health_blocks_task("merge_pr", healthy) blocks = namespace_health_blocks_task("merge_pr", healthy)
import gitea_config
raw_env = process.get("env") if isinstance(process, dict) else None
unconsumed_env = gitea_config.get_unconsumed_gitea_env_overrides(raw_env)
is_client_managed = bool(
env_summary.get("GITEA_CLIENT_MANAGED") in ("1", "true", "yes", "client_managed")
or env_summary.get("GITEA_MCP_CLIENT_MANAGED") in ("1", "true", "yes", "client_managed")
or env_summary.get("GITEA_SERVER_PROVENANCE") == "client_managed"
)
provenance = "client_managed" if is_client_managed else "manual_launch"
return { return {
"success": healthy, "success": healthy,
"healthy": healthy, "healthy": healthy,
@@ -250,9 +240,6 @@ def classify_namespace_probe(
"error_message": error_message or None, "error_message": error_message or None,
"reasons": reasons, "reasons": reasons,
"remediation": remediation, "remediation": remediation,
"provenance": provenance,
"is_client_managed": is_client_managed,
"unconsumed_gitea_env": unconsumed_env,
"diagnostics": { "diagnostics": {
"namespace": ns, "namespace": ns,
"required_tool": tool, "required_tool": tool,
@@ -261,9 +248,6 @@ def classify_namespace_probe(
"env": env_summary, "env": env_summary,
"config_path": config_path, "config_path": config_path,
"probe_source": source, "probe_source": source,
"provenance": provenance,
"is_client_managed": is_client_managed,
"unconsumed_gitea_env": unconsumed_env,
}, },
"blocks_merge_workflow": blocks, "blocks_merge_workflow": blocks,
} }
-2
View File
@@ -44,8 +44,6 @@ def _reset_mutation_authority(monkeypatch):
]: ]:
monkeypatch.delenv(env_key, raising=False) monkeypatch.delenv(env_key, raising=False)
monkeypatch.setenv("GITEA_CLIENT_MANAGED", "1")
# Isolate durable session-state files so tests never share host cache (#559). # Isolate durable session-state files so tests never share host cache (#559).
import tempfile import tempfile
+2 -2
View File
@@ -35,7 +35,7 @@ CONFIG = {
], ],
"forbidden_operations": [], "forbidden_operations": [],
"execution_profile": "full-author", "execution_profile": "full-author",
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "Example-Org/Example-Repo"], "allowed_repositories": ["Example-Org/Example-Repo"],
}, },
"reviewer-no-commit": { "reviewer-no-commit": {
"enabled": True, "enabled": True,
@@ -50,7 +50,7 @@ CONFIG = {
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push" "gitea.repo.commit", "gitea.pr.create", "gitea.branch.push"
], ],
"execution_profile": "reviewer-no-commit", "execution_profile": "reviewer-no-commit",
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "Example-Org/Example-Repo"], "allowed_repositories": ["Example-Org/Example-Repo"],
}, },
}, },
"rules": {"allow_runtime_switching": False}, "rules": {"allow_runtime_switching": False},
+1 -1
View File
@@ -175,7 +175,7 @@ class TestLauncherSnippets(unittest.TestCase):
def test_only_safe_keys_no_secrets(self): def test_only_safe_keys_no_secrets(self):
entry = gitea_config.launcher_entry("prgs", "/cfg/profiles.json")["gitea-tools"] entry = gitea_config.launcher_entry("prgs", "/cfg/profiles.json")["gitea-tools"]
self.assertEqual(set(entry), {"command", "args", "env"}) self.assertEqual(set(entry), {"command", "args", "env"})
self.assertEqual(set(entry["env"]), {"GITEA_MCP_CONFIG", "GITEA_MCP_PROFILE", "GITEA_CLIENT_MANAGED"}) self.assertEqual(set(entry["env"]), {"GITEA_MCP_CONFIG", "GITEA_MCP_PROFILE"})
self.assertEqual(entry["env"]["GITEA_MCP_PROFILE"], "prgs") self.assertEqual(entry["env"]["GITEA_MCP_PROFILE"], "prgs")
blob = json.dumps(entry).lower() blob = json.dumps(entry).lower()
for word in ("token", "password", "secret"): for word in ("token", "password", "secret"):
@@ -1,139 +0,0 @@
"""Tests for Issue #686: Detect and reject manually launched duplicate MCP role servers."""
import os
import unittest
from unittest.mock import patch, MagicMock
from datetime import datetime
import gitea_config
import gitea_mcp_server
import mcp_namespace_health
class TestIssue686ManualMcpProvenance(unittest.TestCase):
def test_client_managed_process_detection(self):
"""Test _is_client_managed_process correctly detects provenance markers."""
with patch.dict(os.environ, {"GITEA_CLIENT_MANAGED": "1"}, clear=True):
self.assertTrue(gitea_mcp_server._is_client_managed_process())
with patch.dict(os.environ, {"GITEA_MCP_CLIENT_MANAGED": "true"}, clear=True):
self.assertTrue(gitea_mcp_server._is_client_managed_process())
with patch.dict(os.environ, {"GITEA_SERVER_PROVENANCE": "client_managed"}, clear=True):
self.assertTrue(gitea_mcp_server._is_client_managed_process())
with patch.dict(os.environ, {"GITEA_CLIENT_MANAGED": "0"}, clear=True):
self.assertFalse(gitea_mcp_server._is_client_managed_process())
def test_unconsumed_gitea_env_overrides(self):
"""Test surfacing of unsupported GITEA_* env overrides (e.g. GITEA_DUMMY)."""
env = {
"GITEA_MCP_PROFILE": "prgs-author",
"GITEA_CLIENT_MANAGED": "1",
"GITEA_DUMMY": "2",
"GITEA_UNKNOWN_FLAG": "abc",
}
unconsumed = gitea_config.get_unconsumed_gitea_env_overrides(env)
self.assertIn("GITEA_DUMMY", unconsumed)
self.assertEqual(unconsumed["GITEA_DUMMY"], "2")
self.assertIn("GITEA_UNKNOWN_FLAG", unconsumed)
self.assertNotIn("GITEA_MCP_PROFILE", unconsumed)
self.assertNotIn("GITEA_CLIENT_MANAGED", unconsumed)
def test_manual_server_mutation_fail_closed(self):
"""AC 2: Mutating tools on a server without client-managed provenance fail closed with a typed blocker."""
with patch.dict(os.environ, {"GITEA_CLIENT_MANAGED": "0"}, clear=True):
block = gitea_mcp_server._provenance_mutation_block(task="create_issue")
self.assertIsNotNone(block)
self.assertFalse(block["success"])
self.assertFalse(block["performed"])
self.assertEqual(block["blocker_kind"], "unsupported_manual_launch")
self.assertEqual(block["provenance"], "manual_launch")
self.assertTrue(any("mutation denied: server process was launched manually" in r for r in block["reasons"]))
self.assertIn("BLOCKED + RECONNECT", block["exact_next_action"])
def test_client_managed_server_mutation_passes_provenance_gate(self):
"""AC 3: Clean client-managed baseline passes the provenance gate."""
with patch.dict(os.environ, {"GITEA_CLIENT_MANAGED": "1"}, clear=True):
block = gitea_mcp_server._provenance_mutation_block(task="create_issue")
self.assertIsNone(block)
@patch("subprocess.run")
@patch("os.path.getmtime")
@patch("os.path.exists")
@patch("os.getpid")
def test_manual_duplicate_does_not_mask_stale_runtime(
self, mock_getpid, mock_exists, mock_getmtime, mock_run
):
"""AC 1 & AC 3: Staleness detection ignores manual duplicates and reports stale supported runtimes."""
mock_getpid.return_value = 12345
mock_exists.return_value = True
code_time = datetime(2026, 7, 8, 14, 0, 0)
mock_getmtime.return_value = code_time.timestamp()
# PID 12345: stale client-managed process (started at 13:00)
# PID 99999: fresh manual duplicate process (started at 15:00, no GITEA_CLIENT_MANAGED)
ps_output = (
" PID LSTART COMMAND\n"
"12345 Wed Jul 8 13:00:00 2026 /path/to/python mcp_server.py\n"
"99999 Wed Jul 8 15:00:00 2026 /path/to/python mcp_server.py\n"
)
mock_run_ps = MagicMock()
mock_run_ps.stdout = ps_output
mock_env_12345 = MagicMock()
mock_env_12345.stdout = "GITEA_MCP_PROFILE=prgs-author GITEA_CLIENT_MANAGED=1"
mock_env_99999 = MagicMock()
mock_env_99999.stdout = "GITEA_MCP_PROFILE=prgs-author GITEA_DUMMY=2"
def side_effect(args, **kwargs):
if args[0] == "ps" and "eww" in args:
pid = args[2]
if pid == "12345":
return mock_env_12345
elif pid == "99999":
return mock_env_99999
elif args[0] == "ps":
return mock_run_ps
raise ValueError(f"Unexpected args: {args}")
mock_run.side_effect = side_effect
reasons = gitea_mcp_server._check_mcp_runtimes_diagnostics("create_issue", ["prgs-author"])
# Manual duplicate process must be flagged
self.assertTrue(any("Duplicate MCP server process(es) detected" in r for r in reasons))
# Unsupported env override (GITEA_DUMMY=2) must be flagged
self.assertTrue(any("unsupported-env: Unsupported GITEA_* environment variable override(s) detected: GITEA_DUMMY=2" in r for r in reasons))
# Stale runtime must NOT be masked by fresh manual process 99999!
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."""
process = {
"pid": 5555,
"profile": "prgs-author",
"env": {
"GITEA_MCP_PROFILE": "prgs-author",
"GITEA_DUMMY": "99",
},
}
res = mcp_namespace_health.classify_namespace_probe(
"gitea-author",
configured=True,
registered_tools=["gitea_whoami"],
probe_result={"success": True},
process=process,
probe_source="client_namespace",
)
self.assertEqual(res["provenance"], "manual_launch")
self.assertFalse(res["is_client_managed"])
self.assertEqual(res["unconsumed_gitea_env"], {"GITEA_DUMMY": "99"})
self.assertEqual(res["diagnostics"]["provenance"], "manual_launch")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,32 @@
"""Regression test for Issue #697: Native workspace preflight explicitly includes untracked files.
Verifies that `_parse_porcelain_entries` includes untracked files (lines starting with '??')
so workspace preflight fails closed on untracked or dirty state.
"""
from __future__ import annotations
import unittest
import gitea_mcp_server as mcp_server
class TestIssue697UntrackedPreflight(unittest.TestCase):
def test_parse_porcelain_entries_includes_untracked_files(self):
porcelain = " M gitea_mcp_server.py\n?? untracked_file.txt\n?? new_dir/test.py\n"
entries = mcp_server._parse_porcelain_entries(porcelain)
self.assertIn("untracked_file.txt", entries)
self.assertIn("new_dir/test.py", entries)
self.assertEqual(entries["untracked_file.txt"], "?? untracked_file.txt")
self.assertEqual(entries["new_dir/test.py"], "?? new_dir/test.py")
def test_parse_porcelain_entries_includes_tracked_and_untracked(self):
porcelain = " M tracked.py\n?? untracked.py\n D deleted.py\n"
entries = mcp_server._parse_porcelain_entries(porcelain)
self.assertEqual(len(entries), 3)
self.assertIn("tracked.py", entries)
self.assertIn("untracked.py", entries)
self.assertIn("deleted.py", entries)
if __name__ == "__main__":
unittest.main()
@@ -1,215 +0,0 @@
"""Regression: author worktree bootstrap from clean control checkout (#892).
#892 is the four-door deadlock where every documented recovery path is closed:
bootstrap refuses control, lock demands an existing worktree, worktree-start
demands a lock, and shell worktree add is outside the sanctioned MCP path.
Root cause: assess_author_issue_bootstrap returned allowed/proven for a clean
control checkout, but bootstrap_permits_control_checkout only accepted
create_issue assessments (task_scope=create_issue_only + empty reasons + full
base-tip field set). Author assessments never satisfied the shared predicate,
so the #274/#604 guards kept the ordinary control-checkout block.
"""
from __future__ import annotations
import os
import tempfile
import unittest
from unittest import mock
import author_issue_bootstrap as aib
import create_issue_bootstrap as cib
CONTROL = "/repo/Gitea-Tools"
MASTER = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
OTHER = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
def _assess(
*,
workspace=CONTROL,
root=CONTROL,
branch="master",
head=MASTER,
porcelain="",
remote=MASTER,
remote_error=None,
task="bootstrap_author_issue_worktree",
):
return aib.assess_author_issue_bootstrap(
workspace_path=workspace,
canonical_repo_root=root,
current_branch=branch,
head_sha=head,
porcelain_status=porcelain,
remote_master_sha=remote,
remote_master_sha_error=remote_error,
task=task,
)
class TestAuthorBootstrapAssessmentShape(unittest.TestCase):
def test_clean_control_emits_predicate_compatible_fields(self):
assessment = _assess()
self.assertTrue(assessment["allowed"])
self.assertTrue(assessment["proven"])
self.assertFalse(assessment["block"])
self.assertFalse(assessment["not_applicable"])
self.assertEqual(assessment["reasons"], [])
self.assertEqual(assessment["task_scope"], "author_issue_bootstrap")
self.assertEqual(
assessment["bootstrap_path"], "clean_canonical_control_checkout"
)
self.assertEqual(assessment["dirty_files"], [])
self.assertIs(assessment["under_branches"], False)
self.assertTrue(assessment["base_tips_verified"])
self.assertEqual(assessment["local_head_sha"], MASTER)
self.assertEqual(assessment["remote_master_sha"], MASTER)
self.assertEqual(assessment["workspace_path"], os.path.realpath(CONTROL))
self.assertEqual(
assessment["canonical_repo_root"], os.path.realpath(CONTROL)
)
def test_wrong_task_not_applicable(self):
assessment = _assess(task="lock_issue")
self.assertTrue(assessment["not_applicable"])
self.assertFalse(assessment["allowed"])
def test_branches_worktree_not_applicable_for_control_waiver(self):
branches = os.path.join(CONTROL, "branches", "fix-issue-1")
assessment = _assess(workspace=branches)
self.assertTrue(assessment["not_applicable"])
self.assertFalse(assessment["allowed"])
self.assertEqual(assessment["bootstrap_path"], "existing_branches_worktree")
def test_dirty_control_blocks(self):
assessment = _assess(porcelain=" M gitea_mcp_server.py\n")
self.assertTrue(assessment["block"])
self.assertFalse(assessment["allowed"])
self.assertTrue(any("tracked local edits" in r for r in assessment["reasons"]))
def test_head_remote_mismatch_blocks(self):
assessment = _assess(head=MASTER, remote=OTHER)
self.assertTrue(assessment["block"])
self.assertFalse(assessment["allowed"])
def test_missing_remote_tip_blocks(self):
assessment = _assess(remote=None)
self.assertTrue(assessment["block"])
self.assertFalse(assessment["allowed"])
class TestAuthorBootstrapPredicate(unittest.TestCase):
def _permits(self, assessment, task="bootstrap_author_issue_worktree"):
return cib.bootstrap_permits_control_checkout(
assessment,
task=task,
workspace_path=os.path.realpath(CONTROL),
canonical_repo_root=os.path.realpath(CONTROL),
)
def test_clean_author_bootstrap_permits(self):
self.assertTrue(self._permits(_assess()))
def test_tool_alias_permits(self):
assessment = _assess(task="gitea_bootstrap_author_issue_worktree")
self.assertTrue(
self._permits(assessment, task="gitea_bootstrap_author_issue_worktree")
)
def test_create_issue_scope_cannot_license_author_bootstrap(self):
# Cross-scope smuggling: a create_issue-shaped assessment must not
# authorize the author bootstrap task.
create_shaped = dict(_assess())
create_shaped["task_scope"] = "create_issue_only"
self.assertFalse(self._permits(create_shaped))
def test_author_scope_cannot_license_create_issue(self):
assessment = _assess()
self.assertFalse(
cib.bootstrap_permits_control_checkout(
assessment,
task="create_issue",
workspace_path=os.path.realpath(CONTROL),
canonical_repo_root=os.path.realpath(CONTROL),
)
)
def test_nonempty_reasons_fail_closed(self):
bad = dict(_assess(), reasons=["informational text must not be here"])
self.assertFalse(self._permits(bad))
def test_dirty_fails_closed(self):
self.assertFalse(self._permits(_assess(porcelain=" M x.py\n")))
def test_mismatch_fails_closed(self):
self.assertFalse(self._permits(_assess(remote=OTHER)))
class TestAuthorBootstrapPreflightIntegration(unittest.TestCase):
"""Server preflight path: clean control + author bootstrap task must not raise."""
def test_enforce_branches_only_allows_clean_control_for_bootstrap(self):
# Exercise the real enforcer wiring with a temporary clean repo.
import gitea_mcp_server as srv
with tempfile.TemporaryDirectory() as tmp:
repo = os.path.join(tmp, "repo")
os.makedirs(os.path.join(repo, "branches"))
# Minimal git repo on master at a known tip.
import subprocess
subprocess.check_call(["git", "init", "-b", "master", repo])
subprocess.check_call(
["git", "-C", repo, "commit", "--allow-empty", "-m", "init"]
)
head = subprocess.check_output(
["git", "-C", repo, "rev-parse", "HEAD"], text=True
).strip()
assessment = aib.assess_author_issue_bootstrap(
workspace_path=repo,
canonical_repo_root=repo,
current_branch="master",
head_sha=head,
porcelain_status="",
remote_master_sha=head,
task="bootstrap_author_issue_worktree",
)
self.assertTrue(
cib.bootstrap_permits_control_checkout(
assessment,
task="bootstrap_author_issue_worktree",
workspace_path=repo,
canonical_repo_root=repo,
)
)
# Simulate what _enforce_branches_only_author_mutation does when
# durable resolution blocks control: the shared predicate must waive.
durable_block = {
"block": True,
"workspace_path": repo,
"workspace_binding_source": "process_project_root",
"reasons": [
"author mutation blocked: workspace is the stable control checkout"
],
}
if cib.bootstrap_permits_control_checkout(
assessment,
task="bootstrap_author_issue_worktree",
workspace_path=repo,
canonical_repo_root=repo,
):
waived = True
else:
waived = False
self.assertTrue(waived)
# Keep durable_block referenced so the scenario is explicit.
self.assertTrue(durable_block["block"])
if __name__ == "__main__":
unittest.main()
@@ -1,346 +0,0 @@
"""Regression: author bootstrap scope reaches workflow_scope_guard (#941).
PR #926 (#892) made ``bootstrap_permits_control_checkout`` accept
``task_scope=author_issue_bootstrap`` and wired that canonical decision into
the #274 branches-only enforcer and the #604 anti-stomp preflight. A third
enforcement path was left unwired.
``workflow_scope_guard.assess_root_source_mutation`` kept its own copy of the
clean-root author decision, gated on ``create_issue_bootstrap.is_create_issue_task``
a task-name allowlist that never contained ``bootstrap_author_issue_worktree``.
So the real call path
gitea_bootstrap_author_issue_worktree
-> verify_preflight_purity
-> _enforce_issue_scope_guard
-> workflow_scope_guard.assess_production_mutation_guards
raised ProductionGuardError(missing_issue_worktree) before
``assess_author_issue_bootstrap`` was ever consulted.
These tests drive the real enforcer, not the authorization helper in
isolation. A helper-only test cannot observe this defect: #892's own predicate
tests all passed while the live bootstrap stayed blocked.
"""
from __future__ import annotations
import os
import subprocess
import tempfile
import unittest
from unittest import mock
import author_issue_bootstrap as aib
import create_issue_bootstrap as cib
import workflow_scope_guard
BOOTSTRAP_TASK = "bootstrap_author_issue_worktree"
BOOTSTRAP_TOOL = "gitea_bootstrap_author_issue_worktree"
def _make_control_repo(tmp: str) -> tuple[str, str]:
"""Create a clean control checkout on master and return (path, head)."""
repo = os.path.join(tmp, "repo")
os.makedirs(os.path.join(repo, "branches"))
subprocess.check_call(
["git", "init", "-b", "master", repo],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
subprocess.check_call(
[
"git", "-C", repo,
"-c", "user.email=t@t", "-c", "user.name=t",
"commit", "--allow-empty", "-m", "init",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
head = subprocess.check_output(
["git", "-C", repo, "rev-parse", "HEAD"], text=True
).strip()
return repo, head
def _assessment(
repo: str,
head: str,
*,
task: str = BOOTSTRAP_TASK,
porcelain: str = "",
remote: str | None = None,
) -> dict:
return aib.assess_author_issue_bootstrap(
workspace_path=repo,
canonical_repo_root=repo,
current_branch="master",
head_sha=head,
porcelain_status=porcelain,
remote_master_sha=head if remote is None else remote,
task=task,
)
class _ControlCheckoutHarness(unittest.TestCase):
"""Drive the real server guard against a temporary clean control checkout."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.repo, self.head = _make_control_repo(self._tmp.name)
# #683 force-on: production guards must execute under pytest.
patcher = mock.patch.dict(
os.environ,
{workflow_scope_guard.FORCE_PRODUCTION_GUARDS_ENV: "1"},
)
patcher.start()
self.addCleanup(patcher.stop)
def _enforce(
self,
task: str,
*,
porcelain: str = "",
assessment: object = "auto",
role_kind: str = "author",
):
"""Call the real _enforce_issue_scope_guard for *task*."""
import gitea_mcp_server as srv
if assessment == "auto":
assessment = _assessment(
self.repo, self.head, task=task, porcelain=porcelain
)
ctx = {
"workspace_path": self.repo,
"canonical_repo_root": self.repo,
"workspace_role_kind": role_kind,
"workspace_binding_source": "process_project_root",
}
git_state = {
"current_branch": "master",
"head_sha": self.head,
"porcelain_status": porcelain,
}
with mock.patch.object(
srv, "_resolve_namespace_mutation_context", return_value=ctx
), mock.patch.object(
srv.issue_lock_worktree,
"read_worktree_git_state",
return_value=git_state,
), mock.patch.object(
srv,
"_session_issue_lock_snapshot",
return_value={
"locked_issue_number": None,
"lock_branch_name": None,
"worktrees_match": False,
},
), mock.patch.object(
srv, "_actual_profile_role", return_value=role_kind
), mock.patch.object(
srv, "_effective_workspace_role", return_value=role_kind
), mock.patch.object(
srv, "_create_issue_bootstrap_assessment", return_value=assessment
):
srv._enforce_issue_scope_guard(None, task=task)
class TestRealPathBootstrapReachesGuard(_ControlCheckoutHarness):
"""The defect and its fix, observed through the real enforcer."""
def test_bootstrap_task_passes_scope_guard_from_clean_control(self):
# Pre-fix this raises ProductionGuardError(missing_issue_worktree)
# because the guard consulted a task-name allowlist instead of the
# canonical authorization decision.
self._enforce(BOOTSTRAP_TASK)
def test_bootstrap_tool_alias_passes_scope_guard(self):
self._enforce(BOOTSTRAP_TOOL)
def test_guard_consults_canonical_predicate(self):
"""The guard must reach bootstrap_permits_control_checkout, not a name list."""
real = cib.bootstrap_permits_control_checkout
seen: list[str | None] = []
def _spy(assessment, *, task, workspace_path, canonical_repo_root):
seen.append(task)
return real(
assessment,
task=task,
workspace_path=workspace_path,
canonical_repo_root=canonical_repo_root,
)
with mock.patch.object(
cib, "bootstrap_permits_control_checkout", side_effect=_spy
):
self._enforce(BOOTSTRAP_TASK)
self.assertIn(
BOOTSTRAP_TASK,
seen,
"workflow_scope_guard did not consult the canonical bootstrap "
"authorization decision",
)
class TestFailClosedOnBadEvidence(_ControlCheckoutHarness):
"""Missing, malformed, or mismatched scope evidence must still block."""
def _assert_blocked(self, **kwargs):
with self.assertRaises(workflow_scope_guard.ProductionGuardError):
self._enforce(BOOTSTRAP_TASK, **kwargs)
def test_missing_assessment_fails_closed(self):
self._assert_blocked(assessment=None)
def test_malformed_assessment_fails_closed(self):
self._assert_blocked(assessment={"allowed": True})
def test_non_dict_assessment_fails_closed(self):
self._assert_blocked(assessment="allowed")
def test_wrong_task_scope_fails_closed(self):
bad = dict(_assessment(self.repo, self.head))
bad["task_scope"] = "create_issue_only"
self._assert_blocked(assessment=bad)
def test_nonempty_reasons_fail_closed(self):
bad = dict(_assessment(self.repo, self.head), reasons=["note"])
self._assert_blocked(assessment=bad)
def test_mismatched_base_tips_fail_closed(self):
bad = dict(_assessment(self.repo, self.head))
bad["remote_master_sha"] = "b" * 40
self._assert_blocked(assessment=bad)
def test_unverified_base_tips_fail_closed(self):
bad = dict(_assessment(self.repo, self.head), base_tips_verified=False)
self._assert_blocked(assessment=bad)
def test_mismatched_workspace_binding_fails_closed(self):
bad = dict(_assessment(self.repo, self.head))
bad["workspace_path"] = os.path.join(self.repo, "elsewhere")
self._assert_blocked(assessment=bad)
def test_mismatched_repo_root_binding_fails_closed(self):
bad = dict(_assessment(self.repo, self.head))
bad["canonical_repo_root"] = os.path.join(self.repo, "other-root")
self._assert_blocked(assessment=bad)
def test_blocked_assessment_fails_closed(self):
bad = dict(_assessment(self.repo, self.head), block=True, allowed=False)
self._assert_blocked(assessment=bad)
class TestOrdinaryControlCheckoutMutationStillForbidden(_ControlCheckoutHarness):
"""The waiver must not leak to ordinary author work."""
def test_ordinary_author_task_still_blocked(self):
with self.assertRaises(workflow_scope_guard.ProductionGuardError):
self._enforce("commit_files", assessment=None)
def test_lock_issue_still_blocked_from_control(self):
with self.assertRaises(workflow_scope_guard.ProductionGuardError):
self._enforce("lock_issue", assessment=None)
def test_bootstrap_assessment_cannot_license_other_task(self):
# Cross-task smuggling: valid bootstrap evidence must not waive a
# different author mutation.
good = _assessment(self.repo, self.head)
with self.assertRaises(workflow_scope_guard.ProductionGuardError):
self._enforce("commit_files", assessment=good)
def test_dirty_control_checkout_still_blocked_for_bootstrap(self):
with self.assertRaises(workflow_scope_guard.ProductionGuardError):
self._enforce(BOOTSTRAP_TASK, porcelain=" M gitea_mcp_server.py\n")
class TestCreateIssueBehaviorUnchanged(_ControlCheckoutHarness):
"""#749 create_issue keeps its own sanctioned path."""
def test_create_issue_still_allowed_from_clean_control(self):
self._enforce("create_issue", assessment=None)
def test_create_issue_tool_alias_still_allowed(self):
self._enforce("gitea_create_issue", assessment=None)
def test_create_issue_blocked_when_control_dirty(self):
with self.assertRaises(workflow_scope_guard.ProductionGuardError):
self._enforce(
"create_issue",
porcelain=" M gitea_mcp_server.py\n",
assessment=None,
)
class TestGuardUnitLevelWiring(unittest.TestCase):
"""assess_root_source_mutation itself must accept and honour the evidence."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.repo, self.head = _make_control_repo(self._tmp.name)
patcher = mock.patch.dict(
os.environ,
{workflow_scope_guard.FORCE_PRODUCTION_GUARDS_ENV: "1"},
)
patcher.start()
self.addCleanup(patcher.stop)
def _assess(self, *, task=BOOTSTRAP_TASK, bootstrap_assessment="auto"):
if bootstrap_assessment == "auto":
bootstrap_assessment = _assessment(self.repo, self.head, task=task)
return workflow_scope_guard.assess_root_source_mutation(
workspace_path=self.repo,
canonical_repo_root=self.repo,
porcelain_status="",
current_branch="master",
role_kind="author",
mutation_task=task,
bootstrap_assessment=bootstrap_assessment,
)
def test_valid_evidence_unblocks(self):
result = self._assess()
self.assertFalse(result["block"])
self.assertIsNone(result["blocker_kind"])
def test_absent_evidence_blocks(self):
result = self._assess(bootstrap_assessment=None)
self.assertTrue(result["block"])
self.assertEqual(
result["blocker_kind"], workflow_scope_guard.BLOCKER_MISSING_WORKTREE
)
def test_reconciler_exemption_preserved(self):
result = workflow_scope_guard.assess_root_source_mutation(
workspace_path=self.repo,
canonical_repo_root=self.repo,
porcelain_status="",
current_branch="master",
role_kind="reconciler",
mutation_task=BOOTSTRAP_TASK,
)
self.assertFalse(result["block"])
def test_signature_accepts_evidence_without_it_being_required(self):
# Callers that supply no evidence keep the pre-existing behaviour.
result = workflow_scope_guard.assess_root_source_mutation(
workspace_path=self.repo,
canonical_repo_root=self.repo,
porcelain_status="",
current_branch="master",
role_kind="author",
mutation_task="create_issue",
)
self.assertFalse(result["block"])
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -35,10 +35,10 @@ class TestMcpStaleRuntime(unittest.TestCase):
# Mock env output for ps eww # Mock env output for ps eww
mock_run_env12345 = MagicMock() mock_run_env12345 = MagicMock()
mock_run_env12345.stdout = "GITEA_MCP_PROFILE=prgs-reconciler GITEA_CLIENT_MANAGED=1" mock_run_env12345.stdout = "GITEA_MCP_PROFILE=prgs-reconciler"
mock_run_env54321 = MagicMock() mock_run_env54321 = MagicMock()
mock_run_env54321.stdout = "GITEA_MCP_PROFILE=prgs-author GITEA_CLIENT_MANAGED=1" mock_run_env54321.stdout = "GITEA_MCP_PROFILE=prgs-author"
def side_effect(args, **kwargs): def side_effect(args, **kwargs):
if args[0] == "ps" and "eww" in args: if args[0] == "ps" and "eww" in args:
@@ -91,7 +91,7 @@ class TestMcpStaleRuntime(unittest.TestCase):
mock_run_ps.stdout = ps_output mock_run_ps.stdout = ps_output
mock_run_env = MagicMock() mock_run_env = MagicMock()
mock_run_env.stdout = "GITEA_MCP_PROFILE=prgs-author GITEA_CLIENT_MANAGED=1" mock_run_env.stdout = "GITEA_MCP_PROFILE=prgs-author"
mock_run_git = MagicMock() mock_run_git = MagicMock()
mock_run_git.stdout = "FAKE2" # different SHA mock_run_git.stdout = "FAKE2" # different SHA
+1 -2
View File
@@ -243,10 +243,9 @@ class TestRuntimeClarity(unittest.TestCase):
self.assertIn("switching is disabled", res["message"].lower()) self.assertIn("switching is disabled", res["message"].lower())
self.assertIsNone(gitea_config._active_profile_override) self.assertIsNone(gitea_config._active_profile_override)
@patch("mcp_server._trusted_session_repository", return_value={"repository": "Example-Org/Example-Repo", "org": "Example-Org", "repo": "Example-Repo", "reasons": []})
@patch("mcp_server.api_request") @patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header") @patch("mcp_server.get_auth_header")
def test_activate_profile_succeeds_when_enabled(self, mock_auth, mock_api, mock_trusted): def test_activate_profile_succeeds_when_enabled(self, mock_auth, mock_api):
self._write_config(CONFIG_SWITCHING_ENABLED) self._write_config(CONFIG_SWITCHING_ENABLED)
# Setup mock responses for whoami checks # Setup mock responses for whoami checks
+1 -30
View File
@@ -279,7 +279,6 @@ def assess_root_source_mutation(
locked_issue_number: int | None = None, locked_issue_number: int | None = None,
role_kind: str | None = None, role_kind: str | None = None,
mutation_task: str | None = None, mutation_task: str | None = None,
bootstrap_assessment: Any | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Fail closed for diagnostic/source edits on the control/root checkout. """Fail closed for diagnostic/source edits on the control/root checkout.
@@ -287,13 +286,6 @@ def assess_root_source_mutation(
tracked source/test files on the control checkout always block, including tracked source/test files on the control checkout always block, including
temporary/diagnostic/test-only intent. temporary/diagnostic/test-only intent.
#941: ``bootstrap_author_issue_worktree`` is judged by the canonical
``create_issue_bootstrap.bootstrap_permits_control_checkout`` decision over
*bootstrap_assessment* the same server-derived evidence the #274 and
#604 guards consume — instead of a task-name allowlist local to this
module. Evidence that is absent, malformed, wrongly scoped, or bound to
another workspace leaves the ordinary block in force.
#749: ``create_issue`` is a pure remote mutation with no local tree write. #749: ``create_issue`` is a pure remote mutation with no local tree write.
When *mutation_task* is create_issue and the control checkout has no dirty When *mutation_task* is create_issue and the control checkout has no dirty
source/test files, the missing-worktree signal is suppressed so the source/test files, the missing-worktree signal is suppressed so the
@@ -344,19 +336,6 @@ def assess_root_source_mutation(
if _cib is not None and _cib.is_create_issue_task(mutation_task): if _cib is not None and _cib.is_create_issue_task(mutation_task):
# #749: clean-root create_issue is the sanctioned bootstrap path. # #749: clean-root create_issue is the sanctioned bootstrap path.
create_issue_bootstrap = True create_issue_bootstrap = True
elif _cib is not None and _cib.bootstrap_permits_control_checkout(
bootstrap_assessment,
task=mutation_task,
workspace_path=workspace,
canonical_repo_root=root,
):
# #941: the author issue-worktree bootstrap is authorized by the
# canonical shared decision over server-derived task-scope
# evidence, never by a task-name allowlist kept in this module.
# The predicate fails closed on missing, malformed, cross-scope,
# dirty, drifted, or wrongly bound evidence, so this arm cannot
# widen the waiver beyond the one sanctioned bootstrap task.
create_issue_bootstrap = True
else: else:
# Explicit missing-worktree signal for force-on author entrypoints. # Explicit missing-worktree signal for force-on author entrypoints.
reasons.append( reasons.append(
@@ -414,15 +393,8 @@ def assess_production_mutation_guards(
require_author_lock: bool = False, require_author_lock: bool = False,
in_test_mode: bool = False, in_test_mode: bool = False,
mutation_task: str | None = None, mutation_task: str | None = None,
bootstrap_assessment: Any | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Compose root + scope production guards when they must be active (#683). """Compose root + scope production guards when they must be active (#683)."""
#941: *bootstrap_assessment* is the server-derived author-bootstrap
evidence, forwarded unchanged to :func:`assess_root_source_mutation` so
this guard reaches the same canonical decision as the #274 and #604
guards. Omitting it preserves the pre-existing behaviour.
"""
if not production_guards_active(in_test_mode=in_test_mode): if not production_guards_active(in_test_mode=in_test_mode):
return { return {
"proven": True, "proven": True,
@@ -442,7 +414,6 @@ def assess_production_mutation_guards(
locked_issue_number=locked_issue_number, locked_issue_number=locked_issue_number,
role_kind=role_kind, role_kind=role_kind,
mutation_task=mutation_task, mutation_task=mutation_task,
bootstrap_assessment=bootstrap_assessment,
) )
if root_assess["block"]: if root_assess["block"]:
return {**root_assess, "skipped": False} return {**root_assess, "skipped": False}