Add author_mutation_worktree assessment and integrate it into MCP preflight so author mutations fail closed outside branches/ session worktrees, with tests for control-checkout and branches-path cases. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
103 lines
3.5 KiB
Python
103 lines
3.5 KiB
Python
"""Branches-only author mutation worktree guard (#274).
|
|
|
|
Author/coder mutations must run from a session-owned worktree under the
|
|
project's ``branches/`` directory, never from the stable control checkout.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
|
|
|
|
|
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."""
|
|
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 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()
|
|
|
|
if workspace == root:
|
|
reasons.append(
|
|
"author mutation blocked: workspace is the stable control checkout; "
|
|
"create or switch to a session-owned worktree under branches/"
|
|
)
|
|
elif not is_path_under_branches(workspace, root):
|
|
reasons.append(
|
|
f"author mutation blocked: workspace '{workspace}' is not under "
|
|
f"'{root}/branches/'; create a branches/<task> worktree first"
|
|
)
|
|
|
|
if 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."
|
|
) |