fix(mcp): require proven base equivalence for the create-issue bootstrap

Remediates review #473 (REQUEST_CHANGES) on PR #759 / issue #757 AC3/AC4.

The bootstrap compared SHAs only inside:

    if remote_tip and local_tip and remote_tip != local_tip:

so agreement was assumed whenever either tip was unknown. A missing local
HEAD, an unresolvable live master, or a resolver exception silently granted
the control-checkout exemption instead of blocking it. An unknown tip is
missing evidence, not proof of equivalence.

Changes:

* assess_create_issue_bootstrap now blocks unless BOTH tips are known and
  equal, with distinct reasons for missing local HEAD, unknown live master,
  and resolver failure. Adds a remote_master_sha_error parameter so a failed
  resolution is reported as missing evidence rather than absence of a
  constraint.
* Both normalized SHAs and a derived base_tips_verified flag are recorded on
  the assessment. normalize_sha() treats only case and surrounding whitespace
  as equivalent spellings of a commit.
* bootstrap_permits_control_checkout re-derives the comparison from the
  recorded tips instead of trusting base_tips_verified, so a hand-built or
  truncated assessment cannot assert agreement it never proved.
* _create_issue_bootstrap_assessment captures the resolver exception and
  forwards it, replacing the silent except -> None.

One shared assessment still serves both the #274 and #604 guards, and
_BOOTSTRAP_UNSET is preserved. No MCP tool signature gains a bootstrap
argument (AC6). No issue or PR number is special-cased (AC10).

Tests: 16 new assertions across two classes covering missing local, missing
remote, both missing, empty/whitespace tips, mismatch, resolver exception at
the assessment site, and resolver exception through the real
verify_preflight_purity path; plus predicate rejection of stripped tips, a
forged base_tips_verified flag, and a missing flag. All 16 fail against the
sources at adc61255 and pass after this change. The valid non-create_issue
lock_issue test is retained unchanged.

Validation: focused #757 suite 51 passed (31 subtests); affected guard and
preflight suites 217 passed (67 subtests); full suite 3637 passed, 2 failed,
6 skipped, 431 subtests. Both failures (test_issue_702 F1 worktree recovery;
test_reconciler_supersession_close org/repo forwarding) are the documented
pre-existing baseline failures on bde5c5fb and are not caused by this change.

Refs #757

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_015KRvvrFtaM5FEvQ6LvDJMH
This commit is contained in:
2026-07-19 12:59:08 -04:00
co-authored by Claude Opus 4.8
parent adc61255b2
commit 9d2c652ae8
3 changed files with 267 additions and 3 deletions
+64 -2
View File
@@ -52,6 +52,18 @@ def is_create_issue_task(task: str | None) -> bool:
return (task or "").strip() in CREATE_ISSUE_TASKS
def normalize_sha(value: str | None) -> str | None:
"""Normalize a Git object id for comparison, or ``None`` when unknown.
Whitespace and case are the only permitted variation between two spellings
of the same commit; anything else is a different commit. Empty and
whitespace-only values normalize to ``None`` so an unknown tip can never
compare equal to another unknown tip.
"""
normalized = (value or "").strip().lower()
return normalized or None
def assess_create_issue_bootstrap(
*,
workspace_path: str,
@@ -60,6 +72,7 @@ def assess_create_issue_bootstrap(
head_sha: str | None = None,
porcelain_status: str = "",
remote_master_sha: str | None = None,
remote_master_sha_error: str | None = None,
task: str | None = None,
) -> dict[str, Any]:
"""Assess whether create_issue may proceed from the control checkout.
@@ -142,8 +155,32 @@ def assess_create_issue_bootstrap(
f"is not an accepted base branch ({'/'.join(sorted(BASE_BRANCHES))})"
)
remote_tip = (remote_master_sha or "").strip() or None
local_tip = (head_sha or "").strip() or None
# #757 AC3/AC4: base-equivalence must be *proven*, never assumed. An
# unknown tip on either side is not evidence of agreement, so a missing
# local HEAD, an unresolvable live master, or a resolver failure all block.
remote_tip = normalize_sha(remote_master_sha)
local_tip = normalize_sha(head_sha)
resolver_error = (remote_master_sha_error or "").strip() or None
if not local_tip:
reasons.append(
"create_issue bootstrap blocked: control checkout HEAD SHA is "
"unknown; base equivalence to live master cannot be proven "
"(fail closed)"
)
if resolver_error:
reasons.append(
"create_issue bootstrap blocked: live master tip could not be "
f"resolved ({resolver_error}); base equivalence cannot be proven "
"(fail closed)"
)
elif not remote_tip:
reasons.append(
"create_issue bootstrap blocked: live master tip is unknown; base "
"equivalence to live master cannot be proven (fail closed)"
)
if remote_tip and local_tip and remote_tip != local_tip:
reasons.append(
"create_issue bootstrap blocked: control checkout HEAD does not match "
@@ -162,6 +199,8 @@ def assess_create_issue_bootstrap(
dirty=dirty,
under_branches=False,
exact_next_action=EXACT_NEXT_ACTION_BOOTSTRAP,
local_head_sha=local_tip,
remote_master_sha=remote_tip,
)
return _result(
@@ -176,6 +215,8 @@ def assess_create_issue_bootstrap(
under_branches=False,
exact_next_action=EXACT_NEXT_ACTION_POST_CREATE,
bootstrap_path="clean_canonical_control_checkout",
local_head_sha=local_tip,
remote_master_sha=remote_tip,
)
@@ -235,6 +276,17 @@ def bootstrap_permits_control_checkout(
if assessment.get("under_branches") is not False:
return False
# Base-equivalence proof (#757 AC3/AC4): both tips must be recorded,
# nonempty, and equal. Re-derived here rather than trusted from the
# assessment's own flag, so a hand-built or truncated assessment cannot
# assert agreement it never proved.
if assessment.get("base_tips_verified") is not True:
return False
local_tip = normalize_sha(assessment.get("local_head_sha"))
remote_tip = normalize_sha(assessment.get("remote_master_sha"))
if not local_tip or not remote_tip or local_tip != remote_tip:
return False
# Binding proof: the assessment must describe *this* workspace and root,
# and that workspace must be exactly the canonical control checkout.
root = os.path.realpath(canonical_repo_root or "")
@@ -279,7 +331,14 @@ def _result(
under_branches: bool,
exact_next_action: str | None = None,
bootstrap_path: str | None = None,
local_head_sha: str | None = None,
remote_master_sha: str | None = None,
) -> dict[str, Any]:
# #757 AC3/AC4: equality is recorded only when BOTH tips are known, so a
# consumer can never read agreement out of two missing values.
base_tips_verified = bool(
local_head_sha and remote_master_sha and local_head_sha == remote_master_sha
)
return {
"not_applicable": not_applicable,
"allowed": allowed,
@@ -294,4 +353,7 @@ def _result(
"exact_next_action": exact_next_action,
"bootstrap_path": bootstrap_path,
"task_scope": "create_issue_only",
"local_head_sha": local_head_sha,
"remote_master_sha": remote_master_sha,
"base_tips_verified": base_tips_verified,
}