Merge pull request 'feat(guard): block direct stable-branch pushes from MCP workflow sessions (Closes #671)' (#677) from fix/issue-671-block-stable-branch-push into master
This commit was merged in pull request #677.
This commit is contained in:
@@ -653,6 +653,10 @@ def verify_preflight_purity(
|
||||
f"'{task}' (fail closed)"
|
||||
)
|
||||
|
||||
# #671: block review/merge/close/completion mutations while the session is
|
||||
# contaminated by a direct stable-branch push attempt (reconciler-exempt).
|
||||
_enforce_stable_branch_contamination_gate(task, remote)
|
||||
|
||||
ctx = _resolve_namespace_mutation_context(worktree_path)
|
||||
workspace = ctx["workspace_path"]
|
||||
canonical_root = ctx["canonical_repo_root"]
|
||||
@@ -810,6 +814,80 @@ def _enforce_root_checkout_guard(worktree_path: str | None = None) -> None:
|
||||
raise RuntimeError(root_checkout_guard.format_root_checkout_guard_error(assessment))
|
||||
|
||||
|
||||
# ── stable-branch push contamination (#671) ──────────────────────────────────
|
||||
# A worker session that attempts a direct stable-branch push (or a
|
||||
# root-checkout local commit) is workflow-contaminated. The marker is durable
|
||||
# (survives daemon process pools like the other session proofs) and keyed per
|
||||
# profile identity. It fails closed on gated mutations until a reconciler
|
||||
# audits and clears it.
|
||||
|
||||
def _stable_contamination_profile_identity() -> str:
|
||||
return mcp_session_state.current_profile_identity(
|
||||
profile_name=get_profile().get("profile_name"),
|
||||
)
|
||||
|
||||
|
||||
def _load_stable_contamination_marker(remote: str | None = None) -> dict | None:
|
||||
return mcp_session_state.load_state(
|
||||
kind=mcp_session_state.KIND_STABLE_BRANCH_CONTAMINATION,
|
||||
remote=remote,
|
||||
profile_identity=_stable_contamination_profile_identity(),
|
||||
)
|
||||
|
||||
|
||||
def _save_stable_contamination_marker(
|
||||
record: dict,
|
||||
*,
|
||||
remote: str | None = None,
|
||||
) -> dict | None:
|
||||
return mcp_session_state.save_state(
|
||||
kind=mcp_session_state.KIND_STABLE_BRANCH_CONTAMINATION,
|
||||
payload=record,
|
||||
remote=remote,
|
||||
profile_identity=_stable_contamination_profile_identity(),
|
||||
)
|
||||
|
||||
|
||||
def _clear_stable_contamination_marker(
|
||||
*,
|
||||
remote: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
) -> None:
|
||||
mcp_session_state.clear_state(
|
||||
kind=mcp_session_state.KIND_STABLE_BRANCH_CONTAMINATION,
|
||||
remote=remote,
|
||||
profile_identity=profile_identity or _stable_contamination_profile_identity(),
|
||||
)
|
||||
|
||||
|
||||
def _enforce_stable_branch_contamination_gate(
|
||||
task: str | None,
|
||||
remote: str | None = None,
|
||||
) -> None:
|
||||
"""#671 AC4: fail closed on gated mutations while contaminated.
|
||||
|
||||
Reconciler role is exempt (the sanctioned audit/clear path). Non-gated
|
||||
tasks (comment_issue, lock_issue) stay allowed so a contaminated worker can
|
||||
still post the durable audit comment and hand off.
|
||||
"""
|
||||
if _preflight_in_test_mode() and not os.environ.get(
|
||||
"GITEA_TEST_FORCE_STABLE_CONTAMINATION"
|
||||
):
|
||||
return
|
||||
marker = _load_stable_contamination_marker(remote)
|
||||
if not marker:
|
||||
return
|
||||
gate = stable_branch_push_guard.assess_contamination_gate(
|
||||
marker,
|
||||
task=task,
|
||||
actual_role=_actual_profile_role(),
|
||||
)
|
||||
if gate["block"]:
|
||||
raise RuntimeError(
|
||||
stable_branch_push_guard.format_contamination_gate_error(gate)
|
||||
)
|
||||
|
||||
|
||||
from mcp.server.fastmcp import FastMCP # noqa: E402
|
||||
|
||||
from gitea_auth import ( # noqa: E402
|
||||
@@ -850,6 +928,7 @@ import merge_approval_gate # noqa: E402
|
||||
import already_landed_reconcile # noqa: E402
|
||||
import author_mutation_worktree # noqa: E402
|
||||
import root_checkout_guard # noqa: E402
|
||||
import stable_branch_push_guard # noqa: E402
|
||||
import remote_repo_guard # noqa: E402
|
||||
import issue_claim_heartbeat # noqa: E402
|
||||
import issue_work_duplicate_gate # noqa: E402
|
||||
@@ -9274,6 +9353,160 @@ def gitea_diagnose_terminal(
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_record_stable_branch_push_attempt(
|
||||
command: str | None = None,
|
||||
remote: str = "dadeschools",
|
||||
ref: str | None = None,
|
||||
session_id: str | None = None,
|
||||
mark: bool = True,
|
||||
current_branch: str | None = None,
|
||||
head_sha: str | None = None,
|
||||
remote_master_sha: str | None = None,
|
||||
is_under_branches: bool | None = None,
|
||||
ahead_count: int | None = None,
|
||||
) -> dict:
|
||||
"""Classify a proposed command for direct stable-branch push intent (#671).
|
||||
|
||||
Worker sessions must never publish stable branches (``master``/``main``/
|
||||
``dev``/...) directly. This tool detects ``git push <remote> master``
|
||||
equivalents (refspecs, ``HEAD:master``, ``--force``, ``--dry-run`` no-op
|
||||
intent, ``:master`` delete) and — separately — a root/control-checkout
|
||||
local commit not carried by an issue feature branch.
|
||||
|
||||
When ``mark`` is true and contamination is detected, a durable
|
||||
``stable_branch_contamination`` marker is written for the active profile
|
||||
identity. Subsequent review/merge/close/completion mutations then fail
|
||||
closed (via the pre-flight gate) until a reconciler audits and clears it.
|
||||
The stored command summary is redacted; secrets never persist.
|
||||
|
||||
Read-only when nothing is detected (or ``mark`` is false). Returns the
|
||||
classification, any root-checkout assessment, and the marker state.
|
||||
"""
|
||||
classification = stable_branch_push_guard.classify_push_command(command)
|
||||
|
||||
root_checkout = None
|
||||
if any(v is not None for v in (current_branch, head_sha, remote_master_sha,
|
||||
is_under_branches, ahead_count)):
|
||||
root_checkout = stable_branch_push_guard.assess_root_checkout_local_commit(
|
||||
current_branch=current_branch,
|
||||
head_sha=head_sha,
|
||||
remote_master_sha=remote_master_sha,
|
||||
is_under_branches=bool(is_under_branches),
|
||||
ahead_count=ahead_count,
|
||||
)
|
||||
|
||||
push_contam = bool(classification.get("contamination"))
|
||||
root_contam = bool(root_checkout and root_checkout.get("contamination"))
|
||||
contaminated = push_contam or root_contam
|
||||
|
||||
marker = None
|
||||
marked = False
|
||||
if contaminated and mark:
|
||||
if push_contam:
|
||||
reason_class = "stable_branch_push"
|
||||
command_redacted = classification.get("redacted_command")
|
||||
detail = "; ".join(classification.get("reasons") or [])
|
||||
resolved_ref = ref or (
|
||||
(classification.get("stable_refs") or [None])[0]
|
||||
)
|
||||
else:
|
||||
reason_class = "root_checkout_commit"
|
||||
command_redacted = None
|
||||
detail = "; ".join(root_checkout.get("reasons") or [])
|
||||
resolved_ref = ref or root_checkout.get("current_branch")
|
||||
record = stable_branch_push_guard.build_contamination_record(
|
||||
reason_class=reason_class,
|
||||
command_redacted=command_redacted,
|
||||
session_id=session_id,
|
||||
remote=remote,
|
||||
ref=resolved_ref,
|
||||
role=_actual_profile_role(),
|
||||
detail=detail,
|
||||
)
|
||||
marker = _save_stable_contamination_marker(record, remote=remote)
|
||||
marked = marker is not None
|
||||
|
||||
return {
|
||||
"classification": classification,
|
||||
"root_checkout": root_checkout,
|
||||
"contaminated": contaminated,
|
||||
"marked": marked,
|
||||
"marker": marker,
|
||||
"profile_identity": _stable_contamination_profile_identity(),
|
||||
"remediation": stable_branch_push_guard.REMEDIATION if contaminated else None,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_audit_stable_branch_contamination(
|
||||
action: str = "inspect",
|
||||
remote: str = "dadeschools",
|
||||
profile_identity: str | None = None,
|
||||
) -> dict:
|
||||
"""Reconciler audit of a stable-branch contamination marker (#671).
|
||||
|
||||
``action='inspect'`` (default, read-only) loads the durable marker for the
|
||||
given ``profile_identity`` (or the active session's) and reports it.
|
||||
|
||||
``action='clear'`` removes the marker so the contaminated session may
|
||||
resume gated mutations. Clearing is the reconciler audit path and requires
|
||||
an active reconciler profile — a worker session must never self-clear
|
||||
(#671 security requirement). ``profile_identity`` targets the contaminated
|
||||
worker's marker (a reconciler runs under its own identity).
|
||||
"""
|
||||
act = (action or "inspect").strip().lower()
|
||||
target_identity = (profile_identity or "").strip() or _stable_contamination_profile_identity()
|
||||
|
||||
marker = mcp_session_state.load_state(
|
||||
kind=mcp_session_state.KIND_STABLE_BRANCH_CONTAMINATION,
|
||||
remote=remote,
|
||||
profile_identity=target_identity,
|
||||
)
|
||||
|
||||
if act == "inspect":
|
||||
return {
|
||||
"action": "inspect",
|
||||
"profile_identity": target_identity,
|
||||
"contaminated": marker is not None,
|
||||
"marker": marker,
|
||||
"read_only": True,
|
||||
}
|
||||
|
||||
if act == "clear":
|
||||
role = _actual_profile_role()
|
||||
if role != "reconciler":
|
||||
return {
|
||||
"action": "clear",
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"profile_identity": target_identity,
|
||||
"reasons": [
|
||||
"stable-branch contamination may only be cleared by a "
|
||||
f"reconciler audit; active role is '{role}' (fail closed). "
|
||||
"The contaminated worker session must not self-clear."
|
||||
],
|
||||
}
|
||||
_clear_stable_contamination_marker(
|
||||
remote=remote,
|
||||
profile_identity=target_identity,
|
||||
)
|
||||
return {
|
||||
"action": "clear",
|
||||
"success": True,
|
||||
"performed": True,
|
||||
"profile_identity": target_identity,
|
||||
"was_contaminated": marker is not None,
|
||||
}
|
||||
|
||||
return {
|
||||
"action": act,
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"reasons": [f"unknown action '{act}'; use 'inspect' or 'clear'"],
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_validate_review_final_report(
|
||||
report_text: str,
|
||||
|
||||
Reference in New Issue
Block a user