Author mutation tools now resolve workspace via explicit worktree_path,
env bindings, or the active author issue lock — never silent fallback to
the control checkout/master. Missing configured bindings fail closed with
operator recovery; create_issue and create_issue_comment agree.
Recovered onto 0568f44 from preserved candidate cbf56ccd (AUTHOR_RECOVERY).
LLM_LOCK_ID=author-618-recovery-508eb3162d01-1784569919
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
776 lines
28 KiB
Python
776 lines
28 KiB
Python
"""Branches-only author mutation worktree guard (#274) with durable resolution (#618).
|
|
|
|
Author/coder mutations must run from a session-owned worktree under the
|
|
project's ``branches/`` directory, never from the stable control checkout.
|
|
|
|
#618 durable resolution:
|
|
- Prefer an explicit validated ``worktree_path`` argument.
|
|
- Else derive the workspace from the active author issue lock's worktree.
|
|
- Env bindings (``GITEA_ACTIVE_WORKTREE`` / ``GITEA_AUTHOR_WORKTREE``) may bind
|
|
when present and valid.
|
|
- Author mutations never silently fall back to the control checkout or master.
|
|
- Missing configured bindings fail closed with a clear operator recovery action.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
|
|
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
|
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
|
|
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
|
|
# Author-only: reviewer/merger/reconciler namespaces use role-specific env vars
|
|
# via namespace_workspace_binding (#510).
|
|
|
|
BOUND_WORKTREE_MISSING = "bound_worktree_missing"
|
|
BOUND_WORKTREE_MISSING_MESSAGE = (
|
|
"bound worktree missing; operator must recreate or repoint the worktree "
|
|
"and reconnect"
|
|
)
|
|
OPERATOR_RECOVERY_RECREATE_REPOINT = (
|
|
"Recreate the worktree under branches/ (scripts/worktree-start or "
|
|
"git worktree add), set GITEA_AUTHOR_WORKTREE / GITEA_ACTIVE_WORKTREE "
|
|
"to that path (or pass worktree_path on mutation tools), keep the control "
|
|
"checkout clean on master, then reconnect the author MCP session and re-run."
|
|
)
|
|
|
|
|
|
def _normalize_path(path: str) -> str:
|
|
return (path or "").replace("\\", "/").rstrip("/")
|
|
|
|
|
|
def is_path_under_branches(path: str, project_root: str | None = None) -> bool:
|
|
"""True when *path* resolves inside ``<project_root>/branches/``."""
|
|
normalized = _normalize_path(path)
|
|
if not normalized:
|
|
return False
|
|
if "/branches/" in f"{normalized}/":
|
|
return True
|
|
if normalized.endswith("/branches"):
|
|
return True
|
|
if project_root:
|
|
root = _normalize_path(os.path.realpath(project_root))
|
|
real = _normalize_path(os.path.realpath(path))
|
|
if real.startswith(f"{root}/"):
|
|
rel = real[len(root) + 1 :]
|
|
return rel == "branches" or rel.startswith("branches/")
|
|
return False
|
|
|
|
|
|
def resolve_mutation_workspace(
|
|
worktree_path: str | None,
|
|
project_root: str,
|
|
*,
|
|
active_worktree_env: str | None = None,
|
|
author_worktree_env: str | None = None,
|
|
) -> str:
|
|
"""Resolve the workspace path inspected before author mutations.
|
|
|
|
Legacy helper: returns the first non-empty candidate path. Prefer
|
|
:func:`resolve_durable_author_worktree` for mutation guards (#618).
|
|
"""
|
|
for candidate in (worktree_path, active_worktree_env, author_worktree_env):
|
|
text = (candidate or "").strip()
|
|
if text:
|
|
return os.path.realpath(os.path.abspath(text))
|
|
return os.path.realpath(project_root)
|
|
|
|
|
|
def _realpath_git_common_dir(workspace_path: str, common_dir: str) -> str:
|
|
"""Resolve ``git rev-parse --git-common-dir`` relative to *workspace_path*."""
|
|
raw = (common_dir or "").strip()
|
|
if not raw:
|
|
return raw
|
|
if os.path.isabs(raw):
|
|
return os.path.realpath(raw)
|
|
return os.path.realpath(os.path.join(workspace_path, raw))
|
|
|
|
|
|
def resolve_canonical_repo_root(workspace_path: str, fallback_project_root: str) -> str:
|
|
"""Return the stable repository root for *workspace_path* via git metadata (#460)."""
|
|
path = (workspace_path or "").strip()
|
|
fallback = os.path.realpath(fallback_project_root)
|
|
if not path:
|
|
return fallback
|
|
try:
|
|
res = subprocess.run(
|
|
["git", "-C", path, "rev-parse", "--git-common-dir"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
common = _realpath_git_common_dir(path, res.stdout)
|
|
except Exception:
|
|
return fallback
|
|
if common.endswith(f"{os.sep}.git"):
|
|
return os.path.dirname(common)
|
|
if os.path.basename(common) == ".git":
|
|
return os.path.dirname(common)
|
|
return fallback
|
|
|
|
|
|
def resolve_author_mutation_context(
|
|
worktree_path: str | None,
|
|
process_project_root: str,
|
|
*,
|
|
active_worktree_env: str | None = None,
|
|
author_worktree_env: str | None = None,
|
|
) -> dict:
|
|
"""Shared workspace resolution for runtime_context and mutation guards (#460)."""
|
|
workspace = resolve_mutation_workspace(
|
|
worktree_path,
|
|
process_project_root,
|
|
active_worktree_env=active_worktree_env,
|
|
author_worktree_env=author_worktree_env,
|
|
)
|
|
process_root = os.path.realpath(process_project_root)
|
|
# Canonical repository identity comes from the MCP process checkout (#460),
|
|
# not from the declared task workspace being validated.
|
|
canonical_root = resolve_canonical_repo_root(process_root, process_root)
|
|
return {
|
|
"workspace_path": workspace,
|
|
"process_project_root": process_root,
|
|
"canonical_repo_root": canonical_root,
|
|
"roots_aligned": canonical_root == process_root,
|
|
}
|
|
|
|
|
|
def assess_workspace_repo_membership(
|
|
*,
|
|
workspace_path: str,
|
|
canonical_repo_root: str,
|
|
) -> dict:
|
|
"""Fail closed when *workspace_path* is not a git worktree of *canonical_repo_root*."""
|
|
workspace = os.path.realpath(workspace_path)
|
|
root = os.path.realpath(canonical_repo_root)
|
|
reasons: list[str] = []
|
|
|
|
if not os.path.exists(workspace):
|
|
reasons.append(f"worktree path '{workspace}' does not exist")
|
|
return _membership_assessment(False, reasons, workspace, root, None)
|
|
|
|
if not os.path.isdir(workspace):
|
|
reasons.append(f"worktree path '{workspace}' is not a directory")
|
|
return _membership_assessment(False, reasons, workspace, root, None)
|
|
|
|
try:
|
|
res = subprocess.run(
|
|
["git", "-C", workspace, "rev-parse", "--git-common-dir"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
common_dir = _realpath_git_common_dir(workspace, res.stdout)
|
|
except Exception:
|
|
reasons.append(f"worktree '{workspace}' is not a valid git repository")
|
|
return _membership_assessment(False, reasons, workspace, root, None)
|
|
|
|
expected_dir = os.path.realpath(os.path.join(root, ".git"))
|
|
if common_dir != expected_dir:
|
|
reasons.append(
|
|
f"worktree '{workspace}' does not belong to the target repository '{root}'"
|
|
)
|
|
return _membership_assessment(not reasons, reasons, workspace, root, common_dir)
|
|
|
|
|
|
def _membership_assessment(
|
|
proven: bool,
|
|
reasons: list[str],
|
|
workspace: str,
|
|
root: str,
|
|
common_dir: str | None,
|
|
) -> dict:
|
|
return {
|
|
"proven": proven,
|
|
"block": not proven,
|
|
"reasons": reasons,
|
|
"workspace_path": workspace,
|
|
"canonical_repo_root": root,
|
|
"git_common_dir": common_dir,
|
|
}
|
|
|
|
|
|
def format_workspace_repo_membership_error(assessment: dict) -> str:
|
|
workspace = assessment.get("workspace_path") or "(unknown)"
|
|
root = assessment.get("canonical_repo_root") or "(unknown)"
|
|
reasons = "; ".join(assessment.get("reasons") or ["unknown repository membership violation"])
|
|
return (
|
|
f"Branches-only mutation guard (#274): {reasons} (fail closed). "
|
|
f"canonical repository root: {root}; workspace: {workspace}."
|
|
)
|
|
|
|
|
|
def assess_author_mutation_worktree(
|
|
*,
|
|
workspace_path: str,
|
|
project_root: str,
|
|
current_branch: str | None = None,
|
|
base_branches: frozenset[str] | None = None,
|
|
) -> dict:
|
|
"""Fail closed when author mutations are not rooted under ``branches/``."""
|
|
bases = base_branches or BASE_BRANCHES
|
|
reasons: list[str] = []
|
|
root = os.path.realpath(project_root)
|
|
workspace = os.path.realpath(workspace_path)
|
|
branch = (current_branch or "").strip()
|
|
|
|
under_branches = is_path_under_branches(workspace, root)
|
|
if not under_branches:
|
|
if workspace == root:
|
|
reasons.append(
|
|
"author mutation blocked: workspace is the stable control checkout; "
|
|
"create or switch to a session-owned worktree under branches/"
|
|
)
|
|
else:
|
|
reasons.append(
|
|
f"author mutation blocked: workspace '{workspace}' is not under "
|
|
f"'{root}/branches/'; create a branches/<task> worktree first"
|
|
)
|
|
|
|
if not under_branches and workspace == root and branch and branch not in bases:
|
|
reasons.append(
|
|
f"control checkout drift: branch '{branch}' is not a stable base "
|
|
f"branch ({'/'.join(sorted(bases))})"
|
|
)
|
|
|
|
proven = not reasons
|
|
return {
|
|
"proven": proven,
|
|
"block": not proven,
|
|
"reasons": reasons,
|
|
"project_root": root,
|
|
"workspace_path": workspace,
|
|
"under_branches": is_path_under_branches(workspace, root),
|
|
"current_branch": branch or None,
|
|
}
|
|
|
|
|
|
def format_author_mutation_worktree_error(assessment: dict) -> str:
|
|
"""Single RuntimeError message for MCP preflight gates."""
|
|
workspace = assessment.get("workspace_path") or "(unknown)"
|
|
root = assessment.get("project_root") or "(unknown)"
|
|
reasons = "; ".join(assessment.get("reasons") or ["unknown branches-only violation"])
|
|
return (
|
|
f"Branches-only mutation guard (#274): {reasons}. "
|
|
f"project root: {root}; workspace: {workspace}. "
|
|
"Create a session-owned worktree under branches/ before mutating."
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #618 durable author worktree resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _abs_real(path: str) -> str:
|
|
return os.path.realpath(os.path.abspath((path or "").strip()))
|
|
|
|
|
|
def assess_path_traversal_safety(
|
|
*,
|
|
path: str,
|
|
canonical_repo_root: str,
|
|
) -> dict:
|
|
"""Fail closed on traversal/symlink escapes outside the target repository.
|
|
|
|
Uses ``realpath`` so intermediate symlinks cannot walk outside
|
|
``canonical_repo_root``. Author mutation workspaces must also land under
|
|
``branches/`` of that root (enforced separately by the branches-only guard).
|
|
"""
|
|
reasons: list[str] = []
|
|
raw = (path or "").strip()
|
|
if not raw:
|
|
return {
|
|
"proven": False,
|
|
"block": True,
|
|
"reasons": ["worktree path is empty (fail closed)"],
|
|
"workspace_path": None,
|
|
"canonical_repo_root": os.path.realpath(canonical_repo_root),
|
|
}
|
|
if "\x00" in raw:
|
|
return {
|
|
"proven": False,
|
|
"block": True,
|
|
"reasons": ["worktree path contains a null byte (fail closed)"],
|
|
"workspace_path": raw,
|
|
"canonical_repo_root": os.path.realpath(canonical_repo_root),
|
|
}
|
|
|
|
root = os.path.realpath(canonical_repo_root)
|
|
# Resolve without requiring existence first: abspath then realpath of parents.
|
|
abs_path = os.path.abspath(raw)
|
|
try:
|
|
real = os.path.realpath(abs_path)
|
|
except OSError as exc:
|
|
return {
|
|
"proven": False,
|
|
"block": True,
|
|
"reasons": [f"worktree path could not be resolved safely: {exc}"],
|
|
"workspace_path": abs_path,
|
|
"canonical_repo_root": root,
|
|
}
|
|
|
|
root_norm = _normalize_path(root)
|
|
real_norm = _normalize_path(real)
|
|
if real_norm != root_norm and not real_norm.startswith(f"{root_norm}/"):
|
|
reasons.append(
|
|
f"worktree path '{real}' escapes canonical repository root '{root}' "
|
|
"(traversal/symlink safety, fail closed)"
|
|
)
|
|
return {
|
|
"proven": not reasons,
|
|
"block": bool(reasons),
|
|
"reasons": reasons,
|
|
"workspace_path": real,
|
|
"canonical_repo_root": root,
|
|
}
|
|
|
|
|
|
def list_git_worktree_paths(canonical_repo_root: str) -> list[str]:
|
|
"""Return realpaths registered in ``git worktree list --porcelain``."""
|
|
root = os.path.realpath(canonical_repo_root)
|
|
try:
|
|
res = subprocess.run(
|
|
["git", "-C", root, "worktree", "list", "--porcelain"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
except Exception:
|
|
return []
|
|
if res.returncode != 0:
|
|
return []
|
|
paths: list[str] = []
|
|
for line in (res.stdout or "").splitlines():
|
|
if line.startswith("worktree "):
|
|
raw = line[len("worktree ") :].strip()
|
|
if raw:
|
|
paths.append(os.path.realpath(raw))
|
|
return paths
|
|
|
|
|
|
def path_in_git_worktree_list(path: str, canonical_repo_root: str) -> bool | None:
|
|
"""True/False when inventory is available; None when git inventory fails.
|
|
|
|
An empty inventory with a working git root is treated as inconclusive
|
|
(``None``) so unit tests and partial sandboxes are not false-negative
|
|
blocked when ``git worktree list`` is mocked/unavailable.
|
|
"""
|
|
root = os.path.realpath(canonical_repo_root)
|
|
try:
|
|
res = subprocess.run(
|
|
["git", "-C", root, "worktree", "list", "--porcelain"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
except Exception:
|
|
return None
|
|
if res.returncode != 0:
|
|
return None
|
|
inventory: list[str] = []
|
|
for line in (res.stdout or "").splitlines():
|
|
if line.startswith("worktree "):
|
|
raw = line[len("worktree ") :].strip()
|
|
if raw:
|
|
inventory.append(os.path.realpath(raw))
|
|
if not inventory:
|
|
return None
|
|
return os.path.realpath(path) in inventory
|
|
|
|
|
|
def assess_bound_worktree_existence(
|
|
*,
|
|
configured_path: str,
|
|
binding_source: str,
|
|
canonical_repo_root: str | None = None,
|
|
role_kind: str = "author",
|
|
profile_name: str | None = None,
|
|
) -> dict:
|
|
"""Fail closed when a configured role-bound worktree path is missing (#618)."""
|
|
raw = (configured_path or "").strip()
|
|
if not raw:
|
|
return {
|
|
"proven": True,
|
|
"block": False,
|
|
"bound_worktree_missing": False,
|
|
"path_exists": None,
|
|
"in_git_worktree_list": None,
|
|
"inspected_git_root": None,
|
|
"reasons": [],
|
|
"configured_path": None,
|
|
"binding_source": binding_source,
|
|
"role_kind": role_kind,
|
|
"profile_name": profile_name,
|
|
"blocker_kind": None,
|
|
"operator_recovery": None,
|
|
}
|
|
|
|
try:
|
|
real = _abs_real(raw)
|
|
except OSError:
|
|
real = os.path.abspath(raw)
|
|
|
|
path_exists = os.path.isdir(real)
|
|
in_list: bool | None = None
|
|
inspected_git_root: str | None = None
|
|
root = (canonical_repo_root or "").strip()
|
|
if root:
|
|
in_list = path_in_git_worktree_list(real, root) if path_exists else False
|
|
if path_exists:
|
|
try:
|
|
res = subprocess.run(
|
|
["git", "-C", real, "rev-parse", "--show-toplevel"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if res.returncode == 0:
|
|
inspected_git_root = (res.stdout or "").strip() or None
|
|
except Exception:
|
|
inspected_git_root = None
|
|
|
|
if path_exists:
|
|
return {
|
|
"proven": True,
|
|
"block": False,
|
|
"bound_worktree_missing": False,
|
|
"path_exists": True,
|
|
"in_git_worktree_list": in_list,
|
|
"inspected_git_root": inspected_git_root,
|
|
"reasons": [],
|
|
"configured_path": real,
|
|
"binding_source": binding_source,
|
|
"role_kind": role_kind,
|
|
"profile_name": profile_name,
|
|
"blocker_kind": None,
|
|
"operator_recovery": None,
|
|
}
|
|
|
|
reasons = [
|
|
BOUND_WORKTREE_MISSING_MESSAGE,
|
|
(
|
|
f"role/profile '{profile_name or role_kind}' binding via {binding_source} "
|
|
f"points to '{real}' which does not exist on disk"
|
|
),
|
|
f"path_exists=false; in_git_worktree_list={in_list}; inspected_git_root=null",
|
|
]
|
|
return {
|
|
"proven": False,
|
|
"block": True,
|
|
"bound_worktree_missing": True,
|
|
"path_exists": False,
|
|
"in_git_worktree_list": False if in_list is not None else False,
|
|
"inspected_git_root": None,
|
|
"reasons": reasons,
|
|
"configured_path": real,
|
|
"binding_source": binding_source,
|
|
"role_kind": role_kind,
|
|
"profile_name": profile_name,
|
|
"blocker_kind": BOUND_WORKTREE_MISSING,
|
|
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
|
|
}
|
|
|
|
|
|
def format_bound_worktree_missing_error(assessment: dict) -> str:
|
|
"""Canonical operator-facing message for a missing author worktree binding."""
|
|
reasons = list(assessment.get("reasons") or [BOUND_WORKTREE_MISSING_MESSAGE])
|
|
recovery = assessment.get("operator_recovery") or OPERATOR_RECOVERY_RECREATE_REPOINT
|
|
profile = assessment.get("profile_name") or assessment.get("role_kind") or "author"
|
|
source = (
|
|
assessment.get("binding_source")
|
|
or assessment.get("workspace_binding_source")
|
|
or "unknown binding"
|
|
)
|
|
path = (
|
|
assessment.get("configured_path")
|
|
or assessment.get("workspace_path")
|
|
or "(unknown)"
|
|
)
|
|
return (
|
|
f"Author worktree binding unhealthy (#618): {'; '.join(reasons)}. "
|
|
f"role/profile: {profile}; binding_source: {source}; configured_path: {path}. "
|
|
f"Operator recovery: {recovery}"
|
|
)
|
|
|
|
|
|
def assess_lock_worktree_ownership(
|
|
*,
|
|
workspace_path: str,
|
|
session_lock_worktree: str | None,
|
|
) -> dict:
|
|
"""When a live lock records a worktree, mutation workspace must match it."""
|
|
locked = (session_lock_worktree or "").strip()
|
|
if not locked:
|
|
return {
|
|
"proven": True,
|
|
"block": False,
|
|
"reasons": [],
|
|
"workspace_path": os.path.realpath(workspace_path) if workspace_path else None,
|
|
"lock_worktree_path": None,
|
|
}
|
|
workspace = os.path.realpath(workspace_path)
|
|
locked_real = os.path.realpath(locked)
|
|
if workspace != locked_real:
|
|
return {
|
|
"proven": False,
|
|
"block": True,
|
|
"reasons": [
|
|
f"active author issue lock worktree '{locked_real}' does not match "
|
|
f"mutation workspace '{workspace}' (lock ownership, fail closed)"
|
|
],
|
|
"workspace_path": workspace,
|
|
"lock_worktree_path": locked_real,
|
|
}
|
|
return {
|
|
"proven": True,
|
|
"block": False,
|
|
"reasons": [],
|
|
"workspace_path": workspace,
|
|
"lock_worktree_path": locked_real,
|
|
}
|
|
|
|
|
|
def resolve_durable_author_worktree(
|
|
*,
|
|
worktree_path: str | None = None,
|
|
worktree: str | None = None,
|
|
process_project_root: str,
|
|
active_worktree_env: str | None = None,
|
|
author_worktree_env: str | None = None,
|
|
session_lock_worktree: str | None = None,
|
|
canonical_repo_root: str | None = None,
|
|
profile_name: str | None = None,
|
|
validate: bool = True,
|
|
) -> dict:
|
|
"""Resolve author mutation workspace without silent control-checkout fallback (#618).
|
|
|
|
Candidate priority:
|
|
1. explicit ``worktree_path`` argument
|
|
2. ``worktree`` argument
|
|
3. ``GITEA_ACTIVE_WORKTREE``
|
|
4. ``GITEA_AUTHOR_WORKTREE``
|
|
5. active author issue lock ``worktree_path``
|
|
6. process project root **only** when it is already under ``branches/``
|
|
|
|
Configured bindings that point at a missing path fail closed immediately
|
|
(no demotion to the control checkout). Validation (when *validate*) covers
|
|
existence, traversal/symlink safety, repository identity, branches/
|
|
containment, and lock ownership.
|
|
"""
|
|
process_root = os.path.realpath(process_project_root)
|
|
canonical = os.path.realpath(canonical_repo_root or process_root)
|
|
reasons: list[str] = []
|
|
role = "author"
|
|
|
|
candidates: list[tuple[str | None, str, bool]] = [
|
|
(worktree_path, "worktree_path argument", False),
|
|
(worktree, "worktree argument", False),
|
|
(active_worktree_env, f"{ACTIVE_WORKTREE_ENV} environment variable", True),
|
|
(author_worktree_env, f"{AUTHOR_WORKTREE_ENV} environment variable", True),
|
|
(session_lock_worktree, "active author issue lock worktree", False),
|
|
]
|
|
|
|
selected_path: str | None = None
|
|
selected_source: str | None = None
|
|
existence: dict | None = None
|
|
|
|
for candidate, source, _configured in candidates:
|
|
text = (candidate or "").strip()
|
|
if not text:
|
|
continue
|
|
try:
|
|
real = _abs_real(text)
|
|
except OSError:
|
|
real = os.path.abspath(text)
|
|
|
|
existence = assess_bound_worktree_existence(
|
|
configured_path=real,
|
|
binding_source=source,
|
|
canonical_repo_root=canonical,
|
|
role_kind=role,
|
|
profile_name=profile_name,
|
|
)
|
|
if existence["block"]:
|
|
# Missing configured binding: fail closed, never fall back (#618).
|
|
return {
|
|
"proven": False,
|
|
"block": True,
|
|
"workspace_path": real,
|
|
"workspace_binding_source": source,
|
|
"process_project_root": process_root,
|
|
"canonical_repo_root": canonical,
|
|
"bound_worktree_missing": True,
|
|
"path_exists": False,
|
|
"in_git_worktree_list": existence.get("in_git_worktree_list"),
|
|
"inspected_git_root": None,
|
|
"reasons": list(existence.get("reasons") or []),
|
|
"blocker_kind": BOUND_WORKTREE_MISSING,
|
|
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
|
|
"silent_control_fallback": False,
|
|
}
|
|
|
|
selected_path = real
|
|
selected_source = source
|
|
break
|
|
|
|
if selected_path is None:
|
|
# No explicit/env/lock binding. Allow process root only when it is a
|
|
# branches/ worktree (MCP launched from the task worktree). Never
|
|
# silently bind the stable control checkout.
|
|
if is_path_under_branches(process_root, canonical):
|
|
selected_path = process_root
|
|
selected_source = "MCP process root under branches/ (session-owned)"
|
|
else:
|
|
return {
|
|
"proven": False,
|
|
"block": True,
|
|
"workspace_path": process_root,
|
|
"workspace_binding_source": "no author worktree binding",
|
|
"process_project_root": process_root,
|
|
"canonical_repo_root": canonical,
|
|
"bound_worktree_missing": False,
|
|
"path_exists": os.path.isdir(process_root),
|
|
"in_git_worktree_list": None,
|
|
"inspected_git_root": None,
|
|
"reasons": [
|
|
"author mutation blocked: workspace is the stable control checkout; "
|
|
"author mutation requires an explicit validated worktree_path "
|
|
"or a worktree derived from the active author issue lock; "
|
|
"silent fallback to the control checkout/master is forbidden (#618)"
|
|
],
|
|
"blocker_kind": "author_worktree_unbound_control_checkout",
|
|
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
|
|
"silent_control_fallback": False,
|
|
}
|
|
|
|
workspace = selected_path
|
|
source = selected_source or "unknown"
|
|
path_exists = os.path.isdir(workspace)
|
|
inspected_git_root: str | None = None
|
|
in_list: bool | None = None
|
|
|
|
if not validate:
|
|
return {
|
|
"proven": True,
|
|
"block": False,
|
|
"workspace_path": workspace,
|
|
"workspace_binding_source": source,
|
|
"process_project_root": process_root,
|
|
"canonical_repo_root": canonical,
|
|
"bound_worktree_missing": False,
|
|
"path_exists": path_exists,
|
|
"in_git_worktree_list": None,
|
|
"inspected_git_root": None,
|
|
"reasons": [],
|
|
"blocker_kind": None,
|
|
"operator_recovery": None,
|
|
"silent_control_fallback": False,
|
|
}
|
|
|
|
# Traversal / symlink safety
|
|
safety = assess_path_traversal_safety(
|
|
path=workspace, canonical_repo_root=canonical
|
|
)
|
|
if safety["block"]:
|
|
reasons.extend(safety["reasons"])
|
|
else:
|
|
workspace = safety["workspace_path"] or workspace
|
|
|
|
# Existence + git inventory
|
|
if not path_exists:
|
|
reasons.append(BOUND_WORKTREE_MISSING_MESSAGE)
|
|
reasons.append(f"resolved worktree '{workspace}' does not exist")
|
|
else:
|
|
in_list = path_in_git_worktree_list(workspace, canonical)
|
|
try:
|
|
res = subprocess.run(
|
|
["git", "-C", workspace, "rev-parse", "--show-toplevel"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if res.returncode == 0:
|
|
inspected_git_root = (res.stdout or "").strip() or None
|
|
except Exception:
|
|
inspected_git_root = None
|
|
if in_list is False:
|
|
# Only hard-fail when inventory was obtained and the path is absent.
|
|
reasons.append(
|
|
f"worktree '{workspace}' is not listed in git worktree list for "
|
|
f"'{canonical}' (fail closed)"
|
|
)
|
|
|
|
# Repository identity
|
|
if path_exists:
|
|
membership = assess_workspace_repo_membership(
|
|
workspace_path=workspace,
|
|
canonical_repo_root=canonical,
|
|
)
|
|
if membership["block"]:
|
|
reasons.extend(membership["reasons"])
|
|
|
|
# branches/ containment
|
|
branches = assess_author_mutation_worktree(
|
|
workspace_path=workspace,
|
|
project_root=canonical,
|
|
)
|
|
if branches["block"]:
|
|
reasons.extend(branches["reasons"])
|
|
|
|
# Lock ownership (when a lock worktree is recorded)
|
|
lock_own = assess_lock_worktree_ownership(
|
|
workspace_path=workspace,
|
|
session_lock_worktree=session_lock_worktree,
|
|
)
|
|
if lock_own["block"]:
|
|
reasons.extend(lock_own["reasons"])
|
|
|
|
# Forbid resolved control checkout even if somehow selected
|
|
if workspace == canonical or workspace == process_root:
|
|
if not is_path_under_branches(workspace, canonical):
|
|
if not any("control checkout" in r for r in reasons):
|
|
reasons.append(
|
|
"author mutation blocked: resolved workspace is the stable "
|
|
"control checkout; silent fallback forbidden (#618)"
|
|
)
|
|
|
|
block = bool(reasons)
|
|
bound_missing = any("does not exist" in r or BOUND_WORKTREE_MISSING_MESSAGE in r for r in reasons)
|
|
return {
|
|
"proven": not block,
|
|
"block": block,
|
|
"workspace_path": workspace,
|
|
"workspace_binding_source": source,
|
|
"process_project_root": process_root,
|
|
"canonical_repo_root": canonical,
|
|
"bound_worktree_missing": bound_missing,
|
|
"path_exists": path_exists,
|
|
"in_git_worktree_list": in_list,
|
|
"inspected_git_root": inspected_git_root,
|
|
"reasons": reasons,
|
|
"blocker_kind": BOUND_WORKTREE_MISSING if bound_missing else (
|
|
"author_worktree_validation_failed" if block else None
|
|
),
|
|
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT if block else None,
|
|
"silent_control_fallback": False,
|
|
}
|
|
|
|
|
|
def format_durable_author_worktree_error(assessment: dict) -> str:
|
|
"""Format fail-closed error for durable author worktree resolution."""
|
|
if assessment.get("bound_worktree_missing") or assessment.get("blocker_kind") == BOUND_WORKTREE_MISSING:
|
|
return format_bound_worktree_missing_error(assessment)
|
|
workspace = assessment.get("workspace_path") or "(unknown)"
|
|
source = assessment.get("workspace_binding_source") or "unknown"
|
|
reasons = "; ".join(
|
|
assessment.get("reasons") or ["author worktree resolution failed"]
|
|
)
|
|
recovery = assessment.get("operator_recovery") or OPERATOR_RECOVERY_RECREATE_REPOINT
|
|
return (
|
|
f"Durable author worktree resolution blocked (#618): {reasons}. "
|
|
f"workspace: {workspace}; binding_source: {source}. "
|
|
f"Operator recovery: {recovery}"
|
|
)
|