"""Namespace-scoped MCP workspace binding (#510). Each role namespace (author, reviewer, merger, reconciler) resolves its own active task workspace. Foreign role worktree environment variables must not poison workspace purity checks in another namespace. """ from __future__ import annotations import os import author_mutation_worktree as amw ACTIVE_WORKTREE_ENV = amw.ACTIVE_WORKTREE_ENV AUTHOR_WORKTREE_ENV = amw.AUTHOR_WORKTREE_ENV REVIEWER_WORKTREE_ENV = "GITEA_REVIEWER_WORKTREE" MERGER_WORKTREE_ENV = "GITEA_MERGER_WORKTREE" RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE" ROLE_WORKTREE_ENVS: dict[str, str] = { "author": AUTHOR_WORKTREE_ENV, "reviewer": REVIEWER_WORKTREE_ENV, "merger": MERGER_WORKTREE_ENV, "reconciler": RECONCILER_WORKTREE_ENV, } NON_AUTHOR_ROLES = frozenset({"reviewer", "merger", "reconciler"}) def normalize_role_kind( role_kind: str | None, *, profile_name: str | None = None, ) -> str: """Map profile/task role to a workspace namespace key.""" role = (role_kind or "author").strip().lower() profile = (profile_name or "").strip().lower() if role == "reviewer" and "merger" in profile: return "merger" if role in ROLE_WORKTREE_ENVS: return role return "author" def _env_value(env: dict[str, str] | os._Environ, key: str) -> str | None: text = (env.get(key) or "").strip() return text or None def resolve_namespace_workspace( *, role_kind: str, worktree_path: str | None = None, worktree: str | None = None, process_project_root: str, env: dict[str, str] | os._Environ | None = None, session_lease_worktree: str | None = None, session_lock_worktree: str | None = None, profile_name: str | None = None, demotions: list[str] | None = None, verify_paths: bool = False, durable_author_result: dict | None = None, ) -> tuple[str, str]: """Return ``(resolved_path, binding_source)`` for *role_kind*. With *verify_paths*, env-sourced candidates whose path no longer exists are demoted (#702) for non-author roles: a binding to a deleted worktree can never name a valid task workspace, so resolution falls through to the next candidate. Explicit arguments are never demoted — a caller-declared path must fail loudly downstream rather than silently rebind. Demotion notes are appended to *demotions* when provided. Author role (#618): never demotes a missing configured binding to the control checkout. When *verify_paths* is true, resolution goes through :func:`author_mutation_worktree.resolve_durable_author_worktree` so mutations either use an explicit validated worktree, derive from the active author issue lock, or fail closed. Runtime-context and mutation guards resolve through :func:`resolve_namespace_mutation_context`, which always verifies. """ env_map = env if env is not None else os.environ role = normalize_role_kind(role_kind, profile_name=profile_name) role_env_key = ROLE_WORKTREE_ENVS[role] # #618: durable author resolution — no silent control/master fallback. if role == "author" and verify_paths: durable = durable_author_result if durable is None: durable = amw.resolve_durable_author_worktree( worktree_path=worktree_path, worktree=worktree, process_project_root=process_project_root, active_worktree_env=_env_value(env_map, ACTIVE_WORKTREE_ENV), author_worktree_env=_env_value(env_map, AUTHOR_WORKTREE_ENV), session_lock_worktree=session_lock_worktree, profile_name=profile_name, # Path selection only here; full validation is re-run in # resolve_namespace_mutation_context with the canonical root. validate=False, ) workspace = durable.get("workspace_path") or os.path.realpath( process_project_root ) source = durable.get("workspace_binding_source") or "no author worktree binding" if demotions is not None and durable.get("bound_worktree_missing"): demotions.append( f"{source} '{workspace}' not demoted: {amw.BOUND_WORKTREE_MISSING_MESSAGE}" ) return workspace, source for candidate, source, env_sourced in ( (worktree_path, "worktree_path argument", False), (worktree, "worktree argument", False), (_env_value(env_map, ACTIVE_WORKTREE_ENV), f"{ACTIVE_WORKTREE_ENV} environment variable", True), (_env_value(env_map, role_env_key), f"{role_env_key} environment variable", True), (session_lease_worktree if role in {"reviewer", "merger"} else None, "reviewer PR lease worktree", False), # Author lock derivation is handled by the durable path above when # verify_paths is true; when verify_paths is false, surface the lock # path as a non-demoted candidate so tooling can inspect it. (session_lock_worktree if role == "author" else None, "active author issue lock worktree", False), ): text = (candidate or "").strip() if not text: continue real = os.path.realpath(os.path.abspath(text)) if verify_paths and env_sourced and not os.path.isdir(real): if demotions is not None: demotions.append( f"{source} '{real}' demoted: path no longer exists " "(stale binding, #702)" ) continue return real, source return os.path.realpath(process_project_root), "MCP server process root (default)" def resolve_namespace_mutation_context( *, role_kind: str, worktree_path: str | None, process_project_root: str, env: dict[str, str] | os._Environ | None = None, session_lease_worktree: str | None = None, session_lock_worktree: str | None = None, worktree: str | None = None, profile_name: str | None = None, configured_canonical_root: str | None = None, ) -> dict: """Shared workspace resolution for runtime_context and mutation guards. When *configured_canonical_root* is supplied (a cross-repository namespace bound to an external target repository, #706), the canonical repository root is that configured target rather than the MCP install checkout. This keeps the branches-only / worktree-membership guards (#274) evaluating against the repository the namespace actually mutates. Without it the single-repo default is preserved: the canonical root follows the process checkout. Author role (#618): uses durable worktree resolution (explicit path, env, or active issue lock) and never silently falls back to the control checkout. """ demotions: list[str] = [] 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 = (configured_canonical_root or "").strip() if configured: canonical_root = os.path.realpath(configured) else: canonical_root = amw.resolve_canonical_repo_root(process_root, process_root) durable: dict | None = None if role == "author": durable = amw.resolve_durable_author_worktree( worktree_path=worktree_path, worktree=worktree, process_project_root=process_root, active_worktree_env=_env_value(env_map, ACTIVE_WORKTREE_ENV), author_worktree_env=_env_value(env_map, AUTHOR_WORKTREE_ENV), session_lock_worktree=session_lock_worktree, canonical_repo_root=canonical_root, profile_name=profile_name, validate=True, ) workspace = durable["workspace_path"] binding_source = durable["workspace_binding_source"] if durable.get("bound_worktree_missing"): demotions.append( f"{binding_source} '{workspace}' not demoted: " f"{amw.BOUND_WORKTREE_MISSING_MESSAGE}" ) else: workspace, binding_source = resolve_namespace_workspace( role_kind=role, worktree_path=worktree_path, worktree=worktree, process_project_root=process_project_root, env=env, session_lease_worktree=session_lease_worktree, session_lock_worktree=session_lock_worktree, profile_name=profile_name, demotions=demotions, verify_paths=True, ) pollution = assess_foreign_role_worktree_pollution( role_kind=role, resolved_workspace=workspace, binding_source=binding_source, env=env, profile_name=profile_name, ) result = { "workspace_path": workspace, "workspace_binding_source": binding_source, "workspace_role_kind": role, "ignored_bindings": demotions + (pollution.get("ignored_bindings") or []), "process_project_root": process_root, "canonical_repo_root": canonical_root, "roots_aligned": canonical_root == process_root, } if durable is not None: result["author_worktree_resolution"] = durable result["bound_worktree_missing"] = bool(durable.get("bound_worktree_missing")) result["path_exists"] = durable.get("path_exists") result["in_git_worktree_list"] = durable.get("in_git_worktree_list") result["inspected_git_root"] = durable.get("inspected_git_root") result["author_worktree_block"] = bool(durable.get("block")) 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") return result def assess_foreign_role_worktree_pollution( *, role_kind: str, resolved_workspace: str, binding_source: str, env: dict[str, str] | os._Environ | None = None, profile_name: str | None = None, ) -> dict: """Detect when a foreign role env would have hijacked workspace binding.""" env_map = env if env is not None else os.environ role = normalize_role_kind(role_kind, profile_name=profile_name) if role == "author": return {"would_pollute": False, "ignored_bindings": []} ignored: list[str] = [] author_path = _env_value(env_map, AUTHOR_WORKTREE_ENV) if author_path: author_real = os.path.realpath(os.path.abspath(author_path)) resolved_real = os.path.realpath(resolved_workspace) if author_real != resolved_real and binding_source != f"{AUTHOR_WORKTREE_ENV} environment variable": ignored.append( f"{AUTHOR_WORKTREE_ENV}={author_real} (ignored for {role} namespace)" ) return { "would_pollute": bool(ignored), "ignored_bindings": ignored, } def assess_metadata_only_worktree_binding( *, role_kind: str, declared_worktree_path: str | None, mutation_workspace: str, process_project_root: str, profile_name: str | None = None, ) -> dict: """Fail closed when declared worktree_path would not redirect mutations.""" declared = (declared_worktree_path or "").strip() process_root = os.path.realpath(process_project_root) mutation_root = os.path.realpath(mutation_workspace) role = normalize_role_kind(role_kind, profile_name=profile_name) if not declared: return {"block": False, "reasons": [], "metadata_only": False} declared_root = os.path.realpath(os.path.abspath(declared)) if declared_root == mutation_root: return {"block": False, "reasons": [], "metadata_only": False} if declared_root != process_root and mutation_root == process_root: return { "block": True, "metadata_only": True, "reasons": [ f"worktree_path is metadata-only for {role} mutations: preflight " f"inspected '{declared_root}' but mutation tools would still " f"validate MCP server process root '{process_root}'" ], "declared_worktree_path": declared_root, "mutation_workspace": mutation_root, "process_project_root": process_root, } return {"block": False, "reasons": [], "metadata_only": False} def format_namespace_workspace_binding_error( *, role_kind: str, workspace_path: str, binding_source: str, reasons: list[str] | None = None, ignored_bindings: list[str] | None = None, dirty_files: list[str] | None = None, operator_recovery: str | None = None, ) -> str: """Canonical error when namespace workspace binding blocks mutations.""" role = normalize_role_kind(role_kind) reason_list = list(reasons or []) # #618: prefer the durable author missing-worktree message when present. if role == "author" and any( amw.BOUND_WORKTREE_MISSING_MESSAGE in r for r in reason_list ): return amw.format_bound_worktree_missing_error( { "reasons": reason_list, "binding_source": binding_source, "configured_path": workspace_path, "role_kind": role, "operator_recovery": operator_recovery or amw.OPERATOR_RECOVERY_RECREATE_REPOINT, } ) try: workspace = os.path.realpath(workspace_path) except OSError: workspace = workspace_path parts = [ f"Namespace workspace binding blocked ({role} namespace, #510): " f"resolved workspace '{workspace}' via {binding_source}." ] if ignored_bindings: parts.append( "Foreign role bindings ignored: " + "; ".join(ignored_bindings) + "." ) if dirty_files: parts.append( "Dirty tracked files in active task workspace: " + ", ".join(dirty_files) + "." ) if reason_list: parts.append("Details: " + "; ".join(reason_list) + ".") if operator_recovery: parts.append(f"Operator recovery: {operator_recovery}") else: parts.append( "Remediation: reconnect or relaunch the MCP server from a clean dedicated " f"branches/ {role} worktree, set " f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} " "to that path, or pass worktree_path on mutation tools. Do not clean or " "reset foreign role worktrees to unblock this namespace." ) return " ".join(parts) def assess_namespace_mutation_workspace( *, role_kind: str, 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, session_lock_worktree: str | None = None, profile_name: str | None = None, current_branch: str | None = None, configured_canonical_root: str | None = None, ) -> dict: """Evaluate namespace workspace binding before preflight/mutation.""" ctx = resolve_namespace_mutation_context( role_kind=role_kind, worktree_path=worktree_path, worktree=worktree, process_project_root=process_project_root, env=env, session_lease_worktree=session_lease_worktree, session_lock_worktree=session_lock_worktree, profile_name=profile_name, configured_canonical_root=configured_canonical_root, ) mutation_workspace = ctx["workspace_path"] binding_source = ctx["workspace_binding_source"] role = ctx["workspace_role_kind"] process_root = ctx["process_project_root"] metadata = assess_metadata_only_worktree_binding( role_kind=role, declared_worktree_path=worktree_path, mutation_workspace=mutation_workspace, process_project_root=process_root, profile_name=profile_name, ) pollution = assess_foreign_role_worktree_pollution( role_kind=role, resolved_workspace=mutation_workspace, binding_source=binding_source, env=env, profile_name=profile_name, ) reasons = list(metadata.get("reasons") or []) operator_recovery = ctx.get("operator_recovery") if role == "author": # #618 durable resolution already validated existence, membership, # branches/, lock ownership, and traversal safety when present. durable_reasons = list(ctx.get("author_worktree_reasons") or []) if durable_reasons: reasons.extend(durable_reasons) elif ctx.get("author_worktree_block"): reasons.append(amw.BOUND_WORKTREE_MISSING_MESSAGE) else: branches = amw.assess_author_mutation_worktree( workspace_path=mutation_workspace, project_root=ctx["canonical_repo_root"], current_branch=current_branch, ) if branches["block"]: reasons.extend(branches["reasons"]) 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[role]} / {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 { "block": block, "reasons": reasons, "mutation_workspace": mutation_workspace, "workspace_binding_source": binding_source, "workspace_role_kind": role, "process_project_root": process_root, "canonical_repo_root": ctx["canonical_repo_root"], "metadata_only": metadata.get("metadata_only", False), "declared_worktree_path": metadata.get("declared_worktree_path"), "ignored_bindings": pollution.get("ignored_bindings") or [], "bound_worktree_missing": bool(ctx.get("bound_worktree_missing")), "path_exists": ctx.get("path_exists"), "in_git_worktree_list": ctx.get("in_git_worktree_list"), "inspected_git_root": ctx.get("inspected_git_root"), "operator_recovery": operator_recovery, "blocker_kind": ctx.get("author_worktree_blocker_kind"), }