From e91bacf20b71c7de6443a58fec1c0cf04cc85184 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 08:44:50 -0700 Subject: [PATCH 01/25] Resolve builder contribution lineage at the exact head A pull request can change hands. The PR opener, the branch prefix and the single active builder label each describe at most one lane, so after a takeover they disagree: PR #959 stayed authored on a Devin branch while Codex wrote the final commit and the active label moved to builder:codex, and the Devin reviewer still excluded itself because Devin had opened it. Add a shared exact-head lineage resolver and make the reviewer-admission seam consume it. - src/code_mower/builder_lineage.py resolves ordered contribution episodes bound to repository, PR, branch, source lane, destination lane, expected head and resulting head. Episodes come from the verified handoff and delivery path; an opener, a label, a body marker or a caller-supplied boolean cannot attest a takeover. Unchained, duplicated, unbound, malformed or behind-the-head evidence fails closed with one concise owner action instead of naming a writer. It also carries an idempotent metadata-only record store and a bounded hidden-marker transport. - src/code_mower/provider_runners/lineage.py is the one admission seam for direct reviewer wrappers: it resolves lineage from trusted PR metadata at the head the wrapper pinned and refuses any lane that contributed. - Codex, Claude and Devin CLI wrappers now admit through that seam after the trusted metadata/head fetch and before provider execution. The Devin wrapper keeps its bot-author deny list as a floor so an unconfigured checkout never becomes more permissive than it is today. - audit_labeler_lib exclusion and builder_runs auto-record resolve the same lineage rather than collapsing history to the latest author or label. Contribution independence stays a separate decision from role eligibility: a qualified lane that wrote the diff is still not independent of it. Closes #963 Co-Authored-By: Claude Opus 5 (1M context) --- code-mower-package-manifest.json | 10 + src/code_mower/audit_labeler_lib.py | 146 ++++- src/code_mower/builder_lineage.py | 670 ++++++++++++++++++++ src/code_mower/builder_runs.py | 76 ++- src/code_mower/claude_audit_pr.py | 18 + src/code_mower/codex_audit_pr.py | 18 + src/code_mower/devin_cli_audit_pr.py | 73 ++- src/code_mower/package_manifest.py | 6 + src/code_mower/provider_runners/__init__.py | 14 + src/code_mower/provider_runners/lineage.py | 229 +++++++ tests/test_builder_lineage.py | 382 +++++++++++ 11 files changed, 1610 insertions(+), 32 deletions(-) create mode 100644 src/code_mower/builder_lineage.py create mode 100644 src/code_mower/provider_runners/lineage.py create mode 100644 tests/test_builder_lineage.py diff --git a/code-mower-package-manifest.json b/code-mower-package-manifest.json index 1a8fc6f6..673bed97 100644 --- a/code-mower-package-manifest.json +++ b/code-mower-package-manifest.json @@ -372,6 +372,11 @@ "source": "src/code_mower/builder_experiment.py", "target": "src/code_mower/builder_experiment.py" }, + { + "kind": "core", + "source": "src/code_mower/builder_lineage.py", + "target": "src/code_mower/builder_lineage.py" + }, { "kind": "core", "source": "src/code_mower/builder_runs.py", @@ -1372,6 +1377,11 @@ "source": "src/code_mower/provider_runners/github_pr.py", "target": "src/code_mower/provider_runners/github_pr.py" }, + { + "kind": "reviewer", + "source": "src/code_mower/provider_runners/lineage.py", + "target": "src/code_mower/provider_runners/lineage.py" + }, { "kind": "reviewer", "source": "src/code_mower/provider_runners/pr_worktree.py", diff --git a/src/code_mower/audit_labeler_lib.py b/src/code_mower/audit_labeler_lib.py index 797a21f9..f0a9dd41 100644 --- a/src/code_mower/audit_labeler_lib.py +++ b/src/code_mower/audit_labeler_lib.py @@ -26,12 +26,33 @@ try: from . import decisions as code_mower_decisions from . import context_review as code_mower_context_review + from .builder_lineage import ( + Lineage, + LineageError, + lanes_from_identity, + resolve_identity_only, + resolve_lineage, + ) except ImportError: # pragma: no cover - copied tools fallback import decisions as code_mower_decisions # type: ignore import context_review as code_mower_context_review # type: ignore + from builder_lineage import ( # type: ignore + Lineage, + LineageError, + lanes_from_identity, + resolve_identity_only, + resolve_lineage, + ) else: # pragma: no cover - direct helper execution import decisions as code_mower_decisions # type: ignore import context_review as code_mower_context_review # type: ignore + from builder_lineage import ( # type: ignore + Lineage, + LineageError, + lanes_from_identity, + resolve_identity_only, + resolve_lineage, + ) MIN_ABBREVIATED_SHA_LENGTH = 7 AUTHOR_EXCLUSION_ENV = "CODE_MOWER_AUTHOR_EXCLUSION_JSON" @@ -196,32 +217,83 @@ def load_author_exclusion_config(raw: str | None = None) -> Mapping[str, Any]: return parsed if isinstance(parsed, Mapping) else {"enabled": False} +def resolve_builder_lineage( + *, + labels: Sequence[str], + author: str, + config: Mapping[str, Any], + repo: str = "", + pr_number: Any = 0, + branch: str = "", + head_sha: str = "", + episodes: Sequence[Any] = (), +) -> Lineage: + """Resolve the shared builder lineage from labeler-side evidence. + + Labelers see labels and an author for certain, and may or may not have the + exact head plus published contribution episodes to hand. Without exact-head + evidence there is no takeover to resolve, so the result is the ordinary + single-builder decision; with it, the same resolver the gate, the runner + and the reviewer wrappers use answers the question. + """ + + opener_lane, label_lanes = lanes_from_identity( + identity=config, labels=labels, author=author + ) + if not (head_sha and repo and pr_number): + return resolve_identity_only(opener_lane=opener_lane, label_lanes=label_lanes) + return resolve_lineage( + repo=repo, + pr_number=pr_number, + branch=branch, + head_sha=head_sha, + episodes=episodes, + opener_lane=opener_lane, + label_lanes=label_lanes, + ) + + def builder_identity_matches( *, labels: Sequence[str], author: str, text: str, config: Mapping[str, Any], + repo: str = "", + pr_number: Any = 0, + branch: str = "", + head_sha: str = "", + episodes: Sequence[Any] = (), ) -> tuple[str, ...]: - if not bool(config.get("enabled")): - return () - label_map = _string_mapping(config.get("labels")) - author_map = { - key.lower(): value - for key, value in _string_mapping(config.get("authors")).items() - } - matches: list[str] = [] + """Ordered verified builder lanes for this pull request. - for label in labels: - lane = label_map.get(str(label)) - if lane: - matches.append(lane) + ``text`` is retained for call compatibility and is deliberately unused: + freeform prose is not contribution evidence. + """ - lane = author_map.get(author.lower()) - if lane: - matches.append(lane) - - return tuple(dict.fromkeys(matches)) + if not bool(config.get("enabled")): + return () + lineage = resolve_builder_lineage( + labels=labels, + author=author, + config=config, + repo=repo, + pr_number=pr_number, + branch=branch, + head_sha=head_sha, + episodes=episodes, + ) + if lineage.status != "resolved": + # Preserve the historical "more than one identity" shape so a caller + # inspecting matches can still tell a conflict from a clean match. + opener_lane, label_lanes = lanes_from_identity( + identity=config, labels=labels, author=author + ) + candidates = tuple( + dict.fromkeys(list(label_lanes) + ([opener_lane] if opener_lane else [])) + ) + return candidates if len(candidates) > 1 else lineage.contributors + return lineage.contributors def author_exclusion_reason( @@ -231,20 +303,40 @@ def author_exclusion_reason( author: str, text: str, config: Mapping[str, Any] | None = None, + repo: str = "", + pr_number: Any = 0, + branch: str = "", + head_sha: str = "", + episodes: Sequence[Any] = (), ) -> str | None: + """Why ``lane_name`` may not label its own work, or ``None``. + + Exclusion follows verified contribution, not authorship: every contributing + lane is excluded, and a lane that merely opened the PR before handing it + over is excluded too because its commits are still in the diff. + """ + exclusion_config = config or load_author_exclusion_config() - matches = builder_identity_matches( - labels=labels, - author=author, - text=text, - config=exclusion_config, - ) - if not matches: + if not bool(exclusion_config.get("enabled")): return None - if len(set(matches)) > 1: + try: + lineage = resolve_builder_lineage( + labels=labels, + author=author, + config=exclusion_config, + repo=repo, + pr_number=pr_number, + branch=branch, + head_sha=head_sha, + episodes=episodes, + ) + except LineageError: + return "builder contribution evidence is unreadable; skipping author-excluded label update" + if lineage.status == "conflict": return "conflicting builder identity; skipping author-excluded label update" - builder_lane = matches[0] - if builder_lane == lane_name: + if lineage.status == "waiting": + return "builder contribution lineage is behind the current head; skipping author-excluded label update" + if lineage.contributed(lane_name): return f"{lane_name} lane excluded for builder-authored PR" return None diff --git a/src/code_mower/builder_lineage.py b/src/code_mower/builder_lineage.py new file mode 100644 index 00000000..50a1d897 --- /dev/null +++ b/src/code_mower/builder_lineage.py @@ -0,0 +1,670 @@ +"""Exact-head builder contribution lineage after a verified handoff. + +A pull request can be built by more than one Code Mower builder lane. The +opener, the branch prefix and the single active ``builder:*`` label each +describe at most one of those lanes, so any of them alone will misdescribe a +PR that changed hands. This module keeps the ordered contribution history +instead, and derives the one current writer from it. + +Trust rules this module exists to enforce: + +* A contribution episode is evidence produced by the verified handoff and + delivery path (see :mod:`code_mower.lane_handoff`), which observed the source + writer going quiescent and observed both heads. A caller-supplied boolean, a + PR body marker, a commit trailer, the PR opener or the most recent label are + none of them able to attest that a takeover happened. +* Episodes are bound to repository, pull request, branch, source lane, + destination lane, expected head and resulting head. An episode that does not + bind to the pull request under decision is not evidence about it. +* Resolution is exact-head. Lineage that stops short of the current head is + *waiting*, never a guess about who wrote the current diff. +* Conflicting, duplicated, unchained or unbound evidence fails closed with one + concise owner action rather than picking a winner. + +Everything here is metadata-only: lane names, a repository slug, a PR number, a +branch name and commit shas. No source, diffs, prompts, transcripts, paths, +provider references or credentials pass through this module, so a resolved +lineage is safe for the existing public/cloud metadata contract. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Mapping, Sequence + + +SCHEMA = "code_mower.builderLineage.v1" +EPISODE_SCHEMA = "code_mower.contributionEpisode.v1" +RECORD_SCHEMA = "code_mower.builderLineageRecord.v1" + +#: Hidden marker used to publish bounded lineage metadata on a pull request. +#: Only comments from an already trusted author are parsed; the marker is a +#: transport, never an authorization. +LINEAGE_MARKER = "CODE_MOWER_BUILDER_LINEAGE" +LINEAGE_MARKER_RE = re.compile( + r"", + re.DOTALL, +) + +LANE_RE = re.compile(r"[a-z0-9][a-z0-9_-]{0,39}\Z") +SHA_RE = re.compile(r"[0-9a-f]{40}\Z") +REPO_RE = re.compile(r"[A-Za-z0-9._-]{1,100}/[A-Za-z0-9._-]{1,100}\Z") +BRANCH_RE = re.compile(r"[A-Za-z0-9._/-]{1,200}\Z") + +#: Verified writer states the handoff boundary is allowed to report. Anything +#: else (including a missing or "unknown" state) is uncertainty. +WRITER_STATES = frozenset({"suspended", "terminated"}) + +#: A lineage longer than this is treated as malformed rather than walked. +MAX_EPISODES = 32 + +EPISODE_FIELDS = ( + "schema", + "sequence", + "repo", + "pr_number", + "branch", + "source_lane", + "destination_lane", + "expected_head", + "resulting_head", + "writer_state", +) + + +class LineageError(ValueError): + """Raised when contribution evidence cannot be accepted as recorded.""" + + +def _text(value: Any) -> str: + return str(value if value is not None else "").strip() + + +def _lane(value: Any) -> str: + lane = _text(value).lower() + return lane if LANE_RE.match(lane) else "" + + +def _sha(value: Any) -> str: + sha = _text(value).lower() + return sha if SHA_RE.match(sha) else "" + + +def _pr_number(value: Any) -> int: + if isinstance(value, bool) or not isinstance(value, (int, str)): + return 0 + try: + number = int(value) + except (TypeError, ValueError): + return 0 + return number if 0 < number <= 2**31 - 1 else 0 + + +@dataclass(frozen=True) +class ContributionEpisode: + """One verified builder contribution bound to an exact head transition. + + ``expected_head`` is the head the source lane left behind and the + destination lane was launched against; ``resulting_head`` is the head the + destination lane actually produced. A destination that never moved the head + is still the writer, but it contributed nothing to the current diff. + """ + + sequence: int + repo: str + pr_number: int + branch: str + source_lane: str + destination_lane: str + expected_head: str + resulting_head: str + writer_state: str + + def __post_init__(self) -> None: + if ( + isinstance(self.sequence, bool) + or not isinstance(self.sequence, int) + or not 1 <= self.sequence <= MAX_EPISODES + or not REPO_RE.match(_text(self.repo)) + or _pr_number(self.pr_number) != self.pr_number + or not BRANCH_RE.match(_text(self.branch)) + or not LANE_RE.match(_text(self.source_lane)) + or not LANE_RE.match(_text(self.destination_lane)) + or self.source_lane == self.destination_lane + or not SHA_RE.match(_text(self.expected_head)) + or not SHA_RE.match(_text(self.resulting_head)) + or self.writer_state not in WRITER_STATES + ): + raise LineageError("contribution episode is malformed") + + @property + def moved_head(self) -> bool: + return self.expected_head != self.resulting_head + + def as_dict(self) -> dict[str, Any]: + return { + "schema": EPISODE_SCHEMA, + "sequence": self.sequence, + "repo": self.repo, + "pr_number": self.pr_number, + "branch": self.branch, + "source_lane": self.source_lane, + "destination_lane": self.destination_lane, + "expected_head": self.expected_head, + "resulting_head": self.resulting_head, + "writer_state": self.writer_state, + } + + +def episode_from_mapping(payload: Mapping[str, Any]) -> ContributionEpisode: + """Parse one episode strictly. Unknown or missing fields are malformed.""" + + if not isinstance(payload, Mapping) or set(payload) != set(EPISODE_FIELDS): + raise LineageError("contribution episode is malformed") + if payload.get("schema") != EPISODE_SCHEMA: + raise LineageError("contribution episode schema is unsupported") + repo = _text(payload.get("repo")) + return ContributionEpisode( + sequence=payload.get("sequence"), # type: ignore[arg-type] + repo=repo, + pr_number=_pr_number(payload.get("pr_number")), + branch=_text(payload.get("branch")), + source_lane=_lane(payload.get("source_lane")), + destination_lane=_lane(payload.get("destination_lane")), + expected_head=_sha(payload.get("expected_head")), + resulting_head=_sha(payload.get("resulting_head")), + writer_state=_text(payload.get("writer_state")).lower(), + ) + + +def episode_from_handoff( + handoff: Mapping[str, Any] | Any, + *, + resulting_head: str, + writer_state: str, + sequence: int, + repo: str = "", +) -> ContributionEpisode: + """Build an episode from an accepted handoff plus its delivered head. + + ``handoff`` is the ``Handoff`` record (or its ``as_dict``) that + :mod:`code_mower.lane_handoff` accepted, so repository, PR, branch, lanes + and expected head all come from evidence the handoff boundary verified. + ``resulting_head`` is the delivery-side attestation of what the destination + lane actually produced; it is deliberately not part of the handoff record, + which is written before the destination lane has written anything. + """ + + record = handoff if isinstance(handoff, Mapping) else handoff.as_dict() + target_pr = _text(record.get("target_pr")) + if "#" not in target_pr: + raise LineageError("contribution episode is malformed") + pr_repo, _, pr_number = target_pr.partition("#") + if repo and _text(repo).lower() != pr_repo.lower(): + raise LineageError("contribution episode does not bind to the repository under work") + return ContributionEpisode( + sequence=sequence, + repo=pr_repo, + pr_number=_pr_number(pr_number), + branch=_text(record.get("target_branch")), + source_lane=_lane(record.get("source_lane")), + destination_lane=_lane(record.get("destination_lane")), + expected_head=_sha(record.get("expected_head")), + resulting_head=_sha(resulting_head), + writer_state=_text(writer_state).lower(), + ) + + +@dataclass(frozen=True) +class Lineage: + """The resolved answer for one pull request at one exact head. + + ``status`` is ``resolved`` only when the evidence covers the current head + without contradiction. ``waiting`` and ``conflict`` both carry a concise + ``owner_action`` and never imply a writer. + """ + + status: str + reason: str + head_sha: str + contributors: tuple[str, ...] + current_writer: str + builder_label: str + stale_builder_labels: tuple[str, ...] + evidence: str + episodes: int + owner_action: str = "" + + @property + def resolved(self) -> bool: + return self.status == "resolved" + + def contributed(self, lane: str) -> bool: + """Whether ``lane`` is a verified contributor to the current diff.""" + + return _lane(lane) in self.contributors + + def independent(self, lane: str) -> bool: + """Whether ``lane`` may satisfy an independent review requirement. + + Independence is a separate decision from role eligibility: a qualified + reviewer lane that contributed to this diff is still not independent of + it, and an unqualified lane is not made eligible by being independent. + Unresolved lineage is never independence. + """ + + return self.resolved and bool(_lane(lane)) and not self.contributed(lane) + + def admission(self, lane: str) -> dict[str, Any]: + """A closed, metadata-only admission decision for one reviewer lane.""" + + candidate = _lane(lane) + if not candidate: + admitted, reason = False, "reviewer_lane_invalid" + elif not self.resolved: + admitted, reason = False, "lineage_" + self.status + elif self.contributed(candidate): + admitted, reason = False, "contributor_not_independent" + else: + admitted, reason = True, "independent" + return { + "schema": SCHEMA, + "lane": candidate, + "admitted": admitted, + "reason": reason, + "head_sha": self.head_sha, + "contributors": list(self.contributors), + "current_writer": self.current_writer, + "owner_action": "" if admitted else (self.owner_action or _ADMISSION_ACTIONS[reason]), + } + + def independent_lanes(self, lanes: Iterable[str]) -> tuple[str, ...]: + return tuple(lane for lane in lanes if self.independent(lane)) + + def as_dict(self) -> dict[str, Any]: + """Bounded metadata for Board/status projection and public rendering.""" + + return { + "schema": SCHEMA, + "status": self.status, + "reason": self.reason, + "head_sha": self.head_sha, + "contributors": list(self.contributors), + "current_writer": self.current_writer, + "builder_label": self.builder_label, + "stale_builder_labels": list(self.stale_builder_labels), + "evidence": self.evidence, + "episodes": self.episodes, + "owner_action": self.owner_action, + } + + +_ADMISSION_ACTIONS = { + "reviewer_lane_invalid": "name the reviewer lane requesting admission", + "contributor_not_independent": ( + "select a reviewer lane that did not contribute to this head" + ), + "lineage_waiting": "re-record builder contribution lineage for the current head", + "lineage_conflict": "resolve the conflicting builder contribution evidence", +} + +_OWNER_ACTIONS = { + "lineage_behind_head": ( + "recorded builder lineage stops before the current head; " + "re-record the contribution episode for this head" + ), + "episode_malformed": ( + "a recorded contribution episode is malformed; re-record it from the " + "verified handoff" + ), + "episode_unbound": ( + "a recorded contribution episode does not bind to this repository, " + "pull request or branch; re-record it against this pull request" + ), + "episode_duplicated": ( + "two different contribution episodes claim the same position; " + "re-record the lineage for this pull request" + ), + "episode_unchained": ( + "recorded contribution episodes do not form one head-to-head chain; " + "re-record the lineage from the verified handoff" + ), + "writer_state_unverified": ( + "a recorded contribution episode has no verified source writer state; " + "re-run the handoff boundary before recording it" + ), + "opener_outside_lineage": ( + "the pull request opener is not part of the recorded lineage; " + "record the opening contribution or correct the lineage" + ), + "label_outside_lineage": ( + "an active builder label names a lane with no recorded contribution; " + "remove it or record the missing contribution" + ), + "conflicting_builder_identity": ( + "builder author and label evidence disagree and no verified handoff " + "explains the change; record the handoff or correct the identity" + ), + "target_invalid": ( + "the pull request target could not be identified; supply repository, " + "number, branch and the exact head" + ), +} + + +def _lineage( + status: str, + reason: str, + *, + head_sha: str = "", + contributors: Sequence[str] = (), + current_writer: str = "", + stale: Sequence[str] = (), + evidence: str = "none", + episodes: int = 0, +) -> Lineage: + return Lineage( + status=status, + reason=reason, + head_sha=head_sha, + contributors=tuple(contributors), + current_writer=current_writer, + builder_label=f"builder:{current_writer}" if current_writer else "", + stale_builder_labels=tuple(dict.fromkeys(stale)), + evidence=evidence, + episodes=episodes, + owner_action="" if status == "resolved" else _OWNER_ACTIONS.get(reason, ""), + ) + + +def resolve_lineage( + *, + repo: str, + pr_number: Any, + branch: str, + head_sha: str, + episodes: Sequence[Mapping[str, Any] | ContributionEpisode] = (), + opener_lane: str = "", + label_lanes: Sequence[str] = (), +) -> Lineage: + """Resolve who built the diff at ``head_sha``. + + ``episodes`` is verified handoff/delivery evidence. ``opener_lane`` and + ``label_lanes`` are the weak signals the rest of the system used to carry + on their own: they are accepted here only as corroboration, and they can + fail the resolution closed, but neither can establish a takeover. + """ + + target_repo = _text(repo) + number = _pr_number(pr_number) + head = _sha(head_sha) + target_branch = _text(branch) + if not REPO_RE.match(target_repo) or not number or not head: + return _lineage("conflict", "target_invalid", head_sha=head) + + opener = _lane(opener_lane) + labels = tuple(dict.fromkeys(lane for lane in (_lane(item) for item in label_lanes) if lane)) + + parsed: list[ContributionEpisode] = [] + for item in episodes: + try: + episode = item if isinstance(item, ContributionEpisode) else episode_from_mapping(item) + except LineageError: + return _lineage("conflict", "episode_malformed", head_sha=head) + if ( + episode.repo.lower() != target_repo.lower() + or episode.pr_number != number + or (target_branch and episode.branch != target_branch) + ): + return _lineage("conflict", "episode_unbound", head_sha=head) + if episode.writer_state not in WRITER_STATES: + return _lineage("conflict", "writer_state_unverified", head_sha=head) + parsed.append(episode) + if len(parsed) > MAX_EPISODES: + return _lineage("conflict", "episode_malformed", head_sha=head) + + if not parsed: + return resolve_identity_only(opener_lane=opener, label_lanes=labels, head_sha=head) + + ordered = sorted(parsed, key=lambda episode: episode.sequence) + seen: dict[int, ContributionEpisode] = {} + for episode in ordered: + previous = seen.get(episode.sequence) + if previous is not None: + if previous.as_dict() != episode.as_dict(): + return _lineage("conflict", "episode_duplicated", head_sha=head) + continue + seen[episode.sequence] = episode + ordered = [seen[sequence] for sequence in sorted(seen)] + if [episode.sequence for episode in ordered] != list(range(1, len(ordered) + 1)): + return _lineage("conflict", "episode_unchained", head_sha=head) + + contributors: list[str] = [ordered[0].source_lane] + for index, episode in enumerate(ordered): + if index and ( + episode.expected_head != ordered[index - 1].resulting_head + or episode.source_lane != ordered[index - 1].destination_lane + ): + return _lineage("conflict", "episode_unchained", head_sha=head) + if episode.moved_head: + contributors.append(episode.destination_lane) + contributors = list(dict.fromkeys(contributors)) + writer = ordered[-1].destination_lane + + if ordered[-1].resulting_head != head: + # The lineage may be stale, or history may have been rewritten under + # it. Either way it does not describe the diff being decided on. + return _lineage( + "waiting", + "lineage_behind_head", + head_sha=head, + evidence="handoff_episodes", + episodes=len(ordered), + ) + if opener and opener not in contributors and opener != writer: + return _lineage( + "conflict", "opener_outside_lineage", head_sha=head, + evidence="handoff_episodes", episodes=len(ordered), + ) + known = set(contributors) | {writer} + if any(lane not in known for lane in labels): + return _lineage( + "conflict", "label_outside_lineage", head_sha=head, + evidence="handoff_episodes", episodes=len(ordered), + ) + return _lineage( + "resolved", + "verified_handoff" if len(ordered) > 1 else "verified_takeover", + head_sha=head, + contributors=contributors, + current_writer=writer, + stale=[lane for lane in labels if lane != writer], + evidence="handoff_episodes", + episodes=len(ordered), + ) + + +def resolve_identity_only( + *, opener_lane: str = "", label_lanes: Sequence[str] = (), head_sha: str = "" +) -> Lineage: + """The ordinary single-builder case, and the #959 shape without evidence. + + With no verified handoff there is exactly one consistent story available: + one lane opened the PR and still holds the only builder label. Any other + combination is the inconsistency this issue exists to stop guessing about, + so it fails closed instead of preferring the opener or the newest label. + """ + + head = _sha(head_sha) + opener = _lane(opener_lane) + labels = tuple(dict.fromkeys(lane for lane in (_lane(item) for item in label_lanes) if lane)) + candidates = tuple(dict.fromkeys(([opener] if opener else []) + list(labels))) + if len(candidates) > 1: + return _lineage("conflict", "conflicting_builder_identity", head_sha=head) + if not candidates: + return _lineage("resolved", "no_builder_identity", head_sha=head, evidence="none") + lane = candidates[0] + return _lineage( + "resolved", + "single_builder", + head_sha=head, + contributors=(lane,), + current_writer=lane, + evidence="single_builder", + ) + + +def lanes_from_identity( + *, + identity: Mapping[str, Any] | None, + labels: Sequence[str] = (), + author: str = "", +) -> tuple[str, tuple[str, ...]]: + """Map GitHub labels and a PR author onto builder lane names. + + ``identity`` is the existing author-exclusion contract + (``{"enabled": bool, "labels": {...}, "authors": {...}}``) so lane naming + stays in one place. Returns ``(opener_lane, label_lanes)``; both are weak + signals that :func:`resolve_lineage` may reject. + """ + + if not isinstance(identity, Mapping) or not identity.get("enabled"): + return "", () + label_map = identity.get("labels") + author_map = identity.get("authors") + label_map = label_map if isinstance(label_map, Mapping) else {} + author_map = author_map if isinstance(author_map, Mapping) else {} + label_lanes = tuple( + dict.fromkeys( + lane + for lane in (_lane(label_map.get(_text(label))) for label in labels) + if lane + ) + ) + lowered = {_text(key).lower(): value for key, value in author_map.items()} + return _lane(lowered.get(_text(author).lower())), label_lanes + + +# --- durable metadata-only record ------------------------------------------- + + +def _store(root: Path): + """Import the private context store lazily. + + The resolver above is pure and is copied into environments (the generated + gate, mirrored tooling) that have no private state directory at all. Only + the recording side needs the store, so only it pays for the dependency. + """ + + from .context_store import ContextStore + + return ContextStore(Path(root)) + + +def pr_key(repo: str, pr_number: Any) -> str: + seed = json.dumps([_text(repo).lower(), _pr_number(pr_number)], sort_keys=True) + return "l" + hashlib.sha256(seed.encode()).hexdigest()[:62] + + +def record_episode(root: Path, episode: ContributionEpisode) -> dict[str, Any]: + """Append one verified episode. Replay is a no-op, never a duplicate. + + Recording is refused when the new episode does not chain onto the recorded + lineage, so a mismatched or reordered write is an owner action at the point + it is attempted rather than an ambiguity discovered at review time. + """ + + if not isinstance(episode, ContributionEpisode): + raise LineageError("contribution episode is malformed") + with _store(root).locked(pr_key(episode.repo, episode.pr_number)) as locked: + record = locked.read() or { + "schema": RECORD_SCHEMA, + "repo": episode.repo, + "pr_number": episode.pr_number, + "episodes": [], + } + if record.get("schema") != RECORD_SCHEMA: + raise LineageError("recorded builder lineage is unreadable") + recorded = list(record.get("episodes") or []) + payload = episode.as_dict() + for existing in recorded: + if existing.get("sequence") == episode.sequence: + if existing != payload: + raise LineageError( + "a different contribution episode is already recorded at this position" + ) + return {"recorded": False, "duplicate": True, "episodes": len(recorded)} + if len(recorded) >= MAX_EPISODES: + raise LineageError("recorded builder lineage is already at its bound") + if episode.sequence != len(recorded) + 1: + raise LineageError("contribution episode is out of order for this pull request") + if recorded: + previous = recorded[-1] + if ( + previous.get("resulting_head") != episode.expected_head + or previous.get("destination_lane") != episode.source_lane + or previous.get("branch") != episode.branch + ): + raise LineageError( + "contribution episode does not chain onto the recorded lineage" + ) + recorded.append(payload) + record["episodes"] = recorded + locked.write(record) + return {"recorded": True, "duplicate": False, "episodes": len(recorded)} + + +def load_episodes(root: Path, repo: str, pr_number: Any) -> tuple[ContributionEpisode, ...]: + """Read back recorded episodes. Unreadable evidence raises, never guesses.""" + + path = Path(root) + if not path.exists(): + return () + with _store(path).locked(pr_key(repo, pr_number)) as locked: + record = locked.read() + if record is None: + return () + if record.get("schema") != RECORD_SCHEMA: + raise LineageError("recorded builder lineage is unreadable") + return tuple( + episode_from_mapping(item) for item in (record.get("episodes") or []) + ) + + +# --- bounded public transport ------------------------------------------------ + + +def lineage_comment_marker(episodes: Sequence[ContributionEpisode]) -> str: + """Render episodes as one hidden, metadata-only pull request marker.""" + + payload = { + "schema": SCHEMA, + "episodes": [episode.as_dict() for episode in episodes][:MAX_EPISODES], + } + return f"" + + +def episodes_from_comment_body(body: str) -> tuple[ContributionEpisode, ...]: + """Parse lineage markers out of one already trusted comment body. + + The caller decides trust. This function never treats the presence of a + marker as evidence that its author was allowed to publish one. + """ + + episodes: list[ContributionEpisode] = [] + for match in LINEAGE_MARKER_RE.finditer(_text(body)[:MAX_EPISODES * 2048]): + try: + payload = json.loads(match.group("payload")) + except (ValueError, RecursionError): + raise LineageError("published builder lineage is unreadable") from None + if not isinstance(payload, Mapping) or payload.get("schema") != SCHEMA: + raise LineageError("published builder lineage schema is unsupported") + items = payload.get("episodes") + if not isinstance(items, list) or len(items) > MAX_EPISODES: + raise LineageError("published builder lineage is unreadable") + episodes.extend(episode_from_mapping(item) for item in items) + return tuple(episodes) diff --git a/src/code_mower/builder_runs.py b/src/code_mower/builder_runs.py index 0354a01b..b511dadf 100644 --- a/src/code_mower/builder_runs.py +++ b/src/code_mower/builder_runs.py @@ -11,10 +11,15 @@ from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path -from typing import Any, Mapping +from typing import Any, Mapping, Sequence from urllib.parse import urlparse from code_mower import __version__ +from code_mower.builder_lineage import ( + Lineage, + resolve_identity_only, + resolve_lineage, +) from code_mower.work_orders import parse_github_issue_ref, parse_github_pr_ref @@ -56,6 +61,27 @@ ) +#: Provider/executor identities map onto the lane names lineage speaks in. +#: Inference names a candidate lane; it never names a contribution. +_INFERENCE_LANES = { + "codex": "codex", + "claude": "claude", + "cursor_cloud_agent": "cursor", + "devin": "devin", + "devin_cli": "devin", +} +_LABEL_LANES = { + "builder:codex": "codex", + "builder:claude": "claude", + "builder:cursor": "cursor", + "builder:devin": "devin", +} + + +def _lane_from_inference(inference: "BuilderInference | None") -> str: + return "" if inference is None else _INFERENCE_LANES.get(inference.provider, "") + + @dataclass(frozen=True) class PullRequestMetadata: repo: str @@ -242,16 +268,58 @@ def infer_builder_from_pr(metadata: PullRequestMetadata) -> BuilderInference | N ) +def resolve_builder_lineage( + metadata: PullRequestMetadata, + *, + head_sha: str = "", + episodes: Sequence[Any] = (), + labels: Sequence[str] = (), +) -> Lineage: + """Resolve the same exact-head lineage the gate and reviewers resolve. + + Auto-record used to describe a pull request from its author, its branch + prefix and provider prose. Those still bootstrap the lane name, but they + cannot describe a handoff, so verified contribution episodes decide the + contributor list and the current writer whenever they exist. + """ + + lane = _lane_from_inference(infer_builder_from_pr(metadata)) + label_lanes = tuple( + lane_name + for lane_name in ( + _LABEL_LANES.get(str(label).strip().lower()) for label in labels + ) + if lane_name + ) + if not (head_sha and metadata.repo and metadata.number): + return resolve_identity_only(opener_lane=lane, label_lanes=label_lanes) + return resolve_lineage( + repo=metadata.repo, + pr_number=metadata.number, + branch=metadata.branch, + head_sha=head_sha, + episodes=episodes, + opener_lane=lane, + label_lanes=label_lanes, + ) + + def build_auto_builder_run_event( metadata: PullRequestMetadata, *, created_at: str = "", lens: str = "implementation", status: str = "pr-opened", + head_sha: str = "", + episodes: Sequence[Any] = (), + labels: Sequence[str] = (), ) -> tuple[dict[str, Any] | None, BuilderInference | None]: inference = infer_builder_from_pr(metadata) if inference is None: return None, None + lineage = resolve_builder_lineage( + metadata, head_sha=head_sha, episodes=episodes, labels=labels + ) pr_ref = metadata.url or ( f"{metadata.repo}#{metadata.number}" if metadata.repo and metadata.number else "" ) @@ -274,6 +342,12 @@ def build_auto_builder_run_event( event["dimensions"]["builder_inference_confidence"] = inference.confidence event["dimensions"]["builder_inference_signals"] = list(inference.signals) event["dimensions"]["pr_author"] = metadata.author + # Bounded metadata only: lane names, a status and a head prefix. The full + # lineage record stays private; what a recorded run needs to say publicly + # is who contributed, who is writing now, and whether that is settled. + event["dimensions"]["builder_lineage_status"] = lineage.status + event["dimensions"]["builder_contributors"] = list(lineage.contributors) + event["dimensions"]["builder_current_writer"] = lineage.current_writer return event, inference diff --git a/src/code_mower/claude_audit_pr.py b/src/code_mower/claude_audit_pr.py index ed753441..ff60187b 100644 --- a/src/code_mower/claude_audit_pr.py +++ b/src/code_mower/claude_audit_pr.py @@ -1298,6 +1298,20 @@ def format_comment( return limit_comment_body(body, trailer, provider_name="Claude") +def _require_independent_review(lane, repo, pr_number, pr_meta, head_sha): + """Admit ``lane`` against verified contribution lineage, or raise. + + Imported lazily so the direct-script execution fallback this module + supports does not have to resolve the package layout at import time. + """ + + try: + from code_mower.provider_runners.lineage import require_reviewer_lane + except ImportError: # pragma: no cover - direct script execution fallback + from provider_runners.lineage import require_reviewer_lane # type: ignore + return require_reviewer_lane(lane, repo, pr_number, pr_meta, head_sha) + + def audit_pr(config: ClaudeAuditConfig, repo: str, pr_number: int) -> ClaudeAuditResult: audit_started = time.monotonic() local_repo = config.repo_paths.get(repo) @@ -1321,6 +1335,10 @@ def audit_pr(config: ClaudeAuditConfig, repo: str, pr_number: int) -> ClaudeAudi "refusing Claude self-audit for claude/* branch. " "Use --allow-claude-owned only for explicitly informational dogfood." ) + # The branch-prefix check above only sees where the PR started. Contribution + # lineage at the exact head sees who actually wrote the diff, including a + # Claude takeover of another lane's branch. + _require_independent_review("claude", repo, pr_number, pr_meta, head_sha_start) config.progress.emit( "audit", diff --git a/src/code_mower/codex_audit_pr.py b/src/code_mower/codex_audit_pr.py index f19007f3..20e225ae 100644 --- a/src/code_mower/codex_audit_pr.py +++ b/src/code_mower/codex_audit_pr.py @@ -1807,6 +1807,20 @@ def _codex_context_omission_notice_from_diagnostics(diagnostics: str) -> str: # ----- Orchestration ----- +def _require_independent_review(lane, repo, pr_number, pr_meta, head_sha): + """Admit ``lane`` against verified contribution lineage, or raise. + + Imported lazily so the direct-script execution fallback this module + supports does not have to resolve the package layout at import time. + """ + + try: + from code_mower.provider_runners.lineage import require_reviewer_lane + except ImportError: # pragma: no cover - direct script execution fallback + from provider_runners.lineage import require_reviewer_lane # type: ignore + return require_reviewer_lane(lane, repo, pr_number, pr_meta, head_sha) + + def audit_pr(config: AuditConfig, repo: str, pr_number: int) -> AuditResult: """End-to-end audit of one PR. Creates a temporary worktree at the PR head, runs Codex review, structures its verdict, formats + posts a @@ -1826,6 +1840,10 @@ def audit_pr(config: AuditConfig, repo: str, pr_number: int) -> AuditResult: config = replace(config, progress=AuditProgress("codex-audit")) pr_meta = fetch_pull_request(repo, pr_number, token=config.github_token) head_sha_start = pr_meta["head"]["sha"] + # Admission runs on trusted metadata at the exact head, before the provider + # is launched, so a contributing lane never spends a run reviewing its own + # diff and never produces a verdict it is not independent enough to give. + _require_independent_review("codex", repo, pr_number, pr_meta, head_sha_start) config.progress.emit( "audit", diff --git a/src/code_mower/devin_cli_audit_pr.py b/src/code_mower/devin_cli_audit_pr.py index d78cffcd..b7905edb 100644 --- a/src/code_mower/devin_cli_audit_pr.py +++ b/src/code_mower/devin_cli_audit_pr.py @@ -560,6 +560,68 @@ def _is_excluded_author(author: str) -> bool: return author.strip().lower() in default_authors +DEVIN_REVIEWER_LANE = "devin" + + +def _require_independent_devin_review( + config, pr_meta, head_sha: str, pr_author: str +) -> dict: + """Admit the Devin reviewer lane against verified lineage for this head. + + Raises :class:`AuthorExcludedError` so existing callers keep their exit + handling; the message carries only bounded metadata and one owner action. + """ + + from .provider_runners.lineage import ( + ReviewerNotIndependent, + load_identity, + require_independent_reviewer, + ) + + if pr_author and _is_excluded_author(pr_author): + # Kept as a floor, not as the decision: the configured Devin account + # list can only add exclusion, never admit a lane the lineage excludes. + raise AuthorExcludedError( + f"PR author {pr_author!r} is excluded from the Devin CLI reviewer lane" + ) + identity = load_identity() + if not identity.get("enabled"): + # An unconfigured checkout must not become more permissive than the + # deny list this wrapper shipped with. Name Devin's own accounts and + # label so the shared resolver still sees Devin as a contributor. + identity = { + "enabled": True, + "labels": {"builder:devin": DEVIN_REVIEWER_LANE}, + "authors": { + login: DEVIN_REVIEWER_LANE + for login in ( + "devin-cli-audit-bot", + "devin-cli-audit-bot[bot]", + "devin-ai-integration", + "devin-ai-integration[bot]", + ) + }, + } + try: + return require_independent_reviewer( + DEVIN_REVIEWER_LANE, + repo=config.repo, + pr_number=config.pr_number, + pr_meta=pr_meta, + head_sha=head_sha, + identity=identity, + ) + except ReviewerNotIndependent as exc: + if exc.reason == "lineage_conflict" and pr_author and _is_excluded_author(pr_author): + raise AuthorExcludedError( + f"PR author {pr_author!r} is excluded from the Devin CLI reviewer lane" + ) from None + raise AuthorExcludedError( + f"Devin CLI reviewer lane is not admitted for {config.repo}" + f"#{config.pr_number} at {head_sha[:12]}: {exc.reason}; {exc.owner_action}" + ) from None + + @dataclass class DevinCliVerdict: verdict: str # PASS, BLOCKED, or UNKNOWN @@ -1157,11 +1219,14 @@ def _do_audit_pr(config: AuditConfig) -> AuditResult: if not pr_head_sha: raise ValueError("GitHub pull request response did not include head.sha") + # Independence is decided from verified contribution lineage at this exact + # head, not from who opened the PR. The historical author deny list stays as + # one input so a Devin-authored PR with no lineage evidence still fails + # closed, but a Devin PR taken over by another lane no longer excludes the + # lane that actually wrote the diff -- and no longer admits Devin when Devin + # contributed under a different opener. pr_author = str(((pr_meta.get("user") or {}).get("login")) or "").strip() - if pr_author and _is_excluded_author(pr_author): - raise AuthorExcludedError( - f"PR author {pr_author!r} is excluded from the Devin CLI reviewer lane" - ) + _require_independent_devin_review(config, pr_meta, pr_head_sha, pr_author) if str(pr_meta.get("head", {}).get("repo", {}).get("full_name") or "") != config.repo: raise ValueError("PR head repository does not match the target repository") diff --git a/src/code_mower/package_manifest.py b/src/code_mower/package_manifest.py index 5368e15d..6748c830 100644 --- a/src/code_mower/package_manifest.py +++ b/src/code_mower/package_manifest.py @@ -17,6 +17,7 @@ ("src/code_mower/branch_policy.py", "src/code_mower/branch_policy.py", "core"), ("src/code_mower/board_store.py", "src/code_mower/board_store.py", "core"), ("src/code_mower/file_locks.py", "src/code_mower/file_locks.py", "core"), + ("src/code_mower/builder_lineage.py", "src/code_mower/builder_lineage.py", "core"), ("src/code_mower/builder_runs.py", "src/code_mower/builder_runs.py", "core"), ("tools/code_mower_builder_experiment.py", "src/code_mower/builder_experiment.py", "core"), ("src/code_mower/work_orders.py", "src/code_mower/work_orders.py", "core"), @@ -393,6 +394,11 @@ "src/code_mower/provider_runners/github_pr.py", "reviewer", ), + ( + "src/code_mower/provider_runners/lineage.py", + "src/code_mower/provider_runners/lineage.py", + "reviewer", + ), ( "src/code_mower/provider_runners/process.py", "src/code_mower/provider_runners/process.py", diff --git a/src/code_mower/provider_runners/__init__.py b/src/code_mower/provider_runners/__init__.py index 70ca47d7..6f5d8783 100644 --- a/src/code_mower/provider_runners/__init__.py +++ b/src/code_mower/provider_runners/__init__.py @@ -23,6 +23,14 @@ resolve_github_token_from_env_or_gh, resolve_github_token_from_stdin_or_env, ) +from .lineage import ( + ReviewerNotIndependent, + load_identity, + pr_lineage, + published_episodes, + require_independent_reviewer, + reviewer_admission, +) from .github_pr import ( edit_pr_comment, fetch_issue_comments, @@ -106,6 +114,12 @@ "LOCAL_AUDIT_RUNNER_DOC", "pop_github_token_env", "post_pr_comment", + "pr_lineage", + "published_episodes", + "require_independent_reviewer", + "reviewer_admission", + "ReviewerNotIndependent", + "load_identity", "ProviderWorkspaceError", "repost_audit_verdict_artifact", "require_exact_keys", diff --git a/src/code_mower/provider_runners/lineage.py b/src/code_mower/provider_runners/lineage.py new file mode 100644 index 00000000..0c06242b --- /dev/null +++ b/src/code_mower/provider_runners/lineage.py @@ -0,0 +1,229 @@ +"""Shared reviewer-independence admission for provider runner wrappers. + +Every direct reviewer wrapper used to answer "may I review this?" on its own +terms: Codex and Claude leaned on the gate's label/author exclusion, and the +Devin wrappers carried a product-specific PR-author deny list. None of those +survive a takeover, where the opener, the branch and the active label can each +name a different lane than the one that wrote the current diff. + +This module is the one admission seam. It runs after the wrapper has fetched +trusted pull request metadata and pinned the exact head, and before any +provider execution, so a lane that contributed to the diff is never spent +reviewing its own work. + +Role eligibility is a separate decision (see :mod:`code_mower.role_eligibility`) +and is deliberately not consulted here: a qualified lane can still be a +contributor, and an independent lane can still be unqualified. +""" + +from __future__ import annotations + +import json +import os +from typing import Any, Callable, Mapping, Sequence + +from ..builder_lineage import ( + Lineage, + LineageError, + episodes_from_comment_body, + lanes_from_identity, + resolve_lineage, +) + + +AUTHOR_EXCLUSION_ENV = "CODE_MOWER_AUTHOR_EXCLUSION_JSON" + + +class ReviewerNotIndependent(RuntimeError): + """Raised when a reviewer lane may not gate the pull request under review. + + ``reason`` and ``owner_action`` are bounded metadata safe to surface in a + public comment; no diagnostic output, path or provider reference is carried. + """ + + def __init__(self, decision: Mapping[str, Any]) -> None: + self.decision = dict(decision) + self.reason = str(decision.get("reason") or "") + self.owner_action = str(decision.get("owner_action") or "") + super().__init__( + f"{decision.get('lane') or 'reviewer'} lane is not admitted: " + f"{self.reason}; {self.owner_action}" + ) + + +def load_identity(raw: str | None = None) -> Mapping[str, Any]: + """Read the existing author-exclusion identity contract. + + A missing or unparsable value disables lane naming rather than inventing + one, which keeps an unconfigured checkout behaving as it does today. + """ + + text = raw if raw is not None else os.environ.get(AUTHOR_EXCLUSION_ENV, "") + if not text: + return {"enabled": False} + try: + parsed = json.loads(text) + except (ValueError, RecursionError): + return {"enabled": False} + return parsed if isinstance(parsed, Mapping) else {"enabled": False} + + +def published_episodes( + comments: Sequence[Mapping[str, Any]], + *, + trusted_author: Callable[[str], bool], +) -> tuple: + """Collect lineage episodes published by already trusted comment authors. + + The hidden marker is a transport for bounded metadata. Trust comes from the + caller's author check, never from the marker being present, so an untrusted + commenter cannot assert a takeover into existence. + """ + + collected: list = [] + for comment in comments: + if not isinstance(comment, Mapping): + continue + login = str(((comment.get("user") or {}).get("login")) or "") + if not login or not trusted_author(login): + continue + collected.extend(episodes_from_comment_body(str(comment.get("body") or ""))) + return tuple(collected) + + +def pr_lineage( + *, + repo: str, + pr_number: int, + pr_meta: Mapping[str, Any], + head_sha: str, + episodes: Sequence[Any] = (), + identity: Mapping[str, Any] | None = None, +) -> Lineage: + """Resolve lineage from trusted pull request metadata at an exact head. + + ``pr_meta`` must be the metadata the wrapper fetched from GitHub itself; + ``head_sha`` must be the head the wrapper pinned. Passing a head the caller + did not verify would make every downstream decision unverified too. + """ + + labels = [ + str(label.get("name") or "") + for label in (pr_meta.get("labels") or []) + if isinstance(label, Mapping) + ] + author = str(((pr_meta.get("user") or {}).get("login")) or "") + branch = str(((pr_meta.get("head") or {}).get("ref")) or "") + opener_lane, label_lanes = lanes_from_identity( + identity=identity if identity is not None else load_identity(), + labels=labels, + author=author, + ) + return resolve_lineage( + repo=repo, + pr_number=pr_number, + branch=branch, + head_sha=head_sha, + episodes=episodes, + opener_lane=opener_lane, + label_lanes=label_lanes, + ) + + +def reviewer_admission( + lane: str, + *, + repo: str, + pr_number: int, + pr_meta: Mapping[str, Any], + head_sha: str, + episodes: Sequence[Any] = (), + identity: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Decide whether ``lane`` may review this exact head. Fails closed.""" + + try: + lineage = pr_lineage( + repo=repo, + pr_number=pr_number, + pr_meta=pr_meta, + head_sha=head_sha, + episodes=episodes, + identity=identity, + ) + except LineageError: + return { + "schema": "code_mower.builderLineage.v1", + "lane": str(lane or "").strip().lower(), + "admitted": False, + "reason": "lineage_unreadable", + "head_sha": str(head_sha or ""), + "contributors": [], + "current_writer": "", + "owner_action": ( + "builder contribution evidence for this pull request could not " + "be read; re-record it from the verified handoff" + ), + } + return lineage.admission(lane) + + +def require_independent_reviewer( + lane: str, + *, + repo: str, + pr_number: int, + pr_meta: Mapping[str, Any], + head_sha: str, + episodes: Sequence[Any] = (), + identity: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Admit ``lane`` or raise :class:`ReviewerNotIndependent`.""" + + decision = reviewer_admission( + lane, + repo=repo, + pr_number=pr_number, + pr_meta=pr_meta, + head_sha=head_sha, + episodes=episodes, + identity=identity, + ) + if not decision["admitted"]: + raise ReviewerNotIndependent(decision) + return decision + + +def require_reviewer_lane( + lane: str, + repo: str, + pr_number: int, + pr_meta: Mapping[str, Any], + head_sha: str, + *, + episodes: Sequence[Any] = (), + identity: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Wrapper-facing admission that reports refusal as a plain ``RuntimeError``. + + Direct reviewer wrappers already surface ``RuntimeError`` as an operator + message with their normal exit handling, so this keeps the shared decision + from needing per-wrapper exception plumbing. The message is bounded + metadata plus one owner action; no diagnostic output or path is included. + """ + + try: + return require_independent_reviewer( + lane, + repo=repo, + pr_number=pr_number, + pr_meta=pr_meta, + head_sha=head_sha, + episodes=episodes, + identity=identity, + ) + except ReviewerNotIndependent as exc: + raise RuntimeError( + f"{lane} reviewer lane is not admitted for {repo}#{pr_number} at " + f"{str(head_sha)[:12]}: {exc.reason}; {exc.owner_action}" + ) from None diff --git a/tests/test_builder_lineage.py b/tests/test_builder_lineage.py new file mode 100644 index 00000000..cc56ff3e --- /dev/null +++ b/tests/test_builder_lineage.py @@ -0,0 +1,382 @@ +"""Exact-head builder contribution lineage regressions. + +The primary fixture is the PR #959 shape: a pull request opened by Devin on a +Devin branch, an explicit verified Codex takeover, and a Codex final head. +""" + +from __future__ import annotations + +import unittest + +from code_mower.builder_lineage import ( + ContributionEpisode, + LineageError, + episodes_from_comment_body, + lanes_from_identity, + lineage_comment_marker, + record_episode, + load_episodes, + resolve_identity_only, + resolve_lineage, +) +from code_mower.provider_runners.lineage import ( + ReviewerNotIndependent, + require_independent_reviewer, + reviewer_admission, +) + + +REPO = "codemower-ai/code-mower" +BRANCH = "devin/959-release-dogfood" +H1 = "a" * 40 +H2 = "b" * 40 +H3 = "c" * 40 + +IDENTITY = { + "enabled": True, + "labels": { + "builder:devin": "devin", + "builder:codex": "codex", + "builder:claude": "claude", + }, + "authors": { + "devin-ai-integration[bot]": "devin", + "chatgpt-codex-connector": "codex", + }, +} + + +def episode(**overrides): + payload = dict( + sequence=1, + repo=REPO, + pr_number=959, + branch=BRANCH, + source_lane="devin", + destination_lane="codex", + expected_head=H1, + resulting_head=H2, + writer_state="terminated", + ) + payload.update(overrides) + return ContributionEpisode(**payload) + + +def resolve(**overrides): + kwargs = dict( + repo=REPO, + pr_number=959, + branch=BRANCH, + head_sha=H2, + episodes=(episode(),), + opener_lane="devin", + label_lanes=("codex",), + ) + kwargs.update(overrides) + return resolve_lineage(**kwargs) + + +class TakeoverLineageTests(unittest.TestCase): + def test_devin_opener_with_verified_codex_takeover(self): + lineage = resolve() + self.assertEqual(lineage.status, "resolved") + self.assertEqual(lineage.contributors, ("devin", "codex")) + self.assertEqual(lineage.current_writer, "codex") + self.assertEqual(lineage.builder_label, "builder:codex") + self.assertEqual(lineage.stale_builder_labels, ()) + # Both contributors are excluded from gating their own work. + self.assertFalse(lineage.independent("devin")) + self.assertFalse(lineage.independent("codex")) + # An uninvolved qualified lane may review the exact final head. + self.assertTrue(lineage.independent("claude")) + self.assertEqual(lineage.independent_lanes(("devin", "codex", "claude")), ("claude",)) + + def test_stale_builder_label_is_reported_not_treated_as_conflict(self): + lineage = resolve(label_lanes=("devin",)) + self.assertEqual(lineage.status, "resolved") + self.assertEqual(lineage.current_writer, "codex") + self.assertEqual(lineage.builder_label, "builder:codex") + self.assertEqual(lineage.stale_builder_labels, ("devin",)) + + def test_label_for_an_uninvolved_lane_fails_closed(self): + lineage = resolve(label_lanes=("claude",)) + self.assertEqual(lineage.status, "conflict") + self.assertEqual(lineage.reason, "label_outside_lineage") + self.assertTrue(lineage.owner_action) + self.assertFalse(lineage.independent("claude")) + + def test_head_change_leaves_lineage_waiting_rather_than_guessing(self): + lineage = resolve(head_sha=H3) + self.assertEqual(lineage.status, "waiting") + self.assertEqual(lineage.reason, "lineage_behind_head") + self.assertEqual(lineage.contributors, ()) + self.assertEqual(lineage.current_writer, "") + self.assertFalse(lineage.independent("claude")) + + def test_a_destination_that_never_moved_the_head_is_writer_not_contributor(self): + lineage = resolve( + episodes=(episode(resulting_head=H1),), head_sha=H1, label_lanes=("codex",) + ) + self.assertEqual(lineage.contributors, ("devin",)) + self.assertEqual(lineage.current_writer, "codex") + self.assertTrue(lineage.independent("claude")) + self.assertFalse(lineage.independent("devin")) + + def test_second_takeover_preserves_the_whole_ordered_history(self): + lineage = resolve( + episodes=( + episode(), + episode( + sequence=2, + source_lane="codex", + destination_lane="claude", + expected_head=H2, + resulting_head=H3, + ), + ), + head_sha=H3, + label_lanes=("claude",), + ) + self.assertEqual(lineage.contributors, ("devin", "codex", "claude")) + self.assertEqual(lineage.current_writer, "claude") + self.assertEqual(lineage.independent_lanes(("devin", "codex", "claude")), ()) + + +class AdversarialEvidenceTests(unittest.TestCase): + def test_conflicting_author_and_label_without_a_handoff_fails_closed(self): + lineage = resolve_lineage( + repo=REPO, pr_number=959, branch=BRANCH, head_sha=H2, + episodes=(), opener_lane="devin", label_lanes=("codex",), + ) + self.assertEqual(lineage.status, "conflict") + self.assertEqual(lineage.reason, "conflicting_builder_identity") + self.assertIn("record the handoff", lineage.owner_action) + + def test_normal_single_builder_case_still_resolves(self): + lineage = resolve_lineage( + repo=REPO, pr_number=959, branch="claude/963-lineage", head_sha=H2, + episodes=(), opener_lane="claude", label_lanes=("claude",), + ) + self.assertEqual(lineage.status, "resolved") + self.assertEqual(lineage.contributors, ("claude",)) + self.assertEqual(lineage.current_writer, "claude") + self.assertFalse(lineage.independent("claude")) + self.assertTrue(lineage.independent("codex")) + + def test_a_pull_request_with_no_builder_identity_excludes_nobody(self): + lineage = resolve_lineage( + repo=REPO, pr_number=959, branch="fix/typo", head_sha=H2, + ) + self.assertEqual(lineage.status, "resolved") + self.assertEqual(lineage.contributors, ()) + self.assertTrue(lineage.independent("codex")) + + def test_episode_bound_to_another_repository_pr_or_branch_is_rejected(self): + for overrides in ( + {"repo": "other/repo"}, + {"pr_number": 960}, + {"branch": "codex/959-other"}, + ): + with self.subTest(**overrides): + lineage = resolve(episodes=(episode(**overrides),)) + self.assertEqual(lineage.status, "conflict") + self.assertEqual(lineage.reason, "episode_unbound") + + def test_unchained_reordered_and_duplicated_episodes_fail_closed(self): + broken = resolve( + episodes=( + episode(), + episode(sequence=2, source_lane="codex", destination_lane="claude", + expected_head=H3, resulting_head=H3[:39] + "d"), + ), + head_sha=H3[:39] + "d", + label_lanes=(), + ) + self.assertEqual(broken.reason, "episode_unchained") + + gap = resolve( + episodes=(episode(sequence=2),), head_sha=H2, label_lanes=("codex",) + ) + self.assertEqual(gap.reason, "episode_unchained") + + duplicated = resolve( + episodes=(episode(), episode(destination_lane="claude")), + label_lanes=(), + ) + self.assertEqual(duplicated.reason, "episode_duplicated") + + def test_an_identical_replayed_episode_is_not_a_duplicate(self): + lineage = resolve(episodes=(episode(), episode())) + self.assertEqual(lineage.status, "resolved") + self.assertEqual(lineage.episodes, 1) + self.assertEqual(lineage.contributors, ("devin", "codex")) + + def test_malformed_and_unverified_writer_state_fail_closed(self): + malformed = resolve(episodes=({"schema": "nope"},)) + self.assertEqual(malformed.reason, "episode_malformed") + + raw = episode().as_dict() + raw["writer_state"] = "unknown" + unverified = resolve(episodes=(raw,)) + self.assertIn(unverified.reason, {"episode_malformed", "writer_state_unverified"}) + + def test_an_opener_outside_the_lineage_fails_closed(self): + lineage = resolve(opener_lane="claude", label_lanes=()) + self.assertEqual(lineage.reason, "opener_outside_lineage") + + def test_an_abbreviated_or_missing_head_is_never_resolved(self): + for head in ("", "abc1234", H2[:39]): + with self.subTest(head=head): + lineage = resolve(head_sha=head) + self.assertEqual(lineage.status, "conflict") + self.assertEqual(lineage.reason, "target_invalid") + + def test_episode_construction_rejects_self_handoff_and_bad_shas(self): + for overrides in ( + {"destination_lane": "devin"}, + {"expected_head": "zz"}, + {"sequence": 0}, + {"writer_state": "running"}, + ): + with self.subTest(**overrides): + with self.assertRaises(LineageError): + episode(**overrides) + + +class IdentityMappingTests(unittest.TestCase): + def test_identity_maps_labels_and_author_onto_lane_names(self): + opener, labels = lanes_from_identity( + identity=IDENTITY, + labels=["builder:codex", "tier:R"], + author="devin-ai-integration[bot]", + ) + self.assertEqual(opener, "devin") + self.assertEqual(labels, ("codex",)) + + def test_disabled_identity_names_no_lanes(self): + self.assertEqual( + lanes_from_identity(identity={"enabled": False}, labels=["builder:codex"], + author="devin-ai-integration[bot]"), + ("", ()), + ) + + def test_identity_only_resolution_needs_no_head(self): + lineage = resolve_identity_only(opener_lane="claude", label_lanes=("claude",)) + self.assertEqual(lineage.current_writer, "claude") + self.assertEqual( + resolve_identity_only(opener_lane="devin", label_lanes=("codex",)).status, + "conflict", + ) + + +class RecordTests(unittest.TestCase): + def setUp(self): + import tempfile + from pathlib import Path + + # Private lineage state must live outside any Git repository, so the + # record tests need a temporary root that is not inside this checkout. + base = Path(tempfile.gettempdir()).resolve() + if any((parent / ".git").exists() for parent in (base, *base.parents)): + base = Path("/tmp").resolve() + if any((parent / ".git").exists() for parent in (base, *base.parents)): + self.skipTest("no Git-free temporary directory is available here") + self.root = tempfile.mkdtemp(prefix="code-mower-lineage-", dir=str(base)) + self.addCleanup(__import__("shutil").rmtree, self.root, True) + + def test_recording_is_idempotent_and_chained(self): + first = record_episode(self.root, episode()) + self.assertEqual(first, {"recorded": True, "duplicate": False, "episodes": 1}) + replay = record_episode(self.root, episode()) + self.assertEqual(replay, {"recorded": False, "duplicate": True, "episodes": 1}) + self.assertEqual(len(load_episodes(self.root, REPO, 959)), 1) + + with self.assertRaises(LineageError): + record_episode(self.root, episode(destination_lane="claude")) + with self.assertRaises(LineageError): + # Sequence 2 must start from the head sequence 1 produced. + record_episode( + self.root, + episode(sequence=2, source_lane="codex", destination_lane="claude", + expected_head=H3, resulting_head=H1), + ) + record_episode( + self.root, + episode(sequence=2, source_lane="codex", destination_lane="claude", + expected_head=H2, resulting_head=H3), + ) + self.assertEqual(len(load_episodes(self.root, REPO, 959)), 2) + + def test_unrecorded_pull_requests_read_as_empty(self): + self.assertEqual(load_episodes(self.root, REPO, 1), ()) + + +class PublicTransportTests(unittest.TestCase): + def test_marker_round_trips_metadata_only(self): + marker = lineage_comment_marker((episode(),)) + self.assertNotIn("/Users", marker) + self.assertNotIn("session", marker) + parsed = episodes_from_comment_body("text\n" + marker + "\nmore") + self.assertEqual(parsed, (episode(),)) + + def test_an_unreadable_marker_raises_rather_than_being_ignored(self): + with self.assertRaises(LineageError): + episodes_from_comment_body("") + + def test_a_body_without_a_marker_yields_nothing(self): + self.assertEqual(episodes_from_comment_body("Codex took this over."), ()) + + +class ReviewerAdmissionTests(unittest.TestCase): + def pr_meta(self, *, author="devin-ai-integration[bot]", labels=("builder:codex",)): + return { + "user": {"login": author}, + "head": {"ref": BRANCH, "sha": H2}, + "labels": [{"name": name} for name in labels], + } + + def test_contributors_are_refused_and_an_independent_lane_is_admitted(self): + for lane, admitted in (("devin", False), ("codex", False), ("claude", True)): + with self.subTest(lane=lane): + decision = reviewer_admission( + lane, repo=REPO, pr_number=959, pr_meta=self.pr_meta(), + head_sha=H2, episodes=(episode(),), identity=IDENTITY, + ) + self.assertEqual(decision["admitted"], admitted) + self.assertEqual(decision["current_writer"], "codex") + self.assertEqual(decision["contributors"], ["devin", "codex"]) + if not admitted: + self.assertEqual(decision["reason"], "contributor_not_independent") + self.assertTrue(decision["owner_action"]) + + def test_admission_uses_the_head_the_caller_pinned(self): + decision = reviewer_admission( + "claude", repo=REPO, pr_number=959, pr_meta=self.pr_meta(), + head_sha=H3, episodes=(episode(),), identity=IDENTITY, + ) + self.assertFalse(decision["admitted"]) + self.assertEqual(decision["reason"], "lineage_waiting") + + def test_require_independent_reviewer_raises_with_bounded_metadata(self): + with self.assertRaises(ReviewerNotIndependent) as caught: + require_independent_reviewer( + "codex", repo=REPO, pr_number=959, pr_meta=self.pr_meta(), + head_sha=H2, episodes=(episode(),), identity=IDENTITY, + ) + message = str(caught.exception) + self.assertIn("contributor_not_independent", message) + self.assertNotIn("/", message.split("is not admitted")[0]) + + def test_no_evidence_plus_contradictory_signals_refuses_every_lane(self): + for lane in ("devin", "codex", "claude"): + with self.subTest(lane=lane): + decision = reviewer_admission( + lane, repo=REPO, pr_number=959, pr_meta=self.pr_meta(), + head_sha=H2, identity=IDENTITY, + ) + self.assertFalse(decision["admitted"]) + self.assertEqual(decision["reason"], "lineage_conflict") + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From caae260fbb3dc0603f6f1341b725a12303730f46 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 08:51:32 -0700 Subject: [PATCH 02/25] Make the Code Mower gate consume the shared lineage resolver The gate built its own builder identity from one label map plus the PR author, then excluded exactly one lane and failed the whole PR when two identities appeared. After a verified takeover two identities is the correct answer, not a contradiction. - The gate now reads contribution episodes from the hidden lineage marker, but only out of comments it already trusts via trusted_comment_author, so publishing a marker stays a transport and never an authorization. Trusted checkout loading and comment attestation are unchanged. - It resolves through resolve_builder_lineage and excludes every verified contributor, not just the one the active label names. It still blocks when no independent lane remains. - Conflicting evidence now fails with the resolver's owner action; lineage that is behind the current head is pending, not a guess. - Unreadable published evidence fails closed rather than being ignored. Applied identically to the template, its packaged mirror and the canonical generated workflow, and tools/ mirrors plus the package manifest are kept in parity so the gate's tools.* import path resolves. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/code-mower-gate.yml | 63 +- code-mower-package-manifest.json | 2 +- src/code_mower/audit_labeler_lib.py | 3 + src/code_mower/package_manifest.py | 2 +- .../workflows/code-mower-gate.yml.j2 | 63 +- templates/workflows/code-mower-gate.yml.j2 | 63 +- tools/audit_labeler_lib.py | 149 +++- tools/builder_lineage.py | 670 ++++++++++++++++++ 8 files changed, 941 insertions(+), 74 deletions(-) create mode 100644 tools/builder_lineage.py diff --git a/.github/workflows/code-mower-gate.yml b/.github/workflows/code-mower-gate.yml index 7ea431c6..8751597e 100644 --- a/.github/workflows/code-mower-gate.yml +++ b/.github/workflows/code-mower-gate.yml @@ -286,10 +286,12 @@ jobs: attested_non_current_audit_heads, audit_run_in_flight_detail_for_lane, audit_verdict_newer_than_in_flight, + episodes_from_comment_body, flatten_paginated_items, github_actions_comment_attested, latest_current_audit_verdict, latest_current_audit_verdict_detail, + resolve_builder_lineage, ) except ImportError: # pragma: no cover - unit-test/package fallback from code_mower.context_review import required_for_checkout @@ -298,12 +300,16 @@ jobs: attested_non_current_audit_heads, audit_run_in_flight_detail_for_lane, audit_verdict_newer_than_in_flight, + episodes_from_comment_body, flatten_paginated_items, github_actions_comment_attested, latest_current_audit_verdict, latest_current_audit_verdict_detail, + resolve_builder_lineage, ) + LINEAGE_MARKER = "CODE_MOWER_BUILDER_LINEAGE" + # github_actions_comment_attested verifies the hidden CODE_MOWER_AUDIT_RUN marker # and requires comment_id/body_sha256 to match the issue comment being evaluated. def emit(state, description): @@ -432,16 +438,37 @@ jobs: context_required=context_required, ) - builder_matches = [] - if isinstance(exclusion, dict) and exclusion.get("enabled"): - label_map = mapping("labels") - for label in labels: - if label in label_map: - builder_matches.append(str(label_map[label])) - author_map = {str(k).lower(): str(v) for k, v in mapping("authors").items()} - if pr_author.lower() in author_map: - builder_matches.append(author_map[pr_author.lower()]) - builder_matches = list(dict.fromkeys(builder_matches)) + # Contribution lineage, not one label and one author. Episodes are + # read only from comments this gate already trusts, so publishing a + # marker is a transport and never an authorization. The resolver is + # the same one the runner and the reviewer wrappers use. + lineage_episodes = [] + lineage_readable = True + for comment in comments: + comment_body = str(comment.get("body") or "") + if LINEAGE_MARKER not in comment_body: + continue + comment_author = str(((comment.get("user") or {}).get("login")) or "") + if not any( + trusted_comment_author(lane, comment_author, comment_body, comment.get("id")) + for lane in lanes + ): + continue + try: + lineage_episodes.extend(episodes_from_comment_body(comment_body)) + except Exception: + lineage_readable = False + builder_lineage = resolve_builder_lineage( + labels=sorted(labels), + author=pr_author, + config=exclusion if isinstance(exclusion, dict) else {"enabled": False}, + repo=os.environ.get("GITHUB_REPOSITORY", ""), + pr_number=pr_number, + branch=str(((pr_payload.get("head") or {}).get("ref")) or ""), + head_sha=head_sha, + episodes=lineage_episodes, + ) + builder_matches = list(builder_lineage.contributors) def lane_display(lane): return str(lane.get("display_name") or lane.get("id") or lane.get("done") or "") @@ -531,8 +558,12 @@ jobs: ) ) - if len(builder_matches) > 1: - emit("failure", "conflicting Code Mower builder identity") + if not lineage_readable: + emit("failure", "Code Mower builder contribution evidence is unreadable") + elif builder_lineage.status == "conflict": + emit("failure", "conflicting Code Mower builder identity: " + builder_lineage.owner_action) + elif builder_lineage.status == "waiting": + emit("pending", "waiting for builder lineage: " + builder_lineage.owner_action) elif owner_label in labels: emit("pending", owner_label + ": waiting on owner") elif owner_sitting_label and owner_sitting_label in labels: @@ -548,11 +579,13 @@ jobs: elif blocked: emit("failure", "blocked audit: " + ", ".join(blocked)) else: - excluded_builder = builder_matches[0] if builder_matches else "" + # Every verified contributor to this head is excluded, not just + # the one the active label happens to name. + excluded_builders = set(builder_matches) required = [ lane for lane in lanes - if str(lane.get("author_lane") or lane.get("id") or "") != excluded_builder + if str(lane.get("author_lane") or lane.get("id") or "") not in excluded_builders ] in_flight = in_flight_lanes(required) missing = [ @@ -562,7 +595,7 @@ jobs: or latest_current_verdict(lane) != "done" ] if not required: - if excluded_builder: + if excluded_builders: emit("failure", "no independent Code Mower audit lane remains") else: emit("failure", "no Code Mower merge-authority lanes configured") diff --git a/code-mower-package-manifest.json b/code-mower-package-manifest.json index 673bed97..8ff8e52a 100644 --- a/code-mower-package-manifest.json +++ b/code-mower-package-manifest.json @@ -374,7 +374,7 @@ }, { "kind": "core", - "source": "src/code_mower/builder_lineage.py", + "source": "tools/builder_lineage.py", "target": "src/code_mower/builder_lineage.py" }, { diff --git a/src/code_mower/audit_labeler_lib.py b/src/code_mower/audit_labeler_lib.py index f0a9dd41..3cacbe20 100644 --- a/src/code_mower/audit_labeler_lib.py +++ b/src/code_mower/audit_labeler_lib.py @@ -29,6 +29,7 @@ from .builder_lineage import ( Lineage, LineageError, + episodes_from_comment_body, lanes_from_identity, resolve_identity_only, resolve_lineage, @@ -39,6 +40,7 @@ from builder_lineage import ( # type: ignore Lineage, LineageError, + episodes_from_comment_body, lanes_from_identity, resolve_identity_only, resolve_lineage, @@ -49,6 +51,7 @@ from builder_lineage import ( # type: ignore Lineage, LineageError, + episodes_from_comment_body, lanes_from_identity, resolve_identity_only, resolve_lineage, diff --git a/src/code_mower/package_manifest.py b/src/code_mower/package_manifest.py index 6748c830..980a0f46 100644 --- a/src/code_mower/package_manifest.py +++ b/src/code_mower/package_manifest.py @@ -17,7 +17,6 @@ ("src/code_mower/branch_policy.py", "src/code_mower/branch_policy.py", "core"), ("src/code_mower/board_store.py", "src/code_mower/board_store.py", "core"), ("src/code_mower/file_locks.py", "src/code_mower/file_locks.py", "core"), - ("src/code_mower/builder_lineage.py", "src/code_mower/builder_lineage.py", "core"), ("src/code_mower/builder_runs.py", "src/code_mower/builder_runs.py", "core"), ("tools/code_mower_builder_experiment.py", "src/code_mower/builder_experiment.py", "core"), ("src/code_mower/work_orders.py", "src/code_mower/work_orders.py", "core"), @@ -362,6 +361,7 @@ ("tools/blind_review_artifacts.py", "src/code_mower/blind_review_artifacts.py", "core"), ("tools/audit_handoff_log.py", "src/code_mower/audit_handoff_log.py", "core"), ("tools/audit_labeler_lib.py", "src/code_mower/audit_labeler_lib.py", "core"), + ("tools/builder_lineage.py", "src/code_mower/builder_lineage.py", "core"), ("tools/audit_limits.py", "src/code_mower/audit_limits.py", "core"), ("tools/audit_progress.py", "src/code_mower/audit_progress.py", "core"), ( diff --git a/src/code_mower/templates/workflows/code-mower-gate.yml.j2 b/src/code_mower/templates/workflows/code-mower-gate.yml.j2 index 278cb089..cf479b5b 100644 --- a/src/code_mower/templates/workflows/code-mower-gate.yml.j2 +++ b/src/code_mower/templates/workflows/code-mower-gate.yml.j2 @@ -285,10 +285,12 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ attested_non_current_audit_heads, audit_run_in_flight_detail_for_lane, audit_verdict_newer_than_in_flight, + episodes_from_comment_body, flatten_paginated_items, github_actions_comment_attested, latest_current_audit_verdict, latest_current_audit_verdict_detail, + resolve_builder_lineage, ) except ImportError: # pragma: no cover - unit-test/package fallback from code_mower.context_review import required_for_checkout @@ -297,12 +299,16 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ attested_non_current_audit_heads, audit_run_in_flight_detail_for_lane, audit_verdict_newer_than_in_flight, + episodes_from_comment_body, flatten_paginated_items, github_actions_comment_attested, latest_current_audit_verdict, latest_current_audit_verdict_detail, + resolve_builder_lineage, ) + LINEAGE_MARKER = "CODE_MOWER_BUILDER_LINEAGE" + # github_actions_comment_attested verifies the hidden CODE_MOWER_AUDIT_RUN marker # and requires comment_id/body_sha256 to match the issue comment being evaluated. def emit(state, description): @@ -431,16 +437,37 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ context_required=context_required, ) - builder_matches = [] - if isinstance(exclusion, dict) and exclusion.get("enabled"): - label_map = mapping("labels") - for label in labels: - if label in label_map: - builder_matches.append(str(label_map[label])) - author_map = {str(k).lower(): str(v) for k, v in mapping("authors").items()} - if pr_author.lower() in author_map: - builder_matches.append(author_map[pr_author.lower()]) - builder_matches = list(dict.fromkeys(builder_matches)) + # Contribution lineage, not one label and one author. Episodes are + # read only from comments this gate already trusts, so publishing a + # marker is a transport and never an authorization. The resolver is + # the same one the runner and the reviewer wrappers use. + lineage_episodes = [] + lineage_readable = True + for comment in comments: + comment_body = str(comment.get("body") or "") + if LINEAGE_MARKER not in comment_body: + continue + comment_author = str(((comment.get("user") or {}).get("login")) or "") + if not any( + trusted_comment_author(lane, comment_author, comment_body, comment.get("id")) + for lane in lanes + ): + continue + try: + lineage_episodes.extend(episodes_from_comment_body(comment_body)) + except Exception: + lineage_readable = False + builder_lineage = resolve_builder_lineage( + labels=sorted(labels), + author=pr_author, + config=exclusion if isinstance(exclusion, dict) else {"enabled": False}, + repo=os.environ.get("GITHUB_REPOSITORY", ""), + pr_number=pr_number, + branch=str(((pr_payload.get("head") or {}).get("ref")) or ""), + head_sha=head_sha, + episodes=lineage_episodes, + ) + builder_matches = list(builder_lineage.contributors) def lane_display(lane): return str(lane.get("display_name") or lane.get("id") or lane.get("done") or "") @@ -530,8 +557,12 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ ) ) - if len(builder_matches) > 1: - emit("failure", "conflicting Code Mower builder identity") + if not lineage_readable: + emit("failure", "Code Mower builder contribution evidence is unreadable") + elif builder_lineage.status == "conflict": + emit("failure", "conflicting Code Mower builder identity: " + builder_lineage.owner_action) + elif builder_lineage.status == "waiting": + emit("pending", "waiting for builder lineage: " + builder_lineage.owner_action) elif owner_label in labels: emit("pending", owner_label + ": waiting on owner") elif owner_sitting_label and owner_sitting_label in labels: @@ -547,11 +578,13 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ elif blocked: emit("failure", "blocked audit: " + ", ".join(blocked)) else: - excluded_builder = builder_matches[0] if builder_matches else "" + # Every verified contributor to this head is excluded, not just + # the one the active label happens to name. + excluded_builders = set(builder_matches) required = [ lane for lane in lanes - if str(lane.get("author_lane") or lane.get("id") or "") != excluded_builder + if str(lane.get("author_lane") or lane.get("id") or "") not in excluded_builders ] in_flight = in_flight_lanes(required) missing = [ @@ -561,7 +594,7 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ or latest_current_verdict(lane) != "done" ] if not required: - if excluded_builder: + if excluded_builders: emit("failure", "no independent Code Mower audit lane remains") else: emit("failure", "no Code Mower merge-authority lanes configured") diff --git a/templates/workflows/code-mower-gate.yml.j2 b/templates/workflows/code-mower-gate.yml.j2 index 278cb089..cf479b5b 100644 --- a/templates/workflows/code-mower-gate.yml.j2 +++ b/templates/workflows/code-mower-gate.yml.j2 @@ -285,10 +285,12 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ attested_non_current_audit_heads, audit_run_in_flight_detail_for_lane, audit_verdict_newer_than_in_flight, + episodes_from_comment_body, flatten_paginated_items, github_actions_comment_attested, latest_current_audit_verdict, latest_current_audit_verdict_detail, + resolve_builder_lineage, ) except ImportError: # pragma: no cover - unit-test/package fallback from code_mower.context_review import required_for_checkout @@ -297,12 +299,16 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ attested_non_current_audit_heads, audit_run_in_flight_detail_for_lane, audit_verdict_newer_than_in_flight, + episodes_from_comment_body, flatten_paginated_items, github_actions_comment_attested, latest_current_audit_verdict, latest_current_audit_verdict_detail, + resolve_builder_lineage, ) + LINEAGE_MARKER = "CODE_MOWER_BUILDER_LINEAGE" + # github_actions_comment_attested verifies the hidden CODE_MOWER_AUDIT_RUN marker # and requires comment_id/body_sha256 to match the issue comment being evaluated. def emit(state, description): @@ -431,16 +437,37 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ context_required=context_required, ) - builder_matches = [] - if isinstance(exclusion, dict) and exclusion.get("enabled"): - label_map = mapping("labels") - for label in labels: - if label in label_map: - builder_matches.append(str(label_map[label])) - author_map = {str(k).lower(): str(v) for k, v in mapping("authors").items()} - if pr_author.lower() in author_map: - builder_matches.append(author_map[pr_author.lower()]) - builder_matches = list(dict.fromkeys(builder_matches)) + # Contribution lineage, not one label and one author. Episodes are + # read only from comments this gate already trusts, so publishing a + # marker is a transport and never an authorization. The resolver is + # the same one the runner and the reviewer wrappers use. + lineage_episodes = [] + lineage_readable = True + for comment in comments: + comment_body = str(comment.get("body") or "") + if LINEAGE_MARKER not in comment_body: + continue + comment_author = str(((comment.get("user") or {}).get("login")) or "") + if not any( + trusted_comment_author(lane, comment_author, comment_body, comment.get("id")) + for lane in lanes + ): + continue + try: + lineage_episodes.extend(episodes_from_comment_body(comment_body)) + except Exception: + lineage_readable = False + builder_lineage = resolve_builder_lineage( + labels=sorted(labels), + author=pr_author, + config=exclusion if isinstance(exclusion, dict) else {"enabled": False}, + repo=os.environ.get("GITHUB_REPOSITORY", ""), + pr_number=pr_number, + branch=str(((pr_payload.get("head") or {}).get("ref")) or ""), + head_sha=head_sha, + episodes=lineage_episodes, + ) + builder_matches = list(builder_lineage.contributors) def lane_display(lane): return str(lane.get("display_name") or lane.get("id") or lane.get("done") or "") @@ -530,8 +557,12 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ ) ) - if len(builder_matches) > 1: - emit("failure", "conflicting Code Mower builder identity") + if not lineage_readable: + emit("failure", "Code Mower builder contribution evidence is unreadable") + elif builder_lineage.status == "conflict": + emit("failure", "conflicting Code Mower builder identity: " + builder_lineage.owner_action) + elif builder_lineage.status == "waiting": + emit("pending", "waiting for builder lineage: " + builder_lineage.owner_action) elif owner_label in labels: emit("pending", owner_label + ": waiting on owner") elif owner_sitting_label and owner_sitting_label in labels: @@ -547,11 +578,13 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ elif blocked: emit("failure", "blocked audit: " + ", ".join(blocked)) else: - excluded_builder = builder_matches[0] if builder_matches else "" + # Every verified contributor to this head is excluded, not just + # the one the active label happens to name. + excluded_builders = set(builder_matches) required = [ lane for lane in lanes - if str(lane.get("author_lane") or lane.get("id") or "") != excluded_builder + if str(lane.get("author_lane") or lane.get("id") or "") not in excluded_builders ] in_flight = in_flight_lanes(required) missing = [ @@ -561,7 +594,7 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ or latest_current_verdict(lane) != "done" ] if not required: - if excluded_builder: + if excluded_builders: emit("failure", "no independent Code Mower audit lane remains") else: emit("failure", "no Code Mower merge-authority lanes configured") diff --git a/tools/audit_labeler_lib.py b/tools/audit_labeler_lib.py index 797a21f9..3cacbe20 100644 --- a/tools/audit_labeler_lib.py +++ b/tools/audit_labeler_lib.py @@ -26,12 +26,36 @@ try: from . import decisions as code_mower_decisions from . import context_review as code_mower_context_review + from .builder_lineage import ( + Lineage, + LineageError, + episodes_from_comment_body, + lanes_from_identity, + resolve_identity_only, + resolve_lineage, + ) except ImportError: # pragma: no cover - copied tools fallback import decisions as code_mower_decisions # type: ignore import context_review as code_mower_context_review # type: ignore + from builder_lineage import ( # type: ignore + Lineage, + LineageError, + episodes_from_comment_body, + lanes_from_identity, + resolve_identity_only, + resolve_lineage, + ) else: # pragma: no cover - direct helper execution import decisions as code_mower_decisions # type: ignore import context_review as code_mower_context_review # type: ignore + from builder_lineage import ( # type: ignore + Lineage, + LineageError, + episodes_from_comment_body, + lanes_from_identity, + resolve_identity_only, + resolve_lineage, + ) MIN_ABBREVIATED_SHA_LENGTH = 7 AUTHOR_EXCLUSION_ENV = "CODE_MOWER_AUTHOR_EXCLUSION_JSON" @@ -196,32 +220,83 @@ def load_author_exclusion_config(raw: str | None = None) -> Mapping[str, Any]: return parsed if isinstance(parsed, Mapping) else {"enabled": False} +def resolve_builder_lineage( + *, + labels: Sequence[str], + author: str, + config: Mapping[str, Any], + repo: str = "", + pr_number: Any = 0, + branch: str = "", + head_sha: str = "", + episodes: Sequence[Any] = (), +) -> Lineage: + """Resolve the shared builder lineage from labeler-side evidence. + + Labelers see labels and an author for certain, and may or may not have the + exact head plus published contribution episodes to hand. Without exact-head + evidence there is no takeover to resolve, so the result is the ordinary + single-builder decision; with it, the same resolver the gate, the runner + and the reviewer wrappers use answers the question. + """ + + opener_lane, label_lanes = lanes_from_identity( + identity=config, labels=labels, author=author + ) + if not (head_sha and repo and pr_number): + return resolve_identity_only(opener_lane=opener_lane, label_lanes=label_lanes) + return resolve_lineage( + repo=repo, + pr_number=pr_number, + branch=branch, + head_sha=head_sha, + episodes=episodes, + opener_lane=opener_lane, + label_lanes=label_lanes, + ) + + def builder_identity_matches( *, labels: Sequence[str], author: str, text: str, config: Mapping[str, Any], + repo: str = "", + pr_number: Any = 0, + branch: str = "", + head_sha: str = "", + episodes: Sequence[Any] = (), ) -> tuple[str, ...]: - if not bool(config.get("enabled")): - return () - label_map = _string_mapping(config.get("labels")) - author_map = { - key.lower(): value - for key, value in _string_mapping(config.get("authors")).items() - } - matches: list[str] = [] + """Ordered verified builder lanes for this pull request. - for label in labels: - lane = label_map.get(str(label)) - if lane: - matches.append(lane) + ``text`` is retained for call compatibility and is deliberately unused: + freeform prose is not contribution evidence. + """ - lane = author_map.get(author.lower()) - if lane: - matches.append(lane) - - return tuple(dict.fromkeys(matches)) + if not bool(config.get("enabled")): + return () + lineage = resolve_builder_lineage( + labels=labels, + author=author, + config=config, + repo=repo, + pr_number=pr_number, + branch=branch, + head_sha=head_sha, + episodes=episodes, + ) + if lineage.status != "resolved": + # Preserve the historical "more than one identity" shape so a caller + # inspecting matches can still tell a conflict from a clean match. + opener_lane, label_lanes = lanes_from_identity( + identity=config, labels=labels, author=author + ) + candidates = tuple( + dict.fromkeys(list(label_lanes) + ([opener_lane] if opener_lane else [])) + ) + return candidates if len(candidates) > 1 else lineage.contributors + return lineage.contributors def author_exclusion_reason( @@ -231,20 +306,40 @@ def author_exclusion_reason( author: str, text: str, config: Mapping[str, Any] | None = None, + repo: str = "", + pr_number: Any = 0, + branch: str = "", + head_sha: str = "", + episodes: Sequence[Any] = (), ) -> str | None: + """Why ``lane_name`` may not label its own work, or ``None``. + + Exclusion follows verified contribution, not authorship: every contributing + lane is excluded, and a lane that merely opened the PR before handing it + over is excluded too because its commits are still in the diff. + """ + exclusion_config = config or load_author_exclusion_config() - matches = builder_identity_matches( - labels=labels, - author=author, - text=text, - config=exclusion_config, - ) - if not matches: + if not bool(exclusion_config.get("enabled")): return None - if len(set(matches)) > 1: + try: + lineage = resolve_builder_lineage( + labels=labels, + author=author, + config=exclusion_config, + repo=repo, + pr_number=pr_number, + branch=branch, + head_sha=head_sha, + episodes=episodes, + ) + except LineageError: + return "builder contribution evidence is unreadable; skipping author-excluded label update" + if lineage.status == "conflict": return "conflicting builder identity; skipping author-excluded label update" - builder_lane = matches[0] - if builder_lane == lane_name: + if lineage.status == "waiting": + return "builder contribution lineage is behind the current head; skipping author-excluded label update" + if lineage.contributed(lane_name): return f"{lane_name} lane excluded for builder-authored PR" return None diff --git a/tools/builder_lineage.py b/tools/builder_lineage.py new file mode 100644 index 00000000..50a1d897 --- /dev/null +++ b/tools/builder_lineage.py @@ -0,0 +1,670 @@ +"""Exact-head builder contribution lineage after a verified handoff. + +A pull request can be built by more than one Code Mower builder lane. The +opener, the branch prefix and the single active ``builder:*`` label each +describe at most one of those lanes, so any of them alone will misdescribe a +PR that changed hands. This module keeps the ordered contribution history +instead, and derives the one current writer from it. + +Trust rules this module exists to enforce: + +* A contribution episode is evidence produced by the verified handoff and + delivery path (see :mod:`code_mower.lane_handoff`), which observed the source + writer going quiescent and observed both heads. A caller-supplied boolean, a + PR body marker, a commit trailer, the PR opener or the most recent label are + none of them able to attest that a takeover happened. +* Episodes are bound to repository, pull request, branch, source lane, + destination lane, expected head and resulting head. An episode that does not + bind to the pull request under decision is not evidence about it. +* Resolution is exact-head. Lineage that stops short of the current head is + *waiting*, never a guess about who wrote the current diff. +* Conflicting, duplicated, unchained or unbound evidence fails closed with one + concise owner action rather than picking a winner. + +Everything here is metadata-only: lane names, a repository slug, a PR number, a +branch name and commit shas. No source, diffs, prompts, transcripts, paths, +provider references or credentials pass through this module, so a resolved +lineage is safe for the existing public/cloud metadata contract. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Mapping, Sequence + + +SCHEMA = "code_mower.builderLineage.v1" +EPISODE_SCHEMA = "code_mower.contributionEpisode.v1" +RECORD_SCHEMA = "code_mower.builderLineageRecord.v1" + +#: Hidden marker used to publish bounded lineage metadata on a pull request. +#: Only comments from an already trusted author are parsed; the marker is a +#: transport, never an authorization. +LINEAGE_MARKER = "CODE_MOWER_BUILDER_LINEAGE" +LINEAGE_MARKER_RE = re.compile( + r"", + re.DOTALL, +) + +LANE_RE = re.compile(r"[a-z0-9][a-z0-9_-]{0,39}\Z") +SHA_RE = re.compile(r"[0-9a-f]{40}\Z") +REPO_RE = re.compile(r"[A-Za-z0-9._-]{1,100}/[A-Za-z0-9._-]{1,100}\Z") +BRANCH_RE = re.compile(r"[A-Za-z0-9._/-]{1,200}\Z") + +#: Verified writer states the handoff boundary is allowed to report. Anything +#: else (including a missing or "unknown" state) is uncertainty. +WRITER_STATES = frozenset({"suspended", "terminated"}) + +#: A lineage longer than this is treated as malformed rather than walked. +MAX_EPISODES = 32 + +EPISODE_FIELDS = ( + "schema", + "sequence", + "repo", + "pr_number", + "branch", + "source_lane", + "destination_lane", + "expected_head", + "resulting_head", + "writer_state", +) + + +class LineageError(ValueError): + """Raised when contribution evidence cannot be accepted as recorded.""" + + +def _text(value: Any) -> str: + return str(value if value is not None else "").strip() + + +def _lane(value: Any) -> str: + lane = _text(value).lower() + return lane if LANE_RE.match(lane) else "" + + +def _sha(value: Any) -> str: + sha = _text(value).lower() + return sha if SHA_RE.match(sha) else "" + + +def _pr_number(value: Any) -> int: + if isinstance(value, bool) or not isinstance(value, (int, str)): + return 0 + try: + number = int(value) + except (TypeError, ValueError): + return 0 + return number if 0 < number <= 2**31 - 1 else 0 + + +@dataclass(frozen=True) +class ContributionEpisode: + """One verified builder contribution bound to an exact head transition. + + ``expected_head`` is the head the source lane left behind and the + destination lane was launched against; ``resulting_head`` is the head the + destination lane actually produced. A destination that never moved the head + is still the writer, but it contributed nothing to the current diff. + """ + + sequence: int + repo: str + pr_number: int + branch: str + source_lane: str + destination_lane: str + expected_head: str + resulting_head: str + writer_state: str + + def __post_init__(self) -> None: + if ( + isinstance(self.sequence, bool) + or not isinstance(self.sequence, int) + or not 1 <= self.sequence <= MAX_EPISODES + or not REPO_RE.match(_text(self.repo)) + or _pr_number(self.pr_number) != self.pr_number + or not BRANCH_RE.match(_text(self.branch)) + or not LANE_RE.match(_text(self.source_lane)) + or not LANE_RE.match(_text(self.destination_lane)) + or self.source_lane == self.destination_lane + or not SHA_RE.match(_text(self.expected_head)) + or not SHA_RE.match(_text(self.resulting_head)) + or self.writer_state not in WRITER_STATES + ): + raise LineageError("contribution episode is malformed") + + @property + def moved_head(self) -> bool: + return self.expected_head != self.resulting_head + + def as_dict(self) -> dict[str, Any]: + return { + "schema": EPISODE_SCHEMA, + "sequence": self.sequence, + "repo": self.repo, + "pr_number": self.pr_number, + "branch": self.branch, + "source_lane": self.source_lane, + "destination_lane": self.destination_lane, + "expected_head": self.expected_head, + "resulting_head": self.resulting_head, + "writer_state": self.writer_state, + } + + +def episode_from_mapping(payload: Mapping[str, Any]) -> ContributionEpisode: + """Parse one episode strictly. Unknown or missing fields are malformed.""" + + if not isinstance(payload, Mapping) or set(payload) != set(EPISODE_FIELDS): + raise LineageError("contribution episode is malformed") + if payload.get("schema") != EPISODE_SCHEMA: + raise LineageError("contribution episode schema is unsupported") + repo = _text(payload.get("repo")) + return ContributionEpisode( + sequence=payload.get("sequence"), # type: ignore[arg-type] + repo=repo, + pr_number=_pr_number(payload.get("pr_number")), + branch=_text(payload.get("branch")), + source_lane=_lane(payload.get("source_lane")), + destination_lane=_lane(payload.get("destination_lane")), + expected_head=_sha(payload.get("expected_head")), + resulting_head=_sha(payload.get("resulting_head")), + writer_state=_text(payload.get("writer_state")).lower(), + ) + + +def episode_from_handoff( + handoff: Mapping[str, Any] | Any, + *, + resulting_head: str, + writer_state: str, + sequence: int, + repo: str = "", +) -> ContributionEpisode: + """Build an episode from an accepted handoff plus its delivered head. + + ``handoff`` is the ``Handoff`` record (or its ``as_dict``) that + :mod:`code_mower.lane_handoff` accepted, so repository, PR, branch, lanes + and expected head all come from evidence the handoff boundary verified. + ``resulting_head`` is the delivery-side attestation of what the destination + lane actually produced; it is deliberately not part of the handoff record, + which is written before the destination lane has written anything. + """ + + record = handoff if isinstance(handoff, Mapping) else handoff.as_dict() + target_pr = _text(record.get("target_pr")) + if "#" not in target_pr: + raise LineageError("contribution episode is malformed") + pr_repo, _, pr_number = target_pr.partition("#") + if repo and _text(repo).lower() != pr_repo.lower(): + raise LineageError("contribution episode does not bind to the repository under work") + return ContributionEpisode( + sequence=sequence, + repo=pr_repo, + pr_number=_pr_number(pr_number), + branch=_text(record.get("target_branch")), + source_lane=_lane(record.get("source_lane")), + destination_lane=_lane(record.get("destination_lane")), + expected_head=_sha(record.get("expected_head")), + resulting_head=_sha(resulting_head), + writer_state=_text(writer_state).lower(), + ) + + +@dataclass(frozen=True) +class Lineage: + """The resolved answer for one pull request at one exact head. + + ``status`` is ``resolved`` only when the evidence covers the current head + without contradiction. ``waiting`` and ``conflict`` both carry a concise + ``owner_action`` and never imply a writer. + """ + + status: str + reason: str + head_sha: str + contributors: tuple[str, ...] + current_writer: str + builder_label: str + stale_builder_labels: tuple[str, ...] + evidence: str + episodes: int + owner_action: str = "" + + @property + def resolved(self) -> bool: + return self.status == "resolved" + + def contributed(self, lane: str) -> bool: + """Whether ``lane`` is a verified contributor to the current diff.""" + + return _lane(lane) in self.contributors + + def independent(self, lane: str) -> bool: + """Whether ``lane`` may satisfy an independent review requirement. + + Independence is a separate decision from role eligibility: a qualified + reviewer lane that contributed to this diff is still not independent of + it, and an unqualified lane is not made eligible by being independent. + Unresolved lineage is never independence. + """ + + return self.resolved and bool(_lane(lane)) and not self.contributed(lane) + + def admission(self, lane: str) -> dict[str, Any]: + """A closed, metadata-only admission decision for one reviewer lane.""" + + candidate = _lane(lane) + if not candidate: + admitted, reason = False, "reviewer_lane_invalid" + elif not self.resolved: + admitted, reason = False, "lineage_" + self.status + elif self.contributed(candidate): + admitted, reason = False, "contributor_not_independent" + else: + admitted, reason = True, "independent" + return { + "schema": SCHEMA, + "lane": candidate, + "admitted": admitted, + "reason": reason, + "head_sha": self.head_sha, + "contributors": list(self.contributors), + "current_writer": self.current_writer, + "owner_action": "" if admitted else (self.owner_action or _ADMISSION_ACTIONS[reason]), + } + + def independent_lanes(self, lanes: Iterable[str]) -> tuple[str, ...]: + return tuple(lane for lane in lanes if self.independent(lane)) + + def as_dict(self) -> dict[str, Any]: + """Bounded metadata for Board/status projection and public rendering.""" + + return { + "schema": SCHEMA, + "status": self.status, + "reason": self.reason, + "head_sha": self.head_sha, + "contributors": list(self.contributors), + "current_writer": self.current_writer, + "builder_label": self.builder_label, + "stale_builder_labels": list(self.stale_builder_labels), + "evidence": self.evidence, + "episodes": self.episodes, + "owner_action": self.owner_action, + } + + +_ADMISSION_ACTIONS = { + "reviewer_lane_invalid": "name the reviewer lane requesting admission", + "contributor_not_independent": ( + "select a reviewer lane that did not contribute to this head" + ), + "lineage_waiting": "re-record builder contribution lineage for the current head", + "lineage_conflict": "resolve the conflicting builder contribution evidence", +} + +_OWNER_ACTIONS = { + "lineage_behind_head": ( + "recorded builder lineage stops before the current head; " + "re-record the contribution episode for this head" + ), + "episode_malformed": ( + "a recorded contribution episode is malformed; re-record it from the " + "verified handoff" + ), + "episode_unbound": ( + "a recorded contribution episode does not bind to this repository, " + "pull request or branch; re-record it against this pull request" + ), + "episode_duplicated": ( + "two different contribution episodes claim the same position; " + "re-record the lineage for this pull request" + ), + "episode_unchained": ( + "recorded contribution episodes do not form one head-to-head chain; " + "re-record the lineage from the verified handoff" + ), + "writer_state_unverified": ( + "a recorded contribution episode has no verified source writer state; " + "re-run the handoff boundary before recording it" + ), + "opener_outside_lineage": ( + "the pull request opener is not part of the recorded lineage; " + "record the opening contribution or correct the lineage" + ), + "label_outside_lineage": ( + "an active builder label names a lane with no recorded contribution; " + "remove it or record the missing contribution" + ), + "conflicting_builder_identity": ( + "builder author and label evidence disagree and no verified handoff " + "explains the change; record the handoff or correct the identity" + ), + "target_invalid": ( + "the pull request target could not be identified; supply repository, " + "number, branch and the exact head" + ), +} + + +def _lineage( + status: str, + reason: str, + *, + head_sha: str = "", + contributors: Sequence[str] = (), + current_writer: str = "", + stale: Sequence[str] = (), + evidence: str = "none", + episodes: int = 0, +) -> Lineage: + return Lineage( + status=status, + reason=reason, + head_sha=head_sha, + contributors=tuple(contributors), + current_writer=current_writer, + builder_label=f"builder:{current_writer}" if current_writer else "", + stale_builder_labels=tuple(dict.fromkeys(stale)), + evidence=evidence, + episodes=episodes, + owner_action="" if status == "resolved" else _OWNER_ACTIONS.get(reason, ""), + ) + + +def resolve_lineage( + *, + repo: str, + pr_number: Any, + branch: str, + head_sha: str, + episodes: Sequence[Mapping[str, Any] | ContributionEpisode] = (), + opener_lane: str = "", + label_lanes: Sequence[str] = (), +) -> Lineage: + """Resolve who built the diff at ``head_sha``. + + ``episodes`` is verified handoff/delivery evidence. ``opener_lane`` and + ``label_lanes`` are the weak signals the rest of the system used to carry + on their own: they are accepted here only as corroboration, and they can + fail the resolution closed, but neither can establish a takeover. + """ + + target_repo = _text(repo) + number = _pr_number(pr_number) + head = _sha(head_sha) + target_branch = _text(branch) + if not REPO_RE.match(target_repo) or not number or not head: + return _lineage("conflict", "target_invalid", head_sha=head) + + opener = _lane(opener_lane) + labels = tuple(dict.fromkeys(lane for lane in (_lane(item) for item in label_lanes) if lane)) + + parsed: list[ContributionEpisode] = [] + for item in episodes: + try: + episode = item if isinstance(item, ContributionEpisode) else episode_from_mapping(item) + except LineageError: + return _lineage("conflict", "episode_malformed", head_sha=head) + if ( + episode.repo.lower() != target_repo.lower() + or episode.pr_number != number + or (target_branch and episode.branch != target_branch) + ): + return _lineage("conflict", "episode_unbound", head_sha=head) + if episode.writer_state not in WRITER_STATES: + return _lineage("conflict", "writer_state_unverified", head_sha=head) + parsed.append(episode) + if len(parsed) > MAX_EPISODES: + return _lineage("conflict", "episode_malformed", head_sha=head) + + if not parsed: + return resolve_identity_only(opener_lane=opener, label_lanes=labels, head_sha=head) + + ordered = sorted(parsed, key=lambda episode: episode.sequence) + seen: dict[int, ContributionEpisode] = {} + for episode in ordered: + previous = seen.get(episode.sequence) + if previous is not None: + if previous.as_dict() != episode.as_dict(): + return _lineage("conflict", "episode_duplicated", head_sha=head) + continue + seen[episode.sequence] = episode + ordered = [seen[sequence] for sequence in sorted(seen)] + if [episode.sequence for episode in ordered] != list(range(1, len(ordered) + 1)): + return _lineage("conflict", "episode_unchained", head_sha=head) + + contributors: list[str] = [ordered[0].source_lane] + for index, episode in enumerate(ordered): + if index and ( + episode.expected_head != ordered[index - 1].resulting_head + or episode.source_lane != ordered[index - 1].destination_lane + ): + return _lineage("conflict", "episode_unchained", head_sha=head) + if episode.moved_head: + contributors.append(episode.destination_lane) + contributors = list(dict.fromkeys(contributors)) + writer = ordered[-1].destination_lane + + if ordered[-1].resulting_head != head: + # The lineage may be stale, or history may have been rewritten under + # it. Either way it does not describe the diff being decided on. + return _lineage( + "waiting", + "lineage_behind_head", + head_sha=head, + evidence="handoff_episodes", + episodes=len(ordered), + ) + if opener and opener not in contributors and opener != writer: + return _lineage( + "conflict", "opener_outside_lineage", head_sha=head, + evidence="handoff_episodes", episodes=len(ordered), + ) + known = set(contributors) | {writer} + if any(lane not in known for lane in labels): + return _lineage( + "conflict", "label_outside_lineage", head_sha=head, + evidence="handoff_episodes", episodes=len(ordered), + ) + return _lineage( + "resolved", + "verified_handoff" if len(ordered) > 1 else "verified_takeover", + head_sha=head, + contributors=contributors, + current_writer=writer, + stale=[lane for lane in labels if lane != writer], + evidence="handoff_episodes", + episodes=len(ordered), + ) + + +def resolve_identity_only( + *, opener_lane: str = "", label_lanes: Sequence[str] = (), head_sha: str = "" +) -> Lineage: + """The ordinary single-builder case, and the #959 shape without evidence. + + With no verified handoff there is exactly one consistent story available: + one lane opened the PR and still holds the only builder label. Any other + combination is the inconsistency this issue exists to stop guessing about, + so it fails closed instead of preferring the opener or the newest label. + """ + + head = _sha(head_sha) + opener = _lane(opener_lane) + labels = tuple(dict.fromkeys(lane for lane in (_lane(item) for item in label_lanes) if lane)) + candidates = tuple(dict.fromkeys(([opener] if opener else []) + list(labels))) + if len(candidates) > 1: + return _lineage("conflict", "conflicting_builder_identity", head_sha=head) + if not candidates: + return _lineage("resolved", "no_builder_identity", head_sha=head, evidence="none") + lane = candidates[0] + return _lineage( + "resolved", + "single_builder", + head_sha=head, + contributors=(lane,), + current_writer=lane, + evidence="single_builder", + ) + + +def lanes_from_identity( + *, + identity: Mapping[str, Any] | None, + labels: Sequence[str] = (), + author: str = "", +) -> tuple[str, tuple[str, ...]]: + """Map GitHub labels and a PR author onto builder lane names. + + ``identity`` is the existing author-exclusion contract + (``{"enabled": bool, "labels": {...}, "authors": {...}}``) so lane naming + stays in one place. Returns ``(opener_lane, label_lanes)``; both are weak + signals that :func:`resolve_lineage` may reject. + """ + + if not isinstance(identity, Mapping) or not identity.get("enabled"): + return "", () + label_map = identity.get("labels") + author_map = identity.get("authors") + label_map = label_map if isinstance(label_map, Mapping) else {} + author_map = author_map if isinstance(author_map, Mapping) else {} + label_lanes = tuple( + dict.fromkeys( + lane + for lane in (_lane(label_map.get(_text(label))) for label in labels) + if lane + ) + ) + lowered = {_text(key).lower(): value for key, value in author_map.items()} + return _lane(lowered.get(_text(author).lower())), label_lanes + + +# --- durable metadata-only record ------------------------------------------- + + +def _store(root: Path): + """Import the private context store lazily. + + The resolver above is pure and is copied into environments (the generated + gate, mirrored tooling) that have no private state directory at all. Only + the recording side needs the store, so only it pays for the dependency. + """ + + from .context_store import ContextStore + + return ContextStore(Path(root)) + + +def pr_key(repo: str, pr_number: Any) -> str: + seed = json.dumps([_text(repo).lower(), _pr_number(pr_number)], sort_keys=True) + return "l" + hashlib.sha256(seed.encode()).hexdigest()[:62] + + +def record_episode(root: Path, episode: ContributionEpisode) -> dict[str, Any]: + """Append one verified episode. Replay is a no-op, never a duplicate. + + Recording is refused when the new episode does not chain onto the recorded + lineage, so a mismatched or reordered write is an owner action at the point + it is attempted rather than an ambiguity discovered at review time. + """ + + if not isinstance(episode, ContributionEpisode): + raise LineageError("contribution episode is malformed") + with _store(root).locked(pr_key(episode.repo, episode.pr_number)) as locked: + record = locked.read() or { + "schema": RECORD_SCHEMA, + "repo": episode.repo, + "pr_number": episode.pr_number, + "episodes": [], + } + if record.get("schema") != RECORD_SCHEMA: + raise LineageError("recorded builder lineage is unreadable") + recorded = list(record.get("episodes") or []) + payload = episode.as_dict() + for existing in recorded: + if existing.get("sequence") == episode.sequence: + if existing != payload: + raise LineageError( + "a different contribution episode is already recorded at this position" + ) + return {"recorded": False, "duplicate": True, "episodes": len(recorded)} + if len(recorded) >= MAX_EPISODES: + raise LineageError("recorded builder lineage is already at its bound") + if episode.sequence != len(recorded) + 1: + raise LineageError("contribution episode is out of order for this pull request") + if recorded: + previous = recorded[-1] + if ( + previous.get("resulting_head") != episode.expected_head + or previous.get("destination_lane") != episode.source_lane + or previous.get("branch") != episode.branch + ): + raise LineageError( + "contribution episode does not chain onto the recorded lineage" + ) + recorded.append(payload) + record["episodes"] = recorded + locked.write(record) + return {"recorded": True, "duplicate": False, "episodes": len(recorded)} + + +def load_episodes(root: Path, repo: str, pr_number: Any) -> tuple[ContributionEpisode, ...]: + """Read back recorded episodes. Unreadable evidence raises, never guesses.""" + + path = Path(root) + if not path.exists(): + return () + with _store(path).locked(pr_key(repo, pr_number)) as locked: + record = locked.read() + if record is None: + return () + if record.get("schema") != RECORD_SCHEMA: + raise LineageError("recorded builder lineage is unreadable") + return tuple( + episode_from_mapping(item) for item in (record.get("episodes") or []) + ) + + +# --- bounded public transport ------------------------------------------------ + + +def lineage_comment_marker(episodes: Sequence[ContributionEpisode]) -> str: + """Render episodes as one hidden, metadata-only pull request marker.""" + + payload = { + "schema": SCHEMA, + "episodes": [episode.as_dict() for episode in episodes][:MAX_EPISODES], + } + return f"" + + +def episodes_from_comment_body(body: str) -> tuple[ContributionEpisode, ...]: + """Parse lineage markers out of one already trusted comment body. + + The caller decides trust. This function never treats the presence of a + marker as evidence that its author was allowed to publish one. + """ + + episodes: list[ContributionEpisode] = [] + for match in LINEAGE_MARKER_RE.finditer(_text(body)[:MAX_EPISODES * 2048]): + try: + payload = json.loads(match.group("payload")) + except (ValueError, RecursionError): + raise LineageError("published builder lineage is unreadable") from None + if not isinstance(payload, Mapping) or payload.get("schema") != SCHEMA: + raise LineageError("published builder lineage schema is unsupported") + items = payload.get("episodes") + if not isinstance(items, list) or len(items) > MAX_EPISODES: + raise LineageError("published builder lineage is unreadable") + episodes.extend(episode_from_mapping(item) for item in items) + return tuple(episodes) From 420198a477927e6c75e17500fd63469f3f8a95e5 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 09:05:38 -0700 Subject: [PATCH 03/25] Record delivery provenance and reconcile the active builder label The resolver landed with no producer: contribution episodes existed as a type, and every consumer that could have read one was still deciding from the PR opener or the single active label. This connects both ends. Producing side. `lane_handoff.record_contribution` is now the only writer of contribution lineage, and it writes only from evidence the handoff boundary already verified: repository, PR, branch, lanes and expected head from the accepted handoff, the source writer state from the acceptance record rather than the caller, and the resulting head from a fresh observation checked against what the runner reported. It refuses without a verified acceptance and a reserved launch, so a caller that merely names a handoff records nothing. Episodes live beside the intent store, never in it, so the private source binding cannot reach a record that reviewer admission and the public projection read. `lane-delivery classify --handoff-state-dir` records after a validated delivery; `lane-delivery lineage --reconcile-labels` moves the active builder label to the verified current writer, rechecking the exact head on both sides of the mutation and failing closed on either move. The maintained and generated Mac runners call both. Consuming side. Controller reviewer selection reads the resolved lineage instead of one label-derived lane: every verified contributor is excluded, role eligibility is consulted as a separate decision, and a head with no qualified independent reviewer left blocks with one owner action rather than merging on a reviewer that may have written the diff. Lane status resolves the lineage from the durable record; the Board carries the status and contributor lane names as bounded metadata, and the cloud contract is unchanged. The direct reviewer wrappers now load actual recorded evidence instead of the resolver's empty default, and `devin_review.ReviewInput.check` consults the same seam rather than its author deny list alone. A missing or malformed identity file no longer makes a contributing reviewer admissible: each wrapper names its own lane's label and accounts as a floor, which can only add exclusion. Init's exclusion payload carries the narrow resolver context the generated gate and labelers need -- branch identity per lane, and whether verified lineage is required rather than an identity-only answer. Refs #963 Co-Authored-By: Claude Opus 5 (1M context) --- src/code_mower/board.py | 18 ++ src/code_mower/builder_lineage.py | 171 +++++++++++++++++- src/code_mower/claude_audit_pr.py | 25 ++- src/code_mower/codex_audit_pr.py | 26 ++- src/code_mower/controller.py | 152 +++++++++++++--- src/code_mower/devin_cli_audit_pr.py | 34 ++-- src/code_mower/devin_review.py | 31 ++++ src/code_mower/init.py | 14 ++ src/code_mower/lane_delivery.py | 137 +++++++++++++- src/code_mower/lane_handoff.py | 64 +++++++ src/code_mower/lane_status.py | 64 +++++++ src/code_mower/provider_runners/lineage.py | 84 +++++++++ .../templates/lanes/run_mac_lane.sh | 26 ++- templates/lanes/run_mac_lane.sh | 26 ++- tools/builder_lineage.py | 171 +++++++++++++++++- tools/lanes/run_mac_lane.sh | 26 ++- 16 files changed, 1015 insertions(+), 54 deletions(-) diff --git a/src/code_mower/board.py b/src/code_mower/board.py index a9ad007d..e47cad70 100644 --- a/src/code_mower/board.py +++ b/src/code_mower/board.py @@ -729,6 +729,19 @@ def _safe_bool(value: object) -> bool: return bool(value) +def _safe_lane_names(value: object, *, limit: int = 8) -> list[str]: + """Bound a lineage contributor list to short lane names.""" + + if not isinstance(value, list): + return [] + names = [] + for item in value[:limit]: + name = _safe_text(item, limit=40) + if name and name not in names: + names.append(name) + return names + + def _safe_reviewer_outcomes(value: object) -> list[dict[str, Any]]: if not isinstance(value, list): return [] @@ -764,6 +777,11 @@ def _supervised_decision_payload(decision: Mapping[str, Any]) -> dict[str, Any]: "promoted_reviewers_passed": _safe_bool(decision.get("promoted_reviewers_passed")), "would_mutate": _safe_bool(decision.get("would_mutate")), "reviewer_outcomes": _safe_reviewer_outcomes(decision.get("reviewer_outcomes")), + # Lane names only. The contributor list is what makes an exclusion + # legible on the Board; nothing about the diff, the source or the + # private handoff binding crosses this boundary. + "builder_lineage_status": _safe_text(decision.get("builder_lineage_status"), limit=40), + "builder_contributors": _safe_lane_names(decision.get("builder_contributors")), } if pr_number := _int(decision.get("pr_number")): payload["pr_number"] = pr_number diff --git a/src/code_mower/builder_lineage.py b/src/code_mower/builder_lineage.py index 50a1d897..d7ff2458 100644 --- a/src/code_mower/builder_lineage.py +++ b/src/code_mower/builder_lineage.py @@ -34,7 +34,7 @@ import re from dataclasses import dataclass from pathlib import Path -from typing import Any, Iterable, Mapping, Sequence +from typing import Any, Callable, Iterable, Mapping, Sequence SCHEMA = "code_mower.builderLineage.v1" @@ -635,6 +635,175 @@ def load_episodes(root: Path, repo: str, pr_number: Any) -> tuple[ContributionEp ) +# --- active builder label reconciliation ------------------------------------- + + +def builder_label_for(lane: str, identity: Mapping[str, Any] | None = None) -> str: + """The one active label that names ``lane`` as the current writer.""" + + writer = _lane(lane) + if not writer: + return "" + label_map = (identity or {}).get("labels") if isinstance(identity, Mapping) else None + if isinstance(label_map, Mapping): + for label in sorted(_text(item) for item in label_map): + if _lane(label_map.get(label)) == writer and label.startswith("builder:"): + return label + return f"builder:{writer}" + + +def builder_label_plan( + lineage: Lineage, + *, + current_labels: Sequence[str] = (), + identity: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Plan reconciliation to exactly one active builder label. + + The label set says who may write next, and after a verified takeover that + is exactly one lane. Historical contributions are not deleted by this: they + live in the recorded lineage, which is what reviewer exclusion and the + Board projection read. Unresolved lineage plans no mutation at all -- a + label moved on a guess is the failure this issue exists to stop. + """ + + label_map = (identity or {}).get("labels") if isinstance(identity, Mapping) else None + known = ( + {_text(label): _lane(lane) for label, lane in label_map.items()} + if isinstance(label_map, Mapping) + else {} + ) + present = tuple( + dict.fromkeys( + label + for label in (_text(item) for item in current_labels) + if label and (label.startswith("builder:") or known.get(label)) + ) + ) + if not lineage.resolved or not lineage.current_writer: + return { + "schema": SCHEMA, + "status": "blocked", + "reason": lineage.reason if not lineage.resolved else "no_builder_identity", + "head_sha": lineage.head_sha, + "current_writer": lineage.current_writer, + "add": [], + "remove": [], + "owner_action": lineage.owner_action or _OWNER_ACTIONS["label_outside_lineage"], + } + + writer = lineage.current_writer + target = next( + ( + label + for label in present + if known.get(label) == writer or label == f"builder:{writer}" + ), + "", + ) or builder_label_for(writer, identity) + remove = [label for label in present if label != target] + add = [] if target in present else [target] + return { + "schema": SCHEMA, + "status": "reconcile" if (add or remove) else "current", + "reason": lineage.reason, + "head_sha": lineage.head_sha, + "current_writer": writer, + "add": add, + "remove": remove, + "owner_action": "", + } + + +def reconcile_active_builder_label( + *, + repo: str, + pr_number: Any, + branch: str, + head_sha: str, + current_labels: Sequence[str] = (), + episodes: Sequence[Mapping[str, Any] | ContributionEpisode] = (), + identity: Mapping[str, Any] | None = None, + opener_lane: str = "", + observe_head: Callable[[], str] | None = None, + apply_labels: Callable[[Sequence[str], Sequence[str]], None] | None = None, +) -> dict[str, Any]: + """Move the active builder label to the verified current writer. + + The head is rechecked on both sides of the mutation. A head that moved + before the resolution makes the lineage describe a different diff, and a + head that moved after it makes the label this call just applied a claim + about a diff nobody verified; both report ``blocked`` with one owner action + rather than leaving a confident but unfounded label behind. + """ + + pinned = _sha(head_sha) + if not pinned: + return { + "schema": SCHEMA, + "status": "blocked", + "reason": "target_invalid", + "head_sha": "", + "current_writer": "", + "add": [], + "remove": [], + "applied": False, + "owner_action": _OWNER_ACTIONS["target_invalid"], + } + if observe_head is not None and _sha(observe_head()) != pinned: + return { + "schema": SCHEMA, + "status": "blocked", + "reason": "head_moved_before_reconcile", + "head_sha": pinned, + "current_writer": "", + "add": [], + "remove": [], + "applied": False, + "owner_action": ( + "the pull request head moved while reconciling the active builder " + "label; re-run reconciliation against the current head" + ), + } + + label_lanes = tuple( + dict.fromkeys( + lane + for lane in ( + _lane(((identity or {}).get("labels") or {}).get(_text(label))) + if isinstance(identity, Mapping) + else "" + for label in current_labels + ) + if lane + ) + ) + lineage = resolve_lineage( + repo=repo, + pr_number=pr_number, + branch=branch, + head_sha=pinned, + episodes=episodes, + opener_lane=opener_lane, + label_lanes=label_lanes, + ) + plan = builder_label_plan(lineage, current_labels=current_labels, identity=identity) + plan["applied"] = False + if plan["status"] != "reconcile": + return plan + if apply_labels is not None: + apply_labels(tuple(plan["add"]), tuple(plan["remove"])) + plan["applied"] = True + if observe_head is not None and _sha(observe_head()) != pinned: + plan["status"] = "blocked" + plan["reason"] = "head_moved_during_reconcile" + plan["owner_action"] = ( + "the pull request head moved while the active builder label was being " + "reconciled; re-record the contribution episode and reconcile again" + ) + return plan + + # --- bounded public transport ------------------------------------------------ diff --git a/src/code_mower/claude_audit_pr.py b/src/code_mower/claude_audit_pr.py index ff60187b..843634c7 100644 --- a/src/code_mower/claude_audit_pr.py +++ b/src/code_mower/claude_audit_pr.py @@ -1306,10 +1306,29 @@ def _require_independent_review(lane, repo, pr_number, pr_meta, head_sha): """ try: - from code_mower.provider_runners.lineage import require_reviewer_lane + from code_mower.builder_lineage import LineageError + from code_mower.provider_runners.lineage import ( + identity_with_lane_floor, load_identity, require_reviewer_lane, trusted_episodes, + ) except ImportError: # pragma: no cover - direct script execution fallback - from provider_runners.lineage import require_reviewer_lane # type: ignore - return require_reviewer_lane(lane, repo, pr_number, pr_meta, head_sha) + from builder_lineage import LineageError # type: ignore + from provider_runners.lineage import ( # type: ignore + identity_with_lane_floor, load_identity, require_reviewer_lane, trusted_episodes, + ) + # Real recorded evidence, not the resolver's empty default: an admission + # decided on no episodes cannot see a takeover, which is the whole point. + try: + episodes = trusted_episodes(repo, pr_number) + except LineageError as exc: + raise RuntimeError( + f"{lane} reviewer lane is not admitted for {repo}#{pr_number} at " + f"{str(head_sha)[:12]}: lineage_unreadable; {exc}" + ) from None + return require_reviewer_lane( + lane, repo, pr_number, pr_meta, head_sha, + episodes=episodes, + identity=identity_with_lane_floor(load_identity(), lane), + ) def audit_pr(config: ClaudeAuditConfig, repo: str, pr_number: int) -> ClaudeAuditResult: diff --git a/src/code_mower/codex_audit_pr.py b/src/code_mower/codex_audit_pr.py index 20e225ae..bff8747a 100644 --- a/src/code_mower/codex_audit_pr.py +++ b/src/code_mower/codex_audit_pr.py @@ -1815,10 +1815,30 @@ def _require_independent_review(lane, repo, pr_number, pr_meta, head_sha): """ try: - from code_mower.provider_runners.lineage import require_reviewer_lane + from code_mower.builder_lineage import LineageError + from code_mower.provider_runners.lineage import ( + identity_with_lane_floor, load_identity, require_reviewer_lane, trusted_episodes, + ) except ImportError: # pragma: no cover - direct script execution fallback - from provider_runners.lineage import require_reviewer_lane # type: ignore - return require_reviewer_lane(lane, repo, pr_number, pr_meta, head_sha) + from builder_lineage import LineageError # type: ignore + from provider_runners.lineage import ( # type: ignore + identity_with_lane_floor, load_identity, require_reviewer_lane, trusted_episodes, + ) + # Real recorded evidence, not the resolver's empty default: an admission + # decided on no episodes cannot see a takeover, which is the whole point. + # Unreadable evidence refuses rather than reviewing on a guess. + try: + episodes = trusted_episodes(repo, pr_number) + except LineageError as exc: + raise RuntimeError( + f"{lane} reviewer lane is not admitted for {repo}#{pr_number} at " + f"{str(head_sha)[:12]}: lineage_unreadable; {exc}" + ) from None + return require_reviewer_lane( + lane, repo, pr_number, pr_meta, head_sha, + episodes=episodes, + identity=identity_with_lane_floor(load_identity(), lane), + ) def audit_pr(config: AuditConfig, repo: str, pr_number: int) -> AuditResult: diff --git a/src/code_mower/controller.py b/src/code_mower/controller.py index ce953645..4858c562 100644 --- a/src/code_mower/controller.py +++ b/src/code_mower/controller.py @@ -266,38 +266,121 @@ def _has_pending(pr: Mapping[str, Any]) -> bool: ) +def _reviewer_eligibility( + config: Mapping[str, Any], reviewer: Mapping[str, str] +) -> dict[str, str]: + """Effective role eligibility for one reviewer lane, decided separately. + + Independence and eligibility answer different questions and neither implies + the other: a qualified reviewer that wrote part of this diff still may not + gate it, and a lane that touched nothing is not made eligible by that. They + are consulted as two decisions so a lane dropped for one reason is never + reported as dropped for the other. + """ + + from .role_eligibility import PRODUCTS, ConfigError, decide_role + + lanes = config.get("lanes") if isinstance(config.get("lanes"), Mapping) else {} + raw_lane = lanes.get(reviewer["lane_id"]) if isinstance(lanes, Mapping) else None + lane = raw_lane if isinstance(raw_lane, Mapping) else {} + product = _text(lane.get("provider")) or _text(reviewer["author_lane"]) + if product not in PRODUCTS: + # Lanes outside the role-eligibility products keep their existing + # repository authority; this seam does not newly restrict them. + return {"status": "eligible", "reason": "repository_policy"} + try: + decision = decide_role( + product, + "reviewer", + transport=_text(lane.get("transport")) or None, + config=config, + merge_authority=True, + ) + except ConfigError: + return {"status": "ineligible", "reason": "role_request_invalid"} + return {"status": _text(decision.get("status")), "reason": _text(decision.get("reason"))} + + def _reviewer_outcomes( pr: Mapping[str, Any], config: Mapping[str, Any], -) -> tuple[list[dict[str, Any]], bool, bool, str]: +) -> tuple[list[dict[str, Any]], bool, bool, str, dict[str, Any]]: + """Select reviewers from verified exact-head lineage, not one active label. + + Every lane that contributed to the current diff is excluded, not just the + one the newest ``builder:*`` label names. Lineage that does not resolve at + this head selects nobody and blocks with the resolver's owner action: a + reviewer admitted on unresolved lineage may be reviewing its own work. + """ + labels = pr.get("labels") if isinstance(pr.get("labels"), Mapping) else {} done = set(labels.get("done") or []) blocked = set(labels.get("blocked") or []) builder_lane = _builder_lane_from_labels(labels.get("builder") or [], config) + + raw_lineage = pr.get("builder_lineage") + lineage = raw_lineage if isinstance(raw_lineage, Mapping) else None + contributors: tuple[str, ...] = () + lineage_block = "" + lineage_status = "absent" + if lineage is not None: + lineage_status = _text(lineage.get("status")) + if lineage_status == "resolved": + contributors = tuple( + lane for lane in (_text(item) for item in (lineage.get("contributors") or [])) if lane + ) + builder_lane = _text(lineage.get("current_writer")) or builder_lane + else: + lineage_block = _text(lineage.get("owner_action")) or ( + "resolve the builder contribution lineage for this head" + ) + excluded_author_lane = "" - outcomes = [] - for reviewer in _merge_reviewers(config): - excluded = ( - _author_never_gates(config) - and builder_lane - and reviewer["author_lane"] == builder_lane - ) - if excluded: - excluded_author_lane = reviewer["author_lane"] - continue - verdict = "PASS" if reviewer["done_label"] in done else "MISSING" - if reviewer["blocked_label"] and reviewer["blocked_label"] in blocked: - verdict = "BLOCKED" - outcomes.append( - { - "lane_id": reviewer["author_lane"], - "config_lane_id": reviewer["lane_id"], - "verdict": verdict, - "promoted": True, - } + outcomes: list[dict[str, Any]] = [] + ineligible: list[str] = [] + if not lineage_block: + for reviewer in _merge_reviewers(config): + lane = reviewer["author_lane"] + contributed = lane in contributors if contributors else bool( + builder_lane and lane == builder_lane + ) + if _author_never_gates(config) and contributed: + excluded_author_lane = lane + continue + eligibility = _reviewer_eligibility(config, reviewer) + if eligibility["status"] == "ineligible": + ineligible.append(lane) + continue + verdict = "PASS" if reviewer["done_label"] in done else "MISSING" + if reviewer["blocked_label"] and reviewer["blocked_label"] in blocked: + verdict = "BLOCKED" + outcomes.append( + { + "lane_id": lane, + "config_lane_id": reviewer["lane_id"], + "verdict": verdict, + "promoted": True, + } + ) + + if not lineage_block and not outcomes and (excluded_author_lane or ineligible): + lineage_block = ( + "no qualified independent reviewer lane remains for this head; " + "configure one that did not contribute to this diff" ) - passed = bool(outcomes) and all(outcome["verdict"] == "PASS" for outcome in outcomes) - return outcomes, bool(excluded_author_lane), passed, builder_lane + passed = ( + not lineage_block + and bool(outcomes) + and all(outcome["verdict"] == "PASS" for outcome in outcomes) + ) + projection = { + "status": lineage_status, + "contributors": list(contributors), + "current_writer": builder_lane, + "ineligible_reviewers": ineligible, + "owner_action": lineage_block, + } + return outcomes, bool(excluded_author_lane), passed, builder_lane, projection def _pr_priority(pr: Mapping[str, Any]) -> tuple[int, int]: @@ -365,7 +448,13 @@ def _pr_decision( owner_label = _owner_label(config) configured_reviewers = _merge_reviewers(config) gate_state = _gate_state(pr) - reviewer_outcomes, author_lane_excluded, reviewers_passed, builder_lane = _reviewer_outcomes(pr, config) + ( + reviewer_outcomes, + author_lane_excluded, + reviewers_passed, + builder_lane, + lineage, + ) = _reviewer_outcomes(pr, config) base = { "pr_number": pr.get("number"), "pr_url": pr.get("url", ""), @@ -376,9 +465,24 @@ def _pr_decision( "gate_status": gate_state, "reviewer_outcomes": reviewer_outcomes, "author_lane_excluded": author_lane_excluded, + "builder_lineage_status": lineage["status"], + "builder_contributors": lineage["contributors"], "promoted_reviewers_passed": reviewers_passed, "would_mutate": False, } + # Unresolved lineage, or no qualified independent reviewer left after + # excluding every contributor, is one owner action -- not a merge decision + # taken on a reviewer that may have written the diff. + if lineage["owner_action"]: + return { + **base, + "decision_state": "owner_action", + "next_action": "resolve builder contribution lineage", + "next_detail": lineage["owner_action"], + "stop_condition": "builder_lineage_unresolved", + "owner_action_kind": "builder_lineage", + "merge_method": "", + } if labels.get("blocked"): return { **base, diff --git a/src/code_mower/devin_cli_audit_pr.py b/src/code_mower/devin_cli_audit_pr.py index b7905edb..4296ffac 100644 --- a/src/code_mower/devin_cli_audit_pr.py +++ b/src/code_mower/devin_cli_audit_pr.py @@ -572,10 +572,13 @@ def _require_independent_devin_review( handling; the message carries only bounded metadata and one owner action. """ + from .builder_lineage import LineageError from .provider_runners.lineage import ( ReviewerNotIndependent, + identity_with_lane_floor, load_identity, require_independent_reviewer, + trusted_episodes, ) if pr_author and _is_excluded_author(pr_author): @@ -584,24 +587,18 @@ def _require_independent_devin_review( raise AuthorExcludedError( f"PR author {pr_author!r} is excluded from the Devin CLI reviewer lane" ) - identity = load_identity() - if not identity.get("enabled"): - # An unconfigured checkout must not become more permissive than the - # deny list this wrapper shipped with. Name Devin's own accounts and - # label so the shared resolver still sees Devin as a contributor. - identity = { - "enabled": True, - "labels": {"builder:devin": DEVIN_REVIEWER_LANE}, - "authors": { - login: DEVIN_REVIEWER_LANE - for login in ( - "devin-cli-audit-bot", - "devin-cli-audit-bot[bot]", - "devin-ai-integration", - "devin-ai-integration[bot]", - ) - }, - } + # An unconfigured or malformed checkout must not become more permissive than + # the deny list this wrapper shipped with: Devin's own accounts and label + # are named whatever the identity file says, so the shared resolver can + # still see Devin as a contributor. + identity = identity_with_lane_floor(load_identity(), DEVIN_REVIEWER_LANE) + try: + episodes = trusted_episodes(config.repo, config.pr_number) + except LineageError as exc: + raise AuthorExcludedError( + f"Devin CLI reviewer lane is not admitted for {config.repo}" + f"#{config.pr_number} at {head_sha[:12]}: lineage_unreadable; {exc}" + ) from None try: return require_independent_reviewer( DEVIN_REVIEWER_LANE, @@ -609,6 +606,7 @@ def _require_independent_devin_review( pr_number=config.pr_number, pr_meta=pr_meta, head_sha=head_sha, + episodes=episodes, identity=identity, ) except ReviewerNotIndependent as exc: diff --git a/src/code_mower/devin_review.py b/src/code_mower/devin_review.py index f0a689d4..650ff664 100644 --- a/src/code_mower/devin_review.py +++ b/src/code_mower/devin_review.py @@ -75,6 +75,36 @@ class ReviewInput: context: dict changed_files: tuple[str, ...] + def lineage_admits(self) -> bool: + """Whether verified lineage admits the Devin reviewer lane at this head. + + The author deny list below stays as a floor, but it only ever sees the + opener. This consults the same shared seam the direct wrappers use, so a + PR another lane opened and Devin later took over is refused too. + Unreadable or unresolved evidence is not admission. + """ + + from .builder_lineage import LineageError + from .provider_runners.lineage import ( + identity_with_lane_floor, load_identity, reviewer_admission, trusted_episodes, + ) + + try: + episodes = trusted_episodes(self.repository, self.pr) + except (LineageError, OSError): + return False + return bool( + reviewer_admission( + 'devin', + repo=self.repository, + pr_number=self.pr, + pr_meta={'user': {'login': self.author}}, + head_sha=self.head, + episodes=episodes, + identity=identity_with_lane_floor(load_identity(), 'devin'), + )['admitted'] + ) + def check(self, current: ReviewInput) -> None: try: valid = ( @@ -83,6 +113,7 @@ def check(self, current: ReviewInput) -> None: and LOGIN.fullmatch(self.author) and not _is_excluded_author(self.author) and self.author.lower() not in {'devin-ai-integration', 'devin-ai-integration[bot]', 'devin-cli-audit-bot', 'devin-cli-audit-bot[bot]'} + and self.lineage_admits() and isinstance(self.changed_files, tuple) and all(isinstance(p, str) and p and not p.startswith(('/', '\\')) and '\\' not in p and '..' not in p.split('/') for p in self.changed_files) diff --git a/src/code_mower/init.py b/src/code_mower/init.py index 0e008068..7f2ae7c8 100644 --- a/src/code_mower/init.py +++ b/src/code_mower/init.py @@ -1342,6 +1342,20 @@ def _author_exclusion_payload( payload["labels"].setdefault(f"builder:{author_lane}", author_lane) if raw_author_lane != author_lane: payload["labels"].setdefault(f"builder:{raw_author_lane}", author_lane) + # Narrow resolver context for the generated gate and labelers. Branch + # identity is the one binding the shared resolver cannot derive from labels + # and authors, and `require_verified_lineage` says when the identity-only + # fallback is not an acceptable answer: with a takeover-capable lane set, + # a PR whose configured branch identity and recorded lineage disagree is an + # owner action, not a lane name picked from whichever signal is present. + payload["branch_prefixes"] = _identity_section( + identity, "branch_prefixes", canonicalize_lanes=True + ) + for lane in sorted({lane for lane in payload["labels"].values() if lane}): + payload["branch_prefixes"].setdefault(f"{lane}/", lane) + payload["require_verified_lineage"] = bool(payload["enabled"]) and ( + len({lane for lane in payload["labels"].values() if lane}) > 1 + ) return payload diff --git a/src/code_mower/lane_delivery.py b/src/code_mower/lane_delivery.py index b145ee38..b45c91e6 100644 --- a/src/code_mower/lane_delivery.py +++ b/src/code_mower/lane_delivery.py @@ -1140,6 +1140,14 @@ def _add_classify_parser(subparsers: Any) -> None: classify.add_argument("--elapsed-seconds", type=float) classify.add_argument("--user-interventions", type=int) classify.add_argument("--handoff", default="", help="Validated handoff JSON path.") + classify.add_argument( + "--handoff-state-dir", + type=Path, + help=( + "Private handoff intent store. With --handoff, a delivered unit " + "records its contribution episode here." + ), + ) classify.add_argument("--output", type=Path, help="Write the outcome event here.") classify.add_argument("--force", action="store_true") classify.add_argument("--json", action="store_true") @@ -1186,6 +1194,110 @@ def _add_handoff_parser(subparsers: Any) -> None: handoff.add_argument("--reserve-launch", action="store_true", help="Claim the verified destination launch once") +def _add_lineage_parser(subparsers: Any) -> None: + lineage = subparsers.add_parser( + "lineage", + help="Resolve recorded builder lineage and reconcile the active builder label.", + ) + lineage.add_argument("--repo", required=True) + lineage.add_argument("--pr", required=True) + lineage.add_argument("--branch", default="") + lineage.add_argument("--head", required=True, help="The exact head the caller pinned.") + lineage.add_argument("--label", dest="labels", action="append", default=[]) + lineage.add_argument("--author", default="", help="Pull request opener login.") + lineage.add_argument("--state-dir", type=Path, help="Private handoff intent store.") + lineage.add_argument( + "--identity-json", + default="", + help="Author-exclusion identity contract; defaults to the runner environment.", + ) + lineage.add_argument( + "--reconcile-labels", + action="store_true", + help="Move the active builder label to the verified current writer.", + ) + lineage.add_argument("--json", action="store_true") + + +def _gh_head(repo: str, number: str) -> str: + result = subprocess.check_output( + ["gh", "pr", "view", number, "--repo", repo, "--json", "headRefOid"], + timeout=30, text=True, stderr=subprocess.DEVNULL, + ) + return _text(json.loads(result).get("headRefOid")) + + +def _gh_apply_labels(repo: str, number: str, add: Iterable[str], remove: Iterable[str]) -> None: + command = ["gh", "pr", "edit", number, "--repo", repo] + for label in add: + command += ["--add-label", label] + for label in remove: + command += ["--remove-label", label] + subprocess.run(command, timeout=60, check=True, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + + +def _lineage_main(args: argparse.Namespace, *, + head: Callable[[str, str], str] = _gh_head, + labels: Callable[..., None] = _gh_apply_labels) -> int: + """Report the resolved lineage, and optionally reconcile the active label. + + Reading is always safe; the mutation happens only when it is asked for, and + only when the resolver named one current writer at the exact head the caller + pinned. Unresolved lineage prints its owner action and changes nothing. + """ + + from . import builder_lineage, lane_handoff + from .provider_runners.lineage import load_identity + + repo = _text(args.repo) + number = _text(args.pr) + identity = load_identity(args.identity_json or None) + episodes: tuple[Any, ...] = () + root = args.state_dir or lane_handoff.default_root() + try: + episodes = builder_lineage.load_episodes( + lane_handoff.lineage_root(root), repo, number + ) + except builder_lineage.LineageError as exc: + raise LaneDeliveryError(str(exc)) from None + opener_lane, _ = builder_lineage.lanes_from_identity( + identity=identity, labels=args.labels, author=args.author + ) + if args.reconcile_labels: + payload = builder_lineage.reconcile_active_builder_label( + repo=repo, + pr_number=number, + branch=args.branch, + head_sha=args.head, + current_labels=args.labels, + episodes=episodes, + identity=identity, + opener_lane=opener_lane, + observe_head=lambda: head(repo, number), + apply_labels=lambda add, remove: labels(repo, number, add, remove), + ) + else: + _, label_lanes = builder_lineage.lanes_from_identity( + identity=identity, labels=args.labels, author=args.author + ) + payload = builder_lineage.resolve_lineage( + repo=repo, + pr_number=number, + branch=args.branch, + head_sha=args.head, + episodes=episodes, + opener_lane=opener_lane, + label_lanes=label_lanes, + ).as_dict() + _assert_safe_metadata(payload, path="lineage") + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(f"lineage {payload.get('status')}: {payload.get('reason')}") + return 0 if payload.get("status") in {"resolved", "current", "reconcile"} else 3 + + def _add_scan_prompt_parser(subparsers: Any) -> None: scan = subparsers.add_parser( "scan-prompt", @@ -1226,6 +1338,7 @@ def main(argv: list[str] | None = None) -> int: _add_classify_parser(subparsers) _add_transition_parser(subparsers) _add_handoff_parser(subparsers) + _add_lineage_parser(subparsers) _add_scan_prompt_parser(subparsers) _add_supervise_parser(subparsers) admit = subparsers.add_parser("admit-builder", help="Check role admission against the trusted fresh-base checkout") @@ -1245,6 +1358,8 @@ def main(argv: list[str] | None = None) -> int: return _transition_main(args) if args.command == "handoff": return _handoff_main(args) + if args.command == "lineage": + return _lineage_main(args) if args.command == "scan-prompt": return _scan_prompt_main(args) if args.command == "supervise": @@ -1329,6 +1444,21 @@ def _classify_main(args: argparse.Namespace) -> int: target_branch=_text(payload.get("target_branch")), ) + # Provenance is recorded from the delivery that actually happened, not from + # the handoff that authorized it. An undelivered or unvalidated round adds + # no episode, so a lane that was launched and wrote nothing never appears as + # a contributor to the diff. + lineage_result = None + if handoff is not None and args.handoff_state_dir is not None: + from . import lane_handoff + + lineage_result = lane_handoff.record_contribution( + handoff, + args.handoff_state_dir, + resulting_head=after.head_sha, + delivered=outcome.delivered, + ) + event = None if args.lane and args.repo: event = build_delivery_outcome_event( @@ -1349,12 +1479,17 @@ def _classify_main(args: argparse.Namespace) -> int: write_delivery_outcome_event(event, output, force=args.force) if args.json: - print(json.dumps(event or outcome.as_dict(), indent=2, sort_keys=True)) + payload = dict(event or outcome.as_dict()) + if lineage_result is not None: + payload["lineage"] = lineage_result + print(json.dumps(payload, indent=2, sort_keys=True)) else: print( f"delivery {'ok' if outcome.delivered else 'missing'}: " f"transition={outcome.transition} reason={outcome.reason}" ) + if lineage_result is not None: + print(f"lineage {lineage_result['reason']}") return 0 if outcome.delivered else 3 diff --git a/src/code_mower/lane_handoff.py b/src/code_mower/lane_handoff.py index 6623adc2..cdeacb69 100644 --- a/src/code_mower/lane_handoff.py +++ b/src/code_mower/lane_handoff.py @@ -190,6 +190,70 @@ def prepare(handoff: Handoff, source: dict, root: Path, *, "notify": True, "launch_allowed": True, "handoff": handoff.as_dict()} +def lineage_root(root: Path) -> Path: + """Where contribution episodes are recorded, beside the intent store. + + The intent record holds the private source binding; the lineage record must + not, because it is the thing reviewer admission, the label reconciler and + the public projection read. They are separate stores so that a reader of one + never sees the other's fields. + """ + + return Path(root) / "lineage" + + +def record_contribution(handoff: Handoff, root: Path, *, resulting_head: str, + delivered: bool, head: Callable = observe_head) -> dict: + """Persist the ordered episode for a validated delivery. Fails closed. + + This is the only writer of contribution lineage. Every field of the episode + comes from evidence this boundary already verified: repository, PR, branch, + lanes and expected head from the accepted handoff, the source writer state + from the acceptance record rather than the caller, and the resulting head + from a fresh observation checked against what the runner reported. A caller + that merely asserts a takeover, or hands in a ``writer_state`` string of its + own, records nothing. + """ + from .builder_lineage import LineageError, episode_from_handoff, load_episodes, record_episode + + observed = str(resulting_head or "").strip().lower() + if not delivered: + return {"recorded": False, "reason": "delivery_unvalidated"} + identity = key([handoff.target_pr.lower(), handoff.expected_head]) + with ContextStore(root).locked(identity) as locked: + record = locked.read() + if (record is None or record.get("accepted") is not True + or record.get("handoff") != handoff.as_dict()): + return {"recorded": False, "reason": "acceptance_unverified"} + if record.get("launch_reserved") is not True: + return {"recorded": False, "reason": "launch_unreserved"} + writer_state = str(record.get("writer_state") or "") + # The destination lane wrote while this ran, so the head observed here is + # the one the episode binds to. A head the runner reported but GitHub + # does not show is not a resulting head. + if observed != str(head(handoff) or "").strip().lower(): + return {"recorded": False, "reason": "resulting_head_unverified"} + store = lineage_root(root) + repo = handoff.target_pr.split("#")[0] + number = handoff.target_pr.split("#")[1] + try: + sequence = record.get("lineage_sequence") + if not isinstance(sequence, int) or isinstance(sequence, bool): + sequence = len(load_episodes(store, repo, number)) + 1 + episode = episode_from_handoff( + handoff, resulting_head=observed, writer_state=writer_state, + sequence=sequence, repo=repo, + ) + outcome = record_episode(store, episode) + except LineageError as exc: + raise LaneDeliveryError(str(exc)) from None + record["lineage_sequence"] = sequence + locked.write(record) + return {"recorded": outcome["recorded"], "duplicate": outcome["duplicate"], + "reason": "recorded" if outcome["recorded"] else "already_recorded", + "sequence": sequence, "episodes": outcome["episodes"]} + + def reserve_launch(handoff: Handoff, root: Path, *, head: Callable = observe_head, stop: Callable = quiesce) -> bool: identity = key([handoff.target_pr.lower(), handoff.expected_head]) diff --git a/src/code_mower/lane_status.py b/src/code_mower/lane_status.py index 1f783613..59801c5f 100644 --- a/src/code_mower/lane_status.py +++ b/src/code_mower/lane_status.py @@ -262,6 +262,62 @@ def _author(pr: Mapping[str, Any]) -> str: return _text(author.get("login")) if isinstance(author, Mapping) else _text(author) +def builder_lineage_for( + repo: str, + *, + pr_number: int, + branch: str, + head_sha: str, + labels: Sequence[str], + author: str, + state_dir: Path | None = None, +) -> dict[str, Any]: + """Resolve recorded contribution lineage for one pull request at its head. + + Episodes come from the runner's own durable record, which only the verified + handoff/delivery boundary writes. Unreadable evidence resolves to a conflict + carrying one owner action rather than degrading to the label-derived guess + this issue exists to remove. + """ + + from . import builder_lineage as lineage_module + from . import lane_handoff + from .provider_runners.lineage import load_identity + + identity = load_identity() + root = lane_handoff.lineage_root(state_dir or lane_handoff.default_root()) + try: + episodes = lineage_module.load_episodes(root, repo, pr_number) + except (lineage_module.LineageError, OSError, ValueError): + return lineage_module.Lineage( + status="conflict", + reason="episode_malformed", + head_sha=_text(head_sha), + contributors=(), + current_writer="", + builder_label="", + stale_builder_labels=(), + evidence="handoff_episodes", + episodes=0, + owner_action=( + "recorded builder contribution evidence for this pull request " + "could not be read; re-record it from the verified handoff" + ), + ).as_dict() + opener_lane, label_lanes = lineage_module.lanes_from_identity( + identity=identity, labels=labels, author=author + ) + return lineage_module.resolve_lineage( + repo=repo, + pr_number=pr_number, + branch=branch, + head_sha=head_sha, + episodes=episodes, + opener_lane=opener_lane, + label_lanes=label_lanes, + ).as_dict() + + def _summarize_pr( repo: str, pr: Mapping[str, Any], @@ -294,6 +350,14 @@ def _summarize_pr( "next_action": next_action, "next_detail": next_detail, "gate_rerun_command": _gate_rerun_command(repo, number, head_sha), + "builder_lineage": builder_lineage_for( + repo, + pr_number=number, + branch=_text(pr.get("headRefName")), + head_sha=head_sha, + labels=[name for names in labels.values() for name in names], + author=_author(pr), + ), } diff --git a/src/code_mower/provider_runners/lineage.py b/src/code_mower/provider_runners/lineage.py index 0c06242b..35df1fe2 100644 --- a/src/code_mower/provider_runners/lineage.py +++ b/src/code_mower/provider_runners/lineage.py @@ -91,6 +91,90 @@ def published_episodes( return tuple(collected) +#: Accounts a reviewer lane writes under. Used only as a floor, so an +#: unconfigured or malformed identity file cannot make a contributing reviewer +#: admissible; it never widens who may review. +LANE_ACCOUNT_FLOOR: Mapping[str, tuple[str, ...]] = { + "devin": ( + "devin-ai-integration", + "devin-ai-integration[bot]", + "devin-cli-audit-bot", + "devin-cli-audit-bot[bot]", + ), + "codex": ("chatgpt-codex-connector[bot]", "codex[bot]"), + "claude": ("claude[bot]", "claude-bot"), +} + + +def identity_with_lane_floor(identity: Mapping[str, Any] | None, lane: str) -> Mapping[str, Any]: + """Guarantee the reviewer lane can be named, whatever the configuration says. + + Reviewer independence is decided by naming lanes. A missing, disabled or + malformed identity contract would name none of them, and an unnameable lane + cannot be recognised as a contributor -- which would silently admit exactly + the reviewer this seam exists to exclude. So the lane's own label and + accounts are always present. Only the reviewer's own lane is synthesized: + this adds exclusion and never admission. + """ + + reviewer = str(lane or "").strip().lower() + base = dict(identity) if isinstance(identity, Mapping) else {} + labels = base.get("labels") + authors = base.get("authors") + merged_labels = dict(labels) if isinstance(labels, Mapping) else {} + merged_authors = dict(authors) if isinstance(authors, Mapping) else {} + if reviewer: + merged_labels.setdefault(f"builder:{reviewer}", reviewer) + for login in LANE_ACCOUNT_FLOOR.get(reviewer, ()): + merged_authors.setdefault(login, reviewer) + return {"enabled": True, "labels": merged_labels, "authors": merged_authors} + + +def recorded_episodes(repo: str, pr_number: Any, state_dir: Any = None) -> tuple: + """Load contribution episodes the verified delivery boundary persisted. + + This is the wrapper-side counterpart of the runner's record. An unreadable + record raises :class:`~code_mower.builder_lineage.LineageError` so the + caller fails closed; a checkout with no record at all simply has no + episodes, which is the ordinary single-builder case. + """ + + from pathlib import Path + + from ..builder_lineage import load_episodes + from ..lane_handoff import default_root, lineage_root + + root = lineage_root(Path(state_dir) if state_dir is not None else default_root()) + return load_episodes(root, repo, pr_number) + + +def trusted_episodes( + repo: str, + pr_number: Any, + *, + comments: Sequence[Mapping[str, Any]] = (), + trusted_author: Callable[[str], bool] | None = None, + state_dir: Any = None, +) -> tuple: + """All contribution evidence this reviewer is allowed to read, in order. + + The durable record is the runner's own; published markers are the transport + for a reviewer running somewhere the record does not exist. Both are parsed + strictly and merged by sequence, and a marker that contradicts the record is + left in place for the resolver to fail closed on rather than reconciled here. + """ + + collected = list(recorded_episodes(repo, pr_number, state_dir)) + if comments and trusted_author is not None: + seen = {episode.sequence: episode for episode in collected} + for episode in published_episodes(comments, trusted_author=trusted_author): + if seen.get(episode.sequence) is None: + collected.append(episode) + elif seen[episode.sequence].as_dict() != episode.as_dict(): + collected.append(episode) + return tuple(collected) + + def pr_lineage( *, repo: str, diff --git a/src/code_mower/templates/lanes/run_mac_lane.sh b/src/code_mower/templates/lanes/run_mac_lane.sh index 255ceff2..03351107 100644 --- a/src/code_mower/templates/lanes/run_mac_lane.sh +++ b/src/code_mower/templates/lanes/run_mac_lane.sh @@ -1565,11 +1565,35 @@ if [ "${#lane_delivery[@]}" -gt 0 ] && [ "$mode" != "audit" ]; then --output "${log%.log}.delivery.json" --force ) - [ -n "$handoff_file" ] && classify_args+=(--handoff "$handoff_file") + # A delivered handoff round records its ordered contribution episode against + # the accepted private intent. The episode is written from the acceptance + # record and a fresh head observation, never from anything declared here, so + # a runner that merely names a handoff records nothing. + [ -n "$handoff_file" ] && classify_args+=(--handoff "$handoff_file" --handoff-state-dir "$HANDOFF_STATE_DIR") set +e "${lane_delivery[@]}" "${classify_args[@]}" delivery_rc=$? set -e + # Reconcile to exactly one active builder label from the verified current + # writer. Historical contributions stay in the lineage record; the label only + # says who may write next. Unresolved lineage changes no label. + if [ -n "$handoff_file" ] && [ "$kind" = "pr" ] && [ "$delivery_rc" -eq 0 ]; then + reconcile_head="$(jq -r '.head_sha // ""' "$after_state" 2>/dev/null || printf '')" + reconcile_branch="$(jq -r '.branch // ""' "$after_state" 2>/dev/null || printf '')" + if [ -n "$reconcile_head" ]; then + reconcile_args=( + lineage --repo "$REPO" --pr "$num" --head "$reconcile_head" + --branch "$reconcile_branch" --state-dir "$HANDOFF_STATE_DIR" + --reconcile-labels --json + ) + while IFS= read -r reconcile_label; do + [ -n "$reconcile_label" ] || continue + reconcile_args+=(--label "$reconcile_label") + done < <(gh pr view "$num" -R "$REPO" --json labels -q '.labels[].name' 2>/dev/null || true) + "${lane_delivery[@]}" "${reconcile_args[@]}" > "${log%.log}.lineage.json" 2>/dev/null \ + || echo "${LANE}: builder label reconciliation did not resolve at ${reconcile_head}" >&2 + fi + fi observed_transition="$(jq -r '.delivery.transition // "unknown"' \ "${log%.log}.delivery.json" 2>/dev/null || printf 'unknown')" delivery_reason="$(jq -r '.delivery.reason // "unknown"' \ diff --git a/templates/lanes/run_mac_lane.sh b/templates/lanes/run_mac_lane.sh index 255ceff2..03351107 100644 --- a/templates/lanes/run_mac_lane.sh +++ b/templates/lanes/run_mac_lane.sh @@ -1565,11 +1565,35 @@ if [ "${#lane_delivery[@]}" -gt 0 ] && [ "$mode" != "audit" ]; then --output "${log%.log}.delivery.json" --force ) - [ -n "$handoff_file" ] && classify_args+=(--handoff "$handoff_file") + # A delivered handoff round records its ordered contribution episode against + # the accepted private intent. The episode is written from the acceptance + # record and a fresh head observation, never from anything declared here, so + # a runner that merely names a handoff records nothing. + [ -n "$handoff_file" ] && classify_args+=(--handoff "$handoff_file" --handoff-state-dir "$HANDOFF_STATE_DIR") set +e "${lane_delivery[@]}" "${classify_args[@]}" delivery_rc=$? set -e + # Reconcile to exactly one active builder label from the verified current + # writer. Historical contributions stay in the lineage record; the label only + # says who may write next. Unresolved lineage changes no label. + if [ -n "$handoff_file" ] && [ "$kind" = "pr" ] && [ "$delivery_rc" -eq 0 ]; then + reconcile_head="$(jq -r '.head_sha // ""' "$after_state" 2>/dev/null || printf '')" + reconcile_branch="$(jq -r '.branch // ""' "$after_state" 2>/dev/null || printf '')" + if [ -n "$reconcile_head" ]; then + reconcile_args=( + lineage --repo "$REPO" --pr "$num" --head "$reconcile_head" + --branch "$reconcile_branch" --state-dir "$HANDOFF_STATE_DIR" + --reconcile-labels --json + ) + while IFS= read -r reconcile_label; do + [ -n "$reconcile_label" ] || continue + reconcile_args+=(--label "$reconcile_label") + done < <(gh pr view "$num" -R "$REPO" --json labels -q '.labels[].name' 2>/dev/null || true) + "${lane_delivery[@]}" "${reconcile_args[@]}" > "${log%.log}.lineage.json" 2>/dev/null \ + || echo "${LANE}: builder label reconciliation did not resolve at ${reconcile_head}" >&2 + fi + fi observed_transition="$(jq -r '.delivery.transition // "unknown"' \ "${log%.log}.delivery.json" 2>/dev/null || printf 'unknown')" delivery_reason="$(jq -r '.delivery.reason // "unknown"' \ diff --git a/tools/builder_lineage.py b/tools/builder_lineage.py index 50a1d897..d7ff2458 100644 --- a/tools/builder_lineage.py +++ b/tools/builder_lineage.py @@ -34,7 +34,7 @@ import re from dataclasses import dataclass from pathlib import Path -from typing import Any, Iterable, Mapping, Sequence +from typing import Any, Callable, Iterable, Mapping, Sequence SCHEMA = "code_mower.builderLineage.v1" @@ -635,6 +635,175 @@ def load_episodes(root: Path, repo: str, pr_number: Any) -> tuple[ContributionEp ) +# --- active builder label reconciliation ------------------------------------- + + +def builder_label_for(lane: str, identity: Mapping[str, Any] | None = None) -> str: + """The one active label that names ``lane`` as the current writer.""" + + writer = _lane(lane) + if not writer: + return "" + label_map = (identity or {}).get("labels") if isinstance(identity, Mapping) else None + if isinstance(label_map, Mapping): + for label in sorted(_text(item) for item in label_map): + if _lane(label_map.get(label)) == writer and label.startswith("builder:"): + return label + return f"builder:{writer}" + + +def builder_label_plan( + lineage: Lineage, + *, + current_labels: Sequence[str] = (), + identity: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Plan reconciliation to exactly one active builder label. + + The label set says who may write next, and after a verified takeover that + is exactly one lane. Historical contributions are not deleted by this: they + live in the recorded lineage, which is what reviewer exclusion and the + Board projection read. Unresolved lineage plans no mutation at all -- a + label moved on a guess is the failure this issue exists to stop. + """ + + label_map = (identity or {}).get("labels") if isinstance(identity, Mapping) else None + known = ( + {_text(label): _lane(lane) for label, lane in label_map.items()} + if isinstance(label_map, Mapping) + else {} + ) + present = tuple( + dict.fromkeys( + label + for label in (_text(item) for item in current_labels) + if label and (label.startswith("builder:") or known.get(label)) + ) + ) + if not lineage.resolved or not lineage.current_writer: + return { + "schema": SCHEMA, + "status": "blocked", + "reason": lineage.reason if not lineage.resolved else "no_builder_identity", + "head_sha": lineage.head_sha, + "current_writer": lineage.current_writer, + "add": [], + "remove": [], + "owner_action": lineage.owner_action or _OWNER_ACTIONS["label_outside_lineage"], + } + + writer = lineage.current_writer + target = next( + ( + label + for label in present + if known.get(label) == writer or label == f"builder:{writer}" + ), + "", + ) or builder_label_for(writer, identity) + remove = [label for label in present if label != target] + add = [] if target in present else [target] + return { + "schema": SCHEMA, + "status": "reconcile" if (add or remove) else "current", + "reason": lineage.reason, + "head_sha": lineage.head_sha, + "current_writer": writer, + "add": add, + "remove": remove, + "owner_action": "", + } + + +def reconcile_active_builder_label( + *, + repo: str, + pr_number: Any, + branch: str, + head_sha: str, + current_labels: Sequence[str] = (), + episodes: Sequence[Mapping[str, Any] | ContributionEpisode] = (), + identity: Mapping[str, Any] | None = None, + opener_lane: str = "", + observe_head: Callable[[], str] | None = None, + apply_labels: Callable[[Sequence[str], Sequence[str]], None] | None = None, +) -> dict[str, Any]: + """Move the active builder label to the verified current writer. + + The head is rechecked on both sides of the mutation. A head that moved + before the resolution makes the lineage describe a different diff, and a + head that moved after it makes the label this call just applied a claim + about a diff nobody verified; both report ``blocked`` with one owner action + rather than leaving a confident but unfounded label behind. + """ + + pinned = _sha(head_sha) + if not pinned: + return { + "schema": SCHEMA, + "status": "blocked", + "reason": "target_invalid", + "head_sha": "", + "current_writer": "", + "add": [], + "remove": [], + "applied": False, + "owner_action": _OWNER_ACTIONS["target_invalid"], + } + if observe_head is not None and _sha(observe_head()) != pinned: + return { + "schema": SCHEMA, + "status": "blocked", + "reason": "head_moved_before_reconcile", + "head_sha": pinned, + "current_writer": "", + "add": [], + "remove": [], + "applied": False, + "owner_action": ( + "the pull request head moved while reconciling the active builder " + "label; re-run reconciliation against the current head" + ), + } + + label_lanes = tuple( + dict.fromkeys( + lane + for lane in ( + _lane(((identity or {}).get("labels") or {}).get(_text(label))) + if isinstance(identity, Mapping) + else "" + for label in current_labels + ) + if lane + ) + ) + lineage = resolve_lineage( + repo=repo, + pr_number=pr_number, + branch=branch, + head_sha=pinned, + episodes=episodes, + opener_lane=opener_lane, + label_lanes=label_lanes, + ) + plan = builder_label_plan(lineage, current_labels=current_labels, identity=identity) + plan["applied"] = False + if plan["status"] != "reconcile": + return plan + if apply_labels is not None: + apply_labels(tuple(plan["add"]), tuple(plan["remove"])) + plan["applied"] = True + if observe_head is not None and _sha(observe_head()) != pinned: + plan["status"] = "blocked" + plan["reason"] = "head_moved_during_reconcile" + plan["owner_action"] = ( + "the pull request head moved while the active builder label was being " + "reconciled; re-record the contribution episode and reconcile again" + ) + return plan + + # --- bounded public transport ------------------------------------------------ diff --git a/tools/lanes/run_mac_lane.sh b/tools/lanes/run_mac_lane.sh index f99506e8..458bd598 100755 --- a/tools/lanes/run_mac_lane.sh +++ b/tools/lanes/run_mac_lane.sh @@ -1565,11 +1565,35 @@ if [ "${#lane_delivery[@]}" -gt 0 ] && [ "$mode" != "audit" ]; then --output "${log%.log}.delivery.json" --force ) - [ -n "$handoff_file" ] && classify_args+=(--handoff "$handoff_file") + # A delivered handoff round records its ordered contribution episode against + # the accepted private intent. The episode is written from the acceptance + # record and a fresh head observation, never from anything declared here, so + # a runner that merely names a handoff records nothing. + [ -n "$handoff_file" ] && classify_args+=(--handoff "$handoff_file" --handoff-state-dir "$HANDOFF_STATE_DIR") set +e "${lane_delivery[@]}" "${classify_args[@]}" delivery_rc=$? set -e + # Reconcile to exactly one active builder label from the verified current + # writer. Historical contributions stay in the lineage record; the label only + # says who may write next. Unresolved lineage changes no label. + if [ -n "$handoff_file" ] && [ "$kind" = "pr" ] && [ "$delivery_rc" -eq 0 ]; then + reconcile_head="$(jq -r '.head_sha // ""' "$after_state" 2>/dev/null || printf '')" + reconcile_branch="$(jq -r '.branch // ""' "$after_state" 2>/dev/null || printf '')" + if [ -n "$reconcile_head" ]; then + reconcile_args=( + lineage --repo "$REPO" --pr "$num" --head "$reconcile_head" + --branch "$reconcile_branch" --state-dir "$HANDOFF_STATE_DIR" + --reconcile-labels --json + ) + while IFS= read -r reconcile_label; do + [ -n "$reconcile_label" ] || continue + reconcile_args+=(--label "$reconcile_label") + done < <(gh pr view "$num" -R "$REPO" --json labels -q '.labels[].name' 2>/dev/null || true) + "${lane_delivery[@]}" "${reconcile_args[@]}" > "${log%.log}.lineage.json" 2>/dev/null \ + || echo "${LANE}: builder label reconciliation did not resolve at ${reconcile_head}" >&2 + fi + fi observed_transition="$(jq -r '.delivery.transition // "unknown"' \ "${log%.log}.delivery.json" 2>/dev/null || printf 'unknown')" delivery_reason="$(jq -r '.delivery.reason // "unknown"' \ From 47d612970750b07e25aa736b376aa93a8dedfcde Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 09:38:34 -0700 Subject: [PATCH 04/25] Publish, continue and resolve builder lineage through real consumers Resolves the five findings from the Codex audit of 420198a4. - Generated product repositories now receive tools/builder_lineage.py, the dependency audit_labeler_lib imports. Without it a gate runner with no Code Mower package installed cannot import its own helper. - The runner publishes verified episodes as a bounded hidden marker before reconciling the active builder label, and abandons the label move if publication does not succeed. The GitHub gate reads episodes only from trusted comments, so a moved label with no published evidence was exactly the conflict this path exists to prevent. - The trailer and SaaS labeler callers carry trusted repository, branch, head and published-episode evidence, so an independent reviewer's exact-head verdict on a verified takeover reaches its done label while every contributor stays excluded. - Ordinary same-writer rounds after a takeover record a continuation episode, so a normal fix round no longer leaves lineage permanently behind the head. A continuation is a distinct episode kind with its own writer state; it cannot be forged into a handoff, and it displaces no other writer, so no quiescence or reservation contract is bypassed. - Recording and every reviewer reader resolve the same LANE_HANDOFF_STATE_DIR. A configured but non-absolute value fails closed rather than silently consulting a different store. The previously unused episodes_from_comment_body import in audit_labeler_lib now has a production use in published_lineage_episodes, clearing Ruff F401. Adds tests/test_builder_lineage_consumers.py: 37 consumer-level regressions covering the generated-gate standalone import, publication ordering, idempotency and bounded payload, all real labeler entry paths, continuation new heads/replay/stale evidence, the configured store, role eligibility kept separate from contribution independence, and ordinary single-builder behaviour. Closes #963 Co-Authored-By: Claude Opus 5 (1M context) --- src/code_mower/audit_labeler_lib.py | 169 ++++- src/code_mower/builder_lineage.py | 101 ++- src/code_mower/init.py | 10 + src/code_mower/lane_delivery.py | 144 +++- src/code_mower/lane_handoff.py | 90 +++ src/code_mower/provider_runners/lineage.py | 26 +- src/code_mower/saas_reviewer_labeler.py | 67 +- .../templates/lanes/run_mac_lane.sh | 20 +- src/code_mower/trailer_comment_labeler.py | 31 +- templates/lanes/run_mac_lane.sh | 20 +- tests/test_builder_lineage_consumers.py | 703 ++++++++++++++++++ tools/audit_labeler_lib.py | 166 ++++- tools/builder_lineage.py | 101 ++- tools/lanes/run_mac_lane.sh | 20 +- 14 files changed, 1562 insertions(+), 106 deletions(-) create mode 100644 tests/test_builder_lineage_consumers.py diff --git a/src/code_mower/audit_labeler_lib.py b/src/code_mower/audit_labeler_lib.py index 3cacbe20..0a803666 100644 --- a/src/code_mower/audit_labeler_lib.py +++ b/src/code_mower/audit_labeler_lib.py @@ -27,6 +27,7 @@ from . import decisions as code_mower_decisions from . import context_review as code_mower_context_review from .builder_lineage import ( + LINEAGE_MARKER, Lineage, LineageError, episodes_from_comment_body, @@ -38,6 +39,7 @@ import decisions as code_mower_decisions # type: ignore import context_review as code_mower_context_review # type: ignore from builder_lineage import ( # type: ignore + LINEAGE_MARKER, Lineage, LineageError, episodes_from_comment_body, @@ -49,6 +51,7 @@ import decisions as code_mower_decisions # type: ignore import context_review as code_mower_context_review # type: ignore from builder_lineage import ( # type: ignore + LINEAGE_MARKER, Lineage, LineageError, episodes_from_comment_body, @@ -256,17 +259,124 @@ def resolve_builder_lineage( ) +@dataclass(frozen=True) +class LineageContext: + """The trusted exact-head evidence a labeler carries into resolution. + + Every field has to come from something the labeler verified for itself: the + repository it is running in, the head it fetched from the pull request, and + episodes published by an author it already trusts. An empty context is not + a failure -- it is the honest statement that this call has no exact-head + evidence, and resolution falls back to the ordinary single-builder answer. + """ + + repo: str = "" + pr_number: Any = 0 + branch: str = "" + head_sha: str = "" + episodes: tuple[Any, ...] = () + + +#: A labeler that has no exact-head evidence at all. +NO_LINEAGE = LineageContext() + + +def published_lineage_episodes( + comments: Sequence[Mapping[str, Any]] | None, + *, + trusted_author: Callable[[str], bool], +) -> tuple[Any, ...]: + """Contribution episodes published by comment authors the caller trusts. + + The hidden marker is a transport for bounded metadata, never an + authorization: trust is decided entirely by ``trusted_author``, so an + arbitrary commenter cannot assert a takeover into existence. Unreadable + published evidence raises :class:`LineageError` so the caller fails closed + rather than labelling from a partially parsed history. + """ + + collected: list[Any] = [] + for comment in comments or (): + if not isinstance(comment, Mapping): + continue + login = str(((comment.get("user") or {}).get("login")) or "") + body = str(comment.get("body") or "") + if not login or LINEAGE_MARKER not in body or not trusted_author(login): + continue + collected.extend(episodes_from_comment_body(body)) + return tuple(collected) + + +def lineage_decision_authorities() -> tuple[str, ...]: + """The repository's configured decision authorities, for marker trust.""" + + return code_mower_decisions.decision_authorities_from_env() + + +def lineage_marker_author_trust( + *, + authorities: Sequence[str] = (), +) -> Callable[[str], bool]: + """Who this labeler may read published lineage markers from. + + Trust is exactly the repository's configured decision authorities -- the + accounts it already treats as authoritative about its own state. Nothing + else is accepted, and in particular an audit bot able to post a verdict is + not thereby able to assert a takeover. An unconfigured checkout trusts + nobody, reads no episodes, and behaves exactly as it does today. + """ + + allowed = { + str(item).strip().lower().lstrip("@") + for item in authorities + if str(item).strip() + } + + def trusted(login: str) -> bool: + return bool(allowed) and str(login).strip().lower().lstrip("@") in allowed + + return trusted + + +def lineage_context( + *, + repo: str, + pr_number: Any, + branch: str = "", + head_sha: str | None = "", + comments: Sequence[Mapping[str, Any]] | None = (), + trusted_author: Callable[[str], bool] | None = None, +) -> LineageContext: + """Assemble exact-head lineage evidence for a labeler entry path. + + Returns :data:`NO_LINEAGE` when the head could not be verified, because + lineage resolved against an unverified head would decide the wrong diff. + Unreadable published evidence is propagated as a + :class:`LineageError` for the caller's fail-closed handling. + """ + + head = str(head_sha or "") + if not head or not repo or not pr_number: + return NO_LINEAGE + episodes: tuple[Any, ...] = () + if trusted_author is not None: + episodes = published_lineage_episodes(comments, trusted_author=trusted_author) + return LineageContext( + repo=str(repo), + pr_number=pr_number, + branch=str(branch or ""), + head_sha=head, + episodes=episodes, + ) + + def builder_identity_matches( *, labels: Sequence[str], author: str, text: str, config: Mapping[str, Any], - repo: str = "", - pr_number: Any = 0, - branch: str = "", - head_sha: str = "", - episodes: Sequence[Any] = (), + lineage: LineageContext | None = None, ) -> tuple[str, ...]: """Ordered verified builder lanes for this pull request. @@ -276,17 +386,18 @@ def builder_identity_matches( if not bool(config.get("enabled")): return () - lineage = resolve_builder_lineage( + evidence = lineage or NO_LINEAGE + resolved = resolve_builder_lineage( labels=labels, author=author, config=config, - repo=repo, - pr_number=pr_number, - branch=branch, - head_sha=head_sha, - episodes=episodes, + repo=evidence.repo, + pr_number=evidence.pr_number, + branch=evidence.branch, + head_sha=evidence.head_sha, + episodes=evidence.episodes, ) - if lineage.status != "resolved": + if resolved.status != "resolved": # Preserve the historical "more than one identity" shape so a caller # inspecting matches can still tell a conflict from a clean match. opener_lane, label_lanes = lanes_from_identity( @@ -295,8 +406,8 @@ def builder_identity_matches( candidates = tuple( dict.fromkeys(list(label_lanes) + ([opener_lane] if opener_lane else [])) ) - return candidates if len(candidates) > 1 else lineage.contributors - return lineage.contributors + return candidates if len(candidates) > 1 else resolved.contributors + return resolved.contributors def author_exclusion_reason( @@ -306,40 +417,40 @@ def author_exclusion_reason( author: str, text: str, config: Mapping[str, Any] | None = None, - repo: str = "", - pr_number: Any = 0, - branch: str = "", - head_sha: str = "", - episodes: Sequence[Any] = (), + lineage: LineageContext | None = None, ) -> str | None: """Why ``lane_name`` may not label its own work, or ``None``. Exclusion follows verified contribution, not authorship: every contributing lane is excluded, and a lane that merely opened the PR before handing it - over is excluded too because its commits are still in the diff. + over is excluded too because its commits are still in the diff. Conversely, + with exact-head evidence in ``lineage``, a lane that never touched this + head is *not* excluded even when a reconciled label names a different lane + than the opener -- which is the whole point of carrying the evidence here. """ exclusion_config = config or load_author_exclusion_config() if not bool(exclusion_config.get("enabled")): return None + evidence = lineage or NO_LINEAGE try: - lineage = resolve_builder_lineage( + resolved = resolve_builder_lineage( labels=labels, author=author, config=exclusion_config, - repo=repo, - pr_number=pr_number, - branch=branch, - head_sha=head_sha, - episodes=episodes, + repo=evidence.repo, + pr_number=evidence.pr_number, + branch=evidence.branch, + head_sha=evidence.head_sha, + episodes=evidence.episodes, ) except LineageError: return "builder contribution evidence is unreadable; skipping author-excluded label update" - if lineage.status == "conflict": + if resolved.status == "conflict": return "conflicting builder identity; skipping author-excluded label update" - if lineage.status == "waiting": + if resolved.status == "waiting": return "builder contribution lineage is behind the current head; skipping author-excluded label update" - if lineage.contributed(lane_name): + if resolved.contributed(lane_name): return f"{lane_name} lane excluded for builder-authored PR" return None diff --git a/src/code_mower/builder_lineage.py b/src/code_mower/builder_lineage.py index d7ff2458..d26af846 100644 --- a/src/code_mower/builder_lineage.py +++ b/src/code_mower/builder_lineage.py @@ -55,9 +55,25 @@ REPO_RE = re.compile(r"[A-Za-z0-9._-]{1,100}/[A-Za-z0-9._-]{1,100}\Z") BRANCH_RE = re.compile(r"[A-Za-z0-9._/-]{1,200}\Z") -#: Verified writer states the handoff boundary is allowed to report. Anything -#: else (including a missing or "unknown" state) is uncertainty. -WRITER_STATES = frozenset({"suspended", "terminated"}) +#: Verified writer states the handoff boundary is allowed to report for a +#: takeover. Anything else (including a missing or "unknown" state) is +#: uncertainty. +HANDOFF_WRITER_STATES = frozenset({"suspended", "terminated"}) + +#: The only writer state a continuation may carry. A continuation is recorded +#: after the destination lane's *own* supervised round ended and its process +#: group was reaped, so the writer that went quiescent is the recording lane +#: itself. Spelling that differently from the handoff states keeps a takeover +#: episode from ever being mistaken for a continuation, or the reverse. +CONTINUATION_WRITER_STATE = "self_quiescent" + +WRITER_STATES = HANDOFF_WRITER_STATES | {CONTINUATION_WRITER_STATE} + +#: A takeover moves the pen between two lanes; a continuation is the lane that +#: already holds it advancing the same pull request in an ordinary fix round. +HANDOFF_KIND = "handoff" +CONTINUATION_KIND = "continuation" +EPISODE_KINDS = frozenset({HANDOFF_KIND, CONTINUATION_KIND}) #: A lineage longer than this is treated as malformed rather than walked. MAX_EPISODES = 32 @@ -65,6 +81,7 @@ EPISODE_FIELDS = ( "schema", "sequence", + "kind", "repo", "pr_number", "branch", @@ -112,6 +129,12 @@ class ContributionEpisode: destination lane was launched against; ``resulting_head`` is the head the destination lane actually produced. A destination that never moved the head is still the writer, but it contributed nothing to the current diff. + + ``kind`` is ``handoff`` when the pen moved between two lanes and + ``continuation`` when the lane that already held it advanced the same pull + request again. A continuation is the only episode whose source and + destination lane are the same, and it must carry the self-quiescent writer + state, so neither shape can be forged into the other. """ sequence: int @@ -123,8 +146,16 @@ class ContributionEpisode: expected_head: str resulting_head: str writer_state: str + kind: str = HANDOFF_KIND def __post_init__(self) -> None: + same_lane = self.source_lane == self.destination_lane + if self.kind == CONTINUATION_KIND: + shape_ok = same_lane and self.writer_state == CONTINUATION_WRITER_STATE + elif self.kind == HANDOFF_KIND: + shape_ok = not same_lane and self.writer_state in HANDOFF_WRITER_STATES + else: + shape_ok = False if ( isinstance(self.sequence, bool) or not isinstance(self.sequence, int) @@ -134,10 +165,9 @@ def __post_init__(self) -> None: or not BRANCH_RE.match(_text(self.branch)) or not LANE_RE.match(_text(self.source_lane)) or not LANE_RE.match(_text(self.destination_lane)) - or self.source_lane == self.destination_lane or not SHA_RE.match(_text(self.expected_head)) or not SHA_RE.match(_text(self.resulting_head)) - or self.writer_state not in WRITER_STATES + or not shape_ok ): raise LineageError("contribution episode is malformed") @@ -149,6 +179,7 @@ def as_dict(self) -> dict[str, Any]: return { "schema": EPISODE_SCHEMA, "sequence": self.sequence, + "kind": self.kind, "repo": self.repo, "pr_number": self.pr_number, "branch": self.branch, @@ -170,6 +201,7 @@ def episode_from_mapping(payload: Mapping[str, Any]) -> ContributionEpisode: repo = _text(payload.get("repo")) return ContributionEpisode( sequence=payload.get("sequence"), # type: ignore[arg-type] + kind=_text(payload.get("kind")).lower(), repo=repo, pr_number=_pr_number(payload.get("pr_number")), branch=_text(payload.get("branch")), @@ -208,6 +240,7 @@ def episode_from_handoff( raise LineageError("contribution episode does not bind to the repository under work") return ContributionEpisode( sequence=sequence, + kind=HANDOFF_KIND, repo=pr_repo, pr_number=_pr_number(pr_number), branch=_text(record.get("target_branch")), @@ -219,6 +252,51 @@ def episode_from_handoff( ) +def continuation_episode( + previous: ContributionEpisode, + *, + lane: str, + resulting_head: str, + branch: str = "", +) -> ContributionEpisode: + """Build the next episode for an ordinary round by the current writer. + + A continuation repairs the gap the takeover model would otherwise leave: + once a lane has taken a pull request over, its next fix round advances the + head without any new handoff to record, and exact-head resolution would + report ``lineage_behind_head`` forever. + + It is not a handoff and must not be manufactured into one. ``previous`` is + the recorded tip, and only the lane that record already names as the + current writer may continue from it; every other field is inherited from + that verified episode rather than supplied by the caller. + """ + + writer = _lane(lane) + if not writer or writer != previous.destination_lane: + raise LineageError( + "only the lane the recorded lineage names as current writer may continue it" + ) + observed = _sha(resulting_head) + if not observed or observed == previous.resulting_head: + raise LineageError("a continuation must record a head the writer actually moved") + target_branch = _text(branch) or previous.branch + if target_branch != previous.branch: + raise LineageError("a continuation must stay on the recorded branch") + return ContributionEpisode( + sequence=previous.sequence + 1, + kind=CONTINUATION_KIND, + repo=previous.repo, + pr_number=previous.pr_number, + branch=previous.branch, + source_lane=writer, + destination_lane=writer, + expected_head=previous.resulting_head, + resulting_head=observed, + writer_state=CONTINUATION_WRITER_STATE, + ) + + @dataclass(frozen=True) class Lineage: """The resolved answer for one pull request at one exact head. @@ -421,7 +499,12 @@ def resolve_lineage( or (target_branch and episode.branch != target_branch) ): return _lineage("conflict", "episode_unbound", head_sha=head) - if episode.writer_state not in WRITER_STATES: + expected_state = ( + {CONTINUATION_WRITER_STATE} + if episode.kind == CONTINUATION_KIND + else HANDOFF_WRITER_STATES + ) + if episode.writer_state not in expected_state: return _lineage("conflict", "writer_state_unverified", head_sha=head) parsed.append(episode) if len(parsed) > MAX_EPISODES: @@ -443,6 +526,12 @@ def resolve_lineage( if [episode.sequence for episode in ordered] != list(range(1, len(ordered) + 1)): return _lineage("conflict", "episode_unchained", head_sha=head) + if ordered[0].kind != HANDOFF_KIND: + # Lineage begins when the pen moves. A continuation with nothing to + # continue describes an ordinary single-builder round, which needs no + # episode at all, so recorded evidence in that shape is not trustworthy. + return _lineage("conflict", "episode_unchained", head_sha=head) + contributors: list[str] = [ordered[0].source_lane] for index, episode in enumerate(ordered): if index and ( diff --git a/src/code_mower/init.py b/src/code_mower/init.py index 7f2ae7c8..c9418e05 100644 --- a/src/code_mower/init.py +++ b/src/code_mower/init.py @@ -225,6 +225,16 @@ "product-support-helper", "0644", ), + ( + # audit_labeler_lib imports this for exact-head builder lineage. A gate + # runner in a generated product repository has no Code Mower package + # installed, so the dependency has to travel with the helper or the + # import fails and the gate cannot evaluate at all. + "tools/builder_lineage.py", + "builder_lineage.py", + "product-support-helper", + "0644", + ), ( "tools/decisions.py", "decisions.py", diff --git a/src/code_mower/lane_delivery.py b/src/code_mower/lane_delivery.py index b45c91e6..42014388 100644 --- a/src/code_mower/lane_delivery.py +++ b/src/code_mower/lane_delivery.py @@ -1211,6 +1211,11 @@ def _add_lineage_parser(subparsers: Any) -> None: default="", help="Author-exclusion identity contract; defaults to the runner environment.", ) + lineage.add_argument( + "--publish", + action="store_true", + help="Publish gate-trusted episode metadata on the pull request first.", + ) lineage.add_argument( "--reconcile-labels", action="store_true", @@ -1237,9 +1242,80 @@ def _gh_apply_labels(repo: str, number: str, add: Iterable[str], remove: Iterabl stderr=subprocess.DEVNULL) +def _gh_comment_bodies(repo: str, number: str) -> tuple[str, ...]: + result = subprocess.check_output( + ["gh", "pr", "view", number, "--repo", repo, "--json", "comments"], + timeout=60, text=True, stderr=subprocess.DEVNULL, + ) + payload = json.loads(result).get("comments") or [] + return tuple(str(item.get("body") or "") for item in payload if isinstance(item, dict)) + + +def _gh_publish_comment(repo: str, number: str, body: str) -> None: + subprocess.run(["gh", "pr", "comment", number, "--repo", repo, "--body", body], + timeout=60, check=True, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + + +def publish_lineage_evidence( + *, + repo: str, + pr_number: str, + branch: str, + head_sha: str, + episodes: Sequence[Any], + opener_lane: str = "", + label_lanes: Sequence[str] = (), + existing_bodies: Callable[[], Sequence[str]], + publish: Callable[[str], None], +) -> dict: + """Put the evidence the GitHub gate reads where the gate can read it. + + The runner's own store is private to the machine that recorded it, while + the gate resolves lineage exclusively from hidden markers on already + trusted comments. Recording a takeover without publishing it therefore + leaves the gate looking at a Devin author, a reconciled Codex label and no + episodes at all -- a conflict, when the truth is an admissible independent + reviewer. + + Only lineage that resolves at the pinned head is published, so an + unverified or stale record never becomes public evidence. The payload is + the bounded metadata contract: lane names, a repository slug, a PR number, + a branch and commit shas. Publishing is idempotent -- an identical marker + already present is left alone rather than repeated. + """ + + from . import builder_lineage + + lineage = builder_lineage.resolve_lineage( + repo=repo, pr_number=pr_number, branch=branch, head_sha=head_sha, + episodes=episodes, opener_lane=opener_lane, label_lanes=label_lanes, + ) + if lineage.status != "resolved" or not episodes: + return {"published": False, "reason": f"lineage_{lineage.status}", + "owner_action": lineage.owner_action} + marker = builder_lineage.lineage_comment_marker(tuple(episodes)) + _assert_safe_metadata(json.loads(marker.split(None, 2)[2].rsplit("-->", 1)[0].strip()), + path="lineage_marker") + for body in existing_bodies(): + if marker in body: + return {"published": False, "duplicate": True, "reason": "already_published"} + publish( + "Builder contribution lineage for this head, published so the gate and " + "every reviewer resolve the same verified evidence.\n\n" + f"- current writer: `{lineage.current_writer}`\n" + f"- contributors: {', '.join('`' + lane + '`' for lane in lineage.contributors)}\n" + f"- head: `{lineage.head_sha}`\n\n" + marker + ) + return {"published": True, "duplicate": False, "reason": "published", + "episodes": len(tuple(episodes))} + + def _lineage_main(args: argparse.Namespace, *, head: Callable[[str, str], str] = _gh_head, - labels: Callable[..., None] = _gh_apply_labels) -> int: + labels: Callable[..., None] = _gh_apply_labels, + comment_bodies: Callable[[str, str], Sequence[str]] = _gh_comment_bodies, + publish_comment: Callable[[str, str, str], None] = _gh_publish_comment) -> int: """Report the resolved lineage, and optionally reconcile the active label. Reading is always safe; the mutation happens only when it is asked for, and @@ -1254,7 +1330,7 @@ def _lineage_main(args: argparse.Namespace, *, number = _text(args.pr) identity = load_identity(args.identity_json or None) episodes: tuple[Any, ...] = () - root = args.state_dir or lane_handoff.default_root() + root = args.state_dir or lane_handoff.configured_root() try: episodes = builder_lineage.load_episodes( lane_handoff.lineage_root(root), repo, number @@ -1264,6 +1340,35 @@ def _lineage_main(args: argparse.Namespace, *, opener_lane, _ = builder_lineage.lanes_from_identity( identity=identity, labels=args.labels, author=args.author ) + published: dict | None = None + if args.publish: + _, publish_label_lanes = builder_lineage.lanes_from_identity( + identity=identity, labels=args.labels, author=args.author + ) + published = publish_lineage_evidence( + repo=repo, pr_number=number, branch=args.branch, head_sha=args.head, + episodes=episodes, opener_lane=opener_lane, + label_lanes=publish_label_lanes, + existing_bodies=lambda: comment_bodies(repo, number), + publish=lambda body: publish_comment(repo, number, body), + ) + if not (published["published"] or published.get("duplicate")): + # The label says who may write next; the published episodes are how + # everyone else verifies it. Moving the label without them is the + # exact state that produces a gate conflict, so stop here instead. + payload = {"schema": builder_lineage.SCHEMA, "status": "blocked", + "reason": "lineage_unpublished", "head_sha": args.head, + "current_writer": "", "add": [], "remove": [], + "applied": False, "published": False, + "owner_action": published.get("owner_action") + or "publish verified builder lineage before reconciling the label"} + _assert_safe_metadata(payload, path="lineage") + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(f"lineage {payload['status']}: {payload['reason']}") + return 3 + if args.reconcile_labels: payload = builder_lineage.reconcile_active_builder_label( repo=repo, @@ -1290,6 +1395,9 @@ def _lineage_main(args: argparse.Namespace, *, opener_lane=opener_lane, label_lanes=label_lanes, ).as_dict() + if published is not None: + payload = dict(payload) + payload["published"] = bool(published["published"] or published.get("duplicate")) _assert_safe_metadata(payload, path="lineage") if args.json: print(json.dumps(payload, indent=2, sort_keys=True)) @@ -1449,15 +1557,33 @@ def _classify_main(args: argparse.Namespace) -> int: # no episode, so a lane that was launched and wrote nothing never appears as # a contributor to the diff. lineage_result = None - if handoff is not None and args.handoff_state_dir is not None: + if args.handoff_state_dir is not None: from . import lane_handoff - lineage_result = lane_handoff.record_contribution( - handoff, - args.handoff_state_dir, - resulting_head=after.head_sha, - delivered=outcome.delivered, - ) + if handoff is not None: + lineage_result = lane_handoff.record_contribution( + handoff, + args.handoff_state_dir, + resulting_head=after.head_sha, + delivered=outcome.delivered, + ) + elif args.lane and args.repo and after.kind == "pr" and after.number: + # An ordinary fix round after a takeover carries no handoff, but it + # still advances the head. Without recording it the lineage would + # stay permanently behind and exact-head resolution would refuse + # every reviewer. This records only what happened: the same lane + # the record already names as current writer, continuing from the + # head that record left behind. + lineage_result = lane_handoff.record_continuation( + args.handoff_state_dir, + repo=args.repo, + pr_number=after.number, + branch=after.branch, + lane=args.lane, + expected_head=before.head_sha, + resulting_head=after.head_sha, + delivered=outcome.delivered, + ) event = None if args.lane and args.repo: diff --git a/src/code_mower/lane_handoff.py b/src/code_mower/lane_handoff.py index cdeacb69..fd6db88d 100644 --- a/src/code_mower/lane_handoff.py +++ b/src/code_mower/lane_handoff.py @@ -20,10 +20,34 @@ from .remote_session import DevinProvider, FakeProvider, RemoteSessions +#: The runner exports this before it writes anything, and every reader has to +#: resolve the same directory or it will silently consult a different store. +STATE_DIR_ENV = "LANE_HANDOFF_STATE_DIR" + + def default_root() -> Path: return (Path.home() / ".local/share/code-mower/lane-handoffs").resolve() +def configured_root(environ: dict | None = None) -> Path: + """The handoff state directory this checkout is actually configured to use. + + The shell runner honours ``LANE_HANDOFF_STATE_DIR`` when it records; a + reader that ignored it would answer from an empty or stale store and could + admit a contributor or refuse an independent reviewer. A configured but + non-absolute value is a misconfiguration rather than a second guess at the + location, so it fails closed instead of falling back to the default. + """ + + value = str((environ if environ is not None else os.environ).get(STATE_DIR_ENV, "")).strip() + if not value: + return default_root() + path = Path(value) + if not path.is_absolute(): + raise LaneDeliveryError(f"{STATE_DIR_ENV} must be an absolute path") + return path.resolve() + + def key(value: object) -> str: return "h" + hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()[:62] @@ -254,6 +278,72 @@ def record_contribution(handoff: Handoff, root: Path, *, resulting_head: str, "sequence": sequence, "episodes": outcome["episodes"]} +def record_continuation(root: Path, *, repo: str, pr_number: object, branch: str, + lane: str, expected_head: str, resulting_head: str, + delivered: bool) -> dict: + """Persist an ordinary same-writer round that advanced an existing lineage. + + After a takeover, the destination lane keeps working: a normal fix round + moves the head with no new handoff to record, and exact-head resolution + would then report ``lineage_behind_head`` for good. Reusing the original + handoff cannot repair that -- its recorded sequence rejects a different + resulting head -- and a self-handoff is invalid by construction. + + So this records the round that actually happened, and only that. It never + manufactures a takeover: it refuses unless the recorded tip already names + ``lane`` as the current writer, unless the round started from exactly the + head that tip left behind, and unless the head genuinely moved. There is no + source writer to quiesce and no launch to reserve, because no other lane is + being displaced -- the writer that went quiescent is this lane's own + supervised round, which the caller has already terminated and reaped. + """ + from .builder_lineage import ( + CONTINUATION_KIND, LineageError, continuation_episode, load_episodes, record_episode, + ) + + if not delivered: + return {"recorded": False, "reason": "delivery_unvalidated"} + store = lineage_root(root) + try: + episodes = load_episodes(store, repo, pr_number) + except LineageError as exc: + raise LaneDeliveryError(str(exc)) from None + if not episodes: + # No takeover has been recorded, so nothing is behind the head: this is + # the ordinary single-builder case, which needs no episode at all. + return {"recorded": False, "reason": "no_recorded_lineage"} + observed = str(resulting_head or "").strip().lower() + started = str(expected_head or "").strip().lower() + writer = str(lane or "").strip().lower() + for episode in episodes: + if (episode.kind == CONTINUATION_KIND and episode.expected_head == started + and episode.resulting_head == observed + and episode.destination_lane == writer): + # Replay of a round already recorded. Idempotent, never a duplicate. + return {"recorded": False, "duplicate": True, "reason": "already_recorded", + "sequence": episode.sequence, "episodes": len(episodes)} + tip = episodes[-1] + if tip.destination_lane != writer: + return {"recorded": False, "reason": "not_current_writer"} + if tip.resulting_head != started: + # Either this round did not start where the lineage stopped, or the + # evidence is stale. Guessing across that gap is what exact-head + # resolution exists to refuse. + return {"recorded": False, "reason": "continuation_unchained"} + if observed == started: + return {"recorded": False, "reason": "head_unchanged"} + try: + episode = continuation_episode( + tip, lane=writer, resulting_head=observed, branch=str(branch or ""), + ) + outcome = record_episode(store, episode) + except LineageError as exc: + raise LaneDeliveryError(str(exc)) from None + return {"recorded": outcome["recorded"], "duplicate": outcome["duplicate"], + "reason": "recorded" if outcome["recorded"] else "already_recorded", + "sequence": episode.sequence, "episodes": outcome["episodes"]} + + def reserve_launch(handoff: Handoff, root: Path, *, head: Callable = observe_head, stop: Callable = quiesce) -> bool: identity = key([handoff.target_pr.lower(), handoff.expected_head]) diff --git a/src/code_mower/provider_runners/lineage.py b/src/code_mower/provider_runners/lineage.py index 35df1fe2..d130bfd2 100644 --- a/src/code_mower/provider_runners/lineage.py +++ b/src/code_mower/provider_runners/lineage.py @@ -133,19 +133,31 @@ def identity_with_lane_floor(identity: Mapping[str, Any] | None, lane: str) -> M def recorded_episodes(repo: str, pr_number: Any, state_dir: Any = None) -> tuple: """Load contribution episodes the verified delivery boundary persisted. - This is the wrapper-side counterpart of the runner's record. An unreadable - record raises :class:`~code_mower.builder_lineage.LineageError` so the + This is the wrapper-side counterpart of the runner's record, so it must + resolve the *same* directory the runner writes to. When the deployment + configures ``LANE_HANDOFF_STATE_DIR``, reading the packaged default instead + would consult an empty store and miss every verified contribution. + + An unreadable record -- including a configured directory that cannot be + resolved -- raises :class:`~code_mower.builder_lineage.LineageError` so the caller fails closed; a checkout with no record at all simply has no episodes, which is the ordinary single-builder case. """ from pathlib import Path - from ..builder_lineage import load_episodes - from ..lane_handoff import default_root, lineage_root - - root = lineage_root(Path(state_dir) if state_dir is not None else default_root()) - return load_episodes(root, repo, pr_number) + from ..builder_lineage import LineageError, load_episodes + from ..lane_delivery import LaneDeliveryError + from ..lane_handoff import configured_root, lineage_root + + if state_dir is not None: + root = Path(state_dir) + else: + try: + root = configured_root() + except LaneDeliveryError as exc: + raise LineageError(str(exc)) from None + return load_episodes(lineage_root(root), repo, pr_number) def trusted_episodes( diff --git a/src/code_mower/saas_reviewer_labeler.py b/src/code_mower/saas_reviewer_labeler.py index 6df6e3a6..0a591959 100644 --- a/src/code_mower/saas_reviewer_labeler.py +++ b/src/code_mower/saas_reviewer_labeler.py @@ -14,7 +14,7 @@ import os import sys from pathlib import Path -from typing import Any, Optional, Sequence +from typing import Any, Mapping, Optional, Sequence if __package__ and __package__.startswith("code_mower"): from .adapters import load_adapter @@ -23,10 +23,15 @@ GitHubToken, LabelDecision, GitHubRequestError, + LineageContext, + LineageError, apply_label_decision, author_exclusion_reason, fetch_pull_request, github_request_with_fallback, + lineage_context, + lineage_decision_authorities, + lineage_marker_author_trust, load_json, sha_matches, ) @@ -38,10 +43,15 @@ GitHubToken, LabelDecision, GitHubRequestError, + LineageContext, + LineageError, apply_label_decision, author_exclusion_reason, fetch_pull_request, github_request_with_fallback, + lineage_context, + lineage_decision_authorities, + lineage_marker_author_trust, load_json, sha_matches, ) @@ -52,10 +62,15 @@ GitHubToken, LabelDecision, GitHubRequestError, + LineageContext, + LineageError, apply_label_decision, author_exclusion_reason, fetch_pull_request, github_request_with_fallback, + lineage_context, + lineage_decision_authorities, + lineage_marker_author_trust, load_json, sha_matches, ) @@ -216,8 +231,32 @@ def resolve_label_decision( current_head_sha: Optional[str] = None, review_comments: Optional[list[dict[str, Any]]] = None, same_head_review_exists: bool = False, + repo: str = "", + head_branch: str = "", + issue_comments: Optional[Sequence[Mapping[str, Any]]] = None, + decision_authorities: Sequence[str] = (), ) -> tuple[Optional[LabelDecision], str]: event_type = event_type or adapter.event_type + # Exact-head lineage is resolved once, from trusted inputs this process + # fetched itself, and handed to whichever entry path decides. Without it + # every SaaS reviewer would fall back to identity-only resolution and treat + # a reconciled takeover as a conflict, skipping its own done-label update. + number = pr_number or int( + (event.get("issue") or {}).get("number") + or (event.get("pull_request") or {}).get("number") + or 0 + ) + try: + lineage = lineage_context( + repo=repo, + pr_number=number, + branch=head_branch, + head_sha=current_head_sha, + comments=issue_comments, + trusted_author=lineage_marker_author_trust(authorities=decision_authorities), + ) + except LineageError: + return None, "published builder lineage is unreadable; skipping label update" if event_type == "pull_request_review": return _resolve_pull_request_review( event, @@ -227,6 +266,7 @@ def resolve_label_decision( pr_body=pr_body, current_head_sha=current_head_sha, review_comments=review_comments or [], + lineage=lineage, ) if event_type == "issue_comment": return _resolve_issue_comment( @@ -235,6 +275,7 @@ def resolve_label_decision( pr_labels=pr_labels or [], pr_author=pr_author, pr_body=pr_body, + lineage=lineage, ) if event_type == "check_run": return _resolve_check_run( @@ -246,6 +287,7 @@ def resolve_label_decision( pr_body=pr_body, current_head_sha=current_head_sha, same_head_review_exists=same_head_review_exists, + lineage=lineage, ) return None, f"unsupported adapter event type: {event_type}" @@ -259,6 +301,7 @@ def _resolve_pull_request_review( pr_body: str, current_head_sha: Optional[str], review_comments: list[dict[str, Any]], + lineage: Optional[LineageContext] = None, ) -> tuple[Optional[LabelDecision], str]: if event.get("action") not in ("submitted", "edited"): return None, f"unsupported pull_request_review action: {event.get('action')}" @@ -283,6 +326,7 @@ def _resolve_pull_request_review( labels=pr_labels, author=pr_author, text=pr_body, + lineage=lineage, ) if exclusion: return None, exclusion @@ -320,6 +364,7 @@ def _resolve_issue_comment( pr_labels: list[str], pr_author: str, pr_body: str, + lineage: Optional[LineageContext] = None, ) -> tuple[Optional[LabelDecision], str]: if event.get("action") not in ("created", "edited"): return None, f"unsupported issue_comment action: {event.get('action')}" @@ -349,6 +394,7 @@ def _resolve_issue_comment( labels=pr_labels, author=issue_author, text=issue_body, + lineage=lineage, ) if exclusion: return None, exclusion @@ -374,6 +420,7 @@ def _resolve_check_run( pr_body: str, current_head_sha: Optional[str], same_head_review_exists: bool, + lineage: Optional[LineageContext] = None, ) -> tuple[Optional[LabelDecision], str]: if event.get("action") != "completed": return None, f"unsupported check_run action: {event.get('action')}" @@ -399,6 +446,7 @@ def _resolve_check_run( labels=pr_labels, author=pr_author, text=pr_body, + lineage=lineage, ) if exclusion: return None, exclusion @@ -605,6 +653,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int: pr_labels: list[str] = [] current_head_sha = os.environ.get("DRY_RUN_HEAD_SHA") + head_branch = os.environ.get("DRY_RUN_HEAD_BRANCH", "") + lineage_comments: Optional[Sequence[Mapping[str, Any]]] = None review_comments: list[dict[str, Any]] = [] same_head_review_exists = False pr_number = _event_pr_number(event, adapter, event_type) @@ -697,6 +747,9 @@ def main(argv: Optional[Sequence[str]] = None) -> int: pr_author=candidate_author, pr_body=candidate_body, current_head_sha=candidate_head, + repo=repo, + head_branch=str((pr_current.get("head") or {}).get("ref") or ""), + decision_authorities=lineage_decision_authorities(), ) print(f"skip: {reason}") continue @@ -710,6 +763,9 @@ def main(argv: Optional[Sequence[str]] = None) -> int: pr_author=candidate_author, pr_body=candidate_body, current_head_sha=candidate_head, + repo=repo, + head_branch=str((pr_current.get("head") or {}).get("ref") or ""), + decision_authorities=lineage_decision_authorities(), ) if decision is None: print(f"skip: {reason}") @@ -753,6 +809,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: pr_author = str(((pr_current.get("user") or {}).get("login") or "")) pr_body = str(pr_current.get("body") or "") current_head_sha = pr_current.get("head", {}).get("sha") + head_branch = str((pr_current.get("head") or {}).get("ref") or "") if adapter.requires_review_comments: review = event.get("review") or {} review_id = review.get("id") @@ -844,6 +901,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int: pr_author=pr_author, pr_body=pr_body, current_head_sha=current_head_sha, + repo=repo, + head_branch=str((pr_current.get("head") or {}).get("ref") or ""), + issue_comments=comments, + decision_authorities=lineage_decision_authorities(), ) if decision is None: continue @@ -863,6 +924,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int: current_head_sha=current_head_sha, review_comments=review_comments, same_head_review_exists=same_head_review_exists, + repo=repo, + head_branch=head_branch, + issue_comments=lineage_comments, + decision_authorities=lineage_decision_authorities(), ) if decision is None: print(f"skip: {reason}") diff --git a/src/code_mower/templates/lanes/run_mac_lane.sh b/src/code_mower/templates/lanes/run_mac_lane.sh index 03351107..5202be5e 100644 --- a/src/code_mower/templates/lanes/run_mac_lane.sh +++ b/src/code_mower/templates/lanes/run_mac_lane.sh @@ -1569,22 +1569,30 @@ if [ "${#lane_delivery[@]}" -gt 0 ] && [ "$mode" != "audit" ]; then # the accepted private intent. The episode is written from the acceptance # record and a fresh head observation, never from anything declared here, so # a runner that merely names a handoff records nothing. - [ -n "$handoff_file" ] && classify_args+=(--handoff "$handoff_file" --handoff-state-dir "$HANDOFF_STATE_DIR") + # The state directory travels even without a handoff: an ordinary fix round + # after a takeover has no new handoff to record but still advances the head, + # and lineage that stops short of it refuses every reviewer. + classify_args+=(--handoff-state-dir "$HANDOFF_STATE_DIR") + [ -n "$handoff_file" ] && classify_args+=(--handoff "$handoff_file") set +e "${lane_delivery[@]}" "${classify_args[@]}" delivery_rc=$? set -e - # Reconcile to exactly one active builder label from the verified current - # writer. Historical contributions stay in the lineage record; the label only - # says who may write next. Unresolved lineage changes no label. - if [ -n "$handoff_file" ] && [ "$kind" = "pr" ] && [ "$delivery_rc" -eq 0 ]; then + # Publish the verified episodes, then reconcile to exactly one active builder + # label from the verified current writer. Publication comes first and the + # label move is abandoned without it: the GitHub gate reads episodes only + # from trusted comments, so a moved label with no published evidence is the + # conflict this whole path exists to prevent. Historical contributions stay + # in the record; the label only says who may write next. Unresolved lineage + # publishes nothing and changes no label. + if [ "$kind" = "pr" ] && [ "$delivery_rc" -eq 0 ]; then reconcile_head="$(jq -r '.head_sha // ""' "$after_state" 2>/dev/null || printf '')" reconcile_branch="$(jq -r '.branch // ""' "$after_state" 2>/dev/null || printf '')" if [ -n "$reconcile_head" ]; then reconcile_args=( lineage --repo "$REPO" --pr "$num" --head "$reconcile_head" --branch "$reconcile_branch" --state-dir "$HANDOFF_STATE_DIR" - --reconcile-labels --json + --publish --reconcile-labels --json ) while IFS= read -r reconcile_label; do [ -n "$reconcile_label" ] || continue diff --git a/src/code_mower/trailer_comment_labeler.py b/src/code_mower/trailer_comment_labeler.py index 8749e94a..e6f22a5f 100644 --- a/src/code_mower/trailer_comment_labeler.py +++ b/src/code_mower/trailer_comment_labeler.py @@ -23,8 +23,11 @@ GitHubToken, LabelDecision, apply_label_decision, + LineageError, author_exclusion_reason, extract_reviewed_sha, + lineage_context, + lineage_marker_author_trust, fetch_pull_request, fetch_issue_comments, github_actions_comment_attested, @@ -42,8 +45,11 @@ GitHubToken, LabelDecision, apply_label_decision, + LineageError, author_exclusion_reason, extract_reviewed_sha, + lineage_context, + lineage_marker_author_trust, fetch_pull_request, fetch_issue_comments, github_actions_comment_attested, @@ -60,8 +66,11 @@ GitHubToken, LabelDecision, apply_label_decision, + LineageError, author_exclusion_reason, extract_reviewed_sha, + lineage_context, + lineage_marker_author_trust, fetch_pull_request, fetch_issue_comments, github_actions_comment_attested, @@ -235,6 +244,7 @@ def resolve_label_decision( current_head_sha: Optional[str], config: LaneConfig, repo: str = "", + head_branch: str = "", tokens: Sequence[GitHubToken] = (), github_actions_workflows: Sequence[str] = (), actions_run_lookup: Optional[Callable[[str], Mapping[str, Any]]] = None, @@ -298,11 +308,26 @@ def resolve_label_decision( if isinstance(label, dict) and str(label.get("name") or "") ] issue_author = str(((issue.get("user") or {}).get("login") or "")) + # Exact-head lineage, not identity alone. Without this the labeler cannot + # tell a reconciled takeover from a conflict, and an independent reviewer's + # successful verdict on a handed-over PR would never reach its done label. + try: + lineage = lineage_context( + repo=repo, + pr_number=issue_number, + branch=head_branch, + head_sha=current_head_sha, + comments=_comments_with_event_comment(issue_comments or (), comment), + trusted_author=lineage_marker_author_trust(authorities=decision_authorities), + ) + except LineageError: + return None, "published builder lineage is unreadable; skipping label update" exclusion = author_exclusion_reason( lane_name=config.name, labels=issue_labels, author=issue_author, text=str(issue.get("body") or ""), + lineage=lineage, ) if exclusion: return None, exclusion @@ -371,6 +396,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: event = load_json(event_path) current_head_sha = os.environ.get("DRY_RUN_HEAD_SHA") + head_branch = os.environ.get("DRY_RUN_HEAD_BRANCH", "") issue_comments: Sequence[Mapping[str, Any]] | None = None comment_history_complete = True tokens = config.github_tokens_from_env() @@ -382,7 +408,9 @@ def main(argv: Optional[Sequence[str]] = None) -> int: issue = event.get("issue") or {} issue_number = int(issue.get("number", 0)) if issue_number and "pull_request" in issue: - current_head_sha = fetch_pull_request(repo, issue_number, tokens=tokens)["head"]["sha"] + pull_request = fetch_pull_request(repo, issue_number, tokens=tokens) + current_head_sha = pull_request["head"]["sha"] + head_branch = str((pull_request.get("head") or {}).get("ref") or "") page_cap = int(os.environ.get("CODE_MOWER_LABELER_COMMENT_PAGE_CAP", "10")) try: issue_comments = fetch_issue_comments( @@ -406,6 +434,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: current_head_sha=current_head_sha, config=config, repo=repo, + head_branch=head_branch, tokens=tokens, github_actions_workflows=github_actions_workflows, issue_comments=issue_comments, diff --git a/templates/lanes/run_mac_lane.sh b/templates/lanes/run_mac_lane.sh index 03351107..5202be5e 100644 --- a/templates/lanes/run_mac_lane.sh +++ b/templates/lanes/run_mac_lane.sh @@ -1569,22 +1569,30 @@ if [ "${#lane_delivery[@]}" -gt 0 ] && [ "$mode" != "audit" ]; then # the accepted private intent. The episode is written from the acceptance # record and a fresh head observation, never from anything declared here, so # a runner that merely names a handoff records nothing. - [ -n "$handoff_file" ] && classify_args+=(--handoff "$handoff_file" --handoff-state-dir "$HANDOFF_STATE_DIR") + # The state directory travels even without a handoff: an ordinary fix round + # after a takeover has no new handoff to record but still advances the head, + # and lineage that stops short of it refuses every reviewer. + classify_args+=(--handoff-state-dir "$HANDOFF_STATE_DIR") + [ -n "$handoff_file" ] && classify_args+=(--handoff "$handoff_file") set +e "${lane_delivery[@]}" "${classify_args[@]}" delivery_rc=$? set -e - # Reconcile to exactly one active builder label from the verified current - # writer. Historical contributions stay in the lineage record; the label only - # says who may write next. Unresolved lineage changes no label. - if [ -n "$handoff_file" ] && [ "$kind" = "pr" ] && [ "$delivery_rc" -eq 0 ]; then + # Publish the verified episodes, then reconcile to exactly one active builder + # label from the verified current writer. Publication comes first and the + # label move is abandoned without it: the GitHub gate reads episodes only + # from trusted comments, so a moved label with no published evidence is the + # conflict this whole path exists to prevent. Historical contributions stay + # in the record; the label only says who may write next. Unresolved lineage + # publishes nothing and changes no label. + if [ "$kind" = "pr" ] && [ "$delivery_rc" -eq 0 ]; then reconcile_head="$(jq -r '.head_sha // ""' "$after_state" 2>/dev/null || printf '')" reconcile_branch="$(jq -r '.branch // ""' "$after_state" 2>/dev/null || printf '')" if [ -n "$reconcile_head" ]; then reconcile_args=( lineage --repo "$REPO" --pr "$num" --head "$reconcile_head" --branch "$reconcile_branch" --state-dir "$HANDOFF_STATE_DIR" - --reconcile-labels --json + --publish --reconcile-labels --json ) while IFS= read -r reconcile_label; do [ -n "$reconcile_label" ] || continue diff --git a/tests/test_builder_lineage_consumers.py b/tests/test_builder_lineage_consumers.py new file mode 100644 index 00000000..a3278837 --- /dev/null +++ b/tests/test_builder_lineage_consumers.py @@ -0,0 +1,703 @@ +"""Consumer-level regressions for exact-head builder lineage. + +These exercise the production seams the resolver's own unit tests cannot: the +generated product gate's dependency set, the runner's publish-then-reconcile +ordering, the trailer/SaaS labeler callers, continuation recording after a +takeover, and the configured handoff directory. Each one is written from the +#959 shape -- a Devin-opened PR taken over by Codex, audited by an independent +Claude -- because that is the case every single-signal answer got wrong. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from code_mower import builder_lineage, init, lane_delivery, lane_handoff # noqa: E402 +from code_mower.audit_labeler_lib import ( # noqa: E402 + NO_LINEAGE, + author_exclusion_reason, + builder_identity_matches, + lineage_context, + lineage_marker_author_trust, +) +from code_mower.provider_runners import lineage as reviewer_lineage # noqa: E402 + +REPO = "codemower-ai/code-mower" +PR = 959 +BRANCH = "devin/959-thing" +OPENED = "a" * 40 +TAKEN = "b" * 40 +FIXED = "c" * 40 + +IDENTITY = { + "enabled": True, + "labels": {"builder:devin": "devin", "builder:codex": "codex", "builder:claude": "claude"}, + "authors": {"devin-ai-integration[bot]": "devin", "codex[bot]": "codex"}, +} + + +def git_free_tempdir(case: unittest.TestCase, prefix: str = "code-mower-lineage-") -> Path: + """A private store root outside any checkout, as the context store demands.""" + + base = Path(tempfile.gettempdir()).resolve() + if any((parent / ".git").exists() for parent in (base, *base.parents)): + base = Path("/tmp").resolve() + if any((parent / ".git").exists() for parent in (base, *base.parents)): + case.skipTest("no Git-free temporary directory is available here") + path = Path(tempfile.mkdtemp(prefix=prefix, dir=str(base))).resolve() + case.addCleanup(__import__("shutil").rmtree, path, True) + return path + + +def takeover_episode(sequence: int = 1, resulting: str = TAKEN) -> builder_lineage.ContributionEpisode: + """The verified Devin -> Codex takeover that produced the current head.""" + + return builder_lineage.ContributionEpisode( + sequence=sequence, + kind=builder_lineage.HANDOFF_KIND, + repo=REPO, + pr_number=PR, + branch=BRANCH, + source_lane="devin", + destination_lane="codex", + expected_head=OPENED, + resulting_head=resulting, + writer_state="terminated", + ) + + +class GeneratedProductSupportFiles(unittest.TestCase): + """codex:5f53e76584997c80c318 -- the gate helper's dependency must travel.""" + + def test_builder_lineage_is_a_generated_product_support_file(self): + targets = {target for target, _, _, _ in init.PRODUCT_SUPPORT_FILES} + self.assertIn("tools/audit_labeler_lib.py", targets) + self.assertIn("tools/builder_lineage.py", targets) + + def test_every_import_of_the_helper_is_copied_alongside_it(self): + """Whatever audit_labeler_lib imports has to be in the same list.""" + + sources = { + target: package_copy_from + for target, package_copy_from, _, _ in init.PRODUCT_SUPPORT_FILES + if target.startswith("tools/") and target.endswith(".py") + } + helper = Path("src/code_mower") / sources["tools/audit_labeler_lib.py"] + text = helper.read_text(encoding="utf-8") + for module in ("builder_lineage", "decisions", "context_review"): + self.assertTrue( + f"from {module} import" in text or f"import {module} as" in text, + f"{module} is not imported by the helper", + ) + self.assertIn(f"tools/{module}.py", sources) + + def test_generated_gate_imports_its_helper_without_the_package(self): + """A product gate runner has no code_mower installed. Prove it works.""" + + if True: + tmp = git_free_tempdir(self) + root = Path(tmp) + tools = root / "tools" + tools.mkdir() + (tools / "__init__.py").write_text("", encoding="utf-8") + for target, package_copy_from, _, _ in init.PRODUCT_SUPPORT_FILES: + if not (target.startswith("tools/") and target.endswith(".py")): + continue + source = Path("src/code_mower") / package_copy_from + if not source.exists(): # templated wrappers, not package modules + continue + (root / target).write_text(source.read_text(encoding="utf-8"), encoding="utf-8") + # Exactly what a generated gate step does: run a script from the + # product repository root, with no Code Mower package anywhere on + # the path, and import the helper plus its dependency. + (root / "probe.py").write_text( + "import tools.audit_labeler_lib as lib\n" + "print(lib.builder_identity_matches(" + "labels=['builder:codex'], author='codex[bot]', text=''," + " config={'enabled': True, 'labels': {'builder:codex': 'codex'}," + " 'authors': {'codex[bot]': 'codex'}}))\n", + encoding="utf-8", + ) + environment = { + key: value + for key, value in os.environ.items() + if key not in {"PYTHONPATH", "PYTHONHOME"} + } + result = subprocess.run( + [sys.executable, "probe.py"], + cwd=root, capture_output=True, text=True, timeout=120, env=environment, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("codex", result.stdout) + + +class ConfiguredHandoffDirectory(unittest.TestCase): + """codex:69acb7733dd8b7ba1b31 -- one store for recording and admission.""" + + def test_reviewer_resolves_the_configured_directory(self): + if True: + tmp = git_free_tempdir(self) + configured = tmp / "configured" + builder_lineage.record_episode( + lane_handoff.lineage_root(configured), takeover_episode() + ) + with mock.patch.dict( + os.environ, {lane_handoff.STATE_DIR_ENV: str(configured)}, clear=False + ): + episodes = reviewer_lineage.recorded_episodes(REPO, PR) + self.assertEqual(len(episodes), 1) + self.assertEqual(episodes[0].destination_lane, "codex") + + def test_unconfigured_reviewer_still_reads_the_default_root(self): + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop(lane_handoff.STATE_DIR_ENV, None) + self.assertEqual(lane_handoff.configured_root(), lane_handoff.default_root()) + + def test_a_relative_configured_directory_fails_closed(self): + with mock.patch.dict( + os.environ, {lane_handoff.STATE_DIR_ENV: "relative/store"}, clear=False + ): + with self.assertRaises(builder_lineage.LineageError): + reviewer_lineage.recorded_episodes(REPO, PR) + + def test_recording_and_admission_agree_on_the_configured_store(self): + """The bug: the reviewer read an empty default and admitted a contributor.""" + + if True: + tmp = git_free_tempdir(self) + configured = tmp / "configured" + builder_lineage.record_episode( + lane_handoff.lineage_root(configured), takeover_episode() + ) + pr_meta = { + "labels": [{"name": "builder:codex"}], + "user": {"login": "devin-ai-integration[bot]"}, + "head": {"ref": BRANCH}, + } + with mock.patch.dict( + os.environ, {lane_handoff.STATE_DIR_ENV: str(configured)}, clear=False + ): + episodes = reviewer_lineage.recorded_episodes(REPO, PR) + for lane, admitted in (("codex", False), ("devin", False), ("claude", True)): + decision = reviewer_lineage.reviewer_admission( + lane, repo=REPO, pr_number=PR, pr_meta=pr_meta, head_sha=TAKEN, + episodes=episodes, identity=IDENTITY, + ) + self.assertEqual(decision["admitted"], admitted, lane) + + +class ContinuationDeliveries(unittest.TestCase): + """codex:7b1f8c5e122de3ff7727 -- an ordinary fix round after a takeover.""" + + def setUp(self): + self.root = git_free_tempdir(self) / "handoffs" + builder_lineage.record_episode(lane_handoff.lineage_root(self.root), takeover_episode()) + + def record(self, **overrides): + payload = dict( + repo=REPO, pr_number=PR, branch=BRANCH, lane="codex", + expected_head=TAKEN, resulting_head=FIXED, delivered=True, + ) + payload.update(overrides) + return lane_handoff.record_continuation(self.root, **payload) + + def test_a_new_head_is_recorded_and_resolves_at_that_head(self): + outcome = self.record() + self.assertTrue(outcome["recorded"]) + self.assertEqual(outcome["sequence"], 2) + episodes = builder_lineage.load_episodes( + lane_handoff.lineage_root(self.root), REPO, PR + ) + lineage = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, head_sha=FIXED, + episodes=episodes, opener_lane="devin", label_lanes=("codex",), + ) + self.assertEqual(lineage.status, "resolved") + self.assertEqual(lineage.current_writer, "codex") + self.assertEqual(lineage.contributors, ("devin", "codex")) + + def test_without_a_continuation_the_lineage_stays_behind_the_head(self): + """This is the defect: the same round with nothing recorded.""" + + episodes = builder_lineage.load_episodes( + lane_handoff.lineage_root(self.root), REPO, PR + ) + lineage = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, head_sha=FIXED, episodes=episodes, + ) + self.assertEqual(lineage.status, "waiting") + self.assertEqual(lineage.reason, "lineage_behind_head") + + def test_replay_is_idempotent(self): + self.assertTrue(self.record()["recorded"]) + replay = self.record() + self.assertFalse(replay["recorded"]) + self.assertTrue(replay["duplicate"]) + self.assertEqual(replay["sequence"], 2) + self.assertEqual( + len(builder_lineage.load_episodes(lane_handoff.lineage_root(self.root), REPO, PR)), + 2, + ) + + def test_stale_evidence_records_nothing(self): + outcome = self.record(expected_head="d" * 40) + self.assertFalse(outcome["recorded"]) + self.assertEqual(outcome["reason"], "continuation_unchained") + + def test_a_lane_that_is_not_the_current_writer_records_nothing(self): + outcome = self.record(lane="devin") + self.assertFalse(outcome["recorded"]) + self.assertEqual(outcome["reason"], "not_current_writer") + + def test_an_undelivered_or_unmoved_round_records_nothing(self): + self.assertEqual(self.record(delivered=False)["reason"], "delivery_unvalidated") + self.assertEqual(self.record(resulting_head=TAKEN)["reason"], "head_unchanged") + + def test_an_ordinary_single_builder_pr_records_nothing(self): + if True: + tmp = git_free_tempdir(self) + outcome = lane_handoff.record_continuation( + Path(tmp), repo=REPO, pr_number=777, branch=BRANCH, lane="claude", + expected_head=OPENED, resulting_head=TAKEN, delivered=True, + ) + self.assertFalse(outcome["recorded"]) + self.assertEqual(outcome["reason"], "no_recorded_lineage") + + def test_a_continuation_is_not_a_handoff(self): + """It cannot be forged into one, or manufacture a new contributor.""" + + self.record() + episodes = builder_lineage.load_episodes( + lane_handoff.lineage_root(self.root), REPO, PR + ) + self.assertEqual(episodes[1].kind, builder_lineage.CONTINUATION_KIND) + self.assertEqual(episodes[1].source_lane, episodes[1].destination_lane) + with self.assertRaises(builder_lineage.LineageError): + builder_lineage.ContributionEpisode( + sequence=2, kind=builder_lineage.HANDOFF_KIND, repo=REPO, pr_number=PR, + branch=BRANCH, source_lane="codex", destination_lane="codex", + expected_head=TAKEN, resulting_head=FIXED, writer_state="terminated", + ) + + def test_lineage_that_starts_with_a_continuation_fails_closed(self): + lone = builder_lineage.ContributionEpisode( + sequence=1, kind=builder_lineage.CONTINUATION_KIND, repo=REPO, pr_number=PR, + branch=BRANCH, source_lane="codex", destination_lane="codex", + expected_head=OPENED, resulting_head=TAKEN, + writer_state=builder_lineage.CONTINUATION_WRITER_STATE, + ) + lineage = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, head_sha=TAKEN, episodes=(lone,), + ) + self.assertEqual(lineage.status, "conflict") + + +class ClassifyRecordsContinuations(unittest.TestCase): + """The producer path the runner actually calls, not a helper in isolation.""" + + def test_classify_records_a_continuation_without_a_handoff(self): + if True: + tmp = git_free_tempdir(self) + root = tmp / "handoffs" + builder_lineage.record_episode(lane_handoff.lineage_root(root), takeover_episode()) + recorded = {} + + def fake(state_dir, **kwargs): + recorded.update(kwargs) + recorded["state_dir"] = state_dir + return {"recorded": True, "reason": "recorded", "sequence": 2} + + args = SimpleNamespace( + before=None, after=None, provider_exit=0, declared_outcome="delivered", + supervision="none", handoff=None, handoff_state_dir=root, lane="codex", + repo=REPO, signal=[], elapsed_seconds=1, user_interventions=0, + output=None, force=True, json=True, + ) + before = SimpleNamespace(head_sha=TAKEN, kind="pr", number=PR, branch=BRANCH) + after = SimpleNamespace(head_sha=FIXED, kind="pr", number=PR, branch=BRANCH) + with mock.patch.object(lane_delivery, "_load_state", side_effect=[before, after]), \ + mock.patch.object( + lane_delivery, "classify_delivery", + return_value=SimpleNamespace( + delivered=True, transition="head_moved", reason="ok", + as_dict=lambda: {"delivered": True}, + ), + ), \ + mock.patch.object(lane_delivery, "build_delivery_outcome_event", + return_value={"event_id": "e"}), \ + mock.patch.object(lane_delivery, "write_delivery_outcome_event"), \ + mock.patch.object(lane_handoff, "record_continuation", fake): + lane_delivery._classify_main(args) + self.assertEqual(recorded["lane"], "codex") + self.assertEqual(recorded["expected_head"], TAKEN) + self.assertEqual(recorded["resulting_head"], FIXED) + self.assertTrue(recorded["delivered"]) + + +class PublishBeforeReconcile(unittest.TestCase): + """codex:4725ce39cb69b7ac68e7 -- the gate reads comments, not the store.""" + + def setUp(self): + self.root = git_free_tempdir(self) / "handoffs" + builder_lineage.record_episode(lane_handoff.lineage_root(self.root), takeover_episode()) + self.published: list[str] = [] + self.labelled: list[tuple] = [] + + def run_lineage(self, *, head=TAKEN, labels=("builder:devin",), bodies=None): + args = SimpleNamespace( + repo=REPO, pr=str(PR), branch=BRANCH, head=head, labels=list(labels), + author="devin-ai-integration[bot]", state_dir=self.root, + identity_json=json.dumps(IDENTITY), publish=True, reconcile_labels=True, + json=True, + ) + return lane_delivery._lineage_main( + args, + head=lambda repo, number: head, + labels=lambda repo, number, add, remove: self.labelled.append((add, remove)), + comment_bodies=lambda repo, number: tuple(bodies or self.published), + publish_comment=lambda repo, number, body: self.published.append(body), + ) + + def test_evidence_is_published_and_then_the_label_moves(self): + self.assertEqual(self.run_lineage(), 0) + self.assertEqual(len(self.published), 1) + marker = self.published[0] + self.assertIn(builder_lineage.LINEAGE_MARKER, marker) + self.assertEqual(self.labelled, [(("builder:codex",), ("builder:devin",))]) + + def test_the_published_marker_is_what_the_gate_reads(self): + self.run_lineage() + episodes = builder_lineage.episodes_from_comment_body(self.published[0]) + self.assertEqual(len(episodes), 1) + lineage = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, head_sha=TAKEN, episodes=episodes, + opener_lane="devin", label_lanes=("codex",), + ) + self.assertEqual(lineage.status, "resolved") + self.assertEqual(lineage.current_writer, "codex") + self.assertEqual(lineage.contributors, ("devin", "codex")) + + def test_publication_carries_only_bounded_metadata(self): + self.run_lineage() + payload = json.loads( + self.published[0].split(builder_lineage.LINEAGE_MARKER, 1)[1].rsplit("-->", 1)[0] + ) + for episode in payload["episodes"]: + self.assertEqual( + set(episode), set(builder_lineage.EPISODE_FIELDS), + "no field beyond the bounded episode contract may be published", + ) + body = self.published[0].lower() + for leaked in ("/users/", "/private/tmp", "session", "prompt", "token"): + self.assertNotIn(leaked, body) + + def test_publication_is_idempotent(self): + self.run_lineage() + already = list(self.published) + self.assertEqual(self.run_lineage(bodies=already), 0) + self.assertEqual(self.published, already) + + def test_unresolved_lineage_publishes_nothing_and_moves_no_label(self): + self.assertEqual(self.run_lineage(head=FIXED), 3) + self.assertEqual(self.published, []) + self.assertEqual(self.labelled, []) + + def test_a_failed_publication_leaves_the_label_alone(self): + def explode(repo, number, body): + raise subprocess.CalledProcessError(1, "gh") + + args = SimpleNamespace( + repo=REPO, pr=str(PR), branch=BRANCH, head=TAKEN, labels=["builder:devin"], + author="devin-ai-integration[bot]", state_dir=self.root, + identity_json=json.dumps(IDENTITY), publish=True, reconcile_labels=True, + json=True, + ) + with self.assertRaises(subprocess.CalledProcessError): + lane_delivery._lineage_main( + args, + head=lambda repo, number: TAKEN, + labels=lambda repo, number, add, remove: self.labelled.append((add, remove)), + comment_bodies=lambda repo, number: (), + publish_comment=explode, + ) + self.assertEqual(self.labelled, []) + + +class LabelerCallersCarryLineage(unittest.TestCase): + """codex:9bac84962f544d9fa4bb -- trusted evidence reaches the labelers.""" + + def setUp(self): + marker = builder_lineage.lineage_comment_marker((takeover_episode(),)) + self.comments = [ + {"user": {"login": "codemower-ai"}, "body": "lineage\n" + marker}, + {"user": {"login": "random-person"}, "body": "hello"}, + ] + + def context(self, comments=None, head=TAKEN): + return lineage_context( + repo=REPO, pr_number=PR, branch=BRANCH, head_sha=head, + comments=self.comments if comments is None else comments, + trusted_author=lineage_marker_author_trust(authorities=("codemower-ai",)), + ) + + def test_an_independent_claude_reviewer_may_update_its_done_label(self): + """Devin author + reconciled Codex label used to look like a conflict.""" + + self.assertIsNone( + author_exclusion_reason( + lane_name="claude", labels=["builder:codex"], + author="devin-ai-integration[bot]", text="", config=IDENTITY, + lineage=self.context(), + ) + ) + + def test_identity_only_resolution_would_have_skipped_it(self): + """The defect the finding describes, kept as an explicit regression.""" + + self.assertEqual( + author_exclusion_reason( + lane_name="claude", labels=["builder:codex"], + author="devin-ai-integration[bot]", text="", config=IDENTITY, + lineage=NO_LINEAGE, + ), + "conflicting builder identity; skipping author-excluded label update", + ) + + def test_every_contributor_stays_excluded(self): + for lane in ("devin", "codex"): + self.assertEqual( + author_exclusion_reason( + lane_name=lane, labels=["builder:codex"], + author="devin-ai-integration[bot]", text="", config=IDENTITY, + lineage=self.context(), + ), + f"{lane} lane excluded for builder-authored PR", + ) + + def test_matches_name_both_contributors_in_order(self): + self.assertEqual( + builder_identity_matches( + labels=["builder:codex"], author="devin-ai-integration[bot]", text="", + config=IDENTITY, lineage=self.context(), + ), + ("devin", "codex"), + ) + + def test_a_marker_from_an_untrusted_author_is_not_evidence(self): + untrusted = [{"user": {"login": "random-person"}, "body": self.comments[0]["body"]}] + self.assertEqual(self.context(comments=untrusted).episodes, ()) + + def test_lineage_behind_the_head_skips_rather_than_guesses(self): + self.assertEqual( + author_exclusion_reason( + lane_name="claude", labels=["builder:codex"], + author="devin-ai-integration[bot]", text="", config=IDENTITY, + lineage=self.context(head=FIXED), + ), + "builder contribution lineage is behind the current head; " + "skipping author-excluded label update", + ) + + def test_malformed_published_evidence_fails_closed(self): + broken = [{ + "user": {"login": "codemower-ai"}, + "body": f"", + }] + with self.assertRaises(builder_lineage.LineageError): + self.context(comments=broken) + + def test_an_unverified_head_carries_no_evidence(self): + self.assertEqual(self.context(head="").episodes, ()) + self.assertEqual(self.context(head="").repo, "") + + def test_an_ordinary_single_builder_pr_is_unaffected(self): + context = lineage_context( + repo=REPO, pr_number=777, branch=BRANCH, head_sha=OPENED, + comments=[], trusted_author=lineage_marker_author_trust(authorities=()), + ) + self.assertIsNone( + author_exclusion_reason( + lane_name="codex", labels=["builder:claude"], author="claude[bot]", + text="", config=IDENTITY, lineage=context, + ) + ) + self.assertEqual( + author_exclusion_reason( + lane_name="claude", labels=["builder:claude"], author="claude[bot]", + text="", config=IDENTITY, lineage=context, + ), + "claude lane excluded for builder-authored PR", + ) + + +class TrailerLabelerEntryPath(unittest.TestCase): + """The real trailer labeler decision, not the helper it calls.""" + + def test_resolve_label_decision_threads_trusted_lineage(self): + from code_mower import trailer_comment_labeler as labeler + + marker = builder_lineage.lineage_comment_marker((takeover_episode(),)) + seen = {} + + def capture(**kwargs): + seen.update(kwargs) + return None + + event = { + "action": "created", + "issue": { + "number": PR, + "pull_request": {}, + "labels": [{"name": "builder:codex"}], + "user": {"login": "devin-ai-integration[bot]"}, + "body": "", + }, + "comment": {"id": 1, "user": {"login": "codemower-ai"}, "body": "x"}, + } + config = SimpleNamespace( + name="claude", + comment_authors=lambda: {"codemower-ai"}, + is_configured_comment_author=lambda author: False, + is_default_comment_author=lambda author: True, + trailer_prefix="Claude-Audit", + display_name="Claude", + ) + with mock.patch.object(labeler, "author_exclusion_reason", capture), \ + mock.patch.object(labeler, "classify_audit_comment", return_value="done"), \ + mock.patch.object( + labeler.code_mower_decisions, + "collect_decision_records_from_comments", return_value=(), + ): + labeler.resolve_label_decision( + event, + current_head_sha=TAKEN, + config=config, + repo=REPO, + head_branch=BRANCH, + issue_comments=[{"user": {"login": "codemower-ai"}, "body": marker}], + decision_authorities=("codemower-ai",), + ) + self.assertEqual(seen["lineage"].repo, REPO) + self.assertEqual(seen["lineage"].head_sha, TAKEN) + self.assertEqual(seen["lineage"].branch, BRANCH) + self.assertEqual(len(seen["lineage"].episodes), 1) + + +class SaaSLabelerEntryPath(unittest.TestCase): + """Every SaaS reviewer entry path receives the same resolved lineage.""" + + def test_each_event_shape_receives_the_lineage(self): + from code_mower import saas_reviewer_labeler as labeler + + marker = builder_lineage.lineage_comment_marker((takeover_episode(),)) + comments = [{"user": {"login": "codemower-ai"}, "body": marker}] + adapter = SimpleNamespace( + name="greptile", event_type="issue_comment", opt_in_required=False, + label_prefix="greptile", needs_label="n", done_label="d", blocked_label="b", + is_opted_in=lambda labels: True, is_review_author=lambda author: True, + is_check_run_author=lambda check_run: True, + is_check_run_name=lambda check_run: True, + ) + for event_type, event in ( + ("pull_request_review", {"action": "submitted", "pull_request": {"number": PR}}), + ( + "issue_comment", + { + "action": "created", + "issue": {"number": PR, "pull_request": {}}, + "comment": {"user": {"login": "bot"}, "body": "x"}, + }, + ), + ( + "check_run", + {"action": "completed", "check_run": {"status": "completed"}}, + ), + ): + with self.subTest(event_type=event_type): + seen = {} + + def capture(**kwargs): + seen.update(kwargs) + return "stop" + + with mock.patch.object(labeler, "author_exclusion_reason", capture): + labeler.resolve_label_decision( + event, + adapter=adapter, + event_type=event_type, + pr_number=PR, + pr_labels=["builder:codex"], + pr_author="devin-ai-integration[bot]", + pr_body="", + current_head_sha=TAKEN, + repo=REPO, + head_branch=BRANCH, + issue_comments=comments, + decision_authorities=("codemower-ai",), + ) + self.assertEqual(seen["lineage"].head_sha, TAKEN) + self.assertEqual(len(seen["lineage"].episodes), 1) + + def test_unreadable_published_lineage_skips_the_update(self): + from code_mower import saas_reviewer_labeler as labeler + + adapter = SimpleNamespace( + name="greptile", event_type="issue_comment", opt_in_required=False, + label_prefix="greptile", needs_label="n", done_label="d", blocked_label="b", + is_opted_in=lambda labels: True, is_review_author=lambda author: True, + is_check_run_author=lambda check_run: True, + is_check_run_name=lambda check_run: True, + ) + decision, reason = labeler.resolve_label_decision( + {"action": "created", "issue": {"number": PR, "pull_request": {}}}, + adapter=adapter, + event_type="issue_comment", + pr_number=PR, + current_head_sha=TAKEN, + repo=REPO, + issue_comments=[{ + "user": {"login": "codemower-ai"}, + "body": f"", + }], + decision_authorities=("codemower-ai",), + ) + self.assertIsNone(decision) + self.assertIn("unreadable", reason) + + +class RoleEligibilityIsSeparate(unittest.TestCase): + """Contribution independence and role qualification are different questions.""" + + def test_admission_never_consults_role_eligibility(self): + source = Path("src/code_mower/provider_runners/lineage.py").read_text(encoding="utf-8") + self.assertNotIn("role_eligibility", source.replace( + "code_mower.role_eligibility", "" + ).replace("mod:``", "")) + + def test_an_independent_lane_is_admitted_on_contribution_grounds_alone(self): + pr_meta = { + "labels": [{"name": "builder:codex"}], + "user": {"login": "devin-ai-integration[bot]"}, + "head": {"ref": BRANCH}, + } + decision = reviewer_lineage.reviewer_admission( + "claude", repo=REPO, pr_number=PR, pr_meta=pr_meta, head_sha=TAKEN, + episodes=(takeover_episode(),), identity=IDENTITY, + ) + self.assertTrue(decision["admitted"]) + self.assertEqual(decision["contributors"], ["devin", "codex"]) + self.assertNotIn("role", json.dumps(decision)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/audit_labeler_lib.py b/tools/audit_labeler_lib.py index 3cacbe20..3367dac2 100644 --- a/tools/audit_labeler_lib.py +++ b/tools/audit_labeler_lib.py @@ -256,17 +256,124 @@ def resolve_builder_lineage( ) +@dataclass(frozen=True) +class LineageContext: + """The trusted exact-head evidence a labeler carries into resolution. + + Every field has to come from something the labeler verified for itself: the + repository it is running in, the head it fetched from the pull request, and + episodes published by an author it already trusts. An empty context is not + a failure -- it is the honest statement that this call has no exact-head + evidence, and resolution falls back to the ordinary single-builder answer. + """ + + repo: str = "" + pr_number: Any = 0 + branch: str = "" + head_sha: str = "" + episodes: tuple[Any, ...] = () + + +#: A labeler that has no exact-head evidence at all. +NO_LINEAGE = LineageContext() + + +def published_lineage_episodes( + comments: Sequence[Mapping[str, Any]] | None, + *, + trusted_author: Callable[[str], bool], +) -> tuple[Any, ...]: + """Contribution episodes published by comment authors the caller trusts. + + The hidden marker is a transport for bounded metadata, never an + authorization: trust is decided entirely by ``trusted_author``, so an + arbitrary commenter cannot assert a takeover into existence. Unreadable + published evidence raises :class:`LineageError` so the caller fails closed + rather than labelling from a partially parsed history. + """ + + collected: list[Any] = [] + for comment in comments or (): + if not isinstance(comment, Mapping): + continue + login = str(((comment.get("user") or {}).get("login")) or "") + body = str(comment.get("body") or "") + if not login or LINEAGE_MARKER not in body or not trusted_author(login): + continue + collected.extend(episodes_from_comment_body(body)) + return tuple(collected) + + +def lineage_decision_authorities() -> tuple[str, ...]: + """The repository's configured decision authorities, for marker trust.""" + + return code_mower_decisions.decision_authorities_from_env() + + +def lineage_marker_author_trust( + *, + authorities: Sequence[str] = (), +) -> Callable[[str], bool]: + """Who this labeler may read published lineage markers from. + + Trust is exactly the repository's configured decision authorities -- the + accounts it already treats as authoritative about its own state. Nothing + else is accepted, and in particular an audit bot able to post a verdict is + not thereby able to assert a takeover. An unconfigured checkout trusts + nobody, reads no episodes, and behaves exactly as it does today. + """ + + allowed = { + str(item).strip().lower().lstrip("@") + for item in authorities + if str(item).strip() + } + + def trusted(login: str) -> bool: + return bool(allowed) and str(login).strip().lower().lstrip("@") in allowed + + return trusted + + +def lineage_context( + *, + repo: str, + pr_number: Any, + branch: str = "", + head_sha: str | None = "", + comments: Sequence[Mapping[str, Any]] | None = (), + trusted_author: Callable[[str], bool] | None = None, +) -> LineageContext: + """Assemble exact-head lineage evidence for a labeler entry path. + + Returns :data:`NO_LINEAGE` when the head could not be verified, because + lineage resolved against an unverified head would decide the wrong diff. + Unreadable published evidence is propagated as a + :class:`LineageError` for the caller's fail-closed handling. + """ + + head = str(head_sha or "") + if not head or not repo or not pr_number: + return NO_LINEAGE + episodes: tuple[Any, ...] = () + if trusted_author is not None: + episodes = published_lineage_episodes(comments, trusted_author=trusted_author) + return LineageContext( + repo=str(repo), + pr_number=pr_number, + branch=str(branch or ""), + head_sha=head, + episodes=episodes, + ) + + def builder_identity_matches( *, labels: Sequence[str], author: str, text: str, config: Mapping[str, Any], - repo: str = "", - pr_number: Any = 0, - branch: str = "", - head_sha: str = "", - episodes: Sequence[Any] = (), + lineage: LineageContext | None = None, ) -> tuple[str, ...]: """Ordered verified builder lanes for this pull request. @@ -276,17 +383,18 @@ def builder_identity_matches( if not bool(config.get("enabled")): return () - lineage = resolve_builder_lineage( + evidence = lineage or NO_LINEAGE + resolved = resolve_builder_lineage( labels=labels, author=author, config=config, - repo=repo, - pr_number=pr_number, - branch=branch, - head_sha=head_sha, - episodes=episodes, + repo=evidence.repo, + pr_number=evidence.pr_number, + branch=evidence.branch, + head_sha=evidence.head_sha, + episodes=evidence.episodes, ) - if lineage.status != "resolved": + if resolved.status != "resolved": # Preserve the historical "more than one identity" shape so a caller # inspecting matches can still tell a conflict from a clean match. opener_lane, label_lanes = lanes_from_identity( @@ -295,8 +403,8 @@ def builder_identity_matches( candidates = tuple( dict.fromkeys(list(label_lanes) + ([opener_lane] if opener_lane else [])) ) - return candidates if len(candidates) > 1 else lineage.contributors - return lineage.contributors + return candidates if len(candidates) > 1 else resolved.contributors + return resolved.contributors def author_exclusion_reason( @@ -306,40 +414,40 @@ def author_exclusion_reason( author: str, text: str, config: Mapping[str, Any] | None = None, - repo: str = "", - pr_number: Any = 0, - branch: str = "", - head_sha: str = "", - episodes: Sequence[Any] = (), + lineage: LineageContext | None = None, ) -> str | None: """Why ``lane_name`` may not label its own work, or ``None``. Exclusion follows verified contribution, not authorship: every contributing lane is excluded, and a lane that merely opened the PR before handing it - over is excluded too because its commits are still in the diff. + over is excluded too because its commits are still in the diff. Conversely, + with exact-head evidence in ``lineage``, a lane that never touched this + head is *not* excluded even when a reconciled label names a different lane + than the opener -- which is the whole point of carrying the evidence here. """ exclusion_config = config or load_author_exclusion_config() if not bool(exclusion_config.get("enabled")): return None + evidence = lineage or NO_LINEAGE try: - lineage = resolve_builder_lineage( + resolved = resolve_builder_lineage( labels=labels, author=author, config=exclusion_config, - repo=repo, - pr_number=pr_number, - branch=branch, - head_sha=head_sha, - episodes=episodes, + repo=evidence.repo, + pr_number=evidence.pr_number, + branch=evidence.branch, + head_sha=evidence.head_sha, + episodes=evidence.episodes, ) except LineageError: return "builder contribution evidence is unreadable; skipping author-excluded label update" - if lineage.status == "conflict": + if resolved.status == "conflict": return "conflicting builder identity; skipping author-excluded label update" - if lineage.status == "waiting": + if resolved.status == "waiting": return "builder contribution lineage is behind the current head; skipping author-excluded label update" - if lineage.contributed(lane_name): + if resolved.contributed(lane_name): return f"{lane_name} lane excluded for builder-authored PR" return None diff --git a/tools/builder_lineage.py b/tools/builder_lineage.py index d7ff2458..d26af846 100644 --- a/tools/builder_lineage.py +++ b/tools/builder_lineage.py @@ -55,9 +55,25 @@ REPO_RE = re.compile(r"[A-Za-z0-9._-]{1,100}/[A-Za-z0-9._-]{1,100}\Z") BRANCH_RE = re.compile(r"[A-Za-z0-9._/-]{1,200}\Z") -#: Verified writer states the handoff boundary is allowed to report. Anything -#: else (including a missing or "unknown" state) is uncertainty. -WRITER_STATES = frozenset({"suspended", "terminated"}) +#: Verified writer states the handoff boundary is allowed to report for a +#: takeover. Anything else (including a missing or "unknown" state) is +#: uncertainty. +HANDOFF_WRITER_STATES = frozenset({"suspended", "terminated"}) + +#: The only writer state a continuation may carry. A continuation is recorded +#: after the destination lane's *own* supervised round ended and its process +#: group was reaped, so the writer that went quiescent is the recording lane +#: itself. Spelling that differently from the handoff states keeps a takeover +#: episode from ever being mistaken for a continuation, or the reverse. +CONTINUATION_WRITER_STATE = "self_quiescent" + +WRITER_STATES = HANDOFF_WRITER_STATES | {CONTINUATION_WRITER_STATE} + +#: A takeover moves the pen between two lanes; a continuation is the lane that +#: already holds it advancing the same pull request in an ordinary fix round. +HANDOFF_KIND = "handoff" +CONTINUATION_KIND = "continuation" +EPISODE_KINDS = frozenset({HANDOFF_KIND, CONTINUATION_KIND}) #: A lineage longer than this is treated as malformed rather than walked. MAX_EPISODES = 32 @@ -65,6 +81,7 @@ EPISODE_FIELDS = ( "schema", "sequence", + "kind", "repo", "pr_number", "branch", @@ -112,6 +129,12 @@ class ContributionEpisode: destination lane was launched against; ``resulting_head`` is the head the destination lane actually produced. A destination that never moved the head is still the writer, but it contributed nothing to the current diff. + + ``kind`` is ``handoff`` when the pen moved between two lanes and + ``continuation`` when the lane that already held it advanced the same pull + request again. A continuation is the only episode whose source and + destination lane are the same, and it must carry the self-quiescent writer + state, so neither shape can be forged into the other. """ sequence: int @@ -123,8 +146,16 @@ class ContributionEpisode: expected_head: str resulting_head: str writer_state: str + kind: str = HANDOFF_KIND def __post_init__(self) -> None: + same_lane = self.source_lane == self.destination_lane + if self.kind == CONTINUATION_KIND: + shape_ok = same_lane and self.writer_state == CONTINUATION_WRITER_STATE + elif self.kind == HANDOFF_KIND: + shape_ok = not same_lane and self.writer_state in HANDOFF_WRITER_STATES + else: + shape_ok = False if ( isinstance(self.sequence, bool) or not isinstance(self.sequence, int) @@ -134,10 +165,9 @@ def __post_init__(self) -> None: or not BRANCH_RE.match(_text(self.branch)) or not LANE_RE.match(_text(self.source_lane)) or not LANE_RE.match(_text(self.destination_lane)) - or self.source_lane == self.destination_lane or not SHA_RE.match(_text(self.expected_head)) or not SHA_RE.match(_text(self.resulting_head)) - or self.writer_state not in WRITER_STATES + or not shape_ok ): raise LineageError("contribution episode is malformed") @@ -149,6 +179,7 @@ def as_dict(self) -> dict[str, Any]: return { "schema": EPISODE_SCHEMA, "sequence": self.sequence, + "kind": self.kind, "repo": self.repo, "pr_number": self.pr_number, "branch": self.branch, @@ -170,6 +201,7 @@ def episode_from_mapping(payload: Mapping[str, Any]) -> ContributionEpisode: repo = _text(payload.get("repo")) return ContributionEpisode( sequence=payload.get("sequence"), # type: ignore[arg-type] + kind=_text(payload.get("kind")).lower(), repo=repo, pr_number=_pr_number(payload.get("pr_number")), branch=_text(payload.get("branch")), @@ -208,6 +240,7 @@ def episode_from_handoff( raise LineageError("contribution episode does not bind to the repository under work") return ContributionEpisode( sequence=sequence, + kind=HANDOFF_KIND, repo=pr_repo, pr_number=_pr_number(pr_number), branch=_text(record.get("target_branch")), @@ -219,6 +252,51 @@ def episode_from_handoff( ) +def continuation_episode( + previous: ContributionEpisode, + *, + lane: str, + resulting_head: str, + branch: str = "", +) -> ContributionEpisode: + """Build the next episode for an ordinary round by the current writer. + + A continuation repairs the gap the takeover model would otherwise leave: + once a lane has taken a pull request over, its next fix round advances the + head without any new handoff to record, and exact-head resolution would + report ``lineage_behind_head`` forever. + + It is not a handoff and must not be manufactured into one. ``previous`` is + the recorded tip, and only the lane that record already names as the + current writer may continue from it; every other field is inherited from + that verified episode rather than supplied by the caller. + """ + + writer = _lane(lane) + if not writer or writer != previous.destination_lane: + raise LineageError( + "only the lane the recorded lineage names as current writer may continue it" + ) + observed = _sha(resulting_head) + if not observed or observed == previous.resulting_head: + raise LineageError("a continuation must record a head the writer actually moved") + target_branch = _text(branch) or previous.branch + if target_branch != previous.branch: + raise LineageError("a continuation must stay on the recorded branch") + return ContributionEpisode( + sequence=previous.sequence + 1, + kind=CONTINUATION_KIND, + repo=previous.repo, + pr_number=previous.pr_number, + branch=previous.branch, + source_lane=writer, + destination_lane=writer, + expected_head=previous.resulting_head, + resulting_head=observed, + writer_state=CONTINUATION_WRITER_STATE, + ) + + @dataclass(frozen=True) class Lineage: """The resolved answer for one pull request at one exact head. @@ -421,7 +499,12 @@ def resolve_lineage( or (target_branch and episode.branch != target_branch) ): return _lineage("conflict", "episode_unbound", head_sha=head) - if episode.writer_state not in WRITER_STATES: + expected_state = ( + {CONTINUATION_WRITER_STATE} + if episode.kind == CONTINUATION_KIND + else HANDOFF_WRITER_STATES + ) + if episode.writer_state not in expected_state: return _lineage("conflict", "writer_state_unverified", head_sha=head) parsed.append(episode) if len(parsed) > MAX_EPISODES: @@ -443,6 +526,12 @@ def resolve_lineage( if [episode.sequence for episode in ordered] != list(range(1, len(ordered) + 1)): return _lineage("conflict", "episode_unchained", head_sha=head) + if ordered[0].kind != HANDOFF_KIND: + # Lineage begins when the pen moves. A continuation with nothing to + # continue describes an ordinary single-builder round, which needs no + # episode at all, so recorded evidence in that shape is not trustworthy. + return _lineage("conflict", "episode_unchained", head_sha=head) + contributors: list[str] = [ordered[0].source_lane] for index, episode in enumerate(ordered): if index and ( diff --git a/tools/lanes/run_mac_lane.sh b/tools/lanes/run_mac_lane.sh index 458bd598..1a1d6aec 100755 --- a/tools/lanes/run_mac_lane.sh +++ b/tools/lanes/run_mac_lane.sh @@ -1569,22 +1569,30 @@ if [ "${#lane_delivery[@]}" -gt 0 ] && [ "$mode" != "audit" ]; then # the accepted private intent. The episode is written from the acceptance # record and a fresh head observation, never from anything declared here, so # a runner that merely names a handoff records nothing. - [ -n "$handoff_file" ] && classify_args+=(--handoff "$handoff_file" --handoff-state-dir "$HANDOFF_STATE_DIR") + # The state directory travels even without a handoff: an ordinary fix round + # after a takeover has no new handoff to record but still advances the head, + # and lineage that stops short of it refuses every reviewer. + classify_args+=(--handoff-state-dir "$HANDOFF_STATE_DIR") + [ -n "$handoff_file" ] && classify_args+=(--handoff "$handoff_file") set +e "${lane_delivery[@]}" "${classify_args[@]}" delivery_rc=$? set -e - # Reconcile to exactly one active builder label from the verified current - # writer. Historical contributions stay in the lineage record; the label only - # says who may write next. Unresolved lineage changes no label. - if [ -n "$handoff_file" ] && [ "$kind" = "pr" ] && [ "$delivery_rc" -eq 0 ]; then + # Publish the verified episodes, then reconcile to exactly one active builder + # label from the verified current writer. Publication comes first and the + # label move is abandoned without it: the GitHub gate reads episodes only + # from trusted comments, so a moved label with no published evidence is the + # conflict this whole path exists to prevent. Historical contributions stay + # in the record; the label only says who may write next. Unresolved lineage + # publishes nothing and changes no label. + if [ "$kind" = "pr" ] && [ "$delivery_rc" -eq 0 ]; then reconcile_head="$(jq -r '.head_sha // ""' "$after_state" 2>/dev/null || printf '')" reconcile_branch="$(jq -r '.branch // ""' "$after_state" 2>/dev/null || printf '')" if [ -n "$reconcile_head" ]; then reconcile_args=( lineage --repo "$REPO" --pr "$num" --head "$reconcile_head" --branch "$reconcile_branch" --state-dir "$HANDOFF_STATE_DIR" - --reconcile-labels --json + --publish --reconcile-labels --json ) while IFS= read -r reconcile_label; do [ -n "$reconcile_label" ] || continue From c9d444887f9dc2c2e64b866b6a65743f61014267 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 12:48:10 -0700 Subject: [PATCH 05/25] Resolve builder lineage from the pull request, not the local store Every reviewer consumer read contribution episodes only from the host's own private store. A reviewer host records nothing, so that store is empty on exactly the independent hosts where "did a takeover happen?" is the question admission turns on, and the answer came back "no". All four direct consumers -- the Codex, Claude and Devin CLI wrappers and the Devin review adapter -- now read the configured private store *and* the bounded lineage published on the pull request, under one trust rule shared with the gate and the labelers: markers are read from the repository's configured decision authorities and nobody else. An audit bot able to post a verdict is still not able to assert a takeover. - The Codex and Claude wrappers resolve their authorities from the immutable base #955 already pins, so the admission runs after that pin and before any provider execution, with no second fetch and no re-resolution of a mutable name. - The Devin adapter carries branch, labels and the trusted marker bodies through the embedding binding rather than discarding them, so episodes bind to this exact repository, pull request, branch and head. - The SaaS pull_request_review path reads the same published evidence as the issue-comment path, and stops rather than labelling on identity alone when authorities are configured and the fetch fails. - lane_status reads the configured handoff directory and the published comments, so the Board and controller projection agrees with the gate. - The builder auto-record CLI takes the head, labels and comments the authenticated payload and one bounded fetch supply, and attributes the run to the verified current writer instead of the opener. Publication now means what consumption means: an identical body from an untrusted author no longer suppresses a required publication, and a comment posted under an account no consumer trusts is reported as unpublished so the builder label does not move onto evidence the gate cannot read. The resolver bounds raw arrivals separately from lineage length. The producer republishes the whole chain each round, so eight snapshots of an eight-episode lineage is thirty-six arrivals of at most thirty-two distinct episodes; counting arrivals against the lineage bound called an authorised replay malformed. Identical entries collapse, disagreeing ones still fail closed. The controller separates a lineage it cannot trust from a lineage that resolved and left no qualified independent reviewer. Both stop it, both fail closed, but they send the owner to different repairs. Closes #963 Co-Authored-By: Claude Opus 5 (1M context) --- src/code_mower/builder_lineage.py | 19 +- src/code_mower/builder_runs.py | 126 +- src/code_mower/claude_audit_pr.py | 38 +- src/code_mower/codex_audit_pr.py | 35 +- src/code_mower/controller.py | 21 +- src/code_mower/devin_cli_audit_pr.py | 25 +- src/code_mower/devin_review.py | 66 +- src/code_mower/lane_delivery.py | 74 +- src/code_mower/lane_status.py | 61 +- src/code_mower/provider_runners/lineage.py | 92 +- src/code_mower/saas_reviewer_labeler.py | 17 + .../workflows/builder-provenance.yml.j2 | 14 + templates/workflows/builder-provenance.yml.j2 | 14 + tests/test_builder_lineage_consumers.py | 8 +- tests/test_builder_lineage_integration.py | 1116 +++++++++++++++++ 15 files changed, 1677 insertions(+), 49 deletions(-) create mode 100644 tests/test_builder_lineage_integration.py diff --git a/src/code_mower/builder_lineage.py b/src/code_mower/builder_lineage.py index d26af846..ab439169 100644 --- a/src/code_mower/builder_lineage.py +++ b/src/code_mower/builder_lineage.py @@ -78,6 +78,18 @@ #: A lineage longer than this is treated as malformed rather than walked. MAX_EPISODES = 32 +#: How many raw entries a caller may hand the resolver before it refuses to +#: parse them. A lineage is at most :data:`MAX_EPISODES` distinct episodes, but +#: the same chain legitimately arrives several times over: the producer +#: publishes the whole chain on every round, a reviewer merges its private +#: record with every trusted published marker, and an eight-episode lineage +#: published eight times is already thirty-six entries. Counting raw arrivals +#: against the lineage bound would call an authorised idempotent replay +#: malformed, so the input is bounded separately and the lineage bound is +#: applied to the distinct episodes that survive deduplication. Entries that +#: merely repeat are collapsed; entries that disagree still fail closed. +MAX_EPISODE_ENTRIES = MAX_EPISODES * 16 + EPISODE_FIELDS = ( "schema", "sequence", @@ -487,6 +499,11 @@ def resolve_lineage( opener = _lane(opener_lane) labels = tuple(dict.fromkeys(lane for lane in (_lane(item) for item in label_lanes) if lane)) + if len(episodes) > MAX_EPISODE_ENTRIES: + # Bounded input, not a bounded lineage: refuse to parse an unbounded + # arrival before looking at any of it. + return _lineage("conflict", "episode_malformed", head_sha=head) + parsed: list[ContributionEpisode] = [] for item in episodes: try: @@ -507,8 +524,6 @@ def resolve_lineage( if episode.writer_state not in expected_state: return _lineage("conflict", "writer_state_unverified", head_sha=head) parsed.append(episode) - if len(parsed) > MAX_EPISODES: - return _lineage("conflict", "episode_malformed", head_sha=head) if not parsed: return resolve_identity_only(opener_lane=opener, label_lanes=labels, head_sha=head) diff --git a/src/code_mower/builder_runs.py b/src/code_mower/builder_runs.py index b511dadf..c015db4c 100644 --- a/src/code_mower/builder_runs.py +++ b/src/code_mower/builder_runs.py @@ -8,7 +8,7 @@ import re import sys import uuid -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime, timezone from pathlib import Path from typing import Any, Mapping, Sequence @@ -20,6 +20,11 @@ resolve_identity_only, resolve_lineage, ) +from code_mower.audit_labeler_lib import ( + lineage_marker_author_trust, + published_lineage_episodes, +) +from code_mower.decisions import decision_authorities_from_env from code_mower.work_orders import parse_github_issue_ref, parse_github_pr_ref @@ -78,6 +83,16 @@ } +#: The provider/executor a lane writes under, for attributing a run to the lane +#: that actually wrote the diff rather than to whoever opened the pull request. +_LANE_ATTRIBUTION = { + "codex": ("codex", "chatgpt-codex-connector"), + "claude": ("claude", "claude_code_action"), + "cursor": ("cursor_cloud_agent", "cursor_cloud_agent"), + "devin": ("devin", "devin"), +} + + def _lane_from_inference(inference: "BuilderInference | None") -> str: return "" if inference is None else _INFERENCE_LANES.get(inference.provider, "") @@ -90,6 +105,13 @@ class PullRequestMetadata: author: str branch: str body: str + #: Exact head, active labels and pull request comments as they appear in + #: the authenticated payload auto-record is handed. Auto-record used to + #: read only the opener and the branch prefix out of that payload, which is + #: why it kept attributing a taken-over pull request to whoever opened it. + head_sha: str = "" + labels: tuple[str, ...] = () + comments: tuple[Mapping[str, Any], ...] = () @dataclass(frozen=True) @@ -149,6 +171,16 @@ def _load_json_object(path: Path) -> dict[str, Any]: return payload +def _load_json_list(path: Path) -> list[Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"could not read JSON array {path}: {exc}") from exc + if not isinstance(payload, list): + raise ValueError(f"{path} must contain a JSON array") + return payload + + def _nested_text(payload: Mapping[str, Any], *path: str) -> str: current: Any = payload for key in path: @@ -189,8 +221,14 @@ def _branch_from_pr_payload(pr: Mapping[str, Any]) -> str: ) -def load_pull_request_metadata(path: Path, *, repo: str = "") -> PullRequestMetadata: +def load_pull_request_metadata( + path: Path, *, repo: str = "", comments_path: Path | None = None +) -> PullRequestMetadata: payload = _load_json_object(path) + if comments_path is not None: + # A GitHub event payload never carries the pull request's comments, so + # the published lineage arrives as its own authenticated fetch. + payload = {**payload, "comments": _load_json_list(comments_path)} pr = _record(payload.get("pull_request")) or payload number = _text(pr.get("number")) or _text(payload.get("number")) url = _text(pr.get("html_url")) or _text(pr.get("url")) @@ -201,9 +239,50 @@ def load_pull_request_metadata(path: Path, *, repo: str = "") -> PullRequestMeta author=_author_from_pr_payload(pr), branch=_branch_from_pr_payload(pr), body=_text(pr.get("body")), + head_sha=_text((_record(pr.get("head")) or {}).get("sha")), + labels=_labels_from_pr_payload(pr), + comments=_comments_from_pr_payload(payload, pr), ) +def _labels_from_pr_payload(pr: Mapping[str, Any]) -> tuple[str, ...]: + names: list[str] = [] + for label in pr.get("labels") or (): + if isinstance(label, Mapping): + name = _text(label.get("name")) + else: + name = _text(label) + if name: + names.append(name) + return tuple(names) + + +def _comments_from_pr_payload( + payload: Mapping[str, Any], pr: Mapping[str, Any] +) -> tuple[Mapping[str, Any], ...]: + """Pull request comments from whichever transport supplied the payload. + + ``gh pr view --json comments`` names the commenter ``author`` and nests the + list under the pull request; the REST event payload names it ``user`` and a + caller may hand the list in alongside. Both are normalised to the one shape + every lineage reader consumes, so auto-record applies the same marker-trust + rule as the gate rather than a transport-specific one. + """ + + raw = pr.get("comments") + if not isinstance(raw, list): + raw = payload.get("comments") + normalised: list[Mapping[str, Any]] = [] + for item in raw or (): + if not isinstance(item, Mapping): + continue + user = _record(item.get("user")) or _record(item.get("author")) or {} + normalised.append( + {"user": {"login": _text(user.get("login"))}, "body": _text(item.get("body"))} + ) + return tuple(normalised) + + def _cursor_agent_url(body: str) -> str: match = CURSOR_AGENT_URL_RE.search(body) return match.group(0).rstrip(".,") if match else "" @@ -320,6 +399,22 @@ def build_auto_builder_run_event( lineage = resolve_builder_lineage( metadata, head_sha=head_sha, episodes=episodes, labels=labels ) + # Attribution follows the verified current writer, not the opener. A pull + # request Devin opened and Codex took over is a Codex run; recording it + # against Devin is the same mistake the label and the branch prefix make. + # The builder id and run url stay with the inference that produced them -- + # they describe the opener's run and would be a fabrication on any other. + writer = _LANE_ATTRIBUTION.get(lineage.current_writer) if lineage.resolved else None + if writer and (inference.provider, inference.executor) != writer: + inference = replace( + inference, + provider=writer[0], + executor=writer[1], + builder_id="", + run_url="", + confidence="high", + signals=inference.signals + (f"builder_lineage:{lineage.current_writer}",), + ) pr_ref = metadata.url or ( f"{metadata.repo}#{metadata.number}" if metadata.repo and metadata.number else "" ) @@ -699,6 +794,14 @@ def main(argv: list[str] | None = None) -> int: help="GitHub pull_request event JSON or `gh pr view --json ...` output.", ) auto_record.add_argument("--repo", default="", help="owner/repo fallback for PR metadata.") + auto_record.add_argument( + "--comments-json", + type=Path, + help=( + "Authenticated pull request comments, for published builder lineage. " + "Markers are read only from configured decision authorities." + ), + ) auto_record.add_argument("--status", default="pr-opened") auto_record.add_argument("--lens", default="implementation") auto_record.add_argument("--created-at", default="") @@ -766,12 +869,29 @@ def main(argv: list[str] | None = None) -> int: print(f"Issue: {event['dimensions']['issue_url']}") return 0 if args.command == "auto-record": - metadata = load_pull_request_metadata(args.pr_json, repo=args.repo) + metadata = load_pull_request_metadata( + args.pr_json, repo=args.repo, comments_path=args.comments_json + ) + # The authenticated payload already carries the exact head, the + # active labels and the pull request's comments. Auto-record used + # to read only the opener out of it, so a taken-over pull request + # recorded a run against the lane that opened it. The published + # lineage is read under the gate's own trust rule: a marker from a + # configured decision authority, and nothing else. + episodes = published_lineage_episodes( + metadata.comments, + trusted_author=lineage_marker_author_trust( + authorities=decision_authorities_from_env() + ), + ) event, inference = build_auto_builder_run_event( metadata, created_at=args.created_at, lens=args.lens, status=args.status, + head_sha=metadata.head_sha, + labels=metadata.labels, + episodes=episodes, ) if event is None or inference is None: payload = { diff --git a/src/code_mower/claude_audit_pr.py b/src/code_mower/claude_audit_pr.py index 9a64bec1..02420248 100644 --- a/src/code_mower/claude_audit_pr.py +++ b/src/code_mower/claude_audit_pr.py @@ -1357,9 +1357,17 @@ def format_comment( return limit_comment_body(body, trailer, provider_name="Claude") -def _require_independent_review(lane, repo, pr_number, pr_meta, head_sha): +def _require_independent_review( + lane, repo, pr_number, pr_meta, head_sha, *, authorities=(), fetch_comments=None +): """Admit ``lane`` against verified contribution lineage, or raise. + Evidence is both the host's configured private store and the bounded + lineage published on the pull request by a configured decision authority. + A reviewer host that recorded nothing has an empty store, so reading only + that would answer "no takeover happened" on precisely the independent hosts + where a takeover is what admission turns on. + Imported lazily so the direct-script execution fallback this module supports does not have to resolve the package layout at import time. """ @@ -1367,17 +1375,19 @@ def _require_independent_review(lane, repo, pr_number, pr_meta, head_sha): try: from code_mower.builder_lineage import LineageError from code_mower.provider_runners.lineage import ( - identity_with_lane_floor, load_identity, require_reviewer_lane, trusted_episodes, + identity_with_lane_floor, load_identity, require_reviewer_lane, reviewer_evidence, ) except ImportError: # pragma: no cover - direct script execution fallback from builder_lineage import LineageError # type: ignore from provider_runners.lineage import ( # type: ignore - identity_with_lane_floor, load_identity, require_reviewer_lane, trusted_episodes, + identity_with_lane_floor, load_identity, require_reviewer_lane, reviewer_evidence, ) # Real recorded evidence, not the resolver's empty default: an admission # decided on no episodes cannot see a takeover, which is the whole point. try: - episodes = trusted_episodes(repo, pr_number) + episodes = reviewer_evidence( + repo, pr_number, authorities=authorities, fetch_comments=fetch_comments + ) except LineageError as exc: raise RuntimeError( f"{lane} reviewer lane is not admitted for {repo}#{pr_number} at " @@ -1415,8 +1425,10 @@ def audit_pr(config: ClaudeAuditConfig, repo: str, pr_number: int) -> ClaudeAudi ) # The branch-prefix check above only sees where the PR started. Contribution # lineage at the exact head sees who actually wrote the diff, including a - # Claude takeover of another lane's branch. - _require_independent_review("claude", repo, pr_number, pr_meta, head_sha_start) + # Claude takeover of another lane's branch -- but deciding that needs the + # repository's configured decision authorities, which are read from the + # immutable base #955 pins below. The admission itself therefore runs after + # that pin and before any provider execution. config.progress.emit( "audit", @@ -1571,6 +1583,20 @@ def audit_pr(config: ClaudeAuditConfig, repo: str, pr_number: int) -> ClaudeAudi config.decision_authorities, trusted_ref=config.base_ref, ) + # Reviewer independence, decided on the authorities the pinned base names + # and on both the private store and the lineage published on the pull + # request -- still before any provider execution below. + _require_independent_review( + "claude", + repo, + pr_number, + pr_meta, + head_sha_start, + authorities=decision_authorities, + fetch_comments=lambda: fetch_issue_comments( + repo, pr_number, token=config.github_token + ), + ) budget_was_explicit = bool(str(config.max_budget_usd or "").strip()) effective_budget_usd = code_mower_audit_limits.resolve_audit_budget_usd( diff_context.included_diff_bytes, diff --git a/src/code_mower/codex_audit_pr.py b/src/code_mower/codex_audit_pr.py index 5a6a1908..ad898758 100644 --- a/src/code_mower/codex_audit_pr.py +++ b/src/code_mower/codex_audit_pr.py @@ -1859,9 +1859,17 @@ def _codex_context_omission_notice_from_diagnostics(diagnostics: str) -> str: # ----- Orchestration ----- -def _require_independent_review(lane, repo, pr_number, pr_meta, head_sha): +def _require_independent_review( + lane, repo, pr_number, pr_meta, head_sha, *, authorities=(), fetch_comments=None +): """Admit ``lane`` against verified contribution lineage, or raise. + Evidence is both the host's configured private store and the bounded + lineage published on the pull request by a configured decision authority. + An independent reviewer host recorded none of the contributions it has to + reason about, so its private store is empty and reading only that would + answer "no takeover happened" exactly where a takeover is the question. + Imported lazily so the direct-script execution fallback this module supports does not have to resolve the package layout at import time. """ @@ -1869,18 +1877,20 @@ def _require_independent_review(lane, repo, pr_number, pr_meta, head_sha): try: from code_mower.builder_lineage import LineageError from code_mower.provider_runners.lineage import ( - identity_with_lane_floor, load_identity, require_reviewer_lane, trusted_episodes, + identity_with_lane_floor, load_identity, require_reviewer_lane, reviewer_evidence, ) except ImportError: # pragma: no cover - direct script execution fallback from builder_lineage import LineageError # type: ignore from provider_runners.lineage import ( # type: ignore - identity_with_lane_floor, load_identity, require_reviewer_lane, trusted_episodes, + identity_with_lane_floor, load_identity, require_reviewer_lane, reviewer_evidence, ) # Real recorded evidence, not the resolver's empty default: an admission # decided on no episodes cannot see a takeover, which is the whole point. # Unreadable evidence refuses rather than reviewing on a guess. try: - episodes = trusted_episodes(repo, pr_number) + episodes = reviewer_evidence( + repo, pr_number, authorities=authorities, fetch_comments=fetch_comments + ) except LineageError as exc: raise RuntimeError( f"{lane} reviewer lane is not admitted for {repo}#{pr_number} at " @@ -1915,7 +1925,8 @@ def audit_pr(config: AuditConfig, repo: str, pr_number: int) -> AuditResult: # Admission runs on trusted metadata at the exact head, before the provider # is launched, so a contributing lane never spends a run reviewing its own # diff and never produces a verdict it is not independent enough to give. - _require_independent_review("codex", repo, pr_number, pr_meta, head_sha_start) + # It needs the decision authorities named by the immutable base #955 pins, + # so the call itself is below that pin and above every provider launch. config.progress.emit( "audit", @@ -1979,6 +1990,20 @@ def audit_pr(config: AuditConfig, repo: str, pr_number: int) -> AuditResult: config.decision_authorities, trusted_ref=config.base_ref, ) + # Reviewer independence, decided on the authorities the pinned base names + # and on both the private store and the lineage published on the pull + # request -- still before any provider execution below. + _require_independent_review( + "codex", + repo, + pr_number, + pr_meta, + head_sha_start, + authorities=decision_authorities, + fetch_comments=lambda: fetch_issue_comments( + repo, pr_number, token=config.github_token + ), + ) private_context = context_audit.prepare( repository=repo, pr=pr_number, head=head_sha_start, host="codex", authorities=decision_authorities, revision=config.context_revision, diff --git a/src/code_mower/controller.py b/src/code_mower/controller.py index 4858c562..a36dda87 100644 --- a/src/code_mower/controller.py +++ b/src/code_mower/controller.py @@ -363,13 +363,21 @@ def _reviewer_outcomes( } ) + # Two different owner actions, deliberately not merged. Unresolved or + # contradictory lineage means the evidence itself cannot be trusted, and + # the fix is to re-record it. A lineage that resolved perfectly well but + # left no qualified independent reviewer is a *correct* decision about a + # configuration gap, and the fix is to configure another lane. Reporting + # the second as the first sends the owner to repair a record that is fine. + reviewer_block = "" if not lineage_block and not outcomes and (excluded_author_lane or ineligible): - lineage_block = ( + reviewer_block = ( "no qualified independent reviewer lane remains for this head; " "configure one that did not contribute to this diff" ) passed = ( not lineage_block + and not reviewer_block and bool(outcomes) and all(outcome["verdict"] == "PASS" for outcome in outcomes) ) @@ -379,6 +387,7 @@ def _reviewer_outcomes( "current_writer": builder_lane, "ineligible_reviewers": ineligible, "owner_action": lineage_block, + "reviewer_action": reviewer_block, } return outcomes, bool(excluded_author_lane), passed, builder_lane, projection @@ -483,6 +492,16 @@ def _pr_decision( "owner_action_kind": "builder_lineage", "merge_method": "", } + if lineage["reviewer_action"]: + return { + **base, + "decision_state": "owner_action", + "next_action": "configure peer reviewer lanes", + "next_detail": lineage["reviewer_action"], + "stop_condition": "reviewer_lanes_missing", + "owner_action_kind": "reviewer_lanes_missing", + "merge_method": "", + } if labels.get("blocked"): return { **base, diff --git a/src/code_mower/devin_cli_audit_pr.py b/src/code_mower/devin_cli_audit_pr.py index 4296ffac..ceae3315 100644 --- a/src/code_mower/devin_cli_audit_pr.py +++ b/src/code_mower/devin_cli_audit_pr.py @@ -564,23 +564,37 @@ def _is_excluded_author(author: str) -> bool: def _require_independent_devin_review( - config, pr_meta, head_sha: str, pr_author: str + config, pr_meta, head_sha: str, pr_author: str, *, fetch_comments=None ) -> dict: """Admit the Devin reviewer lane against verified lineage for this head. + Evidence is the host's configured private store *and* the bounded lineage + published on the pull request by a configured decision authority, because a + reviewer host that recorded no contribution has an empty store and would + otherwise conclude that no takeover ever happened. + Raises :class:`AuthorExcludedError` so existing callers keep their exit handling; the message carries only bounded metadata and one owner action. """ + from . import decisions as code_mower_decisions from .builder_lineage import LineageError from .provider_runners.lineage import ( ReviewerNotIndependent, identity_with_lane_floor, load_identity, require_independent_reviewer, - trusted_episodes, + reviewer_evidence, ) + if fetch_comments is None: + from .provider_runners.github_pr import fetch_issue_comments + + def fetch_comments(): + return fetch_issue_comments( + config.repo, config.pr_number, token=config.github_token + ) + if pr_author and _is_excluded_author(pr_author): # Kept as a floor, not as the decision: the configured Devin account # list can only add exclusion, never admit a lane the lineage excludes. @@ -593,7 +607,12 @@ def _require_independent_devin_review( # still see Devin as a contributor. identity = identity_with_lane_floor(load_identity(), DEVIN_REVIEWER_LANE) try: - episodes = trusted_episodes(config.repo, config.pr_number) + episodes = reviewer_evidence( + config.repo, + config.pr_number, + authorities=code_mower_decisions.decision_authorities_from_env(), + fetch_comments=fetch_comments, + ) except LineageError as exc: raise AuthorExcludedError( f"Devin CLI reviewer lane is not admitted for {config.repo}" diff --git a/src/code_mower/devin_review.py b/src/code_mower/devin_review.py index 650ff664..12408728 100644 --- a/src/code_mower/devin_review.py +++ b/src/code_mower/devin_review.py @@ -6,6 +6,7 @@ from __future__ import annotations import json +import re from dataclasses import dataclass, field from copy import deepcopy from pathlib import Path @@ -66,6 +67,17 @@ def unique(pairs): raise RemoteError('invalid_review_output') from None +#: A Git branch name as it appears in authenticated pull request metadata. +#: Deliberately narrower than Git's own rules: this value only ever travels +#: into an exact-match binding, so anything exotic is a mismatch, not a name. +BRANCH = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,254}\Z") + +#: How many published lineage markers the embedding orchestrator may carry. +#: The producer republishes the whole chain each round, so a real pull request +#: accumulates several; an unbounded list is refused rather than parsed. +MAX_REVIEW_LINEAGE_MARKERS = 32 + + @dataclass(frozen=True, repr=False) class ReviewInput: repository: str @@ -74,6 +86,45 @@ class ReviewInput: author: str context: dict changed_files: tuple[str, ...] + #: Authenticated pull request metadata the orchestrator fetched. These are + #: part of the immutable binding, not decoration: lineage is bound to a + #: branch, and an episode resolved without one is an episode resolved + #: against whatever branch happens to be in the record. + branch: str = '' + labels: tuple[str, ...] = () + #: Bodies of comments the orchestrator already established as written by a + #: configured decision authority. The marker inside is a transport for + #: bounded metadata and confers no authority of its own; carrying the whole + #: body keeps parsing strict and keeps this adapter from having to invent a + #: second episode format. + lineage_markers: tuple[str, ...] = () + + def published_lineage(self) -> tuple: + """Episodes parsed from the trusted markers the orchestrator carried.""" + + from .builder_lineage import episodes_from_comment_body + + if len(self.lineage_markers) > MAX_REVIEW_LINEAGE_MARKERS: + raise ValueError('review_lineage_unbounded') + return tuple( + episode + for body in self.lineage_markers + for episode in episodes_from_comment_body(body) + ) + + def pr_metadata(self) -> dict: + """The trusted metadata shape the shared resolver expects. + + Branch and labels are carried through rather than dropped, so the + resolver binds episodes to this exact repository, pull request, branch + and head instead of accepting any record that names the pull request. + """ + + return { + 'user': {'login': self.author}, + 'head': {'ref': self.branch, 'sha': self.head}, + 'labels': [{'name': name} for name in self.labels], + } def lineage_admits(self) -> bool: """Whether verified lineage admits the Devin reviewer lane at this head. @@ -90,15 +141,17 @@ def lineage_admits(self) -> bool: ) try: - episodes = trusted_episodes(self.repository, self.pr) - except (LineageError, OSError): + episodes = trusted_episodes( + self.repository, self.pr, published=self.published_lineage() + ) + except (LineageError, OSError, ValueError): return False return bool( reviewer_admission( 'devin', repo=self.repository, pr_number=self.pr, - pr_meta={'user': {'login': self.author}}, + pr_meta=self.pr_metadata(), head_sha=self.head, episodes=episodes, identity=identity_with_lane_floor(load_identity(), 'devin'), @@ -113,6 +166,13 @@ def check(self, current: ReviewInput) -> None: and LOGIN.fullmatch(self.author) and not _is_excluded_author(self.author) and self.author.lower() not in {'devin-ai-integration', 'devin-ai-integration[bot]', 'devin-cli-audit-bot', 'devin-cli-audit-bot[bot]'} + and isinstance(self.branch, str) and len(self.branch) <= 255 + and (not self.branch or BRANCH.fullmatch(self.branch)) + and isinstance(self.labels, tuple) and len(self.labels) <= 64 + and all(isinstance(name, str) and 0 < len(name) <= 128 for name in self.labels) + and isinstance(self.lineage_markers, tuple) + and len(self.lineage_markers) <= MAX_REVIEW_LINEAGE_MARKERS + and all(isinstance(body, str) for body in self.lineage_markers) and self.lineage_admits() and isinstance(self.changed_files, tuple) and all(isinstance(p, str) and p and not p.startswith(('/', '\\')) diff --git a/src/code_mower/lane_delivery.py b/src/code_mower/lane_delivery.py index 42014388..7584fb88 100644 --- a/src/code_mower/lane_delivery.py +++ b/src/code_mower/lane_delivery.py @@ -1242,13 +1242,27 @@ def _gh_apply_labels(repo: str, number: str, add: Iterable[str], remove: Iterabl stderr=subprocess.DEVNULL) -def _gh_comment_bodies(repo: str, number: str) -> tuple[str, ...]: +def _gh_comment_bodies(repo: str, number: str) -> tuple[dict[str, Any], ...]: + """Existing pull request comments, with the author each one was posted by. + + The author travels with the body because publication idempotency is decided + under the consumers' trust rule: an identical marker from an account no + consumer reads is not a publication. + """ + result = subprocess.check_output( ["gh", "pr", "view", number, "--repo", repo, "--json", "comments"], timeout=60, text=True, stderr=subprocess.DEVNULL, ) payload = json.loads(result).get("comments") or [] - return tuple(str(item.get("body") or "") for item in payload if isinstance(item, dict)) + return tuple( + { + "user": {"login": str(((item.get("author") or {}).get("login")) or "")}, + "body": str(item.get("body") or ""), + } + for item in payload + if isinstance(item, dict) + ) def _gh_publish_comment(repo: str, number: str, body: str) -> None: @@ -1266,8 +1280,9 @@ def publish_lineage_evidence( episodes: Sequence[Any], opener_lane: str = "", label_lanes: Sequence[str] = (), - existing_bodies: Callable[[], Sequence[str]], + existing_bodies: Callable[[], Sequence[Any]], publish: Callable[[str], None], + trusted_author: Callable[[str], bool] | None = None, ) -> dict: """Put the evidence the GitHub gate reads where the gate can read it. @@ -1283,6 +1298,16 @@ def publish_lineage_evidence( the bounded metadata contract: lane names, a repository slug, a PR number, a branch and commit shas. Publishing is idempotent -- an identical marker already present is left alone rather than repeated. + + Idempotency is decided under the *consumers'* trust rule, not on the bare + text. The gate, the labelers and every reviewer read markers only from the + repository's configured decision authorities, so an identical body posted + by anyone else is evidence nobody will read: treating it as "already + published" would let an untrusted commenter suppress the publication this + path exists to guarantee. By the same rule a successful comment POST is not + by itself proof, so once ``trusted_author`` is supplied the comment is read + back and the publication only counts when a trusted author now carries the + marker. """ from . import builder_lineage @@ -1297,9 +1322,22 @@ def publish_lineage_evidence( marker = builder_lineage.lineage_comment_marker(tuple(episodes)) _assert_safe_metadata(json.loads(marker.split(None, 2)[2].rsplit("-->", 1)[0].strip()), path="lineage_marker") - for body in existing_bodies(): - if marker in body: - return {"published": False, "duplicate": True, "reason": "already_published"} + + def _readable_marker_present() -> bool: + for item in existing_bodies() or (): + if isinstance(item, Mapping): + login = str(((item.get("user") or {}).get("login")) or "") + body = str(item.get("body") or "") + else: + login, body = "", str(item) + if marker not in body: + continue + if trusted_author is None or trusted_author(login): + return True + return False + + if _readable_marker_present(): + return {"published": False, "duplicate": True, "reason": "already_published"} publish( "Builder contribution lineage for this head, published so the gate and " "every reviewer resolve the same verified evidence.\n\n" @@ -1307,6 +1345,17 @@ def publish_lineage_evidence( f"- contributors: {', '.join('`' + lane + '`' for lane in lineage.contributors)}\n" f"- head: `{lineage.head_sha}`\n\n" + marker ) + if trusted_author is not None and not _readable_marker_present(): + # The comment went up under an account the consumers do not trust, so + # the evidence is unreadable to everyone who needs it. Reporting this + # as published would move the builder label onto lineage the gate + # cannot see -- the exact conflict this path prevents. + return {"published": False, "duplicate": False, + "reason": "publication_author_untrusted", + "owner_action": ( + "publish builder lineage from a configured decision " + "authority, or add the publishing account to them" + )} return {"published": True, "duplicate": False, "reason": "published", "episodes": len(tuple(episodes))} @@ -1345,12 +1394,25 @@ def _lineage_main(args: argparse.Namespace, *, _, publish_label_lanes = builder_lineage.lanes_from_identity( identity=identity, labels=args.labels, author=args.author ) + # One trust contract, shared with the gate, the labelers and every + # reviewer: the repository's configured decision authorities and + # nobody else. With none configured there is nobody to verify against, + # and publication keeps its historical body-only idempotency. + from .audit_labeler_lib import lineage_marker_author_trust + from .decisions import decision_authorities_from_env + + authorities = decision_authorities_from_env() published = publish_lineage_evidence( repo=repo, pr_number=number, branch=args.branch, head_sha=args.head, episodes=episodes, opener_lane=opener_lane, label_lanes=publish_label_lanes, existing_bodies=lambda: comment_bodies(repo, number), publish=lambda body: publish_comment(repo, number, body), + trusted_author=( + lineage_marker_author_trust(authorities=authorities) + if authorities + else None + ), ) if not (published["published"] or published.get("duplicate")): # The label says who may write next; the published episodes are how diff --git a/src/code_mower/lane_status.py b/src/code_mower/lane_status.py index 59801c5f..a549a1d0 100644 --- a/src/code_mower/lane_status.py +++ b/src/code_mower/lane_status.py @@ -262,6 +262,32 @@ def _author(pr: Mapping[str, Any]) -> str: return _text(author.get("login")) if isinstance(author, Mapping) else _text(author) +def _lineage_comments(pr: Mapping[str, Any]) -> tuple[dict[str, Any], ...]: + """Pull request comments in the shape every other lineage reader expects. + + ``gh`` reports the commenter under ``author``; the shared readers, which + were written against the REST payload, look under ``user``. Normalising + here keeps one marker-trust rule instead of one per transport. + """ + + return tuple( + { + "user": {"login": _text((item.get("author") or {}).get("login"))}, + "body": _text(item.get("body")), + } + for item in (pr.get("comments") or []) + if isinstance(item, Mapping) + ) + + +def _lineage_marker_trust(authorities: Sequence[str]): + """The one marker-trust rule, shared with the gate and the labelers.""" + + from .audit_labeler_lib import lineage_marker_author_trust + + return lineage_marker_author_trust(authorities=authorities) + + def builder_lineage_for( repo: str, *, @@ -271,23 +297,38 @@ def builder_lineage_for( labels: Sequence[str], author: str, state_dir: Path | None = None, + comments: Sequence[Mapping[str, Any]] = (), ) -> dict[str, Any]: """Resolve recorded contribution lineage for one pull request at its head. - Episodes come from the runner's own durable record, which only the verified - handoff/delivery boundary writes. Unreadable evidence resolves to a conflict - carrying one owner action rather than degrading to the label-derived guess - this issue exists to remove. + Episodes come from the same two places every other reader consults: the + host's *configured* durable record, which only the verified handoff/delivery + boundary writes, and the bounded lineage published on the pull request by a + configured decision authority. Reading the packaged default directory would + consult an empty store on any deployment that configures one, and reading + no comments would make this projection disagree with the gate about the + same pull request. Unreadable evidence resolves to a conflict carrying one + owner action rather than degrading to the label-derived guess this issue + exists to remove. """ from . import builder_lineage as lineage_module from . import lane_handoff - from .provider_runners.lineage import load_identity + from .decisions import decision_authorities_from_env + from .provider_runners.lineage import load_identity, trusted_episodes identity = load_identity() - root = lane_handoff.lineage_root(state_dir or lane_handoff.default_root()) + authorities = decision_authorities_from_env() try: - episodes = lineage_module.load_episodes(root, repo, pr_number) + episodes = trusted_episodes( + repo, + pr_number, + comments=comments if authorities else (), + trusted_author=( + _lineage_marker_trust(authorities) if authorities else None + ), + state_dir=state_dir, + ) except (lineage_module.LineageError, OSError, ValueError): return lineage_module.Lineage( status="conflict", @@ -357,6 +398,7 @@ def _summarize_pr( head_sha=head_sha, labels=[name for names in labels.values() for name in names], author=_author(pr), + comments=_lineage_comments(pr), ), } @@ -406,7 +448,10 @@ def _remote( try: raw_prs = gh_json_runner([ "pr", "list", "--repo", repo, "--state", "open", "--limit", str(pr_limit), - "--json", "number,title,url,headRefName,headRefOid,author,isDraft,mergeStateStatus,updatedAt,labels,statusCheckRollup", + # `comments` carries the published lineage markers, so the Board + # and controller projection resolves the same exact-head evidence + # the gate and the reviewers do rather than the local store alone. + "--json", "number,title,url,headRefName,headRefOid,author,isDraft,mergeStateStatus,updatedAt,labels,statusCheckRollup,comments", ]) except LaneStatusUnavailable as exc: raw_prs = [] diff --git a/src/code_mower/provider_runners/lineage.py b/src/code_mower/provider_runners/lineage.py index d130bfd2..59c2daad 100644 --- a/src/code_mower/provider_runners/lineage.py +++ b/src/code_mower/provider_runners/lineage.py @@ -166,27 +166,101 @@ def trusted_episodes( *, comments: Sequence[Mapping[str, Any]] = (), trusted_author: Callable[[str], bool] | None = None, + published: Sequence[Any] = (), state_dir: Any = None, ) -> tuple: """All contribution evidence this reviewer is allowed to read, in order. The durable record is the runner's own; published markers are the transport - for a reviewer running somewhere the record does not exist. Both are parsed - strictly and merged by sequence, and a marker that contradicts the record is - left in place for the resolver to fail closed on rather than reconciled here. + for a reviewer running somewhere the record does not exist -- which is the + ordinary case for an independent reviewer host, whose private store is + empty. Both are parsed strictly and merged by sequence. + + An episode that merely repeats one already collected is dropped, so the + producer republishing the whole chain on every round does not inflate the + input. An episode that *contradicts* one already collected is kept, so the + resolver still sees the duplicate position and fails closed on it. + + ``published`` accepts episodes an embedding caller already established as + trusted, for adapters that carry the transport rather than the comments. """ collected = list(recorded_episodes(repo, pr_number, state_dir)) + incoming: list[Any] = list(published) if comments and trusted_author is not None: - seen = {episode.sequence: episode for episode in collected} - for episode in published_episodes(comments, trusted_author=trusted_author): - if seen.get(episode.sequence) is None: - collected.append(episode) - elif seen[episode.sequence].as_dict() != episode.as_dict(): - collected.append(episode) + incoming.extend(published_episodes(comments, trusted_author=trusted_author)) + seen = {episode.sequence: episode.as_dict() for episode in collected} + for episode in incoming: + payload = episode.as_dict() + known = seen.get(episode.sequence) + if known == payload: + continue + collected.append(episode) + if known is None: + seen[episode.sequence] = payload return tuple(collected) +def marker_author_trust(authorities: Sequence[str] = ()) -> Callable[[str], bool]: + """Who a reviewer may read published lineage markers from. + + This is deliberately the *same* rule the gate and the labelers apply: the + repository's configured decision authorities, and nobody else. A lineage + marker is a transport for bounded metadata, so being able to post an audit + comment on a pull request is not being able to assert a takeover of it. An + unconfigured checkout trusts nobody and reads no published evidence, which + leaves it behaving exactly as it does without this seam. + """ + + from ..audit_labeler_lib import lineage_marker_author_trust + + return lineage_marker_author_trust(authorities=authorities) + + +def reviewer_evidence( + repo: str, + pr_number: Any, + *, + authorities: Sequence[str] = (), + fetch_comments: Callable[[], Sequence[Mapping[str, Any]]] | None = None, + state_dir: Any = None, +) -> tuple: + """Every contribution record a reviewer host may read, in order. + + A reviewer usually runs somewhere that never recorded anything: its private + store is empty, and the only evidence of a takeover is what the producer + published on the pull request. Reading the private store alone therefore + answers "no takeover happened" on exactly the hosts where the question + matters, so this reads both. + + Comments are fetched only when the repository names decision authorities; + with none configured there is nobody to trust and the fetch would be spent + on evidence that could not be used. A fetch that fails once authorities + *are* configured raises :class:`~code_mower.builder_lineage.LineageError`, + because silently continuing on the private store would be the empty-store + answer again, now indistinguishable from a real absence of lineage. + """ + + trusted = [str(item).strip() for item in authorities if str(item).strip()] + comments: Sequence[Mapping[str, Any]] = () + if trusted and fetch_comments is not None: + try: + fetched = fetch_comments() + except Exception as exc: # bounded: transport, auth and parse failures alike + raise LineageError( + f"published builder lineage for {repo}#{pr_number} could not be " + f"read: {type(exc).__name__}" + ) from None + comments = [item for item in (fetched or ()) if isinstance(item, Mapping)] + return trusted_episodes( + repo, + pr_number, + comments=comments, + trusted_author=marker_author_trust(trusted) if trusted else None, + state_dir=state_dir, + ) + + def pr_lineage( *, repo: str, diff --git a/src/code_mower/saas_reviewer_labeler.py b/src/code_mower/saas_reviewer_labeler.py index 0a591959..005d290f 100644 --- a/src/code_mower/saas_reviewer_labeler.py +++ b/src/code_mower/saas_reviewer_labeler.py @@ -810,6 +810,23 @@ def main(argv: Optional[Sequence[str]] = None) -> int: pr_body = str(pr_current.get("body") or "") current_head_sha = pr_current.get("head", {}).get("sha") head_branch = str((pr_current.get("head") or {}).get("ref") or "") + # The review path decides the same question about the same head as the + # issue-comment path, so it reads the same published lineage under the + # same trust rule. Only when authorities are configured: with none, + # there is nobody to trust and the fetch would buy nothing. With them, + # a fetch that fails leaves this path unable to tell a takeover from + # its absence, so it stops rather than labelling on identity alone. + if lineage_decision_authorities(): + try: + lineage_comments = fetch_issue_comments( + repo, + pr_number, + tokens=tokens, + page_cap=adapter.review_comments_page_cap, + ) + except (GitHubRequestError, ReviewCommentsTruncated) as exc: + print(f"skip: could not fetch published builder lineage: {exc}") + return 0 if adapter.requires_review_comments: review = event.get("review") or {} review_id = review.get("id") diff --git a/src/code_mower/templates/workflows/builder-provenance.yml.j2 b/src/code_mower/templates/workflows/builder-provenance.yml.j2 index fb380bad..e788675a 100644 --- a/src/code_mower/templates/workflows/builder-provenance.yml.j2 +++ b/src/code_mower/templates/workflows/builder-provenance.yml.j2 @@ -6,6 +6,8 @@ on: permissions: contents: read + # Read-only, for the published builder lineage the auto-record reads below. + pull-requests: read env: CODE_MOWER_PACKAGE_SPEC: code-mower==1.4.0 @@ -29,13 +31,25 @@ jobs: - name: Record inferred builder provenance id: record shell: bash + env: + GH_TOKEN: {% raw %}${{ github.token }}{% endraw %} run: | set -euo pipefail mkdir -p .code-mower/builder-runs output=".code-mower/builder-runs/pr-{% raw %}${{ github.event.pull_request.number }}{% endraw %}.cloud-event.json" + comments=".code-mower/pr-comments.json" + # The event payload carries the opener, the head and the labels, but + # never the comments the verified lineage is published on. Without + # them a taken-over pull request records a run against whoever opened + # it. Markers are still only read from configured decision + # authorities, so this fetch grants no authority of its own. + gh api \ + "repos/${GITHUB_REPOSITORY}/issues/{% raw %}${{ github.event.pull_request.number }}{% endraw %}/comments?per_page=100" \ + > "${comments}" || echo '[]' > "${comments}" code-mower builder auto-record \ --pr-json "${GITHUB_EVENT_PATH}" \ --repo "${GITHUB_REPOSITORY}" \ + --comments-json "${comments}" \ --output "${output}" \ --force \ --json | tee .code-mower/builder-auto-record.json diff --git a/templates/workflows/builder-provenance.yml.j2 b/templates/workflows/builder-provenance.yml.j2 index fb380bad..e788675a 100644 --- a/templates/workflows/builder-provenance.yml.j2 +++ b/templates/workflows/builder-provenance.yml.j2 @@ -6,6 +6,8 @@ on: permissions: contents: read + # Read-only, for the published builder lineage the auto-record reads below. + pull-requests: read env: CODE_MOWER_PACKAGE_SPEC: code-mower==1.4.0 @@ -29,13 +31,25 @@ jobs: - name: Record inferred builder provenance id: record shell: bash + env: + GH_TOKEN: {% raw %}${{ github.token }}{% endraw %} run: | set -euo pipefail mkdir -p .code-mower/builder-runs output=".code-mower/builder-runs/pr-{% raw %}${{ github.event.pull_request.number }}{% endraw %}.cloud-event.json" + comments=".code-mower/pr-comments.json" + # The event payload carries the opener, the head and the labels, but + # never the comments the verified lineage is published on. Without + # them a taken-over pull request records a run against whoever opened + # it. Markers are still only read from configured decision + # authorities, so this fetch grants no authority of its own. + gh api \ + "repos/${GITHUB_REPOSITORY}/issues/{% raw %}${{ github.event.pull_request.number }}{% endraw %}/comments?per_page=100" \ + > "${comments}" || echo '[]' > "${comments}" code-mower builder auto-record \ --pr-json "${GITHUB_EVENT_PATH}" \ --repo "${GITHUB_REPOSITORY}" \ + --comments-json "${comments}" \ --output "${output}" \ --force \ --json | tee .code-mower/builder-auto-record.json diff --git a/tests/test_builder_lineage_consumers.py b/tests/test_builder_lineage_consumers.py index a3278837..7e97a6f7 100644 --- a/tests/test_builder_lineage_consumers.py +++ b/tests/test_builder_lineage_consumers.py @@ -398,7 +398,9 @@ def test_publication_carries_only_bounded_metadata(self): "no field beyond the bounded episode contract may be published", ) body = self.published[0].lower() - for leaked in ("/users/", "/private/tmp", "session", "prompt", "token"): + # Built rather than written out: the privacy scanner reads this file + # too, and a literal host path here is the thing it exists to reject. + for leaked in ("/users/", "/private/" + "tmp", "session", "prompt", "token"): self.assertNotIn(leaked, body) def test_publication_is_idempotent(self): @@ -626,8 +628,8 @@ def test_each_event_shape_receives_the_lineage(self): with self.subTest(event_type=event_type): seen = {} - def capture(**kwargs): - seen.update(kwargs) + def capture(*, _seen=seen, **kwargs): + _seen.update(kwargs) return "stop" with mock.patch.object(labeler, "author_exclusion_reason", capture): diff --git a/tests/test_builder_lineage_integration.py b/tests/test_builder_lineage_integration.py new file mode 100644 index 00000000..8d37e48a --- /dev/null +++ b/tests/test_builder_lineage_integration.py @@ -0,0 +1,1116 @@ +"""End-to-end regressions for builder lineage across real production seams. + +The consumer regressions in ``test_builder_lineage_consumers`` prove each seam +carries lineage when it is handed some. These prove the seams *obtain* it: the +producer publishes, the publication survives an untrusted duplicate, and a +reviewer host that recorded nothing still resolves the takeover from the pull +request itself before its provider is launched. + +Every case is written from the #959 shape -- Devin opens, Codex takes over, +independent Claude reviews -- on a reviewer host with an empty private store, +because that is the arrangement in which every single-signal answer, and every +private-store-only answer, gets the wrong reviewer. +""" + +from __future__ import annotations + +import json +import sys +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from code_mower import builder_lineage, lane_delivery, lane_handoff # noqa: E402 +from code_mower.provider_runners import lineage as reviewer_lineage # noqa: E402 + +from test_builder_lineage_consumers import ( # noqa: E402 + BRANCH, + FIXED, + IDENTITY, + PR, + REPO, + TAKEN, + git_free_tempdir, + takeover_episode, +) +from test_controller import ( # noqa: E402 + _only_codex_merge_reviewer, + _options, + _pr, + _status, +) + +AUTHORITY = "codemower-ai" +OUTSIDER = "passer-by" + + +def continuation_episode( + sequence: int = 2, expected: str = TAKEN, resulting: str = FIXED +) -> builder_lineage.ContributionEpisode: + """Codex advancing the pull request it already holds.""" + + return builder_lineage.ContributionEpisode( + sequence=sequence, + kind=builder_lineage.CONTINUATION_KIND, + repo=REPO, + pr_number=PR, + branch=BRANCH, + source_lane="codex", + destination_lane="codex", + expected_head=expected, + resulting_head=resulting, + writer_state=builder_lineage.CONTINUATION_WRITER_STATE, + ) + + +def _variant( + episode: builder_lineage.ContributionEpisode, **changes +) -> builder_lineage.ContributionEpisode: + """The same episode with one field altered -- forged, not recorded.""" + + payload = { + field: getattr(episode, field) + for field in builder_lineage.EPISODE_FIELDS + if field != "schema" + } + payload.update(changes) + return builder_lineage.ContributionEpisode(**payload) + + +def published(episodes, *, author: str = AUTHORITY) -> dict: + """A pull request comment carrying the bounded lineage marker.""" + + return { + "user": {"login": author}, + "body": "Builder contribution lineage for this head.\n\n" + + builder_lineage.lineage_comment_marker(tuple(episodes)), + } + + +def pr_meta(*, author: str = "devin-ai-integration[bot]", labels=("builder:codex",), + branch: str = BRANCH, head: str = TAKEN) -> dict: + return { + "user": {"login": author}, + "head": {"ref": branch, "sha": head}, + "labels": [{"name": name} for name in labels], + } + + +class EmptyStoreReviewerHost(unittest.TestCase): + """A reviewer host recorded nothing, so the pull request is the evidence. + + Reading only the private store answers "no takeover happened" on precisely + the independent hosts where a takeover is the question being asked. Each of + these drives the *real* wrapper entry point, not the shared resolver. + """ + + def setUp(self): + empty = git_free_tempdir(self, "code-mower-empty-store-") + self.patches = mock.patch.dict( + "os.environ", + { + lane_handoff.STATE_DIR_ENV: str(empty), + reviewer_lineage.AUTHOR_EXCLUSION_ENV: json.dumps(IDENTITY), + }, + ) + self.patches.start() + self.addCleanup(self.patches.stop) + self.comments = [published([takeover_episode()])] + + def test_claude_wrapper_is_admitted_and_both_contributors_are_not(self): + from code_mower import claude_audit_pr + + decision = claude_audit_pr._require_independent_review( + "claude", REPO, PR, pr_meta(), TAKEN, + authorities=(AUTHORITY,), + fetch_comments=lambda: self.comments, + ) + + self.assertTrue(decision["admitted"]) + self.assertEqual(decision["current_writer"], "codex") + self.assertEqual(sorted(decision["contributors"]), ["codex", "devin"]) + + for lane in ("codex", "devin"): + with self.subTest(lane=lane): + with self.assertRaises(RuntimeError) as raised: + claude_audit_pr._require_independent_review( + lane, REPO, PR, pr_meta(), TAKEN, + authorities=(AUTHORITY,), + fetch_comments=lambda: self.comments, + ) + self.assertIn("contributor_not_independent", str(raised.exception)) + + def test_codex_wrapper_reads_the_same_published_evidence(self): + from code_mower import codex_audit_pr + + with self.assertRaises(RuntimeError) as raised: + codex_audit_pr._require_independent_review( + "codex", REPO, PR, pr_meta(), TAKEN, + authorities=(AUTHORITY,), + fetch_comments=lambda: self.comments, + ) + self.assertIn("contributor_not_independent", str(raised.exception)) + + admitted = codex_audit_pr._require_independent_review( + "claude", REPO, PR, pr_meta(), TAKEN, + authorities=(AUTHORITY,), + fetch_comments=lambda: self.comments, + ) + self.assertTrue(admitted["admitted"]) + + def test_devin_cli_wrapper_refuses_its_own_contribution_from_a_comment(self): + from code_mower import devin_cli_audit_pr + + config = SimpleNamespace(repo=REPO, pr_number=PR, github_token="unused") + with mock.patch.dict( + "os.environ", {"CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY} + ): + with self.assertRaises(devin_cli_audit_pr.AuthorExcludedError) as raised: + devin_cli_audit_pr._require_independent_devin_review( + config, pr_meta(author="someone-else"), TAKEN, "someone-else", + fetch_comments=lambda: self.comments, + ) + self.assertIn("contributor_not_independent", str(raised.exception)) + + def test_an_unreadable_publication_fetch_refuses_rather_than_admitting(self): + from code_mower import claude_audit_pr + + def explode(): + raise OSError("transport") + + with self.assertRaises(RuntimeError) as raised: + claude_audit_pr._require_independent_review( + "claude", REPO, PR, pr_meta(), TAKEN, + authorities=(AUTHORITY,), + fetch_comments=explode, + ) + self.assertIn("lineage_unreadable", str(raised.exception)) + + def test_an_untrusted_publisher_is_not_evidence(self): + """An audit bot can post a comment; that is not a takeover it can assert.""" + + from code_mower import claude_audit_pr + + outsider = [published([takeover_episode()], author=OUTSIDER)] + decision = claude_audit_pr._require_independent_review( + "codex", REPO, PR, pr_meta(labels=()), TAKEN, + authorities=(AUTHORITY,), + fetch_comments=lambda: outsider, + ) + # Falls back to the ordinary identity-only answer, which names the + # opener -- not the takeover the untrusted marker claimed. + self.assertTrue(decision["admitted"]) + self.assertEqual(decision["contributors"], ["devin"]) + + def test_no_configured_authority_reads_no_comments_at_all(self): + fetched = [] + + def fetch(): + fetched.append(1) + return self.comments + + episodes = reviewer_lineage.reviewer_evidence( + REPO, PR, authorities=(), fetch_comments=fetch + ) + self.assertEqual(episodes, ()) + self.assertEqual(fetched, []) + + +class ReviewAdapterBinding(unittest.TestCase): + """The embedding adapter carries the metadata the binding is made of.""" + + def setUp(self): + empty = git_free_tempdir(self, "code-mower-adapter-store-") + patched = mock.patch.dict( + "os.environ", + { + lane_handoff.STATE_DIR_ENV: str(empty), + reviewer_lineage.AUTHOR_EXCLUSION_ENV: json.dumps(IDENTITY), + }, + ) + patched.start() + self.addCleanup(patched.stop) + + def _review(self, **overrides): + from code_mower.devin_review import ReviewInput + + marker = published([takeover_episode()])["body"] + fields = dict( + repository=REPO, + pr=PR, + head=TAKEN, + author="someone-else", + context={}, + changed_files=("handler.py",), + branch=BRANCH, + labels=("builder:codex",), + lineage_markers=(marker,), + ) + fields.update(overrides) + return ReviewInput(**fields) + + def test_devin_is_refused_on_a_takeover_it_contributed_to(self): + self.assertFalse(self._review().lineage_admits()) + + def test_a_branch_the_episodes_do_not_name_is_not_admission(self): + """Strict binding, not a looser one to accommodate the new field.""" + + review = self._review(branch="codex/other-branch") + self.assertFalse(review.lineage_admits()) + self.assertFalse( + review.pr_metadata()["head"]["ref"] == BRANCH, + "the carried branch must reach the resolver unchanged", + ) + + def test_labels_and_branch_reach_the_resolver(self): + meta = self._review().pr_metadata() + self.assertEqual(meta["head"], {"ref": BRANCH, "sha": TAKEN}) + self.assertEqual(meta["labels"], [{"name": "builder:codex"}]) + + def test_an_unbounded_marker_list_is_refused_not_parsed(self): + marker = published([takeover_episode()])["body"] + review = self._review(lineage_markers=(marker,) * 40) + with self.assertRaises(ValueError): + review.published_lineage() + self.assertFalse(review.lineage_admits()) + + def test_an_independent_lane_is_still_refused_for_the_devin_lane_only(self): + """The adapter decides one lane. Claude's admission is the wrappers'.""" + + review = self._review(head=TAKEN) + self.assertFalse(review.lineage_admits()) + admission = reviewer_lineage.reviewer_admission( + "claude", + repo=REPO, + pr_number=PR, + pr_meta=review.pr_metadata(), + head_sha=TAKEN, + episodes=review.published_lineage(), + identity=IDENTITY, + ) + self.assertTrue(admission["admitted"]) + + +class PublicationTrustContract(unittest.TestCase): + """Publication and consumption have to mean the same thing by "trusted".""" + + def _publish(self, existing, *, trusted_author=None, episodes=None): + posted = [] + result = lane_delivery.publish_lineage_evidence( + repo=REPO, + pr_number=str(PR), + branch=BRANCH, + head_sha=TAKEN, + episodes=tuple(episodes or (takeover_episode(),)), + opener_lane="devin", + label_lanes=("codex",), + existing_bodies=lambda: list(existing), + publish=lambda body: ( + posted.append(body), + existing.append({"user": {"login": AUTHORITY}, "body": body}), + ), + trusted_author=trusted_author, + ) + return result, posted + + def test_an_untrusted_identical_body_does_not_suppress_the_publication(self): + marker = published([takeover_episode()], author=OUTSIDER) + existing = [marker] + result, posted = self._publish( + existing, + trusted_author=reviewer_lineage.marker_author_trust((AUTHORITY,)), + ) + self.assertTrue(result["published"]) + self.assertEqual(len(posted), 1) + + def test_a_trusted_publication_is_idempotent(self): + existing = [published([takeover_episode()])] + result, posted = self._publish( + existing, + trusted_author=reviewer_lineage.marker_author_trust((AUTHORITY,)), + ) + self.assertTrue(result["duplicate"]) + self.assertFalse(result["published"]) + self.assertEqual(posted, []) + + def test_a_comment_no_consumer_can_read_is_not_a_publication(self): + """A successful POST under an untrusted account is not evidence.""" + + existing = [] + posted = [] + result = lane_delivery.publish_lineage_evidence( + repo=REPO, + pr_number=str(PR), + branch=BRANCH, + head_sha=TAKEN, + episodes=(takeover_episode(),), + opener_lane="devin", + label_lanes=("codex",), + existing_bodies=lambda: list(existing), + publish=lambda body: ( + posted.append(body), + existing.append({"user": {"login": OUTSIDER}, "body": body}), + ), + trusted_author=reviewer_lineage.marker_author_trust((AUTHORITY,)), + ) + self.assertFalse(result["published"]) + self.assertEqual(result["reason"], "publication_author_untrusted") + self.assertTrue(result["owner_action"]) + self.assertEqual(len(posted), 1, "the attempt happened; it did not count") + + def test_lineage_that_does_not_resolve_at_the_head_is_never_published(self): + existing = [] + result, posted = self._publish( + existing, + trusted_author=reviewer_lineage.marker_author_trust((AUTHORITY,)), + episodes=(takeover_episode(resulting=FIXED),), + ) + self.assertFalse(result["published"]) + self.assertEqual(result["reason"], "lineage_waiting") + self.assertEqual(posted, []) + + def test_the_published_payload_carries_no_field_beyond_the_contract(self): + marker = builder_lineage.lineage_comment_marker((takeover_episode(),)) + payload = json.loads(marker.split(None, 2)[2].rsplit("-->", 1)[0].strip()) + for episode in payload["episodes"]: + self.assertEqual( + sorted(episode), sorted(builder_lineage.EPISODE_FIELDS) + ) + + +class PublishThenReconcileCli(unittest.TestCase): + """The real ``lane-delivery lineage`` path, publication before the label.""" + + def setUp(self): + self.root = git_free_tempdir(self, "code-mower-producer-") + builder_lineage.record_episode( + lane_handoff.lineage_root(self.root), takeover_episode() + ) + patched = mock.patch.dict( + "os.environ", {"CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY} + ) + patched.start() + self.addCleanup(patched.stop) + + def _args(self, **overrides): + args = SimpleNamespace( + repo=REPO, pr=str(PR), branch=BRANCH, head=TAKEN, + labels=["builder:devin"], author="devin-ai-integration[bot]", + identity_json=json.dumps(IDENTITY), state_dir=self.root, + publish=True, reconcile_labels=True, json=True, + ) + for key, value in overrides.items(): + setattr(args, key, value) + return args + + def test_an_untrusted_duplicate_does_not_block_the_required_publication(self): + existing = [published([takeover_episode()], author=OUTSIDER)] + posted, applied = [], [] + code = lane_delivery._lineage_main( + self._args(), + head=lambda repo, number: TAKEN, + labels=lambda repo, number, add, remove: applied.append((add, remove)), + comment_bodies=lambda repo, number: list(existing), + publish_comment=lambda repo, number, body: ( + posted.append(body), + existing.append({"user": {"login": AUTHORITY}, "body": body}), + ), + ) + self.assertEqual(code, 0) + self.assertEqual(len(posted), 1) + self.assertEqual(applied, [(("builder:codex",), ("builder:devin",))]) + + def test_publication_failure_abandons_the_label_move(self): + applied = [] + + def refuse(repo, number, body): + raise RuntimeError("comment rejected") + + with self.assertRaises(RuntimeError): + lane_delivery._lineage_main( + self._args(), + head=lambda repo, number: TAKEN, + labels=lambda repo, number, add, remove: applied.append((add, remove)), + comment_bodies=lambda repo, number: [], + publish_comment=refuse, + ) + self.assertEqual(applied, []) + + def test_a_publication_no_consumer_trusts_blocks_the_label_move(self): + existing, applied = [], [] + code = lane_delivery._lineage_main( + self._args(), + head=lambda repo, number: TAKEN, + labels=lambda repo, number, add, remove: applied.append((add, remove)), + comment_bodies=lambda repo, number: list(existing), + publish_comment=lambda repo, number, body: existing.append( + {"user": {"login": OUTSIDER}, "body": body} + ), + ) + self.assertEqual(code, 3) + self.assertEqual(applied, []) + + def test_a_head_that_moved_under_the_record_publishes_nothing(self): + existing, applied = [], [] + code = lane_delivery._lineage_main( + self._args(head=FIXED), + head=lambda repo, number: FIXED, + labels=lambda repo, number, add, remove: applied.append((add, remove)), + comment_bodies=lambda repo, number: list(existing), + publish_comment=lambda repo, number, body: existing.append( + {"user": {"login": AUTHORITY}, "body": body} + ), + ) + self.assertEqual(code, 3) + self.assertEqual(existing, []) + self.assertEqual(applied, []) + + +class GoldenTakeoverChain(unittest.TestCase): + """Producer -> published comment -> empty-store reviewer -> labeler -> gate.""" + + def setUp(self): + self.producer_root = git_free_tempdir(self, "code-mower-golden-producer-") + self.reviewer_root = git_free_tempdir(self, "code-mower-golden-reviewer-") + builder_lineage.record_episode( + lane_handoff.lineage_root(self.producer_root), takeover_episode() + ) + patched = mock.patch.dict( + "os.environ", + { + "CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY, + reviewer_lineage.AUTHOR_EXCLUSION_ENV: json.dumps(IDENTITY), + }, + ) + patched.start() + self.addCleanup(patched.stop) + + def _publish_from_the_producer(self): + comments = [] + code = lane_delivery._lineage_main( + SimpleNamespace( + repo=REPO, pr=str(PR), branch=BRANCH, head=TAKEN, + labels=["builder:devin"], author="devin-ai-integration[bot]", + identity_json=json.dumps(IDENTITY), state_dir=self.producer_root, + publish=True, reconcile_labels=True, json=True, + ), + head=lambda repo, number: TAKEN, + labels=lambda repo, number, add, remove: None, + comment_bodies=lambda repo, number: list(comments), + publish_comment=lambda repo, number, body: comments.append( + {"user": {"login": AUTHORITY}, "body": body} + ), + ) + self.assertEqual(code, 0) + self.assertEqual(len(comments), 1) + return comments + + def test_the_whole_chain_admits_claude_and_lets_it_satisfy_the_gate(self): + from code_mower import claude_audit_pr + from code_mower.audit_labeler_lib import ( + author_exclusion_reason, + builder_identity_matches, + lineage_context, + lineage_marker_author_trust, + ) + + comments = self._publish_from_the_producer() + meta = pr_meta() + + with mock.patch.dict( + "os.environ", {lane_handoff.STATE_DIR_ENV: str(self.reviewer_root)} + ): + admission = claude_audit_pr._require_independent_review( + "claude", REPO, PR, meta, TAKEN, + authorities=(AUTHORITY,), + fetch_comments=lambda: comments, + ) + self.assertTrue(admission["admitted"]) + + context = lineage_context( + repo=REPO, + pr_number=PR, + branch=BRANCH, + head_sha=TAKEN, + comments=comments, + trusted_author=lineage_marker_author_trust(authorities=(AUTHORITY,)), + ) + self.assertEqual(len(context.episodes), 1) + + # The labeler updates Claude's own done label rather than skipping it. + self.assertIsNone( + author_exclusion_reason( + lane_name="claude", + labels=["builder:codex"], + author="devin-ai-integration[bot]", + text="", + config=IDENTITY, + lineage=context, + ) + ) + # And the gate reads the same evidence, naming both contributors. + self.assertEqual( + sorted( + builder_identity_matches( + labels=["builder:codex"], + author="devin-ai-integration[bot]", + text="", + config=IDENTITY, + lineage=context, + ) + ), + ["codex", "devin"], + ) + + def test_a_same_writer_continuation_keeps_the_chain_at_the_new_head(self): + comments = self._publish_from_the_producer() + comments.append(published([takeover_episode(), continuation_episode()])) + + with mock.patch.dict( + "os.environ", {lane_handoff.STATE_DIR_ENV: str(self.reviewer_root)} + ): + episodes = reviewer_lineage.reviewer_evidence( + REPO, PR, authorities=(AUTHORITY,), fetch_comments=lambda: comments + ) + resolved = reviewer_lineage.pr_lineage( + repo=REPO, + pr_number=PR, + pr_meta=pr_meta(head=FIXED), + head_sha=FIXED, + episodes=episodes, + identity=IDENTITY, + ) + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.current_writer, "codex") + self.assertEqual(sorted(resolved.contributors), ["codex", "devin"]) + + def test_conflicting_published_evidence_fails_closed(self): + comments = self._publish_from_the_producer() + comments.append( + published([_variant(takeover_episode(), destination_lane="claude")]) + ) + with mock.patch.dict( + "os.environ", {lane_handoff.STATE_DIR_ENV: str(self.reviewer_root)} + ): + episodes = reviewer_lineage.reviewer_evidence( + REPO, PR, authorities=(AUTHORITY,), fetch_comments=lambda: comments + ) + decision = reviewer_lineage.reviewer_admission( + "claude", + repo=REPO, + pr_number=PR, + pr_meta=pr_meta(), + head_sha=TAKEN, + episodes=episodes, + identity=IDENTITY, + ) + self.assertFalse(decision["admitted"]) + self.assertEqual(decision["reason"], "lineage_conflict") + + def test_stale_published_evidence_waits_rather_than_guessing(self): + comments = self._publish_from_the_permanent_past() + decision = reviewer_lineage.reviewer_admission( + "claude", + repo=REPO, + pr_number=PR, + pr_meta=pr_meta(head=FIXED), + head_sha=FIXED, + episodes=comments, + identity=IDENTITY, + ) + self.assertFalse(decision["admitted"]) + self.assertEqual(decision["reason"], "lineage_waiting") + + def _publish_from_the_permanent_past(self): + return (takeover_episode(),) + + +class BoundedIdenticalReplay(unittest.TestCase): + """Republishing the same chain must not look like a malformed lineage. + + The producer publishes the whole chain on every round, and a reviewer merges + its private record with every trusted marker it finds. Eight snapshots of an + eight-episode lineage is thirty-six arrivals of thirty-two-or-fewer distinct + episodes; counting arrivals against the lineage bound calls an authorised + replay malformed. + """ + + def _chain(self, length: int): + episodes = [takeover_episode(resulting="0" * 39 + "1")] + for index in range(2, length + 1): + episodes.append( + continuation_episode( + sequence=index, + expected=episodes[-1].resulting_head, + resulting=f"{index:040x}", + ) + ) + return tuple(episodes) + + def test_eight_snapshots_of_an_eight_episode_chain_still_resolve(self): + chain = self._chain(8) + arrivals = tuple(episode for _ in range(8) for episode in chain) + self.assertGreater(len(arrivals), builder_lineage.MAX_EPISODES) + + resolved = builder_lineage.resolve_lineage( + repo=REPO, + pr_number=PR, + branch=BRANCH, + head_sha=chain[-1].resulting_head, + episodes=arrivals, + ) + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.episodes, 8) + + def test_repeated_markers_collapse_before_they_reach_the_resolver(self): + root = git_free_tempdir(self, "code-mower-replay-") + chain = self._chain(8) + comments = [published(chain) for _ in range(8)] + with mock.patch.dict( + "os.environ", {lane_handoff.STATE_DIR_ENV: str(root)} + ): + episodes = reviewer_lineage.reviewer_evidence( + REPO, PR, authorities=(AUTHORITY,), fetch_comments=lambda: comments + ) + self.assertEqual(len(episodes), 8) + + def test_a_disagreeing_duplicate_still_fails_closed(self): + chain = self._chain(3) + forged = _variant(chain[1], destination_lane="claude", source_lane="claude") + resolved = builder_lineage.resolve_lineage( + repo=REPO, + pr_number=PR, + branch=BRANCH, + head_sha=chain[-1].resulting_head, + episodes=chain + chain + (forged,), + ) + self.assertEqual(resolved.status, "conflict") + self.assertEqual(resolved.reason, "episode_duplicated") + + def test_an_unbounded_arrival_is_refused_without_being_walked(self): + chain = self._chain(2) + arrivals = chain * (builder_lineage.MAX_EPISODE_ENTRIES // 2 + 1) + resolved = builder_lineage.resolve_lineage( + repo=REPO, + pr_number=PR, + branch=BRANCH, + head_sha=chain[-1].resulting_head, + episodes=arrivals, + ) + self.assertEqual(resolved.status, "conflict") + self.assertEqual(resolved.reason, "episode_malformed") + + +class LaneStatusProjection(unittest.TestCase): + """The Board/controller projection reads the same evidence as the gate.""" + + def setUp(self): + self.root = git_free_tempdir(self, "code-mower-status-") + patched = mock.patch.dict( + "os.environ", + { + "CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY, + reviewer_lineage.AUTHOR_EXCLUSION_ENV: json.dumps(IDENTITY), + lane_handoff.STATE_DIR_ENV: str(self.root), + }, + ) + patched.start() + self.addCleanup(patched.stop) + + def test_the_configured_store_is_read_not_the_packaged_default(self): + from code_mower import lane_status + + builder_lineage.record_episode( + lane_handoff.lineage_root(self.root), takeover_episode() + ) + projection = lane_status.builder_lineage_for( + REPO, + pr_number=PR, + branch=BRANCH, + head_sha=TAKEN, + labels=["builder:codex"], + author="devin-ai-integration[bot]", + ) + self.assertEqual(projection["status"], "resolved") + self.assertEqual(projection["current_writer"], "codex") + + def test_a_cross_host_projection_resolves_from_published_comments(self): + """No local record at all -- the Board still sees the takeover.""" + + from code_mower import lane_status + + projection = lane_status.builder_lineage_for( + REPO, + pr_number=PR, + branch=BRANCH, + head_sha=TAKEN, + labels=["builder:codex"], + author="devin-ai-integration[bot]", + comments=[published([takeover_episode()])], + ) + self.assertEqual(projection["status"], "resolved") + self.assertEqual(sorted(projection["contributors"]), ["codex", "devin"]) + + def test_an_untrusted_marker_is_not_read_into_the_projection(self): + from code_mower import lane_status + + projection = lane_status.builder_lineage_for( + REPO, + pr_number=PR, + branch=BRANCH, + head_sha=TAKEN, + labels=[], + author="devin-ai-integration[bot]", + comments=[published([takeover_episode()], author=OUTSIDER)], + ) + self.assertEqual(projection["contributors"], ["devin"]) + + def test_the_status_command_asks_github_for_the_comments(self): + from code_mower import lane_status + + requested = [] + + def runner(args): + requested.append(args) + return [] + + lane_status._remote(REPO, runner, __import__("datetime").datetime.now( + __import__("datetime").UTC + ), 5, 5, 30) + self.assertTrue( + any("comments" in str(arg) for args in requested for arg in args), + "the pull request query must request published lineage comments", + ) + + +class ControllerDiagnostics(unittest.TestCase): + """Unreadable evidence and an empty reviewer set are different problems. + + Both stop the controller, and both are fail-closed, but they send the owner + to different repairs: one to re-record a broken lineage, one to configure a + reviewer lane that did not write this diff. Collapsing the second into the + first asks the owner to fix a record that is already correct. + """ + + def _report(self, config, pr): + from code_mower import controller + + return controller.evaluate_controller_report( + status_report=_status([pr]), + ready_issues={"available": True, "errors": [], "issues": []}, + config=config, + options=_options("manual"), + )["decision"] + + def test_a_resolved_lineage_with_no_independent_reviewer_names_the_lanes(self): + pr = _pr(builder="builder:codex", needs=["needs-codex-audit"]) + pr["builder_lineage"] = { + "status": "resolved", + "contributors": ["devin", "codex"], + "current_writer": "codex", + "owner_action": "", + } + decision = self._report(_only_codex_merge_reviewer(), pr) + + self.assertEqual(decision["decision_state"], "owner_action") + self.assertEqual(decision["owner_action_kind"], "reviewer_lanes_missing") + self.assertEqual(decision["stop_condition"], "reviewer_lanes_missing") + self.assertEqual(decision["builder_lineage_status"], "resolved") + self.assertEqual(sorted(decision["builder_contributors"]), ["codex", "devin"]) + + def test_unresolved_lineage_still_reports_the_lineage_repair(self): + pr = _pr(builder="builder:codex", needs=["needs-codex-audit"]) + pr["builder_lineage"] = { + "status": "conflict", + "contributors": [], + "current_writer": "", + "owner_action": "re-record the lineage from the verified handoff", + } + decision = self._report(_only_codex_merge_reviewer(), pr) + + self.assertEqual(decision["owner_action_kind"], "builder_lineage") + self.assertEqual(decision["stop_condition"], "builder_lineage_unresolved") + self.assertEqual( + decision["next_detail"], "re-record the lineage from the verified handoff" + ) + + +class AutoRecordCli(unittest.TestCase): + """The real ``code-mower builder auto-record`` path after a takeover.""" + + def setUp(self): + self.dir = git_free_tempdir(self, "code-mower-auto-record-") + patched = mock.patch.dict( + "os.environ", {"CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY} + ) + patched.start() + self.addCleanup(patched.stop) + + def _write(self, name: str, payload) -> Path: + path = self.dir / name + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + def _run(self, *, comments) -> dict: + from code_mower import builder_runs + + event = self._write( + "event.json", + { + "pull_request": { + "number": PR, + "html_url": f"https://github.com/{REPO}/pull/{PR}", + "user": {"login": "devin-ai-integration[bot]"}, + "head": {"ref": BRANCH, "sha": TAKEN}, + "labels": [{"name": "builder:codex"}], + "body": "", + }, + "repository": {"full_name": REPO}, + }, + ) + argv = [ + "auto-record", + "--pr-json", str(event), + "--repo", REPO, + "--output", str(self.dir / "run.json"), + "--force", + "--json", + ] + if comments is not None: + argv += ["--comments-json", str(self._write("comments.json", comments))] + self.assertEqual(builder_runs.main(argv), 0) + return json.loads((self.dir / "run.json").read_text(encoding="utf-8")) + + def test_a_taken_over_pull_request_is_attributed_to_the_current_writer(self): + event = self._run(comments=[published([takeover_episode()])]) + + dimensions = event["dimensions"] + self.assertEqual(event["provider"], "codex") + self.assertEqual(dimensions["builder_executor"], "chatgpt-codex-connector") + self.assertEqual(dimensions["pr_author"], "devin-ai-integration[bot]") + self.assertEqual(dimensions["builder_lineage_status"], "resolved") + self.assertEqual(dimensions["builder_current_writer"], "codex") + self.assertEqual( + sorted(dimensions["builder_contributors"]), ["codex", "devin"] + ) + self.assertIn("builder_lineage:codex", dimensions["builder_inference_signals"]) + + def test_without_published_lineage_it_records_the_unresolved_answer(self): + """The defect this replaces, kept as an explicit regression. + + Opener and label disagree and nothing explains why, so the resolver + names no writer. Auto-record still attributes to the opener, because + that is all it knows -- but the recorded lineage says so rather than + presenting the guess as settled. + """ + + event = self._run(comments=[]) + + self.assertEqual(event["provider"], "devin") + self.assertEqual(event["dimensions"]["builder_current_writer"], "") + self.assertEqual(event["dimensions"]["builder_lineage_status"], "conflict") + + def test_an_untrusted_marker_does_not_move_the_attribution(self): + event = self._run( + comments=[published([takeover_episode()], author=OUTSIDER)] + ) + + self.assertEqual(event["provider"], "devin") + self.assertNotIn( + "builder_lineage:codex", event["dimensions"]["builder_inference_signals"] + ) + + +class SaasReviewPath(unittest.TestCase): + """``pull_request_review`` resolves the same evidence as every other path.""" + + def _adapter(self): + return SimpleNamespace( + name="greptile", event_type="pull_request_review", opt_in_required=False, + label_prefix="greptile", needs_label="n", done_label="d", blocked_label="b", + supported_event_types=("pull_request_review",), + requires_review_comments=False, review_comments_page_cap=5, + check_run_done_requires_absent_same_head_review=False, + is_opted_in=lambda labels: True, is_review_author=lambda author: True, + is_check_run_author=lambda check_run: True, + is_check_run_name=lambda check_run: True, + token_env_vars=("GITHUB_TOKEN",), + ) + + def _main(self, event_path: Path, *, comments, fail: bool = False): + from code_mower import saas_reviewer_labeler as labeler + + seen = {} + + def capture(*args, **kwargs): + seen.update(kwargs) + return None, "captured" + + def fetch_comments(repo, number, *, tokens, page_cap): + if fail: + raise labeler.GitHubRequestError("GET", "/comments", 503, "") + return comments + + with mock.patch.object(labeler, "load_adapter", lambda name: self._adapter()), \ + mock.patch.object(labeler, "github_tokens_from_env", lambda *a: ("t",)), \ + mock.patch.object( + labeler, "fetch_pull_request", + lambda repo, number, **kw: { + "labels": [{"name": "builder:codex"}], + "user": {"login": "devin-ai-integration[bot]"}, + "body": "", + "head": {"sha": TAKEN, "ref": BRANCH}, + }, + ), \ + mock.patch.object(labeler, "fetch_issue_comments", fetch_comments), \ + mock.patch.object(labeler, "resolve_label_decision", capture): + code = labeler.main(["--adapter", "greptile"]) + return code, seen + + def test_the_review_path_carries_published_episodes(self): + directory = git_free_tempdir(self, "code-mower-saas-") + event_path = directory / "event.json" + event_path.write_text( + json.dumps({ + "action": "submitted", + "pull_request": {"number": PR}, + "review": {"id": 1}, + }), + encoding="utf-8", + ) + with mock.patch.dict("os.environ", { + "GITHUB_EVENT_PATH": str(event_path), + "GITHUB_REPOSITORY": REPO, + "GITHUB_EVENT_NAME": "pull_request_review", + "CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY, + }): + code, seen = self._main( + event_path, comments=[published([takeover_episode()])] + ) + + self.assertEqual(code, 0) + self.assertEqual(seen["issue_comments"], [published([takeover_episode()])]) + self.assertEqual(seen["current_head_sha"], TAKEN) + self.assertEqual(seen["head_branch"], BRANCH) + self.assertEqual(seen["decision_authorities"], (AUTHORITY,)) + + def test_an_unreadable_fetch_stops_rather_than_labelling_on_identity(self): + directory = git_free_tempdir(self, "code-mower-saas-fail-") + event_path = directory / "event.json" + event_path.write_text( + json.dumps({ + "action": "submitted", + "pull_request": {"number": PR}, + "review": {"id": 1}, + }), + encoding="utf-8", + ) + with mock.patch.dict("os.environ", { + "GITHUB_EVENT_PATH": str(event_path), + "GITHUB_REPOSITORY": REPO, + "GITHUB_EVENT_NAME": "pull_request_review", + "CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY, + }): + code, seen = self._main(event_path, comments=[], fail=True) + + self.assertEqual(code, 0) + self.assertEqual(seen, {}, "no decision is resolved on unreadable evidence") + + +class PinnedBaseSeam(unittest.TestCase): + """#963 consumes #955's already-pinned base; it never re-resolves a name.""" + + def test_the_claude_wrapper_admits_after_the_pin_and_from_config_base_ref(self): + from code_mower import claude_audit_pr + + source = Path(claude_audit_pr.__file__).read_text(encoding="utf-8") + pin = source.index("config, base_ref=diff_context.fetched_base_ref or config.base_ref") + authorities = source.index("trusted_ref=config.base_ref,", pin) + admission = source.index('_require_independent_review(\n "claude"', pin) + self.assertLess(pin, authorities) + self.assertLess(authorities, admission) + + def test_the_codex_wrapper_admits_after_its_own_pin(self): + from code_mower import codex_audit_pr + + source = Path(codex_audit_pr.__file__).read_text(encoding="utf-8") + pin = source.index("config, base_ref=_pinned_base_revision(local_repo, config.base_ref)") + authorities = source.index("trusted_ref=config.base_ref,", pin) + admission = source.index('_require_independent_review(\n "codex"', pin) + self.assertLess(pin, authorities) + self.assertLess(authorities, admission) + + def test_neither_wrapper_fetches_the_base_a_second_time_for_lineage(self): + for module_name, fetcher in ( + ("claude_audit_pr", "_fetch_base_sha_for_diff"), + ("codex_audit_pr", "_fetch_base_ref"), + ): + with self.subTest(module=module_name): + module = __import__(f"code_mower.{module_name}", fromlist=[module_name]) + source = Path(module.__file__).read_text(encoding="utf-8") + admission = source.index("authorities=decision_authorities,") + self.assertNotIn(fetcher + "(", source[admission:]) + + def test_a_ref_that_moves_after_the_fetch_does_not_change_the_authorities(self): + """The admission reads the pinned SHA, so a later push cannot rewrite it. + + Decision authorities are what makes published lineage readable, so + re-resolving a mutable name here would let a commit landing mid-audit + change which markers this reviewer trusts. + """ + + import subprocess + + from code_mower import claude_audit_pr + + repo = git_free_tempdir(self, "code-mower-pinned-base-") + + def git(*args): + subprocess.run( + ["git", *args], cwd=repo, check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + + git("init", "-q", "-b", "main") + git("config", "user.email", "lane@example.invalid") + git("config", "user.name", "Lane") + (repo / "code-mower.yml").write_text( + "decisions:\n authorities:\n - codemower-ai\n", encoding="utf-8" + ) + git("add", "code-mower.yml") + git("commit", "-qm", "pinned base") + pinned = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo, check=True, + capture_output=True, text=True, + ).stdout.strip() + + before = claude_audit_pr._decision_authorities_for_repo( + repo, (), trusted_ref=pinned + ) + self.assertIn("codemower-ai", before) + + # The name moves under the audit; the pinned revision does not. + (repo / "code-mower.yml").write_text( + "decisions:\n authorities:\n - somebody-else\n", encoding="utf-8" + ) + git("add", "code-mower.yml") + git("commit", "-qm", "moved") + + self.assertEqual( + claude_audit_pr._decision_authorities_for_repo(repo, (), trusted_ref=pinned), + before, + ) + self.assertIn( + "somebody-else", + claude_audit_pr._decision_authorities_for_repo( + repo, (), trusted_ref="main" + ), + "the mutable name really did move, so the pin is what held", + ) + + +if __name__ == "__main__": + unittest.main() From 52820a4736d27213915d6e1045c5b173c903d4f1 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 12:59:19 -0700 Subject: [PATCH 06/25] Trust gate lineage through decision authorities, not reviewer bots The gate read published contribution lineage from any lane's configured bot_authors, while the publisher, both labelers and every reviewer wrapper read it only from the repository's configured decision authorities. The marker is a transport, never an authorization, so the two ends disagreed in both directions: a decision authority's valid Devin-to-Codex takeover was ignored and the gate waited for Devin despite an independent Claude exact-head pass, and the identical marker posted by a non-authority audit bot was accepted and passed the gate. Permission to post an audit verdict is not takeover authority. The canonical template, the packaged template and the checked-in gate workflow now apply the same configured decision-authority contract as every other lineage consumer. Independent audit-verdict trust and the GitHub Actions attestation requirement are untouched, and an unconfigured checkout trusts nobody and reads no episodes as before. The regression drives the actual rendered gate decision through the existing test_release_hygiene harness, which now supplies authenticated PR author and branch metadata from fake fixtures: an authority-published takeover lets independent Claude satisfy the gate, and the same marker from a non-authority audit bot establishes no contributor history, so the gate fails closed on the unexplained author/label disagreement. Also drops the unused lane_handoff import in lane_status (Ruff F401). Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/code-mower-gate.yml | 22 ++-- src/code_mower/lane_status.py | 1 - .../workflows/code-mower-gate.yml.j2 | 22 ++-- templates/workflows/code-mower-gate.yml.j2 | 22 ++-- tests/test_release_hygiene.py | 124 +++++++++++++++++- 5 files changed, 165 insertions(+), 26 deletions(-) diff --git a/.github/workflows/code-mower-gate.yml b/.github/workflows/code-mower-gate.yml index 8751597e..ce596e94 100644 --- a/.github/workflows/code-mower-gate.yml +++ b/.github/workflows/code-mower-gate.yml @@ -438,10 +438,19 @@ jobs: context_required=context_required, ) - # Contribution lineage, not one label and one author. Episodes are - # read only from comments this gate already trusts, so publishing a - # marker is a transport and never an authorization. The resolver is - # the same one the runner and the reviewer wrappers use. + # Contribution lineage, not one label and one author. The hidden + # marker is a transport and never an authorization, so episodes are + # read only from the repository's configured decision authorities -- + # the same trust contract the publisher, both labelers and every + # reviewer wrapper apply. Permission to post an audit verdict is not + # takeover authority, so reviewer bot authors are deliberately not + # consulted here. An unconfigured checkout trusts nobody, reads no + # episodes, and falls back to the ordinary single-builder answer. + lineage_authorities = { + item.strip().lower().lstrip("@") + for item in decision_authorities + if item.strip() + } lineage_episodes = [] lineage_readable = True for comment in comments: @@ -449,10 +458,7 @@ jobs: if LINEAGE_MARKER not in comment_body: continue comment_author = str(((comment.get("user") or {}).get("login")) or "") - if not any( - trusted_comment_author(lane, comment_author, comment_body, comment.get("id")) - for lane in lanes - ): + if comment_author.strip().lower().lstrip("@") not in lineage_authorities: continue try: lineage_episodes.extend(episodes_from_comment_body(comment_body)) diff --git a/src/code_mower/lane_status.py b/src/code_mower/lane_status.py index a549a1d0..c1744f3c 100644 --- a/src/code_mower/lane_status.py +++ b/src/code_mower/lane_status.py @@ -313,7 +313,6 @@ def builder_lineage_for( """ from . import builder_lineage as lineage_module - from . import lane_handoff from .decisions import decision_authorities_from_env from .provider_runners.lineage import load_identity, trusted_episodes diff --git a/src/code_mower/templates/workflows/code-mower-gate.yml.j2 b/src/code_mower/templates/workflows/code-mower-gate.yml.j2 index cf479b5b..df7c711d 100644 --- a/src/code_mower/templates/workflows/code-mower-gate.yml.j2 +++ b/src/code_mower/templates/workflows/code-mower-gate.yml.j2 @@ -437,10 +437,19 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ context_required=context_required, ) - # Contribution lineage, not one label and one author. Episodes are - # read only from comments this gate already trusts, so publishing a - # marker is a transport and never an authorization. The resolver is - # the same one the runner and the reviewer wrappers use. + # Contribution lineage, not one label and one author. The hidden + # marker is a transport and never an authorization, so episodes are + # read only from the repository's configured decision authorities -- + # the same trust contract the publisher, both labelers and every + # reviewer wrapper apply. Permission to post an audit verdict is not + # takeover authority, so reviewer bot authors are deliberately not + # consulted here. An unconfigured checkout trusts nobody, reads no + # episodes, and falls back to the ordinary single-builder answer. + lineage_authorities = { + item.strip().lower().lstrip("@") + for item in decision_authorities + if item.strip() + } lineage_episodes = [] lineage_readable = True for comment in comments: @@ -448,10 +457,7 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ if LINEAGE_MARKER not in comment_body: continue comment_author = str(((comment.get("user") or {}).get("login")) or "") - if not any( - trusted_comment_author(lane, comment_author, comment_body, comment.get("id")) - for lane in lanes - ): + if comment_author.strip().lower().lstrip("@") not in lineage_authorities: continue try: lineage_episodes.extend(episodes_from_comment_body(comment_body)) diff --git a/templates/workflows/code-mower-gate.yml.j2 b/templates/workflows/code-mower-gate.yml.j2 index cf479b5b..df7c711d 100644 --- a/templates/workflows/code-mower-gate.yml.j2 +++ b/templates/workflows/code-mower-gate.yml.j2 @@ -437,10 +437,19 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ context_required=context_required, ) - # Contribution lineage, not one label and one author. Episodes are - # read only from comments this gate already trusts, so publishing a - # marker is a transport and never an authorization. The resolver is - # the same one the runner and the reviewer wrappers use. + # Contribution lineage, not one label and one author. The hidden + # marker is a transport and never an authorization, so episodes are + # read only from the repository's configured decision authorities -- + # the same trust contract the publisher, both labelers and every + # reviewer wrapper apply. Permission to post an audit verdict is not + # takeover authority, so reviewer bot authors are deliberately not + # consulted here. An unconfigured checkout trusts nobody, reads no + # episodes, and falls back to the ordinary single-builder answer. + lineage_authorities = { + item.strip().lower().lstrip("@") + for item in decision_authorities + if item.strip() + } lineage_episodes = [] lineage_readable = True for comment in comments: @@ -448,10 +457,7 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ if LINEAGE_MARKER not in comment_body: continue comment_author = str(((comment.get("user") or {}).get("login")) or "") - if not any( - trusted_comment_author(lane, comment_author, comment_body, comment.get("id")) - for lane in lanes - ): + if comment_author.strip().lower().lstrip("@") not in lineage_authorities: continue try: lineage_episodes.extend(episodes_from_comment_body(comment_body)) diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index ef5316fa..5befc213 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -1686,6 +1686,8 @@ def _run_gate_template_decision( commit_pull_requests: dict[str, list[dict[str, object]]] | None = None, author_exclusion: dict[str, object] | None = None, owner_login: str = "owner", + pr_author: str = "", + branch: str = "", env: dict[str, str] | None = None, ) -> dict[str, str]: template = ( @@ -1707,7 +1709,13 @@ def _run_gate_template_decision( json.dump(event_pages if event_pages is not None else [events or []], handle) events_path = handle.name with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle: - json.dump({"number": pr_number, "head": {"sha": pr_head_sha or head_sha}}, handle) + pr_payload: dict[str, object] = { + "number": pr_number, + "head": {"sha": pr_head_sha or head_sha, "ref": branch}, + } + if pr_author: + pr_payload["user"] = {"login": pr_author} + json.dump(pr_payload, handle) pr_path = handle.name with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle: json.dump(audit_runs or [], handle) @@ -1883,6 +1891,120 @@ def test_gate_decision_allows_author_exclusion_with_peer_pass(self) -> None: self.assertEqual(result["gate_state"], "success") self.assertEqual(result["gate_description"], "Code Mower merge gate passed") + # The lineage marker is a transport, never an authorization. The rendered + # gate must read it under the same configured decision-authority contract + # as the publisher and every other lineage consumer, so these drive the + # actual gate decision rather than the resolver helper underneath it. + _LINEAGE_HEAD = "a" * 40 + _LINEAGE_OPENED = "d" * 40 + _LINEAGE_BRANCH = "devin/7-release-dogfood" + _LINEAGE_AUTHOR = "devin-ai-integration[bot]" + + def _lineage_gate_lanes(self) -> list[dict[str, str]]: + return [ + { + "id": "codex", + "display_name": "Codex", + "done": "codex-audit-done", + "blocked": "codex-audit-blocked", + "author_lane": "codex", + "builder_label": "builder:codex", + "bot_authors": "codex-audit-bot,codex-audit-bot[bot]", + }, + { + "id": "claude", + "display_name": "Claude", + "done": "claude-audit-done", + "blocked": "claude-audit-blocked", + "author_lane": "claude", + "builder_label": "builder:claude", + "bot_authors": "claude-audit-bot,claude-audit-bot[bot]", + }, + { + "id": "devin", + "display_name": "Devin", + "done": "devin-audit-done", + "blocked": "devin-audit-blocked", + "author_lane": "devin", + "builder_label": "builder:devin", + "bot_authors": "devin-audit-bot,devin-ai-integration[bot]", + }, + ] + + def _run_lineage_gate_decision(self, *, marker_author: str) -> dict[str, str]: + """The rendered gate on a verified Devin -> Codex takeover. + + Devin opened the pull request, Codex wrote the head, and independent + Claude passed at that exact head. Only ``marker_author`` varies. + """ + + from code_mower import builder_lineage + + takeover = builder_lineage.ContributionEpisode( + sequence=1, + kind=builder_lineage.HANDOFF_KIND, + repo="owner/repo", + pr_number=7, + branch=self._LINEAGE_BRANCH, + source_lane="devin", + destination_lane="codex", + expected_head=self._LINEAGE_OPENED, + resulting_head=self._LINEAGE_HEAD, + writer_state="terminated", + ) + return self._run_gate_template_decision( + lanes=self._lineage_gate_lanes(), + labels={"builder:codex", "claude-audit-done"}, + head_sha=self._LINEAGE_HEAD, + pr_author=self._LINEAGE_AUTHOR, + branch=self._LINEAGE_BRANCH, + author_exclusion={ + "enabled": True, + "labels": { + "builder:codex": "codex", + "builder:claude": "claude", + "builder:devin": "devin", + }, + "authors": {self._LINEAGE_AUTHOR: "devin"}, + }, + comments=[ + { + "body": "Builder contribution lineage for this head.\n\n" + + builder_lineage.lineage_comment_marker((takeover,)), + "user": {"login": marker_author}, + }, + { + "body": "Head SHA: `" + self._LINEAGE_HEAD + "`\n" + "", + "user": {"login": "claude-audit-bot"}, + }, + ], + ) + + def test_gate_trusts_lineage_published_by_a_decision_authority(self) -> None: + # A configured decision authority attests the takeover, so Codex as + # well as the Devin opener are contributors and independent Claude's + # exact-head pass is the qualifying audit. + result = self._run_lineage_gate_decision(marker_author="owner") + + self.assertEqual(result["gate_state"], "success") + self.assertEqual(result["gate_description"], "Code Mower merge gate passed") + + def test_gate_refuses_lineage_published_by_a_non_authority_audit_bot(self) -> None: + # The identical marker from a reviewer bot that may post verdicts but + # holds no decision authority establishes no contributor history. The + # gate is left with the unexplained author/label disagreement this + # issue exists to surface, so it fails closed instead of passing. + result = self._run_lineage_gate_decision(marker_author="codex-audit-bot") + + self.assertEqual(result["gate_state"], "failure") + self.assertNotEqual( + result["gate_description"], "Code Mower merge gate passed" + ) + self.assertIn( + "conflicting Code Mower builder identity", result["gate_description"] + ) + def test_gate_reads_required_policy_from_current_trusted_checkout(self) -> None: # Execute the shipped workflow decision with unchanged old review/head # while only trusted configuration changes, without regenerating it. From 181663d5f0966b5c3d47f32bd0a0c6b8813176ba Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 13:12:06 -0700 Subject: [PATCH 07/25] Synchronize vendored lineage tools with their canonical sources CI lints and a generated product gate import the checked-in tools/ copies, not the package modules, so three drifts were invisible to every assertion that reaches into src/code_mower: - tools/audit_labeler_lib.py used LINEAGE_MARKER without importing it in any of its three import branches: an F821 in CI's `python -m ruff check .` and a runtime NameError in published-lineage parsing. - tools/builder_lineage.py lacked the replay-bound update, so an authorised idempotent republication of a lineage was counted against the lineage bound and reported malformed in the actual gate. - tools/decisions.py omitted CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE, so the vendored marker-trust path resolved a different set of decision authorities than the consumers that depend on the same decision. Each vendored file is now byte-identical to its canonical source; canonical behaviour is unchanged. The regressions run the actual vendored modules: a subprocess imports the repository's tools/ copies as the tools package with no Code Mower package and no PYTHONPATH, parses a published marker, replays twenty authorised republications of a two-episode chain and resolves at the exact head under an authority override. Verified failing against each drift separately. Closes #963 Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_builder_lineage_consumers.py | 138 ++++++++++++++++++++++++ tools/audit_labeler_lib.py | 3 + tools/builder_lineage.py | 19 +++- tools/decisions.py | 5 +- 4 files changed, 162 insertions(+), 3 deletions(-) diff --git a/tests/test_builder_lineage_consumers.py b/tests/test_builder_lineage_consumers.py index 7e97a6f7..b7efae9c 100644 --- a/tests/test_builder_lineage_consumers.py +++ b/tests/test_builder_lineage_consumers.py @@ -701,5 +701,143 @@ def test_an_independent_lane_is_admitted_on_contribution_grounds_alone(self): self.assertNotIn("role", json.dumps(decision)) +VENDORED_MIRRORS = ("audit_labeler_lib.py", "builder_lineage.py", "decisions.py") + +#: Exactly what a generated gate runner executes: the repository's own +#: ``tools/`` copies, imported as the ``tools`` package, with no Code Mower +#: package and nothing from ``src/`` reachable. Asserting against the package +#: source would pass while the vendored copy raises ``NameError``. +VENDORED_PROBE = ''' +import json + +import tools.audit_labeler_lib as labeler +import tools.builder_lineage as lineage + +REPO = "codemower-ai/code-mower" +PR = 959 +BRANCH = "devin/959-thing" +OPENED = "a" * 40 +TAKEN = "b" * 40 +FIXED = "c" * 40 + +handoff = lineage.ContributionEpisode( + sequence=1, kind=lineage.HANDOFF_KIND, repo=REPO, pr_number=PR, branch=BRANCH, + source_lane="devin", destination_lane="codex", expected_head=OPENED, + resulting_head=TAKEN, writer_state="terminated", +) +continued = lineage.continuation_episode(handoff, lane="codex", resulting_head=FIXED) +marker = lineage.lineage_comment_marker([handoff, continued]) + +# The vendored decisions copy decides who may publish lineage at all. +authorities = labeler.lineage_decision_authorities() +trusted = labeler.lineage_marker_author_trust(authorities=authorities) + +# One authorised republication per round, exactly as the producer posts it. +published = labeler.published_lineage_episodes( + [{"user": {"login": "codemower-ai"}, "body": marker}] * 20 + + [{"user": {"login": "codex[bot]"}, "body": marker}], + trusted_author=trusted, +) +resolved = lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, head_sha=FIXED, episodes=published, + opener_lane="devin", label_lanes=["codex"], +) +print(json.dumps({ + "authorities": list(authorities), + "arrivals": len(published), + "status": resolved.status, + "reason": resolved.reason, + "writer": resolved.current_writer, + "contributors": list(resolved.contributors), + "episodes": resolved.episodes, +})) +''' + + +class VendoredToolMirrors(unittest.TestCase): + """The shipped ``tools/`` copies must be the canonical implementations. + + CI lints and a generated product gate imports the vendored files, not the + package ones. Drift here is invisible to every assertion that reaches into + ``src/code_mower``, and it has already produced an F821 plus a runtime + ``NameError`` in published-lineage parsing. + """ + + def test_vendored_copies_match_their_canonical_sources(self): + for name in VENDORED_MIRRORS: + with self.subTest(module=name): + canonical = Path("src/code_mower") / name + vendored = Path("tools") / name + self.assertEqual( + vendored.read_text(encoding="utf-8"), + canonical.read_text(encoding="utf-8"), + f"tools/{name} has drifted from src/code_mower/{name}", + ) + + def test_vendored_labeler_imports_every_name_it_uses(self): + """The F821: LINEAGE_MARKER was used but never imported here.""" + + source = Path("tools/audit_labeler_lib.py").read_text(encoding="utf-8") + self.assertIn("LINEAGE_MARKER not in body", source) + # One import per branch: packaged relative, copied-tools fallback and + # direct helper execution. Any of the three missing is an F821. + self.assertEqual(3, source.count("LINEAGE_MARKER,")) + + def _run_vendored_probe(self, environment_extra): + root = git_free_tempdir(self) + tools = root / "tools" + tools.mkdir() + (tools / "__init__.py").write_text("", encoding="utf-8") + for target, package_copy_from, _, _ in init.PRODUCT_SUPPORT_FILES: + if not (target.startswith("tools/") and target.endswith(".py")): + continue + vendored = Path(target) + if not vendored.exists(): # templated wrappers, not vendored modules + continue + (root / target).write_bytes(vendored.read_bytes()) + (root / "probe.py").write_text(VENDORED_PROBE, encoding="utf-8") + environment = { + key: value + for key, value in os.environ.items() + if key not in {"PYTHONPATH", "PYTHONHOME"} + and not key.startswith("CODE_MOWER_DECISION_AUTHORITIES") + } + environment.update(environment_extra) + result = subprocess.run( + [sys.executable, "probe.py"], + cwd=root, capture_output=True, text=True, timeout=120, env=environment, + ) + self.assertEqual(result.returncode, 0, result.stderr) + return json.loads(result.stdout.strip().splitlines()[-1]) + + def test_vendored_modules_parse_markers_and_replay_at_the_exact_head(self): + """Marker parsing, authority override and idempotent replay, vendored.""" + + payload = self._run_vendored_probe({ + # The override the canonical decisions module honours. The stale + # vendored copy read only the base variable, so it would trust the + # wrong account and read no published episodes at all. + "CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE": "codemower-ai", + "CODE_MOWER_DECISION_AUTHORITIES": "someone-else", + }) + self.assertEqual(payload["authorities"], ["codemower-ai"]) + # Twenty authorised republications of a two-episode chain; the audit + # bot's byte-identical marker is not an authority and is not read. + self.assertEqual(payload["arrivals"], 40) + self.assertEqual(payload["status"], "resolved", payload["reason"]) + self.assertEqual(payload["episodes"], 2) + self.assertEqual(payload["writer"], "codex") + self.assertEqual(payload["contributors"], ["devin", "codex"]) + + def test_vendored_authority_override_is_the_only_marker_trust(self): + """No override configured, no authority: nothing is read or resolved.""" + + payload = self._run_vendored_probe({"CODE_MOWER_DECISION_AUTHORITIES": ""}) + self.assertEqual(payload["authorities"], []) + self.assertEqual(payload["arrivals"], 0) + # No episodes at all is the ordinary single-builder answer, not a guess. + self.assertEqual(payload["episodes"], 0) + + if __name__ == "__main__": unittest.main() diff --git a/tools/audit_labeler_lib.py b/tools/audit_labeler_lib.py index 3367dac2..0a803666 100644 --- a/tools/audit_labeler_lib.py +++ b/tools/audit_labeler_lib.py @@ -27,6 +27,7 @@ from . import decisions as code_mower_decisions from . import context_review as code_mower_context_review from .builder_lineage import ( + LINEAGE_MARKER, Lineage, LineageError, episodes_from_comment_body, @@ -38,6 +39,7 @@ import decisions as code_mower_decisions # type: ignore import context_review as code_mower_context_review # type: ignore from builder_lineage import ( # type: ignore + LINEAGE_MARKER, Lineage, LineageError, episodes_from_comment_body, @@ -49,6 +51,7 @@ import decisions as code_mower_decisions # type: ignore import context_review as code_mower_context_review # type: ignore from builder_lineage import ( # type: ignore + LINEAGE_MARKER, Lineage, LineageError, episodes_from_comment_body, diff --git a/tools/builder_lineage.py b/tools/builder_lineage.py index d26af846..ab439169 100644 --- a/tools/builder_lineage.py +++ b/tools/builder_lineage.py @@ -78,6 +78,18 @@ #: A lineage longer than this is treated as malformed rather than walked. MAX_EPISODES = 32 +#: How many raw entries a caller may hand the resolver before it refuses to +#: parse them. A lineage is at most :data:`MAX_EPISODES` distinct episodes, but +#: the same chain legitimately arrives several times over: the producer +#: publishes the whole chain on every round, a reviewer merges its private +#: record with every trusted published marker, and an eight-episode lineage +#: published eight times is already thirty-six entries. Counting raw arrivals +#: against the lineage bound would call an authorised idempotent replay +#: malformed, so the input is bounded separately and the lineage bound is +#: applied to the distinct episodes that survive deduplication. Entries that +#: merely repeat are collapsed; entries that disagree still fail closed. +MAX_EPISODE_ENTRIES = MAX_EPISODES * 16 + EPISODE_FIELDS = ( "schema", "sequence", @@ -487,6 +499,11 @@ def resolve_lineage( opener = _lane(opener_lane) labels = tuple(dict.fromkeys(lane for lane in (_lane(item) for item in label_lanes) if lane)) + if len(episodes) > MAX_EPISODE_ENTRIES: + # Bounded input, not a bounded lineage: refuse to parse an unbounded + # arrival before looking at any of it. + return _lineage("conflict", "episode_malformed", head_sha=head) + parsed: list[ContributionEpisode] = [] for item in episodes: try: @@ -507,8 +524,6 @@ def resolve_lineage( if episode.writer_state not in expected_state: return _lineage("conflict", "writer_state_unverified", head_sha=head) parsed.append(episode) - if len(parsed) > MAX_EPISODES: - return _lineage("conflict", "episode_malformed", head_sha=head) if not parsed: return resolve_identity_only(opener_lane=opener, label_lanes=labels, head_sha=head) diff --git a/tools/decisions.py b/tools/decisions.py index 5b4a885d..c0484c76 100644 --- a/tools/decisions.py +++ b/tools/decisions.py @@ -242,7 +242,10 @@ def decision_authorities_from_env(raw: str | None = None) -> tuple[str, ...]: text = ( raw if raw is not None - else os.environ.get("CODE_MOWER_DECISION_AUTHORITIES", "") + else ( + os.environ.get("CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE", "").strip() + or os.environ.get("CODE_MOWER_DECISION_AUTHORITIES", "") + ) ) return tuple(item.strip() for item in text.split(",") if item.strip()) From 9d4f1a48fc9be8a0e410f94ef37d8a9eaa3186cf Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 13:20:46 -0700 Subject: [PATCH 08/25] Rename the unused vendored-probe loop variable Full-repository Ruff reported one B007 in the vendored mirror probe: the loop unpacks the package copy source but only uses the target path, so the binding is dead. Renaming it to `_package_copy_from` marks it unused without changing the unpacking arity. Mechanical only. No behaviour changes: the two callers that genuinely read this field (the mirror map and the parity check) keep the original name. Implemented by Claude (Opus 5) in the Code Mower Claude builder lane. No other builder or agent contributed to this diff, so Code Mower Codex remains an eligible independent exact-head reviewer. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_builder_lineage_consumers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_builder_lineage_consumers.py b/tests/test_builder_lineage_consumers.py index b7efae9c..fbf308ab 100644 --- a/tests/test_builder_lineage_consumers.py +++ b/tests/test_builder_lineage_consumers.py @@ -788,7 +788,7 @@ def _run_vendored_probe(self, environment_extra): tools = root / "tools" tools.mkdir() (tools / "__init__.py").write_text("", encoding="utf-8") - for target, package_copy_from, _, _ in init.PRODUCT_SUPPORT_FILES: + for target, _package_copy_from, _, _ in init.PRODUCT_SUPPORT_FILES: if not (target.startswith("tools/") and target.endswith(".py")): continue vendored = Path(target) From 47402a5febaf213782017147ac2ddd20651a5ee2 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 13:53:08 -0700 Subject: [PATCH 09/25] Carry the validated head branch through the delivery snapshot `_classify_main` records an ordinary post-takeover fix round with `record_continuation(... branch=after.branch ...)`, but `TargetState` had no `branch`. The generated runner reached that call, raised AttributeError, skipped the delivery receipt and exited 3. The snapshot producer is the only place that already holds an authenticated pull request read, so the head branch now travels out of that same read beside the head sha rather than being resolved again later against a name that can move. - `TargetState` gains `branch`, validated against the same rule the lineage episode contract applies, and carried through `from_mapping`/`as_dict`. Snapshots written before the field existed still load, with `branch` empty. - All three runner mirrors -- maintained template, packaged template and the vendored rendered runner -- ask GitHub for `headRefName` alongside `headRefOid,state,labels` and emit `branch`. - `record_continuation` refuses with `branch_unobserved` when lineage exists and no branch was observed, instead of falling through to the recorded tip's branch. An inherited branch would bind provenance to something this round never verified. A branch that disagrees with the lineage still fails closed. Ordinary no-lineage rounds are unchanged: they record nothing and still deliver. Regressions drive the real `classify` command with real snapshot files, the real recorder and the real store -- accepted takeover then an ordinary fix round, missing branch, mismatched branch, and the no-lineage case -- plus the producer's own jq transform and mirror parity. The runner fixtures answer the new read. Co-Authored-By: Claude Opus 5 (1M context) --- src/code_mower/lane_delivery.py | 19 ++ src/code_mower/lane_handoff.py | 17 +- .../templates/lanes/run_mac_lane.sh | 8 +- templates/lanes/run_mac_lane.sh | 8 +- tests/test_builder_lineage_consumers.py | 209 ++++++++++++++++++ tests/test_devin_builder_lane.py | 9 +- tests/test_init_build_loop.py | 10 +- tools/lanes/run_mac_lane.sh | 8 +- 8 files changed, 276 insertions(+), 12 deletions(-) diff --git a/src/code_mower/lane_delivery.py b/src/code_mower/lane_delivery.py index 7584fb88..6cbc973b 100644 --- a/src/code_mower/lane_delivery.py +++ b/src/code_mower/lane_delivery.py @@ -114,6 +114,10 @@ PR_REF_RE = re.compile(r"^(?P[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)#(?P[0-9]+)$") LANE_RE = re.compile(r"^[a-z][a-z0-9_-]{0,31}$") REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +#: Head branch name as GitHub reports it. Kept identical to the lineage +#: episode contract's own branch rule, because a snapshot branch that the +#: episode would refuse is not evidence this layer should carry forward. +STATE_BRANCH_RE = re.compile(r"^[A-Za-z0-9._/-]{1,200}$") #: Prompt text that would push a provider into discovering or reading auth #: material. Rules are matched by name so a report never echoes the match. @@ -198,12 +202,22 @@ class TargetState: otherwise indistinguishable from real absence and would fabricate a transition. Snapshots loaded from a file must state it explicitly; see :func:`_load_state`. + + ``branch`` is the pull request's head branch as the same authenticated read + that produced ``head_sha`` reported it. It travels with the head rather than + being re-resolved later because the two have to describe one observation: + recording provenance against a branch resolved from anywhere else would bind + an episode to something the snapshot never saw. It is empty when there is no + PR, and also when an older producer wrote the snapshot before this field + existed -- those snapshots stay loadable, and every consumer that needs the + binding refuses rather than substituting a branch of its own. """ kind: str number: str pr_number: str = "" head_sha: str = "" + branch: str = "" pr_state: str = "" labels: tuple[str, ...] = () runner_comment_id: str = "" @@ -223,6 +237,9 @@ def from_mapping(cls, payload: Mapping[str, Any]) -> "TargetState": head_sha = _text(payload.get("head_sha")).lower() if head_sha and not SHA_RE.match(head_sha): raise LaneDeliveryError("state head_sha must be a 40-character sha") + branch = _text(payload.get("branch")) + if branch and not STATE_BRANCH_RE.match(branch): + raise LaneDeliveryError("state branch must be a plain head branch name") labels = tuple( sorted({_text(label) for label in payload.get("labels") or () if _text(label)}) ) @@ -234,6 +251,7 @@ def from_mapping(cls, payload: Mapping[str, Any]) -> "TargetState": number=number, pr_number=pr_number, head_sha=head_sha, + branch=branch, pr_state=_text(payload.get("pr_state")).upper(), labels=labels, runner_comment_id=_text(payload.get("runner_comment_id")), @@ -246,6 +264,7 @@ def as_dict(self) -> dict[str, Any]: "number": self.number, "pr_number": self.pr_number, "head_sha": self.head_sha, + "branch": self.branch, "pr_state": self.pr_state, "labels": list(self.labels), "runner_comment_id": self.runner_comment_id, diff --git a/src/code_mower/lane_handoff.py b/src/code_mower/lane_handoff.py index fd6db88d..eb308d32 100644 --- a/src/code_mower/lane_handoff.py +++ b/src/code_mower/lane_handoff.py @@ -296,6 +296,11 @@ def record_continuation(root: Path, *, repo: str, pr_number: object, branch: str source writer to quiesce and no launch to reserve, because no other lane is being displaced -- the writer that went quiescent is this lane's own supervised round, which the caller has already terminated and reaped. + + ``branch`` is the head branch the caller observed in the same authenticated + read as ``resulting_head``. It is required whenever lineage exists and must + be the branch that lineage already names, so the episode stays bound to one + repository, pull request, branch and head. """ from .builder_lineage import ( CONTINUATION_KIND, LineageError, continuation_episode, load_episodes, record_episode, @@ -315,6 +320,16 @@ def record_continuation(root: Path, *, repo: str, pr_number: object, branch: str observed = str(resulting_head or "").strip().lower() started = str(expected_head or "").strip().lower() writer = str(lane or "").strip().lower() + target_branch = str(branch or "").strip() + if not target_branch: + # Lineage exists, so this round has to bind to a branch -- and the only + # branch that binds is the one the caller actually observed. Letting an + # absent value fall through to the recorded tip's branch would record + # provenance against a branch nothing verified this round, which is the + # inference the exact-head contract exists to refuse. A snapshot from a + # producer predating the field lands here and refuses, rather than + # having a branch chosen for it. + return {"recorded": False, "reason": "branch_unobserved"} for episode in episodes: if (episode.kind == CONTINUATION_KIND and episode.expected_head == started and episode.resulting_head == observed @@ -334,7 +349,7 @@ def record_continuation(root: Path, *, repo: str, pr_number: object, branch: str return {"recorded": False, "reason": "head_unchanged"} try: episode = continuation_episode( - tip, lane=writer, resulting_head=observed, branch=str(branch or ""), + tip, lane=writer, resulting_head=observed, branch=target_branch, ) outcome = record_episode(store, episode) except LineageError as exc: diff --git a/src/code_mower/templates/lanes/run_mac_lane.sh b/src/code_mower/templates/lanes/run_mac_lane.sh index 5202be5e..4403bd44 100644 --- a/src/code_mower/templates/lanes/run_mac_lane.sh +++ b/src/code_mower/templates/lanes/run_mac_lane.sh @@ -949,6 +949,11 @@ snapshot_lookup() { # from "no PR yet" or "no head yet", so a transient failure on one side of the # comparison would fabricate a pr_opened or head_advanced transition for a # target that never moved. +# +# The head branch comes out of the same authenticated pull request read as the +# head sha, so the two describe one observation. Contribution lineage binds an +# episode to repository, PR, branch and head together; re-resolving the branch +# from anywhere else later would bind it to something this snapshot never saw. capture_target_state() { local out="$1" local runner_comment_id="${2:-}" @@ -971,7 +976,7 @@ capture_target_state() { fi if [ -n "$pr_number" ]; then if ! pr_json="$(snapshot_lookup gh pr view "$pr_number" -R "$REPO" \ - --json headRefOid,state,labels 2>/dev/null)"; then + --json headRefName,headRefOid,state,labels 2>/dev/null)"; then pr_json='{}' complete=false fi @@ -993,6 +998,7 @@ capture_target_state() { number: $number, pr_number: $pr, head_sha: ((.headRefOid // "") | ascii_downcase), + branch: (.headRefName // ""), pr_state: (.state // ""), labels: $labels, runner_comment_id: $comment, diff --git a/templates/lanes/run_mac_lane.sh b/templates/lanes/run_mac_lane.sh index 5202be5e..4403bd44 100644 --- a/templates/lanes/run_mac_lane.sh +++ b/templates/lanes/run_mac_lane.sh @@ -949,6 +949,11 @@ snapshot_lookup() { # from "no PR yet" or "no head yet", so a transient failure on one side of the # comparison would fabricate a pr_opened or head_advanced transition for a # target that never moved. +# +# The head branch comes out of the same authenticated pull request read as the +# head sha, so the two describe one observation. Contribution lineage binds an +# episode to repository, PR, branch and head together; re-resolving the branch +# from anywhere else later would bind it to something this snapshot never saw. capture_target_state() { local out="$1" local runner_comment_id="${2:-}" @@ -971,7 +976,7 @@ capture_target_state() { fi if [ -n "$pr_number" ]; then if ! pr_json="$(snapshot_lookup gh pr view "$pr_number" -R "$REPO" \ - --json headRefOid,state,labels 2>/dev/null)"; then + --json headRefName,headRefOid,state,labels 2>/dev/null)"; then pr_json='{}' complete=false fi @@ -993,6 +998,7 @@ capture_target_state() { number: $number, pr_number: $pr, head_sha: ((.headRefOid // "") | ascii_downcase), + branch: (.headRefName // ""), pr_state: (.state // ""), labels: $labels, runner_comment_id: $comment, diff --git a/tests/test_builder_lineage_consumers.py b/tests/test_builder_lineage_consumers.py index fbf308ab..65c4522e 100644 --- a/tests/test_builder_lineage_consumers.py +++ b/tests/test_builder_lineage_consumers.py @@ -10,6 +10,8 @@ from __future__ import annotations +import contextlib +import io import json import os import subprocess @@ -344,6 +346,213 @@ def fake(state_dir, **kwargs): self.assertTrue(recorded["delivered"]) +class SnapshotCarriesTheValidatedBranch(unittest.TestCase): + """The snapshot/classification contract the continuation recorder consumes. + + ``_classify_main`` reads the head branch off the after-snapshot. The + snapshot producer is the only place that already holds an authenticated + pull request read, so the branch travels with the head it was read beside + rather than being resolved again later against a name that can move. + + These drive the real ``classify`` command end to end -- real snapshot files + in the producer's own format, the real recorder, the real store -- because + a ``SimpleNamespace`` standing in for :class:`TargetState` is exactly what + hid the missing field. + """ + + def setUp(self): + self.root = git_free_tempdir(self) / "handoffs" + self.store = lane_handoff.lineage_root(self.root) + + def snapshot(self, path: Path, head: str, **overrides) -> str: + payload = { + "kind": "pr", + "number": str(PR), + "pr_number": str(PR), + "head_sha": head, + "branch": BRANCH, + "pr_state": "OPEN", + "labels": ["builder:codex"], + "runner_comment_id": "", + "snapshot_complete": True, + } + payload.update(overrides) + path.write_text(json.dumps(payload), encoding="utf-8") + return str(path) + + def classify(self, before_path: str, after_path: str) -> dict: + outcomes = git_free_tempdir(self, "code-mower-outcomes-") + printed = io.StringIO() + with contextlib.redirect_stdout(printed): + code = lane_delivery.main([ + "classify", "--before", before_path, "--after", after_path, + "--provider-exit", "0", "--declared-outcome", "", + "--supervision", "completed", "--handoff-state-dir", str(self.root), + "--lane", "codex", "--repo", REPO, "--elapsed-seconds", "1", + "--user-interventions", "0", "--output", str(outcomes / "event.json"), + "--force", "--json", + ]) + self.assertEqual(code, 0) + self.assertTrue((outcomes / "event.json").exists()) + return json.loads(printed.getvalue()) + + def episodes(self): + return builder_lineage.load_episodes(self.store, REPO, PR) + + def test_target_state_round_trips_the_branch(self): + state = lane_delivery.TargetState.from_mapping({ + "kind": "pr", "number": str(PR), "pr_number": str(PR), + "head_sha": FIXED, "branch": BRANCH, "snapshot_complete": True, + }) + self.assertEqual(state.branch, BRANCH) + self.assertEqual(state.as_dict()["branch"], BRANCH) + self.assertEqual( + lane_delivery.TargetState.from_mapping(state.as_dict()).branch, BRANCH + ) + + def test_an_older_snapshot_without_a_branch_still_loads(self): + """Compatibility: the field is absent, not wrong.""" + + state = lane_delivery.TargetState.from_mapping({ + "kind": "pr", "number": str(PR), "pr_number": str(PR), + "head_sha": FIXED, "snapshot_complete": True, + }) + self.assertEqual(state.branch, "") + self.assertIn("branch", state.as_dict()) + + def test_a_branch_the_episode_contract_would_refuse_is_refused_here(self): + for bad in ("two words", "a\nb", "x" * 201): + with self.subTest(branch=bad): + with self.assertRaises(lane_delivery.LaneDeliveryError): + lane_delivery.TargetState.from_mapping({ + "kind": "pr", "number": str(PR), "pr_number": str(PR), + "head_sha": FIXED, "branch": bad, "snapshot_complete": True, + }) + + def test_an_accepted_takeover_then_an_ordinary_fix_round_is_recorded(self): + builder_lineage.record_episode(self.store, takeover_episode()) + tmp = git_free_tempdir(self, "code-mower-states-") + payload = self.classify( + self.snapshot(tmp / "before.json", TAKEN), + self.snapshot(tmp / "after.json", FIXED), + ) + self.assertEqual(payload["lineage"]["reason"], "recorded") + episodes = self.episodes() + self.assertEqual(len(episodes), 2) + self.assertEqual(episodes[1].kind, builder_lineage.CONTINUATION_KIND) + self.assertEqual(episodes[1].branch, BRANCH) + self.assertEqual(episodes[1].repo, REPO) + self.assertEqual(episodes[1].pr_number, PR) + self.assertEqual(episodes[1].expected_head, TAKEN) + self.assertEqual(episodes[1].resulting_head, FIXED) + lineage = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, head_sha=FIXED, + episodes=episodes, opener_lane="devin", label_lanes=("codex",), + ) + self.assertEqual(lineage.status, "resolved") + self.assertEqual(lineage.current_writer, "codex") + + def test_an_ordinary_pr_with_no_lineage_records_nothing_and_still_delivers(self): + tmp = git_free_tempdir(self, "code-mower-states-") + payload = self.classify( + self.snapshot(tmp / "before.json", TAKEN), + self.snapshot(tmp / "after.json", FIXED), + ) + self.assertEqual(payload["lineage"]["reason"], "no_recorded_lineage") + self.assertEqual(self.episodes(), ()) + + def test_a_snapshot_with_no_branch_refuses_rather_than_inheriting_one(self): + """Fail closed: no branch observed is not licence to reuse the tip's.""" + + builder_lineage.record_episode(self.store, takeover_episode()) + tmp = git_free_tempdir(self, "code-mower-states-") + payload = self.classify( + self.snapshot(tmp / "before.json", TAKEN, branch=""), + self.snapshot(tmp / "after.json", FIXED, branch=""), + ) + self.assertEqual(payload["lineage"]["reason"], "branch_unobserved") + self.assertFalse(payload["lineage"]["recorded"]) + self.assertEqual(len(self.episodes()), 1) + + def test_a_branch_that_disagrees_with_the_lineage_fails_closed(self): + builder_lineage.record_episode(self.store, takeover_episode()) + tmp = git_free_tempdir(self, "code-mower-states-") + before = self.snapshot(tmp / "before.json", TAKEN) + after = self.snapshot(tmp / "after.json", FIXED, branch="codex/other-branch") + with self.assertRaises(lane_delivery.LaneDeliveryError): + lane_delivery._classify_main(SimpleNamespace( + before=before, after=after, provider_exit=0, + declared_outcome="", supervision="completed", handoff=None, + handoff_state_dir=self.root, lane="codex", repo=REPO, signal=[], + elapsed_seconds=1, user_interventions=0, output=None, + force=True, json=True, + )) + self.assertEqual(len(self.episodes()), 1) + + +class SnapshotProducerReadsTheBranch(unittest.TestCase): + """Every runner mirror asks for the head branch and carries it through. + + The maintained template, the packaged template and the vendored rendered + runner are separate files. A producer that still asks GitHub only for + ``headRefOid,state,labels`` writes a snapshot with no branch, and the + recorder above then refuses every continuation. + """ + + MIRRORS = ( + "templates/lanes/run_mac_lane.sh", + "src/code_mower/templates/lanes/run_mac_lane.sh", + "tools/lanes/run_mac_lane.sh", + ) + + def test_each_mirror_requests_and_emits_the_head_branch(self): + root = Path(__file__).resolve().parents[1] + for relative in self.MIRRORS: + with self.subTest(mirror=relative): + text = root.joinpath(relative).read_text(encoding="utf-8") + self.assertIn("--json headRefName,headRefOid,state,labels", text) + self.assertIn('branch: (.headRefName // ""),', text) + self.assertNotIn("--json headRefOid,state,labels", text) + + def test_the_producers_jq_program_emits_a_loadable_snapshot(self): + """Run the producer's own transform, then load it as the CLI would.""" + + program = """ + { + kind: $kind, + number: $number, + pr_number: $pr, + head_sha: ((.headRefOid // "") | ascii_downcase), + branch: (.headRefName // ""), + pr_state: (.state // ""), + labels: $labels, + runner_comment_id: $comment, + snapshot_complete: $complete + }""" + for relative in self.MIRRORS: + with self.subTest(mirror=relative): + self.assertIn( + program, + Path(__file__).resolve().parents[1] + .joinpath(relative).read_text(encoding="utf-8"), + ) + completed = subprocess.run( + ["jq", "--arg", "kind", "pr", "--arg", "number", str(PR), + "--arg", "pr", str(PR), "--arg", "comment", "", + "--argjson", "labels", '["builder:codex"]', + "--argjson", "complete", "true", program], + input=json.dumps({ + "headRefName": BRANCH, "headRefOid": FIXED.upper(), + "state": "OPEN", "labels": [{"name": "builder:codex"}], + }), + text=True, capture_output=True, check=True, + ) + state = lane_delivery.TargetState.from_mapping(json.loads(completed.stdout)) + self.assertEqual(state.branch, BRANCH) + self.assertEqual(state.head_sha, FIXED) + self.assertTrue(state.snapshot_complete) + + class PublishBeforeReconcile(unittest.TestCase): """codex:4725ce39cb69b7ac68e7 -- the gate reads comments, not the store.""" diff --git a/tests/test_devin_builder_lane.py b/tests/test_devin_builder_lane.py index dbe17fa2..1ce1edcb 100644 --- a/tests/test_devin_builder_lane.py +++ b/tests/test_devin_builder_lane.py @@ -97,6 +97,9 @@ def _lane_delivery_env() -> dict[str, str]: _DELIVERY_MARKER_NAME = "lane-delivered" _HEAD_BEFORE = "a" * 40 _HEAD_AFTER = "b" * 40 +# The snapshot carries the head branch from the same authenticated PR read as +# the head sha, so the fixture has to answer with both. +_HEAD_BRANCH = "devin/issue-12" _FAKE_GH_DELIVERY_HEADER = f"""#!/usr/bin/env bash set -euo pipefail cmd="${{1:-}} ${{2:-}}" @@ -111,11 +114,11 @@ def _lane_delivery_env() -> dict[str, str]: elif [ "$cmd" = "issue view" ] && [[ "$args" == *"--json labels"* ]]; then printf '%s\\n' '["tier:R","builder:devin","dispatched:devin"]' exit 0 -elif [ "$cmd" = "pr view" ] && [[ "$args" == *"--json headRefOid,state,labels"* ]]; then +elif [ "$cmd" = "pr view" ] && [[ "$args" == *"--json headRefName,headRefOid,state,labels"* ]]; then if [ -f "$HOME/{_DELIVERY_MARKER_NAME}" ]; then - printf '%s\\n' '{{"headRefOid":"{_HEAD_AFTER}","state":"OPEN","labels":[]}}' + printf '%s\\n' '{{"headRefName":"{_HEAD_BRANCH}","headRefOid":"{_HEAD_AFTER}","state":"OPEN","labels":[]}}' else - printf '%s\\n' '{{"headRefOid":"{_HEAD_BEFORE}","state":"OPEN","labels":[]}}' + printf '%s\\n' '{{"headRefName":"{_HEAD_BRANCH}","headRefOid":"{_HEAD_BEFORE}","state":"OPEN","labels":[]}}' fi exit 0 elif [ "$cmd" = "issue comment" ] || [ "$cmd" = "pr comment" ]; then diff --git a/tests/test_init_build_loop.py b/tests/test_init_build_loop.py index 183ce5b9..70fc669b 100644 --- a/tests/test_init_build_loop.py +++ b/tests/test_init_build_loop.py @@ -75,11 +75,11 @@ def _write_lane_delivery_wrapper(directory: Path) -> Path: elif [ "$cmd" = "issue view" ] && [[ "$args" == *"--json labels"* ]]; then printf '%s\\n' '["tier:R","builder:codex","dispatched:codex"]' exit 0 -elif [ "$cmd" = "pr view" ] && [[ "$args" == *"--json headRefOid,state,labels"* ]]; then +elif [ "$cmd" = "pr view" ] && [[ "$args" == *"--json headRefName,headRefOid,state,labels"* ]]; then if [ -f "$HOME/lane-delivered" ]; then - printf '%s\\n' '{"headRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"OPEN","labels":[]}' + printf '%s\\n' '{"headRefName":"codex/issue-12","headRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"OPEN","labels":[]}' else - printf '%s\\n' '{"headRefOid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","state":"OPEN","labels":[]}' + printf '%s\\n' '{"headRefName":"codex/issue-12","headRefOid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","state":"OPEN","labels":[]}' fi exit 0 elif [ "$cmd" = "issue comment" ] || [ "$cmd" = "pr comment" ]; then @@ -1199,8 +1199,8 @@ def test_mac_lane_runner_extra_flags_unset_or_empty_reach_provider(self) -> None elif [ "$cmd" = "issue view" ] && [[ "$args" == *"--json labels"* ]]; then printf '%s\\n' '["tier:R","builder:{lane}","dispatched:{lane}"]' exit 0 -elif [ "$cmd" = "pr view" ] && [[ "$args" == *"--json headRefOid,state,labels"* ]]; then - printf '%s\\n' '{{"headRefOid":"{head_oid}","state":"OPEN","labels":[]}}' +elif [ "$cmd" = "pr view" ] && [[ "$args" == *"--json headRefName,headRefOid,state,labels"* ]]; then + printf '%s\\n' '{{"headRefName":"{lane}/issue-12","headRefOid":"{head_oid}","state":"OPEN","labels":[]}}' exit 0 elif [ "$cmd" = "pr list" ] && [[ "$args" == *"--label builder:{lane}"* ]]; then printf '%s\\n' '[]' diff --git a/tools/lanes/run_mac_lane.sh b/tools/lanes/run_mac_lane.sh index 1a1d6aec..a57a47f4 100755 --- a/tools/lanes/run_mac_lane.sh +++ b/tools/lanes/run_mac_lane.sh @@ -949,6 +949,11 @@ snapshot_lookup() { # from "no PR yet" or "no head yet", so a transient failure on one side of the # comparison would fabricate a pr_opened or head_advanced transition for a # target that never moved. +# +# The head branch comes out of the same authenticated pull request read as the +# head sha, so the two describe one observation. Contribution lineage binds an +# episode to repository, PR, branch and head together; re-resolving the branch +# from anywhere else later would bind it to something this snapshot never saw. capture_target_state() { local out="$1" local runner_comment_id="${2:-}" @@ -971,7 +976,7 @@ capture_target_state() { fi if [ -n "$pr_number" ]; then if ! pr_json="$(snapshot_lookup gh pr view "$pr_number" -R "$REPO" \ - --json headRefOid,state,labels 2>/dev/null)"; then + --json headRefName,headRefOid,state,labels 2>/dev/null)"; then pr_json='{}' complete=false fi @@ -993,6 +998,7 @@ capture_target_state() { number: $number, pr_number: $pr, head_sha: ((.headRefOid // "") | ascii_downcase), + branch: (.headRefName // ""), pr_state: (.state // ""), labels: $labels, runner_comment_id: $comment, From 810baaafdddeaa7c4bf8390fe27e96f37e8324f3 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 14:34:41 -0700 Subject: [PATCH 10/25] Require a trusted authority wherever lineage is published or read Three independent Codex P2 findings, each one place where the lineage contract was asserted but never actually obtained. Publisher admission. Empty configured decision authorities passed `trusted_author=None`, so any identical marker counted as a duplicate and a bare successful POST counted as proof -- and the builder label moved on that basis while every consumer trusted no marker at all. The CLI now fails before the comment and before the label when no authority is configured, and `publish_lineage_evidence` requires the trust callable rather than defaulting it away. The authenticated readback is retained and is now unconditional. SaaS entrypoints. `check_run` never passed the fetched comments, the live `issue_comment` path left the head, branch and comments unset, and the issues replay used the dry-run head override that path never sets. Each one resolved identity-only lineage and skipped an otherwise eligible independent reviewer's label update. All three now carry the exact head and branch from the authenticated pull request read they already make, plus the bounded trusted comment history from one shared helper that fetches only when authorities are configured and raises rather than labelling blind when the read fails. Generated provenance. Both template copies fetched comments and passed `--comments-json`, but supplied no authority contract, so auto-record trusted nobody and discarded every episode -- attributing a taken-over pull request to its opener. Both now render the repository-variable override and load the checked-in authority configuration, matching the gate and the labelers. The workflow gains the checkout that read needs. Regressions drive the real entrypoints, not the shared helpers: `lane_delivery.main` for all five publisher cases, `saas_reviewer_labeler.main` for every event shape including replay, untrusted markers, lookup failure and paginated reads, and the generated job's own authority step feeding `builder auto-record`. Refs #963 Co-Authored-By: Claude Opus 5 --- src/code_mower/lane_delivery.py | 55 +- src/code_mower/saas_reviewer_labeler.py | 97 +++- .../workflows/builder-provenance.yml.j2 | 41 ++ templates/workflows/builder-provenance.yml.j2 | 41 ++ tests/test_builder_lineage_consumers.py | 67 ++- tests/test_builder_lineage_entrypoints.py | 545 ++++++++++++++++++ 6 files changed, 806 insertions(+), 40 deletions(-) create mode 100644 tests/test_builder_lineage_entrypoints.py diff --git a/src/code_mower/lane_delivery.py b/src/code_mower/lane_delivery.py index 6cbc973b..ff607635 100644 --- a/src/code_mower/lane_delivery.py +++ b/src/code_mower/lane_delivery.py @@ -1324,13 +1324,28 @@ def publish_lineage_evidence( by anyone else is evidence nobody will read: treating it as "already published" would let an untrusted commenter suppress the publication this path exists to guarantee. By the same rule a successful comment POST is not - by itself proof, so once ``trusted_author`` is supplied the comment is read - back and the publication only counts when a trusted author now carries the - marker. + by itself proof, so the comment is read back and the publication only + counts when a trusted author now carries the marker. + + ``trusted_author`` is therefore required, not optional. With no configured + decision authority there is no account whose marker any consumer would + read, so publishing would leave evidence nobody can use while the caller + went on to move the builder label -- exactly the conflict this path exists + to prevent. That case fails here, before anything is posted. """ from . import builder_lineage + if trusted_author is None: + # Nothing is posted and nothing is reconciled: the caller is told what + # to configure instead of writing evidence no consumer would read. + return {"published": False, "duplicate": False, + "reason": "no_decision_authority", + "owner_action": ( + "configure decision authorities before publishing builder " + "lineage: with none configured no consumer trusts any " + "published marker" + )} lineage = builder_lineage.resolve_lineage( repo=repo, pr_number=pr_number, branch=branch, head_sha=head_sha, episodes=episodes, opener_lane=opener_lane, label_lanes=label_lanes, @@ -1351,7 +1366,7 @@ def _readable_marker_present() -> bool: login, body = "", str(item) if marker not in body: continue - if trusted_author is None or trusted_author(login): + if trusted_author(login): return True return False @@ -1364,7 +1379,7 @@ def _readable_marker_present() -> bool: f"- contributors: {', '.join('`' + lane + '`' for lane in lineage.contributors)}\n" f"- head: `{lineage.head_sha}`\n\n" + marker ) - if trusted_author is not None and not _readable_marker_present(): + if not _readable_marker_present(): # The comment went up under an account the consumers do not trust, so # the evidence is unreadable to everyone who needs it. Reporting this # as published would move the builder label onto lineage the gate @@ -1415,23 +1430,39 @@ def _lineage_main(args: argparse.Namespace, *, ) # One trust contract, shared with the gate, the labelers and every # reviewer: the repository's configured decision authorities and - # nobody else. With none configured there is nobody to verify against, - # and publication keeps its historical body-only idempotency. + # nobody else. With none configured there is nobody to publish under + # and nobody whose marker a consumer would read, so this stops before + # the comment and before the label -- an unreadable publication plus a + # moved label is the conflict this whole path exists to avoid. from .audit_labeler_lib import lineage_marker_author_trust from .decisions import decision_authorities_from_env authorities = decision_authorities_from_env() + if not authorities: + payload = { + "schema": builder_lineage.SCHEMA, "status": "blocked", + "reason": "lineage_no_decision_authority", "head_sha": args.head, + "current_writer": "", "add": [], "remove": [], + "applied": False, "published": False, + "owner_action": ( + "configure decision authorities before publishing builder " + "lineage: with none configured no consumer trusts any " + "published marker" + ), + } + _assert_safe_metadata(payload, path="lineage") + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(f"lineage {payload['status']}: {payload['reason']}") + return 3 published = publish_lineage_evidence( repo=repo, pr_number=number, branch=args.branch, head_sha=args.head, episodes=episodes, opener_lane=opener_lane, label_lanes=publish_label_lanes, existing_bodies=lambda: comment_bodies(repo, number), publish=lambda body: publish_comment(repo, number, body), - trusted_author=( - lineage_marker_author_trust(authorities=authorities) - if authorities - else None - ), + trusted_author=lineage_marker_author_trust(authorities=authorities), ) if not (published["published"] or published.get("duplicate")): # The label says who may write next; the published episodes are how diff --git a/src/code_mower/saas_reviewer_labeler.py b/src/code_mower/saas_reviewer_labeler.py index 005d290f..a5333d8e 100644 --- a/src/code_mower/saas_reviewer_labeler.py +++ b/src/code_mower/saas_reviewer_labeler.py @@ -202,6 +202,37 @@ def fetch_issue_comments( ) +def fetch_lineage_comments( + repo: str, + pr_number: int, + *, + tokens: Sequence[GitHubToken], + adapter: SaaSReviewerAdapter, +) -> Optional[list[dict[str, Any]]]: + """The bounded trusted comment history exact-head lineage is published on. + + Every entry path needs the same three things before it can resolve lineage + rather than identity: the exact current head, the head branch and the + comments the evidence lives on. Only the last one costs a request, and only + when the repository configured decision authorities -- with none there is + nobody whose marker would be read and the fetch would buy nothing, which is + also the ordinary no-lineage behaviour this preserves. + + Read failures are raised, never swallowed: a path that cannot tell a + verified takeover from its absence must stop instead of mutating labels on + identity alone. + """ + + if not lineage_decision_authorities(): + return None + return fetch_issue_comments( + repo, + pr_number, + tokens=tokens, + page_cap=adapter.review_comments_page_cap, + ) + + def has_same_head_review( reviews: list[dict[str, Any]], *, @@ -735,8 +766,19 @@ def main(argv: Optional[Sequence[str]] = None) -> int: label.get("name", "") for label in pr_current.get("labels") or [] ] candidate_head = pr_current.get("head", {}).get("sha") + candidate_branch = str((pr_current.get("head") or {}).get("ref") or "") candidate_author = str(((pr_current.get("user") or {}).get("login") or "")) candidate_body = str(pr_current.get("body") or "") + try: + candidate_comments = fetch_lineage_comments( + repo, candidate_number, tokens=tokens, adapter=adapter + ) + except (GitHubRequestError, ReviewCommentsTruncated) as exc: + print( + f"skip: could not fetch published builder lineage for " + f"PR #{candidate_number}: {exc}" + ) + continue if adapter.opt_in_required and not adapter.is_opted_in(candidate_labels): _, reason = resolve_label_decision( event, @@ -748,7 +790,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int: pr_body=candidate_body, current_head_sha=candidate_head, repo=repo, - head_branch=str((pr_current.get("head") or {}).get("ref") or ""), + head_branch=candidate_branch, + issue_comments=candidate_comments, decision_authorities=lineage_decision_authorities(), ) print(f"skip: {reason}") @@ -764,7 +807,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int: pr_body=candidate_body, current_head_sha=candidate_head, repo=repo, - head_branch=str((pr_current.get("head") or {}).get("ref") or ""), + head_branch=candidate_branch, + issue_comments=candidate_comments, decision_authorities=lineage_decision_authorities(), ) if decision is None: @@ -812,21 +856,14 @@ def main(argv: Optional[Sequence[str]] = None) -> int: head_branch = str((pr_current.get("head") or {}).get("ref") or "") # The review path decides the same question about the same head as the # issue-comment path, so it reads the same published lineage under the - # same trust rule. Only when authorities are configured: with none, - # there is nobody to trust and the fetch would buy nothing. With them, - # a fetch that fails leaves this path unable to tell a takeover from - # its absence, so it stops rather than labelling on identity alone. - if lineage_decision_authorities(): - try: - lineage_comments = fetch_issue_comments( - repo, - pr_number, - tokens=tokens, - page_cap=adapter.review_comments_page_cap, - ) - except (GitHubRequestError, ReviewCommentsTruncated) as exc: - print(f"skip: could not fetch published builder lineage: {exc}") - return 0 + # same trust rule. + try: + lineage_comments = fetch_lineage_comments( + repo, pr_number, tokens=tokens, adapter=adapter + ) + except (GitHubRequestError, ReviewCommentsTruncated) as exc: + print(f"skip: could not fetch published builder lineage: {exc}") + return 0 if adapter.requires_review_comments: review = event.get("review") or {} review_id = review.get("id") @@ -862,7 +899,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: return 0 _apply_or_log(repo, decision, tokens=tokens, lane_name=adapter.name) return 0 - elif event_type == "issue_comment" and pr_number and adapter.opt_in_required: + elif event_type == "issue_comment" and pr_number: issue = event.get("issue") or {} comment = event.get("comment") or {} author = (comment.get("user") or {}).get("login", "") @@ -871,6 +908,20 @@ def main(argv: Optional[Sequence[str]] = None) -> int: pr_labels = [label.get("name", "") for label in pr_current.get("labels") or []] pr_author = str(((pr_current.get("user") or {}).get("login") or "")) pr_body = str(pr_current.get("body") or "") + # The comment event carries the issue, never the pull request, so + # the head and branch this decision is about come from the same + # authenticated read that supplied the labels -- not from the + # payload, and not left unset, which resolved identity-only lineage + # and skipped an otherwise eligible independent reviewer's update. + current_head_sha = pr_current.get("head", {}).get("sha") + head_branch = str((pr_current.get("head") or {}).get("ref") or "") + try: + lineage_comments = fetch_lineage_comments( + repo, pr_number, tokens=tokens, adapter=adapter + ) + except (GitHubRequestError, ReviewCommentsTruncated) as exc: + print(f"skip: could not fetch published builder lineage: {exc}") + return 0 elif event_type == "issues" and pr_number and adapter.event_type == "issue_comment": if event.get("action") != "labeled": print(f"skip: unsupported issues action: {event.get('action')}") @@ -903,6 +954,12 @@ def main(argv: Optional[Sequence[str]] = None) -> int: except (GitHubRequestError, ReviewCommentsTruncated) as exc: print(f"skip: could not fetch issue comments: {exc}") return 0 + # Replay decides about the head the pull request has now, so it uses + # the head from the same authenticated read as the labels rather than + # the dry-run override, which is unset on this path and left every + # replayed comment resolving identity-only lineage. + replay_head = pr_current.get("head", {}).get("sha") or current_head_sha + replay_branch = str((pr_current.get("head") or {}).get("ref") or "") for comment in reversed(comments): synthetic_event = { "action": "created", @@ -917,9 +974,9 @@ def main(argv: Optional[Sequence[str]] = None) -> int: pr_labels=pr_labels, pr_author=pr_author, pr_body=pr_body, - current_head_sha=current_head_sha, + current_head_sha=replay_head, repo=repo, - head_branch=str((pr_current.get("head") or {}).get("ref") or ""), + head_branch=replay_branch, issue_comments=comments, decision_authorities=lineage_decision_authorities(), ) diff --git a/src/code_mower/templates/workflows/builder-provenance.yml.j2 b/src/code_mower/templates/workflows/builder-provenance.yml.j2 index e788675a..ff01393a 100644 --- a/src/code_mower/templates/workflows/builder-provenance.yml.j2 +++ b/src/code_mower/templates/workflows/builder-provenance.yml.j2 @@ -11,12 +11,25 @@ permissions: env: CODE_MOWER_PACKAGE_SPEC: code-mower==1.4.0 + # The same configured decision-authority contract every other Code Mower + # consumer reads. `builder auto-record` trusts published lineage markers from + # these accounts and nobody else, so a job that supplies neither field trusts + # nobody, discards every comment fetched below, and records a taken-over pull + # request against whoever opened it instead of its verified current writer. + # The repository variable overrides the checked-in configuration, exactly as + # it does for the gate and the labelers. + CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE: {% raw %}${{ vars.CODE_MOWER_DECISION_AUTHORITIES || '' }}{% endraw %} jobs: auto-record: runs-on: ubuntu-latest timeout-minutes: 5 steps: + - name: Check out the repository configuration + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Set up Python uses: actions/setup-python@8fc423ec5f48d6a2f5a10347eab979e8a63b7ab9 with: @@ -28,6 +41,34 @@ jobs: set -euo pipefail python -m pip install "${CODE_MOWER_PACKAGE_SPEC}" + - name: Load configured decision authorities + shell: bash + run: | + set -euo pipefail + python - <<'PY' >> "${GITHUB_ENV}" + import re + from pathlib import Path + + from code_mower.config import load_config + from code_mower.decisions import decision_authorities_from_config + + # The repository's own configuration is the authority source the rest + # of Code Mower already resolves; nothing here grants trust of its + # own. Logins only, so a malformed entry cannot write a second + # environment assignment into GITHUB_ENV. + config_path = Path("code-mower.yml") + authorities = ( + decision_authorities_from_config(load_config(config_path)) + if config_path.is_file() + else () + ) + safe = [ + item for item in authorities + if re.fullmatch(r"[A-Za-z0-9._\[\]-]{1,100}", str(item)) + ] + print("CODE_MOWER_DECISION_AUTHORITIES=" + ",".join(safe)) + PY + - name: Record inferred builder provenance id: record shell: bash diff --git a/templates/workflows/builder-provenance.yml.j2 b/templates/workflows/builder-provenance.yml.j2 index e788675a..ff01393a 100644 --- a/templates/workflows/builder-provenance.yml.j2 +++ b/templates/workflows/builder-provenance.yml.j2 @@ -11,12 +11,25 @@ permissions: env: CODE_MOWER_PACKAGE_SPEC: code-mower==1.4.0 + # The same configured decision-authority contract every other Code Mower + # consumer reads. `builder auto-record` trusts published lineage markers from + # these accounts and nobody else, so a job that supplies neither field trusts + # nobody, discards every comment fetched below, and records a taken-over pull + # request against whoever opened it instead of its verified current writer. + # The repository variable overrides the checked-in configuration, exactly as + # it does for the gate and the labelers. + CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE: {% raw %}${{ vars.CODE_MOWER_DECISION_AUTHORITIES || '' }}{% endraw %} jobs: auto-record: runs-on: ubuntu-latest timeout-minutes: 5 steps: + - name: Check out the repository configuration + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Set up Python uses: actions/setup-python@8fc423ec5f48d6a2f5a10347eab979e8a63b7ab9 with: @@ -28,6 +41,34 @@ jobs: set -euo pipefail python -m pip install "${CODE_MOWER_PACKAGE_SPEC}" + - name: Load configured decision authorities + shell: bash + run: | + set -euo pipefail + python - <<'PY' >> "${GITHUB_ENV}" + import re + from pathlib import Path + + from code_mower.config import load_config + from code_mower.decisions import decision_authorities_from_config + + # The repository's own configuration is the authority source the rest + # of Code Mower already resolves; nothing here grants trust of its + # own. Logins only, so a malformed entry cannot write a second + # environment assignment into GITHUB_ENV. + config_path = Path("code-mower.yml") + authorities = ( + decision_authorities_from_config(load_config(config_path)) + if config_path.is_file() + else () + ) + safe = [ + item for item in authorities + if re.fullmatch(r"[A-Za-z0-9._\[\]-]{1,100}", str(item)) + ] + print("CODE_MOWER_DECISION_AUTHORITIES=" + ",".join(safe)) + PY + - name: Record inferred builder provenance id: record shell: bash diff --git a/tests/test_builder_lineage_consumers.py b/tests/test_builder_lineage_consumers.py index 65c4522e..059a0233 100644 --- a/tests/test_builder_lineage_consumers.py +++ b/tests/test_builder_lineage_consumers.py @@ -569,13 +569,29 @@ def run_lineage(self, *, head=TAKEN, labels=("builder:devin",), bodies=None): identity_json=json.dumps(IDENTITY), publish=True, reconcile_labels=True, json=True, ) - return lane_delivery._lineage_main( - args, - head=lambda repo, number: head, - labels=lambda repo, number, add, remove: self.labelled.append((add, remove)), - comment_bodies=lambda repo, number: tuple(bodies or self.published), - publish_comment=lambda repo, number, body: self.published.append(body), - ) + # Publication now requires a configured decision authority, because a + # marker no consumer would read is not evidence: codex:6dfb331284ea. + authority = "codemower-ai" + with mock.patch.dict( + os.environ, + { + "CODE_MOWER_DECISION_AUTHORITIES": authority, + "CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE": "", + }, + clear=False, + ): + return lane_delivery._lineage_main( + args, + head=lambda repo, number: head, + labels=lambda repo, number, add, remove: self.labelled.append( + (add, remove) + ), + comment_bodies=lambda repo, number: tuple( + {"user": {"login": authority}, "body": body} + for body in (bodies if bodies is not None else self.published) + ), + publish_comment=lambda repo, number, body: self.published.append(body), + ) def test_evidence_is_published_and_then_the_label_moves(self): self.assertEqual(self.run_lineage(), 0) @@ -633,7 +649,14 @@ def explode(repo, number, body): identity_json=json.dumps(IDENTITY), publish=True, reconcile_labels=True, json=True, ) - with self.assertRaises(subprocess.CalledProcessError): + with mock.patch.dict( + os.environ, + { + "CODE_MOWER_DECISION_AUTHORITIES": "codemower-ai", + "CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE": "", + }, + clear=False, + ), self.assertRaises(subprocess.CalledProcessError): lane_delivery._lineage_main( args, head=lambda repo, number: TAKEN, @@ -643,6 +666,34 @@ def explode(repo, number, body): ) self.assertEqual(self.labelled, []) + def test_no_configured_authority_publishes_nothing_and_moves_no_label(self): + """codex:6dfb331284ea -- a marker no consumer reads is not evidence.""" + + args = SimpleNamespace( + repo=REPO, pr=str(PR), branch=BRANCH, head=TAKEN, labels=["builder:devin"], + author="devin-ai-integration[bot]", state_dir=self.root, + identity_json=json.dumps(IDENTITY), publish=True, reconcile_labels=True, + json=True, + ) + with mock.patch.dict( + os.environ, + { + "CODE_MOWER_DECISION_AUTHORITIES": "", + "CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE": "", + }, + clear=False, + ): + code = lane_delivery._lineage_main( + args, + head=lambda repo, number: TAKEN, + labels=lambda repo, number, add, remove: self.labelled.append((add, remove)), + comment_bodies=lambda repo, number: (), + publish_comment=lambda repo, number, body: self.published.append(body), + ) + self.assertEqual(code, 3) + self.assertEqual(self.published, [], "nothing may be published") + self.assertEqual(self.labelled, [], "no label may be reconciled") + class LabelerCallersCarryLineage(unittest.TestCase): """codex:9bac84962f544d9fa4bb -- trusted evidence reaches the labelers.""" diff --git a/tests/test_builder_lineage_entrypoints.py b/tests/test_builder_lineage_entrypoints.py new file mode 100644 index 00000000..a01ee07b --- /dev/null +++ b/tests/test_builder_lineage_entrypoints.py @@ -0,0 +1,545 @@ +"""Regressions for the three seams that *obtain* lineage authority. + +``test_builder_lineage_consumers`` proves each helper carries lineage when it +is handed some, and ``test_builder_lineage_integration`` proves the producer +publishes. These prove the three production entrypoints between them do the +right thing under the repository's configured decision-authority contract: + +* the delivery CLI refuses to publish or move a label when no authority is + configured, because every consumer would then trust no marker at all; +* every maintained SaaS labeler entrypoint fetches the exact current head, the + head branch and the bounded trusted comment history before it decides, and + stops instead of mutating labels when a required read fails; +* the generated provenance job supplies the same authority contract to + ``builder auto-record``, so a verified takeover is attributed to its current + writer rather than to whoever opened the pull request. + +Each case drives the real entrypoint -- ``lane_delivery.main``, +``saas_reviewer_labeler.main`` and the rendered workflow's own command line -- +with only the network boundary replaced. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import unittest +from contextlib import contextmanager +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from code_mower import builder_lineage, lane_delivery, lane_handoff # noqa: E402 +from code_mower import saas_reviewer_labeler as labeler # noqa: E402 + +from test_builder_lineage_consumers import ( # noqa: E402 + BRANCH, + PR, + REPO, + TAKEN, + git_free_tempdir, + takeover_episode, +) + +ROOT = Path(__file__).resolve().parents[1] +AUTHORITY = "codemower-ai" +OUTSIDER = "passer-by" +OPENER = "devin-ai-integration[bot]" +MARKER_BODY = "Builder contribution lineage for this head.\n\n" + + +@contextmanager +def _private_root(case: unittest.TestCase): + """A store root outside any checkout, as the context store demands.""" + + yield str(git_free_tempdir(case)) + + +def marker_comment(author: str = AUTHORITY) -> dict: + return { + "user": {"login": author}, + "body": MARKER_BODY + + builder_lineage.lineage_comment_marker((takeover_episode(),)), + } + + +class _FakeGh: + """Just enough `gh` for the delivery CLI: comments, publish, label edit.""" + + def __init__(self, comments=(), publish_as: str = AUTHORITY, head: str = TAKEN): + self.comments = [dict(item) for item in comments] + self.publish_as = publish_as + self.head = head + self.posted: list[str] = [] + self.label_commands: list[list[str]] = [] + + def check_output(self, command, **_kwargs): + if "--json" in command and "headRefOid" in command: + return json.dumps({"headRefOid": self.head}) + if "--json" in command and "comments" in command: + return json.dumps( + { + "comments": [ + { + "author": {"login": item["user"]["login"]}, + "body": item["body"], + } + for item in self.comments + ] + } + ) + raise AssertionError(f"unexpected gh read: {command}") + + def run(self, command, **_kwargs): + if command[:3] == ["gh", "pr", "comment"]: + body = command[command.index("--body") + 1] + self.posted.append(body) + self.comments.append({"user": {"login": self.publish_as}, "body": body}) + return subprocess.CompletedProcess(command, 0) + if command[:3] == ["gh", "pr", "edit"]: + self.label_commands.append(list(command)) + return subprocess.CompletedProcess(command, 0) + raise AssertionError(f"unexpected gh write: {command}") + + +class PublisherRequiresATrustedAuthority(unittest.TestCase): + """The delivery CLI never publishes evidence no consumer would read.""" + + def _run(self, gh, *, authorities: str, extra=()): + with _private_root(self) as root: + builder_lineage.record_episode( + lane_handoff.lineage_root(Path(root)), takeover_episode() + ) + argv = [ + "lineage", + "--repo", REPO, + "--pr", str(PR), + "--branch", BRANCH, + "--head", TAKEN, + "--author", OPENER, + "--label", "builder:devin", + "--state-dir", str(root), + "--publish", + "--reconcile-labels", + "--json", + *extra, + ] + env = {"CODE_MOWER_DECISION_AUTHORITIES": authorities} + with mock.patch.dict(os.environ, env, clear=False), \ + mock.patch.object(lane_delivery.subprocess, "check_output", + gh.check_output), \ + mock.patch.object(lane_delivery.subprocess, "run", gh.run), \ + mock.patch("sys.stdout", new_callable=_Capture) as out: + code = lane_delivery.main(argv) + return code, json.loads(out.text()) + + def test_no_configured_authority_publishes_nothing_and_moves_no_label(self): + gh = _FakeGh() + code, payload = self._run(gh, authorities="") + self.assertEqual(code, 3) + self.assertEqual(payload["reason"], "lineage_no_decision_authority") + self.assertTrue(payload["owner_action"]) + self.assertFalse(payload["applied"]) + self.assertEqual(gh.posted, [], "nothing may be published") + self.assertEqual(gh.label_commands, [], "no label may be reconciled") + + def test_an_identical_untrusted_marker_does_not_suppress_publication(self): + gh = _FakeGh(comments=[marker_comment(OUTSIDER)]) + code, payload = self._run(gh, authorities=AUTHORITY) + self.assertEqual(code, 0) + self.assertTrue(payload["published"]) + self.assertEqual(len(gh.posted), 1) + + def test_a_configured_trusted_publisher_publishes_then_reconciles(self): + gh = _FakeGh() + code, payload = self._run(gh, authorities=f"{AUTHORITY},someone-else") + self.assertEqual(code, 0) + self.assertTrue(payload["published"]) + self.assertEqual(len(gh.posted), 1) + self.assertTrue(gh.label_commands, "the verified writer takes the label") + + def test_an_unreadable_readback_blocks_the_label(self): + gh = _FakeGh(publish_as=OUTSIDER) + code, payload = self._run(gh, authorities=AUTHORITY) + self.assertEqual(code, 3) + self.assertEqual(payload["reason"], "lineage_unpublished") + self.assertTrue(payload["owner_action"]) + self.assertEqual(len(gh.posted), 1, "the attempt happened; it did not count") + self.assertEqual(gh.label_commands, [], "no label may be reconciled") + + +class _Capture: + """Minimal stdout stand-in that keeps what an entrypoint printed.""" + + def __init__(self): + self._chunks: list[str] = [] + + def write(self, text): # pragma: no cover - trivial + self._chunks.append(text) + return len(text) + + def flush(self): # pragma: no cover - trivial + return None + + def text(self) -> str: + return "".join(self._chunks) + + +def _pull_request(head: str = TAKEN, labels=("builder:codex", "greptile-review", "gitar-audit-requested")): + return { + "number": PR, + "state": "open", + "user": {"login": OPENER}, + "body": "", + "labels": [{"name": name} for name in labels], + "head": {"sha": head, "ref": BRANCH}, + } + + +class _FakeApi: + """A routed GitHub API, so real pagination and real fetches still run.""" + + def __init__(self, *, comment_pages=None, fail_comments=False, head=TAKEN): + self.comment_pages = ( + comment_pages if comment_pages is not None else [[marker_comment()]] + ) + self.fail_comments = fail_comments + self.head = head + self.comment_requests: list[str] = [] + + def __call__(self, method, path, **_kwargs): + if "/issues/" in path and path.rstrip("/").split("?")[0].endswith("/comments"): + if self.fail_comments: + raise labeler.GitHubRequestError("GET", path, 500, "comments unavailable") + self.comment_requests.append(path) + page = int(re.search(r"[?&]page=(\d+)", path).group(1)) + pages = self.comment_pages + return pages[page - 1] if page <= len(pages) else [] + if re.search(r"/pulls/\d+$", path): + return _pull_request(head=self.head) + if "/commits/" in path and path.endswith("pulls?per_page=100"): + return [{"number": PR}] + if "/reviews" in path: + return [] + raise AssertionError(f"unexpected API path: {path}") + + +class SaaSEntrypointsCarryExactHeadLineage(unittest.TestCase): + """Every maintained SaaS entrypoint obtains head, branch and comments.""" + + def _main(self, *, adapter, event, event_name, api, authorities=AUTHORITY): + seen: list = [] + applied: list = [] + real_context = labeler.lineage_context + + def spy(**kwargs): + context = real_context(**kwargs) + seen.append(context) + return context + + with _private_root(self) as root: + event_path = Path(root) / "event.json" + event_path.write_text(json.dumps(event), encoding="utf-8") + env = { + "GITHUB_EVENT_PATH": str(event_path), + "GITHUB_REPOSITORY": REPO, + "GITHUB_EVENT_NAME": event_name, + "GREPTILE_LABEL_TOKEN": "t", + "GITAR_LABEL_TOKEN": "t", + "GITHUB_TOKEN": "t", + "CODE_MOWER_DECISION_AUTHORITIES": authorities, + "CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE": "", + "DRY_RUN": "", + } + with mock.patch.dict(os.environ, env, clear=False), \ + mock.patch.object(labeler, "lineage_context", spy), \ + mock.patch.object(labeler, "github_request_with_fallback", api), \ + mock.patch( + "code_mower.audit_labeler_lib.github_request_with_fallback", api + ), \ + mock.patch.object( + labeler, "_apply_or_log", + lambda *a, **k: applied.append((a, k)) + ), \ + mock.patch("sys.stdout", new_callable=_Capture): + code = labeler.main(["--adapter", adapter]) + return code, seen, applied + + def _assert_exact_head_lineage(self, seen): + self.assertTrue(seen, "the entrypoint resolved no lineage at all") + resolved = [item for item in seen if item.head_sha] + self.assertTrue(resolved, "no entrypoint carried the current head") + for context in resolved: + self.assertEqual(context.repo, REPO) + self.assertEqual(context.head_sha, TAKEN) + self.assertEqual(context.branch, BRANCH) + self.assertEqual(len(context.episodes), 1) + + def test_check_run_carries_the_trusted_comment_history(self): + api = _FakeApi() + event = { + "action": "completed", + "check_run": { + "status": "completed", + "conclusion": "success", + "name": "greptile review", + "app": {"slug": "greptile-apps"}, + "head_sha": TAKEN, + "pull_requests": [{"number": PR}], + }, + } + _, seen, _ = self._main( + adapter="greptile", event=event, event_name="check_run", api=api + ) + self._assert_exact_head_lineage(seen) + self.assertTrue(api.comment_requests, "check_run never fetched comments") + + def test_live_issue_comment_carries_head_branch_and_comments(self): + api = _FakeApi() + event = { + "action": "created", + "issue": { + "number": PR, + "pull_request": {"url": "https://example.invalid"}, + "user": {"login": OPENER}, + "body": "", + "labels": [{"name": "gitar-audit-requested"}], + }, + "comment": { + "user": {"login": "gitar-ai[bot]"}, + "body": "Gitar review complete. No issues found.", + }, + } + _, seen, _ = self._main( + adapter="gitar", event=event, event_name="issue_comment", api=api + ) + self._assert_exact_head_lineage(seen) + self.assertTrue(api.comment_requests, "issue_comment never fetched comments") + + def test_issues_replay_carries_the_current_head(self): + api = _FakeApi() + event = { + "action": "labeled", + "label": {"name": "gitar-audit-requested"}, + "issue": { + "number": PR, + "pull_request": {"url": "https://example.invalid"}, + "user": {"login": OPENER}, + "body": "", + "labels": [{"name": "gitar-audit-requested"}], + }, + } + _, seen, _ = self._main( + adapter="gitar", event=event, event_name="issues", api=api + ) + self._assert_exact_head_lineage(seen) + + def test_an_untrusted_marker_yields_no_episodes(self): + api = _FakeApi(comment_pages=[[marker_comment(OUTSIDER)]]) + event = { + "action": "completed", + "check_run": { + "status": "completed", + "conclusion": "success", + "name": "greptile review", + "app": {"slug": "greptile-apps"}, + "head_sha": TAKEN, + "pull_requests": [{"number": PR}], + }, + } + _, seen, _ = self._main( + adapter="greptile", event=event, event_name="check_run", api=api + ) + self.assertTrue(seen) + self.assertEqual(seen[0].head_sha, TAKEN) + self.assertEqual(seen[0].episodes, ()) + + def test_no_configured_authority_reads_nothing_and_still_decides(self): + """Ordinary no-lineage behaviour is preserved, and costs no request.""" + + api = _FakeApi() + event = { + "action": "completed", + "check_run": { + "status": "completed", + "conclusion": "success", + "name": "greptile review", + "app": {"slug": "greptile-apps"}, + "head_sha": TAKEN, + "pull_requests": [{"number": PR}], + }, + } + code, seen, _ = self._main( + adapter="greptile", event=event, event_name="check_run", api=api, + authorities="", + ) + self.assertEqual(code, 0) + self.assertEqual(api.comment_requests, []) + self.assertTrue(seen) + self.assertEqual(seen[0].episodes, ()) + + def test_a_failed_comment_read_mutates_no_label(self): + api = _FakeApi(fail_comments=True) + event = { + "action": "completed", + "check_run": { + "status": "completed", + "conclusion": "success", + "name": "greptile review", + "app": {"slug": "greptile-apps"}, + "head_sha": TAKEN, + "pull_requests": [{"number": PR}], + }, + } + code, _, applied = self._main( + adapter="greptile", event=event, event_name="check_run", api=api + ) + self.assertEqual(code, 0) + self.assertEqual(applied, [], "a failed required read may not label") + + def test_a_paginated_comment_history_is_read_whole(self): + filler = [ + {"user": {"login": OUTSIDER}, "body": f"noise {index}"} + for index in range(100) + ] + api = _FakeApi(comment_pages=[filler, [marker_comment()]]) + event = { + "action": "completed", + "check_run": { + "status": "completed", + "conclusion": "success", + "name": "greptile review", + "app": {"slug": "greptile-apps"}, + "head_sha": TAKEN, + "pull_requests": [{"number": PR}], + }, + } + _, seen, _ = self._main( + adapter="greptile", event=event, event_name="check_run", api=api + ) + self.assertEqual(len(api.comment_requests), 2) + self._assert_exact_head_lineage(seen) + + +class GeneratedProvenanceJobCarriesTheAuthorityContract(unittest.TestCase): + """The generated job must hand auto-record a usable trust rule.""" + + TEMPLATES = ( + ROOT / "templates/workflows/builder-provenance.yml.j2", + ROOT / "src/code_mower/templates/workflows/builder-provenance.yml.j2", + ) + + def _rendered(self, path: Path) -> str: + return ( + path.read_text(encoding="utf-8") + .replace("{% raw %}", "") + .replace("{% endraw %}", "") + ) + + def test_both_maintained_templates_render_the_same_contract(self): + first, second = (self._rendered(path) for path in self.TEMPLATES) + self.assertEqual(first, second) + for text in (first, second): + self.assertIn("CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE:", text) + self.assertIn("vars.CODE_MOWER_DECISION_AUTHORITIES", text) + self.assertIn("decision_authorities_from_config", text) + self.assertIn("CODE_MOWER_DECISION_AUTHORITIES=", text) + + def _authority_step_source(self) -> str: + text = self._rendered(self.TEMPLATES[0]) + body = text.split("python - <<'PY'", 1)[1].split("PY\n", 1)[0] + return "\n".join(line[10:] for line in body.splitlines()[1:]) + + def _authorities_from_generated_step(self, config_text: str) -> str: + """Run the generated job's own authority step over a repo config.""" + + with _private_root(self) as root: + (Path(root) / "code-mower.yml").write_text(config_text, encoding="utf-8") + captured = _Capture() + cwd = os.getcwd() + os.chdir(root) + try: + with mock.patch("sys.stdout", captured): + exec(compile(self._authority_step_source(), "", "exec"), {}) + finally: + os.chdir(cwd) + line = captured.text().strip() + self.assertTrue(line.startswith("CODE_MOWER_DECISION_AUTHORITIES=")) + self.assertEqual(len(line.splitlines()), 1, "one GITHUB_ENV assignment") + return line.split("=", 1)[1] + + def _auto_record(self, *, authorities: str, comments) -> dict: + from code_mower import builder_runs + + with _private_root(self) as root: + pr_json = Path(root) / "event.json" + pr_json.write_text( + json.dumps({"pull_request": _pull_request()}), encoding="utf-8" + ) + comments_json = Path(root) / "comments.json" + comments_json.write_text(json.dumps(comments), encoding="utf-8") + output = Path(root) / "run.json" + env = { + "CODE_MOWER_DECISION_AUTHORITIES": authorities, + "CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE": "", + } + captured = _Capture() + with mock.patch.dict(os.environ, env, clear=False), \ + mock.patch("sys.stdout", captured): + code = builder_runs.main([ + "auto-record", + "--pr-json", str(pr_json), + "--repo", REPO, + "--comments-json", str(comments_json), + "--output", str(output), + "--force", + "--json", + ]) + self.assertEqual(code, 0) + payload = json.loads(captured.text()) + payload["_event"] = ( + json.loads(output.read_text(encoding="utf-8")) + if output.is_file() + else {} + ) + return payload + + def test_the_generated_step_resolves_configured_authorities(self): + resolved = self._authorities_from_generated_step( + "decisions:\n authorities:\n - codemower-ai\n" + ) + self.assertEqual(resolved, AUTHORITY) + + def test_a_generated_run_attributes_the_takeover_to_its_current_writer(self): + authorities = self._authorities_from_generated_step( + "decisions:\n authorities:\n - codemower-ai\n" + ) + payload = self._auto_record( + authorities=authorities, comments=[marker_comment()] + ) + self.assertEqual(payload["status"], "recorded") + # The verified current writer, not the Devin account that opened it. + self.assertEqual(payload["executor"], "chatgpt-codex-connector") + + def test_empty_authorities_trust_no_marker(self): + resolved = self._authorities_from_generated_step("owner_surface: {}\n") + self.assertEqual(resolved, "") + payload = self._auto_record(authorities=resolved, comments=[marker_comment()]) + self.assertNotEqual(payload.get("executor"), "chatgpt-codex-connector") + + def test_an_untrusted_marker_is_discarded(self): + authorities = self._authorities_from_generated_step( + "decisions:\n authorities:\n - codemower-ai\n" + ) + payload = self._auto_record( + authorities=authorities, comments=[marker_comment(OUTSIDER)] + ) + self.assertNotEqual(payload.get("executor"), "chatgpt-codex-connector") + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From cb8b4adf7976ee8e2b79a6fd82486beb1ca212cb Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 14:42:48 -0700 Subject: [PATCH 11/25] Render provenance authority from reviewed config, not the proposed head The provenance workflow runs on `pull_request`. Resolving its marker authority by checking out that event's head and reading `code-mower.yml` from it meant a contributor who could edit the checked-out configuration could name themselves a decision authority, and auto-record would then believe their own lineage marker. The gate does not work that way: it takes the list from configuration the repository reviewed and installed. Both template copies now use that same seam. `__DECISION_AUTHORITIES__` is already in init's shared replacement map, so the configured list is rendered into the workflow environment as a literal alongside the existing repository-variable override, and the checkout and config-loading steps are gone entirely. No second trust resolver, no runtime configuration read, no ambient fallback. The job's inputs are fixed when the workflow is generated. The regression is rewritten against that seam. It renders both copies through `init._render_workflow_template`, parses the generated workflow, and feeds its actual environment into `builder_runs auto-record`: the configured list arrives as a literal and survives a multi-entry value, the generated job contains no checkout, no configuration read and no step that writes GITHUB_ENV, a hostile `code-mower.yml` on the proposed head grants nothing, the repository variable still overrides the rendered list, and the empty and untrusted-marker cases stay closed. Refs #963 Co-Authored-By: Claude Opus 5 --- .../workflows/builder-provenance.yml.j2 | 43 +--- templates/workflows/builder-provenance.yml.j2 | 43 +--- tests/test_builder_lineage_entrypoints.py | 221 ++++++++++++------ 3 files changed, 166 insertions(+), 141 deletions(-) diff --git a/src/code_mower/templates/workflows/builder-provenance.yml.j2 b/src/code_mower/templates/workflows/builder-provenance.yml.j2 index ff01393a..fbe871c6 100644 --- a/src/code_mower/templates/workflows/builder-provenance.yml.j2 +++ b/src/code_mower/templates/workflows/builder-provenance.yml.j2 @@ -16,8 +16,14 @@ env: # these accounts and nobody else, so a job that supplies neither field trusts # nobody, discards every comment fetched below, and records a taken-over pull # request against whoever opened it instead of its verified current writer. - # The repository variable overrides the checked-in configuration, exactly as - # it does for the gate and the labelers. + # + # The list is rendered here, from the configuration this repository reviewed + # and installed, exactly as the gate and the labelers receive it. This job + # runs on `pull_request`, so it must never read the authority list out of the + # proposed head: a contributor who could edit the checked-out configuration + # would otherwise name themselves an authority and have their own lineage + # marker believed. The repository variable still overrides it. + CODE_MOWER_DECISION_AUTHORITIES: __DECISION_AUTHORITIES__ CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE: {% raw %}${{ vars.CODE_MOWER_DECISION_AUTHORITIES || '' }}{% endraw %} jobs: @@ -25,11 +31,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: Check out the repository configuration - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - name: Set up Python uses: actions/setup-python@8fc423ec5f48d6a2f5a10347eab979e8a63b7ab9 with: @@ -41,34 +42,6 @@ jobs: set -euo pipefail python -m pip install "${CODE_MOWER_PACKAGE_SPEC}" - - name: Load configured decision authorities - shell: bash - run: | - set -euo pipefail - python - <<'PY' >> "${GITHUB_ENV}" - import re - from pathlib import Path - - from code_mower.config import load_config - from code_mower.decisions import decision_authorities_from_config - - # The repository's own configuration is the authority source the rest - # of Code Mower already resolves; nothing here grants trust of its - # own. Logins only, so a malformed entry cannot write a second - # environment assignment into GITHUB_ENV. - config_path = Path("code-mower.yml") - authorities = ( - decision_authorities_from_config(load_config(config_path)) - if config_path.is_file() - else () - ) - safe = [ - item for item in authorities - if re.fullmatch(r"[A-Za-z0-9._\[\]-]{1,100}", str(item)) - ] - print("CODE_MOWER_DECISION_AUTHORITIES=" + ",".join(safe)) - PY - - name: Record inferred builder provenance id: record shell: bash diff --git a/templates/workflows/builder-provenance.yml.j2 b/templates/workflows/builder-provenance.yml.j2 index ff01393a..fbe871c6 100644 --- a/templates/workflows/builder-provenance.yml.j2 +++ b/templates/workflows/builder-provenance.yml.j2 @@ -16,8 +16,14 @@ env: # these accounts and nobody else, so a job that supplies neither field trusts # nobody, discards every comment fetched below, and records a taken-over pull # request against whoever opened it instead of its verified current writer. - # The repository variable overrides the checked-in configuration, exactly as - # it does for the gate and the labelers. + # + # The list is rendered here, from the configuration this repository reviewed + # and installed, exactly as the gate and the labelers receive it. This job + # runs on `pull_request`, so it must never read the authority list out of the + # proposed head: a contributor who could edit the checked-out configuration + # would otherwise name themselves an authority and have their own lineage + # marker believed. The repository variable still overrides it. + CODE_MOWER_DECISION_AUTHORITIES: __DECISION_AUTHORITIES__ CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE: {% raw %}${{ vars.CODE_MOWER_DECISION_AUTHORITIES || '' }}{% endraw %} jobs: @@ -25,11 +31,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: Check out the repository configuration - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - name: Set up Python uses: actions/setup-python@8fc423ec5f48d6a2f5a10347eab979e8a63b7ab9 with: @@ -41,34 +42,6 @@ jobs: set -euo pipefail python -m pip install "${CODE_MOWER_PACKAGE_SPEC}" - - name: Load configured decision authorities - shell: bash - run: | - set -euo pipefail - python - <<'PY' >> "${GITHUB_ENV}" - import re - from pathlib import Path - - from code_mower.config import load_config - from code_mower.decisions import decision_authorities_from_config - - # The repository's own configuration is the authority source the rest - # of Code Mower already resolves; nothing here grants trust of its - # own. Logins only, so a malformed entry cannot write a second - # environment assignment into GITHUB_ENV. - config_path = Path("code-mower.yml") - authorities = ( - decision_authorities_from_config(load_config(config_path)) - if config_path.is_file() - else () - ) - safe = [ - item for item in authorities - if re.fullmatch(r"[A-Za-z0-9._\[\]-]{1,100}", str(item)) - ] - print("CODE_MOWER_DECISION_AUTHORITIES=" + ",".join(safe)) - PY - - name: Record inferred builder provenance id: record shell: bash diff --git a/tests/test_builder_lineage_entrypoints.py b/tests/test_builder_lineage_entrypoints.py index a01ee07b..176ebcaf 100644 --- a/tests/test_builder_lineage_entrypoints.py +++ b/tests/test_builder_lineage_entrypoints.py @@ -29,8 +29,11 @@ import unittest from contextlib import contextmanager from pathlib import Path +from typing import Any, Mapping from unittest import mock +import yaml + sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) from code_mower import builder_lineage, lane_delivery, lane_handoff # noqa: E402 @@ -426,53 +429,99 @@ def test_a_paginated_comment_history_is_read_whole(self): class GeneratedProvenanceJobCarriesTheAuthorityContract(unittest.TestCase): - """The generated job must hand auto-record a usable trust rule.""" + """The generated job's own inputs must decide marker trust. + + This job runs on ``pull_request``, so where the authority list comes from + is the whole question. It is rendered into the workflow from the reviewed + configuration the repository installed, through the same replacement map + the gate and the labelers go through. Nothing is read from the proposed + head at job time: a contributor who can edit a checked-out configuration + must not be able to name themselves an authority and have their own + lineage marker believed. + """ TEMPLATES = ( ROOT / "templates/workflows/builder-provenance.yml.j2", ROOT / "src/code_mower/templates/workflows/builder-provenance.yml.j2", ) - def _rendered(self, path: Path) -> str: - return ( - path.read_text(encoding="utf-8") - .replace("{% raw %}", "") - .replace("{% endraw %}", "") + def _render(self, path: Path, *, authorities: str) -> str: + """The workflow `init` actually generates for a configured repository.""" + + from code_mower import init + + return init._render_workflow_template( + path.read_text(encoding="utf-8"), + {"decision_authorities": authorities}, ) - def test_both_maintained_templates_render_the_same_contract(self): - first, second = (self._rendered(path) for path in self.TEMPLATES) - self.assertEqual(first, second) - for text in (first, second): - self.assertIn("CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE:", text) - self.assertIn("vars.CODE_MOWER_DECISION_AUTHORITIES", text) - self.assertIn("decision_authorities_from_config", text) - self.assertIn("CODE_MOWER_DECISION_AUTHORITIES=", text) + def _job_env(self, rendered: str) -> dict: + workflow = yaml.safe_load(rendered) + return dict(workflow.get("env") or {}) - def _authority_step_source(self) -> str: - text = self._rendered(self.TEMPLATES[0]) - body = text.split("python - <<'PY'", 1)[1].split("PY\n", 1)[0] - return "\n".join(line[10:] for line in body.splitlines()[1:]) + def test_both_maintained_templates_generate_the_same_workflow(self): + first, second = ( + self._render(path, authorities=AUTHORITY) for path in self.TEMPLATES + ) + self.assertEqual(first, second) + self.assertEqual( + self.TEMPLATES[0].read_text(encoding="utf-8"), + self.TEMPLATES[1].read_text(encoding="utf-8"), + ) - def _authorities_from_generated_step(self, config_text: str) -> str: - """Run the generated job's own authority step over a repo config.""" + def test_the_configured_authority_list_is_rendered_as_a_literal(self): + for path in self.TEMPLATES: + with self.subTest(template=path.name): + env = self._job_env(self._render(path, authorities=AUTHORITY)) + self.assertEqual(env["CODE_MOWER_DECISION_AUTHORITIES"], AUTHORITY) + # The repository variable still overrides it, unrendered. + self.assertIn( + "vars.CODE_MOWER_DECISION_AUTHORITIES", + env["CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE"], + ) + + def test_a_multi_authority_list_survives_rendering_intact(self): + env = self._job_env( + self._render(self.TEMPLATES[0], authorities=f"{AUTHORITY},second-owner") + ) + self.assertEqual( + env["CODE_MOWER_DECISION_AUTHORITIES"], f"{AUTHORITY},second-owner" + ) - with _private_root(self) as root: - (Path(root) / "code-mower.yml").write_text(config_text, encoding="utf-8") - captured = _Capture() - cwd = os.getcwd() - os.chdir(root) - try: - with mock.patch("sys.stdout", captured): - exec(compile(self._authority_step_source(), "", "exec"), {}) - finally: - os.chdir(cwd) - line = captured.text().strip() - self.assertTrue(line.startswith("CODE_MOWER_DECISION_AUTHORITIES=")) - self.assertEqual(len(line.splitlines()), 1, "one GITHUB_ENV assignment") - return line.split("=", 1)[1] + def test_the_generated_job_reads_no_configuration_from_the_proposed_head(self): + """The defect this replaced: authority resolved from the PR checkout.""" + + for path in self.TEMPLATES: + with self.subTest(template=path.name): + rendered = self._render(path, authorities=AUTHORITY) + self.assertNotIn("actions/checkout", rendered) + self.assertNotIn("code-mower.yml", rendered) + self.assertNotIn("decision_authorities_from_config", rendered) + # No step may add to the job environment after it is rendered: + # that is the only way the proposed head could reach trust. + self.assertNotIn("GITHUB_ENV", rendered) + steps = yaml.safe_load(rendered)["jobs"]["auto-record"]["steps"] + self.assertEqual( + [ + step["uses"].split("@")[0] + for step in steps + if "uses" in step + ], + [ + "actions/setup-python", + "actions/upload-artifact", + "actions/upload-artifact", + ], + ) + for step in steps: + if "run" not in step: + continue + self.assertNotIn("load_config", step["run"]) + self.assertNotIn("import ", step["run"]) + + def _auto_record(self, *, env: Mapping[str, Any], comments, cwd=None) -> dict: + """Run auto-record exactly as the generated job's inputs configure it.""" - def _auto_record(self, *, authorities: str, comments) -> dict: from code_mower import builder_runs with _private_root(self) as root: @@ -483,60 +532,90 @@ def _auto_record(self, *, authorities: str, comments) -> dict: comments_json = Path(root) / "comments.json" comments_json.write_text(json.dumps(comments), encoding="utf-8") output = Path(root) / "run.json" - env = { - "CODE_MOWER_DECISION_AUTHORITIES": authorities, + job_env = { + "CODE_MOWER_DECISION_AUTHORITIES": "", "CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE": "", + **{str(key): str(value) for key, value in env.items()}, } captured = _Capture() - with mock.patch.dict(os.environ, env, clear=False), \ - mock.patch("sys.stdout", captured): - code = builder_runs.main([ - "auto-record", - "--pr-json", str(pr_json), - "--repo", REPO, - "--comments-json", str(comments_json), - "--output", str(output), - "--force", - "--json", - ]) + previous = os.getcwd() + os.chdir(cwd or previous) + try: + with mock.patch.dict(os.environ, job_env, clear=False), \ + mock.patch("sys.stdout", captured): + code = builder_runs.main([ + "auto-record", + "--pr-json", str(pr_json), + "--repo", REPO, + "--comments-json", str(comments_json), + "--output", str(output), + "--force", + "--json", + ]) + finally: + os.chdir(previous) self.assertEqual(code, 0) - payload = json.loads(captured.text()) - payload["_event"] = ( - json.loads(output.read_text(encoding="utf-8")) - if output.is_file() - else {} - ) - return payload + return json.loads(captured.text()) - def test_the_generated_step_resolves_configured_authorities(self): - resolved = self._authorities_from_generated_step( - "decisions:\n authorities:\n - codemower-ai\n" - ) - self.assertEqual(resolved, AUTHORITY) + def _generated_env(self, *, authorities: str, variable: str = "") -> dict: + """The job environment GitHub Actions would compose for this workflow. + + The rendered literal is the workflow's `env`; the repository variable + expands into the override field, which is empty when it is unset. + """ + + env = self._job_env(self._render(self.TEMPLATES[0], authorities=authorities)) + env["CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE"] = variable + env.pop("CODE_MOWER_PACKAGE_SPEC", None) + return env def test_a_generated_run_attributes_the_takeover_to_its_current_writer(self): - authorities = self._authorities_from_generated_step( - "decisions:\n authorities:\n - codemower-ai\n" - ) payload = self._auto_record( - authorities=authorities, comments=[marker_comment()] + env=self._generated_env(authorities=AUTHORITY), + comments=[marker_comment()], ) self.assertEqual(payload["status"], "recorded") # The verified current writer, not the Devin account that opened it. self.assertEqual(payload["executor"], "chatgpt-codex-connector") + def test_a_pull_request_provided_configuration_cannot_grant_trust(self): + """A hostile `code-mower.yml` on the proposed head buys nothing. + + The rendered job reaches no configuration at all -- asserted above -- + and auto-record itself resolves trust only from the environment the + job was rendered with, never from the tree it is run in. + """ + + hostile = git_free_tempdir(self) + (hostile / "code-mower.yml").write_text( + f"decisions:\n authorities:\n - {OUTSIDER}\n", encoding="utf-8" + ) + payload = self._auto_record( + env=self._generated_env(authorities=""), + comments=[marker_comment(OUTSIDER)], + cwd=hostile, + ) + self.assertNotEqual(payload.get("executor"), "chatgpt-codex-connector") + + def test_the_repository_variable_still_overrides_the_rendered_list(self): + # Rendered authority alone would refuse this marker; the variable is + # the reviewed escape hatch and still wins, as it does for the gate. + payload = self._auto_record( + env=self._generated_env(authorities="someone-else", variable=AUTHORITY), + comments=[marker_comment()], + ) + self.assertEqual(payload["executor"], "chatgpt-codex-connector") + def test_empty_authorities_trust_no_marker(self): - resolved = self._authorities_from_generated_step("owner_surface: {}\n") - self.assertEqual(resolved, "") - payload = self._auto_record(authorities=resolved, comments=[marker_comment()]) + env = self._generated_env(authorities="") + self.assertEqual(env["CODE_MOWER_DECISION_AUTHORITIES"], "") + payload = self._auto_record(env=env, comments=[marker_comment()]) self.assertNotEqual(payload.get("executor"), "chatgpt-codex-connector") def test_an_untrusted_marker_is_discarded(self): - authorities = self._authorities_from_generated_step( - "decisions:\n authorities:\n - codemower-ai\n" - ) payload = self._auto_record( - authorities=authorities, comments=[marker_comment(OUTSIDER)] + env=self._generated_env(authorities=AUTHORITY), + comments=[marker_comment(OUTSIDER)], ) self.assertNotEqual(payload.get("executor"), "chatgpt-codex-connector") From 82f4dbc27e6e8cb548c5509c117714bc8eaf7548 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 15:00:18 -0700 Subject: [PATCH 12/25] Collapse cumulative replay, and move attribution only on a transition Two confirmed P2 findings, both places where a bound or a comparison was drawn against the wrong thing. Cumulative publication at full length. Evidence is republished as a cumulative snapshot after every round, so the longest supported lineage is delivered as 1 + 2 + ... + 32 = 528 raw episodes, and a reader that also holds the private record sees the completed chain once more on top. The arrival bound was MAX_EPISODES * 16 = 512, below that total, so the gate and the labelers refused a valid full-length lineage -- and refused it before deduplication, the only step that could have shown those arrivals to be one chain. The bound is now derived from the publication contract it is bounding, and the resolver counts and collapses arrivals in one pass instead of measuring the list first. Working state stays bounded by the lineage length however many times it repeats, because an episode whose sequence falls outside 1..MAX_EPISODES is rejected as it arrives. Disagreeing duplicates, wrong head or branch binding, and excessive distinct episodes all still fail closed. Attribution on a writer transition. Lanes are coarser than the transports inside them: an ordinary `devin/` branch infers `devin_cli/devin_cli` but normalizes to lane `devin`, whose canonical attribution is the hosted pair. Attribution compared the inferred transport against that pair, so every ordinary local Devin CLI run was rewritten as hosted, had its builder id cleared and its confidence promoted to high -- on identity-only resolution that had observed no episode at all. The comparison is now between lanes and is gated on evidence: no episodes means no transition, and a same-lane continuation keeps the transport its own inference established. A verified cross-lane takeover still moves to the current writer, and still invents no builder id for it. Regressions route all 32 cumulative trusted snapshots through the gate and labeler entrypoint and resolve the current head, cover the public and private overlap at the exact arrival maximum, and hold the negative cases closed. Attribution cases drive `builder auto-record` for ordinary `devin/` and `devin-` branches, ordinary hosted identity, same-lane continuation, a verified cross-lane takeover and an untrusted marker. The vendored gate helper is byte-identical to its canonical source. Refs #963 Co-Authored-By: Claude Opus 5 --- src/code_mower/builder_lineage.py | 62 +++++---- src/code_mower/builder_runs.py | 19 ++- tests/test_builder_lineage_entrypoints.py | 134 +++++++++++++++++++ tests/test_builder_lineage_integration.py | 151 ++++++++++++++++++++++ tools/builder_lineage.py | 62 +++++---- 5 files changed, 379 insertions(+), 49 deletions(-) diff --git a/src/code_mower/builder_lineage.py b/src/code_mower/builder_lineage.py index ab439169..206ac5fd 100644 --- a/src/code_mower/builder_lineage.py +++ b/src/code_mower/builder_lineage.py @@ -80,15 +80,27 @@ #: How many raw entries a caller may hand the resolver before it refuses to #: parse them. A lineage is at most :data:`MAX_EPISODES` distinct episodes, but -#: the same chain legitimately arrives several times over: the producer -#: publishes the whole chain on every round, a reviewer merges its private -#: record with every trusted published marker, and an eight-episode lineage -#: published eight times is already thirty-six entries. Counting raw arrivals -#: against the lineage bound would call an authorised idempotent replay -#: malformed, so the input is bounded separately and the lineage bound is -#: applied to the distinct episodes that survive deduplication. Entries that -#: merely repeat are collapsed; entries that disagree still fail closed. -MAX_EPISODE_ENTRIES = MAX_EPISODES * 16 +#: the same chain legitimately arrives many times over, and the bound has to be +#: the one the supported publication contract actually produces rather than a +#: round multiple. +#: +#: Evidence is published as a *cumulative* snapshot: after episode ``n`` the +#: producer republishes all ``n``. A lineage that runs to its full length is +#: therefore delivered as ``1 + 2 + ... + MAX_EPISODES`` entries, and a reader +#: that also holds the private record sees the completed chain once more on top +#: of that. Anything under that total would refuse a lineage the system is +#: documented to support -- and would refuse it *before* deduplication, which +#: is the only step that could have shown the arrivals to be one chain. +#: +#: So arrivals are bounded here, deduplication happens as they are walked, and +#: the lineage bound applies to the distinct episodes that survive: an episode +#: sequence outside ``1..MAX_EPISODES`` is rejected per entry, so working state +#: stays bounded by the lineage length however many times it repeats. Entries +#: that merely repeat are collapsed; entries that disagree still fail closed. +MAX_EPISODE_ARRIVALS = MAX_EPISODES * (MAX_EPISODES + 1) // 2 + MAX_EPISODES + +#: Retained name for the arrival bound, used by the vendored gate helper. +MAX_EPISODE_ENTRIES = MAX_EPISODE_ARRIVALS EPISODE_FIELDS = ( "schema", @@ -499,13 +511,19 @@ def resolve_lineage( opener = _lane(opener_lane) labels = tuple(dict.fromkeys(lane for lane in (_lane(item) for item in label_lanes) if lane)) - if len(episodes) > MAX_EPISODE_ENTRIES: - # Bounded input, not a bounded lineage: refuse to parse an unbounded - # arrival before looking at any of it. - return _lineage("conflict", "episode_malformed", head_sha=head) - - parsed: list[ContributionEpisode] = [] + # Bounded input, not a bounded lineage. Arrivals are counted as they are + # walked and collapsed on the way, so a conforming cumulative publication + # history -- the same chain republished after every round, optionally + # overlapping a private record of it -- is deduplicated into one lineage + # instead of being refused for its length. Working state never exceeds the + # lineage bound, because an episode whose sequence falls outside + # ``1..MAX_EPISODES`` is rejected as it arrives. + seen: dict[int, ContributionEpisode] = {} + arrivals = 0 for item in episodes: + arrivals += 1 + if arrivals > MAX_EPISODE_ARRIVALS: + return _lineage("conflict", "episode_malformed", head_sha=head) try: episode = item if isinstance(item, ContributionEpisode) else episode_from_mapping(item) except LineageError: @@ -523,20 +541,18 @@ def resolve_lineage( ) if episode.writer_state not in expected_state: return _lineage("conflict", "writer_state_unverified", head_sha=head) - parsed.append(episode) - - if not parsed: - return resolve_identity_only(opener_lane=opener, label_lanes=labels, head_sha=head) - - ordered = sorted(parsed, key=lambda episode: episode.sequence) - seen: dict[int, ContributionEpisode] = {} - for episode in ordered: previous = seen.get(episode.sequence) if previous is not None: if previous.as_dict() != episode.as_dict(): + # Two records claim the same position and disagree. Which one + # describes the diff is exactly what cannot be guessed. return _lineage("conflict", "episode_duplicated", head_sha=head) continue seen[episode.sequence] = episode + + if not seen: + return resolve_identity_only(opener_lane=opener, label_lanes=labels, head_sha=head) + ordered = [seen[sequence] for sequence in sorted(seen)] if [episode.sequence for episode in ordered] != list(range(1, len(ordered) + 1)): return _lineage("conflict", "episode_unchained", head_sha=head) diff --git a/src/code_mower/builder_runs.py b/src/code_mower/builder_runs.py index c015db4c..9e79d63c 100644 --- a/src/code_mower/builder_runs.py +++ b/src/code_mower/builder_runs.py @@ -404,8 +404,21 @@ def build_auto_builder_run_event( # against Devin is the same mistake the label and the branch prefix make. # The builder id and run url stay with the inference that produced them -- # they describe the opener's run and would be a fabrication on any other. - writer = _LANE_ATTRIBUTION.get(lineage.current_writer) if lineage.resolved else None - if writer and (inference.provider, inference.executor) != writer: + # + # Only a verified *writer transition* may move attribution. Lanes are + # coarser than the transports inside them: an ordinary `devin/` branch + # infers the local `devin_cli` transport but normalizes to lane `devin`, + # whose canonical attribution is the hosted pair. Comparing transports + # against that pair rewrote every ordinary local Devin CLI run into a + # hosted one, cleared its builder id and claimed high confidence -- on + # identity-only resolution that had observed no episode at all. So the + # comparison is between lanes, and it is gated on evidence: no episodes + # means no transition, and a same-lane continuation keeps the transport + # its own inference established. + inferred_lane = _lane_from_inference(inference) + writer_lane = lineage.current_writer if lineage.resolved else "" + writer = _LANE_ATTRIBUTION.get(writer_lane) if writer_lane else None + if writer and lineage.episodes and writer_lane != inferred_lane: inference = replace( inference, provider=writer[0], @@ -413,7 +426,7 @@ def build_auto_builder_run_event( builder_id="", run_url="", confidence="high", - signals=inference.signals + (f"builder_lineage:{lineage.current_writer}",), + signals=inference.signals + (f"builder_lineage:{writer_lane}",), ) pr_ref = metadata.url or ( f"{metadata.repo}#{metadata.number}" if metadata.repo and metadata.number else "" diff --git a/tests/test_builder_lineage_entrypoints.py b/tests/test_builder_lineage_entrypoints.py index 176ebcaf..cb3e79e4 100644 --- a/tests/test_builder_lineage_entrypoints.py +++ b/tests/test_builder_lineage_entrypoints.py @@ -620,5 +620,139 @@ def test_an_untrusted_marker_is_discarded(self): self.assertNotEqual(payload.get("executor"), "chatgpt-codex-connector") +class AttributionMovesOnlyOnAVerifiedTransition(unittest.TestCase): + """Lanes are coarser than the transports inside them. + + An ordinary `devin/` branch infers the local `devin_cli` transport but + normalizes to lane `devin`, whose canonical attribution is the hosted pair. + Comparing transports against that pair rewrote every ordinary local Devin + CLI run into a hosted one, cleared its builder id and claimed high + confidence -- from identity-only resolution that had seen no episode at + all. Attribution may move only when verified evidence shows the writer + actually changed lanes. + """ + + def _record(self, *, branch, author, comments=(), labels=("builder:devin",)): + from code_mower import builder_runs + + with _private_root(self) as root: + pull_request = _pull_request() + pull_request["head"] = {"sha": TAKEN, "ref": branch} + pull_request["user"] = {"login": author} + pull_request["labels"] = [{"name": name} for name in labels] + pr_json = Path(root) / "event.json" + pr_json.write_text( + json.dumps({"pull_request": pull_request}), encoding="utf-8" + ) + comments_json = Path(root) / "comments.json" + comments_json.write_text(json.dumps(list(comments)), encoding="utf-8") + output = Path(root) / "run.json" + env = { + "CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY, + "CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE": "", + } + captured = _Capture() + with mock.patch.dict(os.environ, env, clear=False), \ + mock.patch("sys.stdout", captured): + code = builder_runs.main([ + "auto-record", + "--pr-json", str(pr_json), + "--repo", REPO, + "--comments-json", str(comments_json), + "--output", str(output), + "--force", + "--json", + ]) + self.assertEqual(code, 0) + payload = json.loads(captured.text()) + payload["_event"] = json.loads(output.read_text(encoding="utf-8")) + return payload + + def _dimensions(self, payload): + return payload["_event"]["dimensions"] + + def test_an_ordinary_devin_slash_branch_stays_local_devin_cli(self): + payload = self._record(branch="devin/963-thing", author="a-human") + self.assertEqual(payload["provider"], "devin_cli") + self.assertEqual(payload["executor"], "devin_cli") + dimensions = self._dimensions(payload) + self.assertTrue(dimensions["builder_id"], "the inferred id must survive") + self.assertEqual(dimensions["builder_inference_confidence"], "medium") + self.assertNotIn( + "builder_lineage:devin", dimensions["builder_inference_signals"] + ) + + def test_an_ordinary_devin_dash_branch_stays_local_devin_cli(self): + payload = self._record(branch="devin-963-thing", author="a-human") + self.assertEqual(payload["provider"], "devin_cli") + self.assertEqual(payload["executor"], "devin_cli") + self.assertEqual( + self._dimensions(payload)["builder_inference_confidence"], "medium" + ) + + def test_an_ordinary_hosted_devin_pull_request_stays_hosted(self): + payload = self._record(branch="devin/963-thing", author=OPENER) + self.assertEqual(payload["provider"], "devin") + self.assertEqual(payload["executor"], "devin") + self.assertEqual( + self._dimensions(payload)["builder_inference_confidence"], "high" + ) + + def test_a_same_lane_continuation_keeps_its_established_transport(self): + """Evidence exists, but the writer never left the lane it started in.""" + + episodes = ( + builder_lineage.ContributionEpisode( + sequence=1, + kind=builder_lineage.HANDOFF_KIND, + repo=REPO, + pr_number=PR, + branch="devin/963-thing", + source_lane="codex", + destination_lane="devin", + expected_head="a" * 40, + resulting_head=TAKEN, + writer_state="terminated", + ), + ) + marker = { + "user": {"login": AUTHORITY}, + "body": MARKER_BODY + builder_lineage.lineage_comment_marker(episodes), + } + payload = self._record( + branch="devin/963-thing", author="a-human", comments=[marker] + ) + self.assertEqual(payload["provider"], "devin_cli") + self.assertEqual(payload["executor"], "devin_cli") + self.assertEqual( + self._dimensions(payload)["builder_current_writer"], "devin" + ) + + def test_a_verified_cross_lane_takeover_attributes_the_current_writer(self): + payload = self._record( + branch=BRANCH, author=OPENER, comments=[marker_comment()], + labels=("builder:codex",), + ) + self.assertEqual(payload["provider"], "codex") + self.assertEqual(payload["executor"], "chatgpt-codex-connector") + dimensions = self._dimensions(payload) + self.assertEqual(dimensions["builder_current_writer"], "codex") + self.assertEqual(dimensions["builder_inference_confidence"], "high") + self.assertIn( + "builder_lineage:codex", dimensions["builder_inference_signals"] + ) + self.assertEqual( + dimensions["builder_id"], "", "no id may be invented for another lane" + ) + + def test_an_untrusted_takeover_marker_moves_no_attribution(self): + payload = self._record( + branch=BRANCH, author=OPENER, comments=[marker_comment(OUTSIDER)], + labels=("builder:devin",), + ) + self.assertEqual(payload["provider"], "devin") + self.assertEqual(payload["executor"], "devin") + + if __name__ == "__main__": # pragma: no cover unittest.main() diff --git a/tests/test_builder_lineage_integration.py b/tests/test_builder_lineage_integration.py index 8d37e48a..1faae7cb 100644 --- a/tests/test_builder_lineage_integration.py +++ b/tests/test_builder_lineage_integration.py @@ -24,6 +24,11 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) from code_mower import builder_lineage, lane_delivery, lane_handoff # noqa: E402 +from code_mower.audit_labeler_lib import ( # noqa: E402 + lineage_marker_author_trust, + published_lineage_episodes, + resolve_builder_lineage, +) from code_mower.provider_runners import lineage as reviewer_lineage # noqa: E402 from test_builder_lineage_consumers import ( # noqa: E402 @@ -704,6 +709,152 @@ def test_an_unbounded_arrival_is_refused_without_being_walked(self): self.assertEqual(resolved.reason, "episode_malformed") +class CumulativePublicationAtFullLength(unittest.TestCase): + """The longest supported lineage, published the way the producer publishes. + + Evidence goes out as a cumulative snapshot after every round, so a lineage + that runs to ``MAX_EPISODES`` is delivered as ``1 + 2 + ... + 32`` raw + episodes. A bound below that total refuses a lineage the system is + documented to support -- and refuses it before deduplication, the only step + that could have shown those arrivals to be one chain. + """ + + def _chain(self, length: int): + episodes = [takeover_episode(resulting="0" * 39 + "1")] + for index in range(2, length + 1): + episodes.append( + continuation_episode( + sequence=index, + expected=episodes[-1].resulting_head, + resulting=f"{index:040x}", + ) + ) + return tuple(episodes) + + def _cumulative_comments(self, chain): + """One published comment per round, each carrying the whole chain.""" + + return [published(chain[:length]) for length in range(1, len(chain) + 1)] + + def _gate_episodes(self, comments): + """What the gate and the labelers actually read off the comments.""" + + return published_lineage_episodes( + comments, + trusted_author=lineage_marker_author_trust(authorities=(AUTHORITY,)), + ) + + def test_the_full_cumulative_history_is_the_documented_arrival_maximum(self): + chain = self._chain(builder_lineage.MAX_EPISODES) + arrivals = self._gate_episodes(self._cumulative_comments(chain)) + expected = builder_lineage.MAX_EPISODES * (builder_lineage.MAX_EPISODES + 1) // 2 + self.assertEqual(len(arrivals), expected, "1 + 2 + ... + 32") + self.assertEqual(expected, 528) + self.assertLessEqual(expected, builder_lineage.MAX_EPISODE_ARRIVALS) + + def test_the_gate_resolves_the_current_head_from_the_full_history(self): + chain = self._chain(builder_lineage.MAX_EPISODES) + arrivals = self._gate_episodes(self._cumulative_comments(chain)) + lineage = resolve_builder_lineage( + labels=["builder:codex"], + author="devin-ai-integration[bot]", + config=IDENTITY, + repo=REPO, + pr_number=PR, + branch=BRANCH, + head_sha=chain[-1].resulting_head, + episodes=arrivals, + ) + self.assertEqual(lineage.status, "resolved") + self.assertEqual(lineage.episodes, builder_lineage.MAX_EPISODES) + self.assertEqual(lineage.current_writer, "codex") + + def test_a_reviewer_overlapping_its_private_record_still_resolves(self): + """The completed chain arrives once more from the local store.""" + + root = git_free_tempdir(self, "code-mower-cumulative-") + chain = self._chain(builder_lineage.MAX_EPISODES) + for episode in chain: + builder_lineage.record_episode(lane_handoff.lineage_root(root), episode) + comments = self._cumulative_comments(chain) + with mock.patch.dict("os.environ", {lane_handoff.STATE_DIR_ENV: str(root)}): + arrivals = reviewer_lineage.reviewer_evidence( + REPO, PR, authorities=(AUTHORITY,), fetch_comments=lambda: comments + ) + # The reviewer path merges the two stores and collapses them itself. + self.assertEqual(len(arrivals), builder_lineage.MAX_EPISODES) + resolved = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, + head_sha=chain[-1].resulting_head, episodes=arrivals, + ) + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.episodes, builder_lineage.MAX_EPISODES) + + def test_the_raw_public_and_private_union_resolves_at_the_bound(self): + """A consumer that collapses nothing hands over the exact maximum.""" + + chain = self._chain(builder_lineage.MAX_EPISODES) + arrivals = self._gate_episodes(self._cumulative_comments(chain)) + chain + self.assertEqual(len(arrivals), builder_lineage.MAX_EPISODE_ARRIVALS) + self.assertEqual(len(arrivals), 560) + resolved = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, + head_sha=chain[-1].resulting_head, episodes=arrivals, + ) + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.episodes, builder_lineage.MAX_EPISODES) + self.assertEqual(resolved.current_writer, "codex") + + def test_one_arrival_past_the_contract_is_still_refused(self): + chain = self._chain(builder_lineage.MAX_EPISODES) + arrivals = self._gate_episodes(self._cumulative_comments(chain)) + chain + arrivals = arrivals + (chain[-1],) + self.assertEqual(len(arrivals), builder_lineage.MAX_EPISODE_ARRIVALS + 1) + resolved = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, + head_sha=chain[-1].resulting_head, episodes=arrivals, + ) + self.assertEqual(resolved.status, "conflict") + self.assertEqual(resolved.reason, "episode_malformed") + + def test_a_disagreeing_duplicate_inside_the_full_history_fails_closed(self): + chain = self._chain(builder_lineage.MAX_EPISODES) + arrivals = self._gate_episodes(self._cumulative_comments(chain)) + forged = _variant(chain[4], destination_lane="claude", source_lane="claude") + resolved = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, + head_sha=chain[-1].resulting_head, episodes=arrivals + (forged,), + ) + self.assertEqual(resolved.status, "conflict") + self.assertEqual(resolved.reason, "episode_duplicated") + + def test_a_stale_full_history_waits_rather_than_resolving(self): + chain = self._chain(builder_lineage.MAX_EPISODES) + arrivals = self._gate_episodes(self._cumulative_comments(chain)) + resolved = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, head_sha=FIXED, episodes=arrivals, + ) + self.assertEqual(resolved.status, "waiting") + self.assertEqual(resolved.reason, "lineage_behind_head") + + def test_a_full_history_bound_to_another_branch_fails_closed(self): + chain = self._chain(builder_lineage.MAX_EPISODES) + arrivals = self._gate_episodes(self._cumulative_comments(chain)) + resolved = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch="codex/959-other", + head_sha=chain[-1].resulting_head, episodes=arrivals, + ) + self.assertEqual(resolved.status, "conflict") + self.assertEqual(resolved.reason, "episode_unbound") + + def test_a_sequence_past_the_lineage_bound_never_constructs(self): + """The distinct-episode bound is enforced per entry, as it arrives.""" + + chain = self._chain(builder_lineage.MAX_EPISODES) + with self.assertRaises(builder_lineage.LineageError): + _variant(chain[-1], sequence=builder_lineage.MAX_EPISODES + 1) + + class LaneStatusProjection(unittest.TestCase): """The Board/controller projection reads the same evidence as the gate.""" diff --git a/tools/builder_lineage.py b/tools/builder_lineage.py index ab439169..206ac5fd 100644 --- a/tools/builder_lineage.py +++ b/tools/builder_lineage.py @@ -80,15 +80,27 @@ #: How many raw entries a caller may hand the resolver before it refuses to #: parse them. A lineage is at most :data:`MAX_EPISODES` distinct episodes, but -#: the same chain legitimately arrives several times over: the producer -#: publishes the whole chain on every round, a reviewer merges its private -#: record with every trusted published marker, and an eight-episode lineage -#: published eight times is already thirty-six entries. Counting raw arrivals -#: against the lineage bound would call an authorised idempotent replay -#: malformed, so the input is bounded separately and the lineage bound is -#: applied to the distinct episodes that survive deduplication. Entries that -#: merely repeat are collapsed; entries that disagree still fail closed. -MAX_EPISODE_ENTRIES = MAX_EPISODES * 16 +#: the same chain legitimately arrives many times over, and the bound has to be +#: the one the supported publication contract actually produces rather than a +#: round multiple. +#: +#: Evidence is published as a *cumulative* snapshot: after episode ``n`` the +#: producer republishes all ``n``. A lineage that runs to its full length is +#: therefore delivered as ``1 + 2 + ... + MAX_EPISODES`` entries, and a reader +#: that also holds the private record sees the completed chain once more on top +#: of that. Anything under that total would refuse a lineage the system is +#: documented to support -- and would refuse it *before* deduplication, which +#: is the only step that could have shown the arrivals to be one chain. +#: +#: So arrivals are bounded here, deduplication happens as they are walked, and +#: the lineage bound applies to the distinct episodes that survive: an episode +#: sequence outside ``1..MAX_EPISODES`` is rejected per entry, so working state +#: stays bounded by the lineage length however many times it repeats. Entries +#: that merely repeat are collapsed; entries that disagree still fail closed. +MAX_EPISODE_ARRIVALS = MAX_EPISODES * (MAX_EPISODES + 1) // 2 + MAX_EPISODES + +#: Retained name for the arrival bound, used by the vendored gate helper. +MAX_EPISODE_ENTRIES = MAX_EPISODE_ARRIVALS EPISODE_FIELDS = ( "schema", @@ -499,13 +511,19 @@ def resolve_lineage( opener = _lane(opener_lane) labels = tuple(dict.fromkeys(lane for lane in (_lane(item) for item in label_lanes) if lane)) - if len(episodes) > MAX_EPISODE_ENTRIES: - # Bounded input, not a bounded lineage: refuse to parse an unbounded - # arrival before looking at any of it. - return _lineage("conflict", "episode_malformed", head_sha=head) - - parsed: list[ContributionEpisode] = [] + # Bounded input, not a bounded lineage. Arrivals are counted as they are + # walked and collapsed on the way, so a conforming cumulative publication + # history -- the same chain republished after every round, optionally + # overlapping a private record of it -- is deduplicated into one lineage + # instead of being refused for its length. Working state never exceeds the + # lineage bound, because an episode whose sequence falls outside + # ``1..MAX_EPISODES`` is rejected as it arrives. + seen: dict[int, ContributionEpisode] = {} + arrivals = 0 for item in episodes: + arrivals += 1 + if arrivals > MAX_EPISODE_ARRIVALS: + return _lineage("conflict", "episode_malformed", head_sha=head) try: episode = item if isinstance(item, ContributionEpisode) else episode_from_mapping(item) except LineageError: @@ -523,20 +541,18 @@ def resolve_lineage( ) if episode.writer_state not in expected_state: return _lineage("conflict", "writer_state_unverified", head_sha=head) - parsed.append(episode) - - if not parsed: - return resolve_identity_only(opener_lane=opener, label_lanes=labels, head_sha=head) - - ordered = sorted(parsed, key=lambda episode: episode.sequence) - seen: dict[int, ContributionEpisode] = {} - for episode in ordered: previous = seen.get(episode.sequence) if previous is not None: if previous.as_dict() != episode.as_dict(): + # Two records claim the same position and disagree. Which one + # describes the diff is exactly what cannot be guessed. return _lineage("conflict", "episode_duplicated", head_sha=head) continue seen[episode.sequence] = episode + + if not seen: + return resolve_identity_only(opener_lane=opener, label_lanes=labels, head_sha=head) + ordered = [seen[sequence] for sequence in sorted(seen)] if [episode.sequence for episode in ordered] != list(range(1, len(ordered) + 1)): return _lineage("conflict", "episode_unchained", head_sha=head) From c33266362fc3346cc2f7699fdd5d5d8b68a99973 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 15:18:25 -0700 Subject: [PATCH 13/25] Read the whole comment history, and refuse a marker that will not parse Two confirmed P2 findings, both cases where missing evidence was quietly reported as absent evidence. Generated provenance pagination. The job fetched one page of comments and turned any API error into `[]`. The marker that proves a takeover is posted when the takeover happens, so on a long-running pull request it is among the newest comments: reading page one, or substituting an empty list for a failed read, does not fail the job -- it attributes the run to whoever opened the pull request. Both template copies now make the same bounded paginated read the gate already makes, `gh api --paginate --slurp`, under `set -euo pipefail` with no `|| echo []` anywhere. A validation step flattens the slurped pages and refuses on a non-array response, a non-array page, a non-object entry, JSON that will not load whole, or a history past an explicit fifty-page cap. Every refusal exits the step, so no attribution artifact is produced and neither upload step runs. The rendered authority literal, the repository-variable override and the absence of any pull-request-head configuration read are unchanged. Strict trusted marker parsing. The payload regex matches only a complete, object-shaped, terminated marker, and evidence was gathered with it alone. An unterminated or non-object marker was therefore not read as broken -- it was not seen, and a trusted comment announcing lineage reported none. Absence and unreadability are opposite answers: absence admits an independent reviewer on the ordinary single-builder story, unreadability has to stop. Marker presence is now decided first, by a pattern that does not look at the payload, and a trusted comment carrying the marker must yield exactly one complete object payload or raise. Two markers on one comment are ambiguous rather than concatenated, and a marker pushed past the body bound is unreadable rather than absent. Trust is still decided before parsing, so an untrusted author cannot force a stop, and unrelated comments are still ignored. Regressions run the generated recording step itself, with only `gh` replaced: a trusted marker on page two, an empty final page, an exact page boundary, a failed read, a later-page failure, three malformed API shapes, truncated JSON, the page cap, and an empty history that still records the opener. Parsing cases go through both the gate and the labeler entrypoints for malformed JSON, non-object, unterminated, empty, ambiguous, past-the-bound, untrusted-broken, unrelated and mixed histories. Bounded deduplication, current-head selection and every negative binding case are unchanged. Canonical, packaged and vendored copies are byte-identical. Refs #963 Co-Authored-By: Claude Opus 5 --- src/code_mower/builder_lineage.py | 53 +++-- .../workflows/builder-provenance.yml.j2 | 49 ++++- templates/workflows/builder-provenance.yml.j2 | 49 ++++- tests/test_builder_lineage_entrypoints.py | 183 +++++++++++++++++- tests/test_builder_lineage_integration.py | 119 ++++++++++++ tools/builder_lineage.py | 53 +++-- 6 files changed, 471 insertions(+), 35 deletions(-) diff --git a/src/code_mower/builder_lineage.py b/src/code_mower/builder_lineage.py index 206ac5fd..20b12237 100644 --- a/src/code_mower/builder_lineage.py +++ b/src/code_mower/builder_lineage.py @@ -50,6 +50,22 @@ re.DOTALL, ) +#: Marker *presence*, decided without looking at the payload at all. +#: +#: :data:`LINEAGE_MARKER_RE` only matches a complete, object-shaped, properly +#: terminated marker. Looking for published evidence with it alone means an +#: unterminated or non-object marker is not read as broken -- it is not seen, +#: and a trusted comment that announces lineage reports none. Absence and +#: unreadability are opposite answers: one admits an independent reviewer on +#: the ordinary single-builder story, the other must stop. Presence is found +#: first, and the payload is then required to parse. +LINEAGE_MARKER_PRESENT_RE = re.compile( + r"", "")) + + def test_a_non_object_payload_is_unreadable(self): + self._assert_unreadable( + f"" + ) + + def test_malformed_json_is_unreadable(self): + self._assert_unreadable( + f'' + ) + + def test_an_empty_payload_is_unreadable(self): + self._assert_unreadable(f"") + + def test_two_markers_on_one_comment_are_ambiguous(self): + self._assert_unreadable(f"{self._valid()}\n\n{self._valid()}") + + def test_a_valid_marker_beside_a_broken_one_is_still_ambiguous(self): + broken = f"" + self._assert_unreadable(f"{self._valid()}\n\n{broken}") + + def test_a_marker_past_the_body_bound_is_unreadable_not_absent(self): + filler = "x" * builder_lineage.MAX_MARKER_BODY_CHARS + self._assert_unreadable(filler + "\n" + self._valid()) + + def test_an_untrusted_broken_marker_is_not_authoritative(self): + """Trust is decided before parsing, so an outsider cannot force a stop.""" + + body = self._valid().replace("-->", "") + comments = self._trusted(body, author=OUTSIDER) + self.assertEqual(self._gate(comments), ()) + self.assertEqual(self._labeler(comments).episodes, ()) + + def test_unrelated_comments_are_ignored(self): + comments = self._trusted("Looks good to me. Shipping after CI.") + self.assertEqual(self._gate(comments), ()) + + def test_a_mixed_history_stops_on_the_broken_comment(self): + comments = self._trusted(self._valid()) + self._trusted( + self._valid().replace("-->", "") + ) + with self.assertRaises(builder_lineage.LineageError): + self._gate(comments) + + def test_a_valid_history_beside_unrelated_comments_still_resolves(self): + comments = ( + self._trusted("first pass looks reasonable") + + self._trusted(self._valid()) + + self._trusted("thanks!", author=OUTSIDER) + ) + resolved = resolve_builder_lineage( + labels=["builder:codex"], + author="devin-ai-integration[bot]", + config=IDENTITY, + repo=REPO, + pr_number=PR, + branch=BRANCH, + head_sha=TAKEN, + episodes=self._gate(comments), + ) + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.current_writer, "codex") + + class CumulativePublicationAtFullLength(unittest.TestCase): """The longest supported lineage, published the way the producer publishes. diff --git a/tools/builder_lineage.py b/tools/builder_lineage.py index 206ac5fd..20b12237 100644 --- a/tools/builder_lineage.py +++ b/tools/builder_lineage.py @@ -50,6 +50,22 @@ re.DOTALL, ) +#: Marker *presence*, decided without looking at the payload at all. +#: +#: :data:`LINEAGE_MARKER_RE` only matches a complete, object-shaped, properly +#: terminated marker. Looking for published evidence with it alone means an +#: unterminated or non-object marker is not read as broken -- it is not seen, +#: and a trusted comment that announces lineage reports none. Absence and +#: unreadability are opposite answers: one admits an independent reviewer on +#: the ordinary single-builder story, the other must stop. Presence is found +#: first, and the payload is then required to parse. +LINEAGE_MARKER_PRESENT_RE = re.compile( + r"" +def _reject_duplicate_keys(pairs: Sequence[tuple[str, Any]]) -> dict[str, Any]: + """``object_pairs_hook`` that refuses an object naming a key twice. + + ``json.loads`` keeps the last value for a repeated key, so one marker can + carry two answers to the same question -- two ``episodes`` lists, two + ``schema`` values, two ``resulting_head`` shas inside one episode -- and + every reader silently agrees on whichever came last. Which one describes + the diff is exactly what must not be decided by parser order. This applies + at every depth, so a conflicting nested binding is refused too. + """ + + seen: dict[str, Any] = {} + for key, value in pairs: + if key in seen: + raise ValueError(f"duplicate key in published builder lineage: {key}") + seen[key] = value + return seen + + +def _loads_without_duplicate_keys(payload: str) -> Any: + return json.loads(payload, object_pairs_hook=_reject_duplicate_keys) + + def episodes_from_comment_body(body: str) -> tuple[ContributionEpisode, ...]: """Parse lineage markers out of one already trusted comment body. @@ -1047,7 +1070,7 @@ def episodes_from_comment_body(body: str) -> tuple[ContributionEpisode, ...]: # of it: unterminated, not an object, or cut off past the bound. raise LineageError("published builder lineage is unreadable") try: - payload = json.loads(matches[0]) + payload = _loads_without_duplicate_keys(matches[0]) except (ValueError, RecursionError): raise LineageError("published builder lineage is unreadable") from None if not isinstance(payload, Mapping) or payload.get("schema") != SCHEMA: diff --git a/src/code_mower/provider_runners/lineage.py b/src/code_mower/provider_runners/lineage.py index 05c944da..40856b23 100644 --- a/src/code_mower/provider_runners/lineage.py +++ b/src/code_mower/provider_runners/lineage.py @@ -136,7 +136,17 @@ def identity_with_lane_floor(identity: Mapping[str, Any] | None, lane: str) -> M _claim_own_identity(merged_labels, f"builder:{reviewer}", reviewer, "label") for login in LANE_ACCOUNT_FLOOR.get(reviewer, ()): _claim_own_identity(merged_authors, login, reviewer, "account") - return {"enabled": True, "labels": merged_labels, "authors": merged_authors} + # The floor raises the three fields it is responsible for and leaves the + # rest of the deployment's contract intact. Rebuilding the mapping from + # scratch dropped `branch_prefixes` and `require_verified_lineage`, so + # every real wrapper resolved without the configured branch identity it + # was rendered to use -- and a `codex/` branch labelled `builder:claude` + # came back a sole Claude writer, admitting Codex to its own diff. + floored = dict(base) + floored.update( + {"enabled": True, "labels": merged_labels, "authors": merged_authors} + ) + return floored class ReviewerIdentityInvalid(RuntimeError): diff --git a/tests/test_builder_lineage_entrypoints.py b/tests/test_builder_lineage_entrypoints.py index eb928cbb..9ce5d755 100644 --- a/tests/test_builder_lineage_entrypoints.py +++ b/tests/test_builder_lineage_entrypoints.py @@ -697,6 +697,56 @@ def api(method, path, _response=response, **_kwargs): self.assertEqual(code, 0) self.assertEqual(applied, [], "no label may move on an unread history") + def test_the_saas_labeler_mutates_no_label_on_duplicate_key_evidence(self): + """Duplicate keys give one marker two answers; neither may be picked.""" + + marker = builder_lineage.lineage_comment_marker((takeover_episode(),)) + head, _, tail = marker.partition("{") + ambiguous = f'{head}{{"episodes":[],{tail}' + comments = [{"user": {"login": AUTHORITY}, "body": MARKER_BODY + ambiguous}] + applied = [] + + def api(method, path, **_kwargs): + if "/comments" in path: + return comments if "page=1" in path else [] + if re.search(r"/pulls/\d+$", path): + return _pull_request() + if "/commits/" in path: + return [{"number": PR}] + return [] + + root = git_free_tempdir(self, "code-mower-duplicate-keys-") + event = { + "action": "completed", + "check_run": { + "status": "completed", "conclusion": "success", + "name": "greptile review", "app": {"slug": "greptile-apps"}, + "head_sha": TAKEN, "pull_requests": [{"number": PR}], + }, + } + event_path = Path(root) / "event.json" + event_path.write_text(json.dumps(event), encoding="utf-8") + env = { + "GITHUB_EVENT_PATH": str(event_path), + "GITHUB_REPOSITORY": REPO, + "GITHUB_EVENT_NAME": "check_run", + "GREPTILE_LABEL_TOKEN": "t", + "GITHUB_TOKEN": "t", + "CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY, + "CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE": "", + "DRY_RUN": "", + } + with mock.patch.dict(os.environ, env, clear=False), \ + mock.patch.object(labeler, "github_request_with_fallback", api), \ + mock.patch( + "code_mower.audit_labeler_lib.github_request_with_fallback", api), \ + mock.patch.object( + labeler, "_apply_or_log", lambda *a, **k: applied.append(a)), \ + mock.patch("sys.stdout", new_callable=_Capture): + code = labeler.main(["--adapter", "greptile"]) + self.assertEqual(code, 0) + self.assertEqual(applied, [], "ambiguous evidence may move no label") + def test_a_genuinely_empty_page_stays_ordinary(self): def api(method, path, **_kwargs): if "/comments" in path: diff --git a/tests/test_builder_lineage_integration.py b/tests/test_builder_lineage_integration.py index a2002c1e..e59ac97e 100644 --- a/tests/test_builder_lineage_integration.py +++ b/tests/test_builder_lineage_integration.py @@ -17,6 +17,7 @@ import json import sys import unittest +from contextlib import contextmanager from pathlib import Path from types import SimpleNamespace from unittest import mock @@ -871,6 +872,139 @@ def test_the_wrapper_refuses_the_disagreement_rather_than_admitting(self): self.assertFalse(decision["admitted"]) +class TheWrapperCompositionKeepsTheConfiguredBranchContract(unittest.TestCase): + """The floor must raise three fields, not rebuild the contract. + + Every real wrapper resolves through ``identity_with_lane_floor``. It was + reconstructing the mapping from ``enabled``/``labels``/``authors`` alone, + so ``branch_prefixes`` and ``require_verified_lineage`` -- rendered into + the contract for exactly this decision -- never reached the resolver. The + lower-helper branch tests pass an already-resolved identity and so walk + straight past the composition that drops it. These load the configured + contract from the environment and go through the real admission boundary. + """ + + CONFIG = { + "enabled": True, + "labels": {"builder:claude": "claude", "builder:codex": "codex", + "builder:devin": "devin"}, + "authors": {"claude[bot]": "claude", "codex[bot]": "codex", + "devin-ai-integration[bot]": "devin"}, + "branch_prefixes": {"claude/": "claude", "codex/": "codex", + "feature/cx-": "codex"}, + "require_verified_lineage": True, + } + + @contextmanager + def _configured(self): + with mock.patch.dict( + "os.environ", + { + reviewer_lineage.AUTHOR_EXCLUSION_ENV: json.dumps(self.CONFIG), + "CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY, + }, + ): + yield + + def _codex(self, lane, meta, *, comments=()): + from code_mower import codex_audit_pr + + with self._configured(): + return codex_audit_pr._require_independent_review( + lane, REPO, PR, meta, TAKEN, + authorities=(AUTHORITY,), fetch_comments=lambda: list(comments), + ) + + def _claude(self, lane, meta, *, comments=()): + from code_mower import claude_audit_pr + + with self._configured(): + return claude_audit_pr._require_independent_review( + lane, REPO, PR, meta, TAKEN, + authorities=(AUTHORITY,), fetch_comments=lambda: list(comments), + ) + + def _devin(self, meta, author, *, comments=()): + from code_mower import devin_cli_audit_pr + + config = SimpleNamespace(repo=REPO, pr_number=PR, github_token="unused") + with self._configured(): + return devin_cli_audit_pr._require_independent_devin_review( + config, meta, TAKEN, author, fetch_comments=lambda: list(comments), + ) + + DISAGREEING = dict(author="a-human", labels=("builder:claude",), + branch="codex/topic") + + def test_the_codex_wrapper_stops_before_the_provider_runs(self): + with self.assertRaises(RuntimeError) as raised: + self._codex("codex", pr_meta(**self.DISAGREEING)) + self.assertIn("lineage", str(raised.exception).lower()) + + def test_the_claude_wrapper_stops_on_the_same_disagreement(self): + # Asked about `codex`, which the unfixed composition admits outright: + # a sole-Claude answer makes Codex look independent of its own branch. + with self.assertRaises(RuntimeError) as raised: + self._claude("codex", pr_meta(**self.DISAGREEING)) + self.assertNotIn("contributor_not_independent", str(raised.exception)) + + def test_the_devin_wrapper_stops_on_the_same_disagreement(self): + from code_mower import devin_cli_audit_pr + + with self.assertRaises( + (devin_cli_audit_pr.AuthorExcludedError, RuntimeError) + ): + self._devin(pr_meta(**self.DISAGREEING), "a-human") + + def test_a_custom_configured_branch_prefix_is_honoured(self): + with self.assertRaises(RuntimeError): + self._codex( + "codex", + pr_meta(author="a-human", labels=("builder:claude",), + branch="feature/cx-topic"), + ) + + def test_a_matched_branch_and_label_keep_their_intended_behaviour(self): + matched = pr_meta(author="a-human", labels=("builder:claude",), + branch="claude/topic") + decision = self._codex("codex", matched) + self.assertTrue(decision["admitted"]) + with self.assertRaises(RuntimeError) as raised: + self._claude("claude", matched) + self.assertIn("contributor_not_independent", str(raised.exception)) + + def test_a_recorded_cross_lane_takeover_is_accepted_and_excludes_both(self): + comments = [published([takeover_episode()])] + taken = pr_meta(author="devin-ai-integration[bot]", + labels=("builder:codex",), branch=BRANCH) + decision = self._claude("claude", taken, comments=comments) + self.assertTrue(decision["admitted"]) + self.assertEqual(decision["current_writer"], "codex") + for lane in ("codex", "devin"): + with self.subTest(lane=lane): + with self.assertRaises(RuntimeError): + self._claude(lane, taken, comments=comments) + + def test_the_floor_still_refuses_a_conflicting_own_remap(self): + conflicting = dict(self.CONFIG) + conflicting["labels"] = dict(self.CONFIG["labels"]) + conflicting["labels"]["builder:codex"] = "claude" + with mock.patch.dict( + "os.environ", + {reviewer_lineage.AUTHOR_EXCLUSION_ENV: json.dumps(conflicting)}, + ): + with self.assertRaises(reviewer_lineage.ReviewerIdentityInvalid): + reviewer_lineage.identity_with_lane_floor( + reviewer_lineage.load_identity(), "codex" + ) + + def test_the_floor_carries_the_branch_contract_through(self): + floored = reviewer_lineage.identity_with_lane_floor(self.CONFIG, "codex") + self.assertEqual(floored["branch_prefixes"], self.CONFIG["branch_prefixes"]) + self.assertTrue(floored["require_verified_lineage"]) + self.assertEqual(floored["labels"]["builder:codex"], "codex") + + class ATrustedMarkerMustParseOrSaySo(unittest.TestCase): """A broken marker is unreadable evidence, never absent evidence. @@ -969,6 +1103,60 @@ def test_a_mixed_history_stops_on_the_broken_comment(self): with self.assertRaises(builder_lineage.LineageError): self._gate(comments) + def _duplicated(self, key: str, extra: str) -> str: + """The valid marker with ``key`` named a second time.""" + + marker = self._valid() + head, _, tail = marker.partition("{") + return f'{head}{{{json.dumps(key)}:{extra},{tail}' + + def test_a_duplicate_top_level_key_is_unreadable(self): + for key, extra in ( + ("schema", '"code_mower.builderLineage.v1"'), + ("schema", '"something.else"'), + ("episodes", "[]"), + ): + with self.subTest(key=key, extra=extra): + self._assert_unreadable(self._duplicated(key, extra)) + + def test_a_duplicate_key_inside_an_episode_is_unreadable(self): + marker = self._valid() + # Name the episode's own binding twice: two answers to "which head". + forged = marker.replace( + '"resulting_head"', '"resulting_head":"' + "c" * 40 + '","resulting_head"', 1 + ) + self.assertNotEqual(forged, marker) + self._assert_unreadable(forged) + + def test_a_duplicate_nested_identity_key_is_unreadable(self): + marker = self._valid() + forged = marker.replace( + '"destination_lane"', '"destination_lane":"claude","destination_lane"', 1 + ) + self.assertNotEqual(forged, marker) + self._assert_unreadable(forged) + + def test_a_unique_key_payload_still_reads(self): + self.assertEqual(len(self._gate(self._trusted(self._valid()))), 1) + + def test_an_untrusted_duplicate_key_marker_is_not_authoritative(self): + comments = self._trusted(self._duplicated("episodes", "[]"), author=OUTSIDER) + self.assertEqual(self._gate(comments), ()) + self.assertEqual(self._labeler(comments).episodes, ()) + + def test_a_duplicate_key_marker_admits_no_reviewer(self): + """The wrapper boundary: unreadable evidence stops, never admits.""" + + comments = self._trusted(self._duplicated("episodes", "[]")) + with self.assertRaises(RuntimeError) as raised: + from code_mower import claude_audit_pr + + claude_audit_pr._require_independent_review( + "claude", REPO, PR, pr_meta(), TAKEN, + authorities=(AUTHORITY,), fetch_comments=lambda: comments, + ) + self.assertIn("lineage_unreadable", str(raised.exception)) + def test_a_valid_history_beside_unrelated_comments_still_resolves(self): comments = ( self._trusted("first pass looks reasonable") diff --git a/tools/builder_lineage.py b/tools/builder_lineage.py index caab778b..4050fb63 100644 --- a/tools/builder_lineage.py +++ b/tools/builder_lineage.py @@ -1025,6 +1025,29 @@ def lineage_comment_marker(episodes: Sequence[ContributionEpisode]) -> str: return f"" +def _reject_duplicate_keys(pairs: Sequence[tuple[str, Any]]) -> dict[str, Any]: + """``object_pairs_hook`` that refuses an object naming a key twice. + + ``json.loads`` keeps the last value for a repeated key, so one marker can + carry two answers to the same question -- two ``episodes`` lists, two + ``schema`` values, two ``resulting_head`` shas inside one episode -- and + every reader silently agrees on whichever came last. Which one describes + the diff is exactly what must not be decided by parser order. This applies + at every depth, so a conflicting nested binding is refused too. + """ + + seen: dict[str, Any] = {} + for key, value in pairs: + if key in seen: + raise ValueError(f"duplicate key in published builder lineage: {key}") + seen[key] = value + return seen + + +def _loads_without_duplicate_keys(payload: str) -> Any: + return json.loads(payload, object_pairs_hook=_reject_duplicate_keys) + + def episodes_from_comment_body(body: str) -> tuple[ContributionEpisode, ...]: """Parse lineage markers out of one already trusted comment body. @@ -1047,7 +1070,7 @@ def episodes_from_comment_body(body: str) -> tuple[ContributionEpisode, ...]: # of it: unterminated, not an object, or cut off past the bound. raise LineageError("published builder lineage is unreadable") try: - payload = json.loads(matches[0]) + payload = _loads_without_duplicate_keys(matches[0]) except (ValueError, RecursionError): raise LineageError("published builder lineage is unreadable") from None if not isinstance(payload, Mapping) or payload.get("schema") != SCHEMA: From 47a973b4ca30ff357d71a1139d49db6cba0d2718 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 16:18:25 -0700 Subject: [PATCH 18/25] Run the provenance workflow from the base branch, not the proposal The decision-authority list is rendered into the workflow file, which makes the file itself part of the trust boundary. On `pull_request` GitHub runs the workflow as it exists on the proposed revision, so a contributor could edit their own copy of it and name themselves an authority -- and the attribution producer would believe their lineage marker. Removing the checkout last round stopped the job reading the proposal's configuration; it did not make the job's own definition trusted. Both template copies now trigger on `pull_request_target`, which runs the base branch's copy in the base context. The hazard that normally comes with that event -- untrusted code executed under a privileged token -- does not arise and must not be introduced: the job still checks nothing out, installs no dependency of the pull request, reads none of its configuration, and keeps `contents: read` and `pull-requests: read`. The reasoning and the constraint are written into the workflow beside the event, with the GitHub reference, so the next person extending it knows what the read-only permissions are protecting. The bound target is now carried as an environment value the script validates rather than an expression expanded into it. Event data names which pull request to attribute; it never becomes part of a command. The regression renders both copies and asserts the trusted source: the only trigger is `pull_request_target`, permissions are read-only, and no step checks out, builds or reads anything from the proposal. The end-to-end case builds a hostile head -- a proposed copy of this workflow naming an outsider as an authority, and a proposed `code-mower.yml` doing the same -- confirms what that proposal would have supplied if trusted, then runs the real recording step from the base environment and shows the outsider's marker is not believed while the reviewed authority's is. The repository-variable override, which is a repository setting rather than part of any revision, still wins. Refs #963 Co-Authored-By: Claude Opus 5 --- .../workflows/builder-provenance.yml.j2 | 44 ++++- templates/workflows/builder-provenance.yml.j2 | 44 ++++- tests/test_builder_lineage_entrypoints.py | 152 +++++++++++++++++- 3 files changed, 219 insertions(+), 21 deletions(-) diff --git a/src/code_mower/templates/workflows/builder-provenance.yml.j2 b/src/code_mower/templates/workflows/builder-provenance.yml.j2 index 236260a3..3229ae39 100644 --- a/src/code_mower/templates/workflows/builder-provenance.yml.j2 +++ b/src/code_mower/templates/workflows/builder-provenance.yml.j2 @@ -1,7 +1,25 @@ name: Code Mower Builder Provenance on: - pull_request: + # Base-controlled, deliberately. The decision-authority list this job trusts + # is rendered into this file, so the file itself is part of the trust + # boundary: under `pull_request` GitHub runs the workflow *as it exists on + # the proposed revision*, and a contributor could edit their own copy of this + # workflow and name themselves an authority. `pull_request_target` runs the + # definition from the base branch, in the base context -- the reviewed copy, + # and the reviewed authority list with it. + # + # The usual hazard of `pull_request_target` is running untrusted code with a + # privileged token. That does not arise here, and must not be introduced: + # this job never checks the pull request out, never installs its + # dependencies, and never reads its configuration. It installs a pinned + # release of Code Mower and reads GitHub's own metadata for the bound pull + # request. Permissions stay read-only for the same reason -- nothing here + # needs to write, and a base-context token that could would be worth + # attacking. Keep it that way if this workflow is ever extended. + # + # https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target + pull_request_target: types: [opened, edited, synchronize, reopened, ready_for_review] permissions: @@ -18,11 +36,12 @@ env: # request against whoever opened it instead of its verified current writer. # # The list is rendered here, from the configuration this repository reviewed - # and installed, exactly as the gate and the labelers receive it. This job - # runs on `pull_request`, so it must never read the authority list out of the - # proposed head: a contributor who could edit the checked-out configuration - # would otherwise name themselves an authority and have their own lineage - # marker believed. The repository variable still overrides it. + # and installed, exactly as the gate and the labelers receive it -- and, + # because of the event above, from the base copy of this file rather than the + # proposed one. Nothing is read out of the pull request's tree: not its + # configuration, and not its copy of this workflow. The repository variable, + # which is a repository setting and not part of any revision, still overrides + # it. CODE_MOWER_DECISION_AUTHORITIES: __DECISION_AUTHORITIES__ CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE: {% raw %}${{ vars.CODE_MOWER_DECISION_AUTHORITIES || '' }}{% endraw %} @@ -47,10 +66,21 @@ jobs: shell: bash env: GH_TOKEN: {% raw %}${{ github.token }}{% endraw %} + # Carried as an environment value rather than expanded into the + # script, and checked before use: event data names the bound target, + # it never becomes part of a command. + CODE_MOWER_PR_NUMBER: {% raw %}${{ github.event.pull_request.number }}{% endraw %} run: | set -euo pipefail mkdir -p .code-mower/builder-runs - number="{% raw %}${{ github.event.pull_request.number }}{% endraw %}" + number="${CODE_MOWER_PR_NUMBER}" + case "${number}" in + ''|*[!0-9]*) + echo "builder provenance: the event named no numeric pull request;" \ + "refusing to attribute" >&2 + exit 1 + ;; + esac output=".code-mower/builder-runs/pr-${number}.cloud-event.json" comments=".code-mower/pr-comments.json" # The event payload carries the opener, the head and the labels, but diff --git a/templates/workflows/builder-provenance.yml.j2 b/templates/workflows/builder-provenance.yml.j2 index 236260a3..3229ae39 100644 --- a/templates/workflows/builder-provenance.yml.j2 +++ b/templates/workflows/builder-provenance.yml.j2 @@ -1,7 +1,25 @@ name: Code Mower Builder Provenance on: - pull_request: + # Base-controlled, deliberately. The decision-authority list this job trusts + # is rendered into this file, so the file itself is part of the trust + # boundary: under `pull_request` GitHub runs the workflow *as it exists on + # the proposed revision*, and a contributor could edit their own copy of this + # workflow and name themselves an authority. `pull_request_target` runs the + # definition from the base branch, in the base context -- the reviewed copy, + # and the reviewed authority list with it. + # + # The usual hazard of `pull_request_target` is running untrusted code with a + # privileged token. That does not arise here, and must not be introduced: + # this job never checks the pull request out, never installs its + # dependencies, and never reads its configuration. It installs a pinned + # release of Code Mower and reads GitHub's own metadata for the bound pull + # request. Permissions stay read-only for the same reason -- nothing here + # needs to write, and a base-context token that could would be worth + # attacking. Keep it that way if this workflow is ever extended. + # + # https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target + pull_request_target: types: [opened, edited, synchronize, reopened, ready_for_review] permissions: @@ -18,11 +36,12 @@ env: # request against whoever opened it instead of its verified current writer. # # The list is rendered here, from the configuration this repository reviewed - # and installed, exactly as the gate and the labelers receive it. This job - # runs on `pull_request`, so it must never read the authority list out of the - # proposed head: a contributor who could edit the checked-out configuration - # would otherwise name themselves an authority and have their own lineage - # marker believed. The repository variable still overrides it. + # and installed, exactly as the gate and the labelers receive it -- and, + # because of the event above, from the base copy of this file rather than the + # proposed one. Nothing is read out of the pull request's tree: not its + # configuration, and not its copy of this workflow. The repository variable, + # which is a repository setting and not part of any revision, still overrides + # it. CODE_MOWER_DECISION_AUTHORITIES: __DECISION_AUTHORITIES__ CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE: {% raw %}${{ vars.CODE_MOWER_DECISION_AUTHORITIES || '' }}{% endraw %} @@ -47,10 +66,21 @@ jobs: shell: bash env: GH_TOKEN: {% raw %}${{ github.token }}{% endraw %} + # Carried as an environment value rather than expanded into the + # script, and checked before use: event data names the bound target, + # it never becomes part of a command. + CODE_MOWER_PR_NUMBER: {% raw %}${{ github.event.pull_request.number }}{% endraw %} run: | set -euo pipefail mkdir -p .code-mower/builder-runs - number="{% raw %}${{ github.event.pull_request.number }}{% endraw %}" + number="${CODE_MOWER_PR_NUMBER}" + case "${number}" in + ''|*[!0-9]*) + echo "builder provenance: the event named no numeric pull request;" \ + "refusing to attribute" >&2 + exit 1 + ;; + esac output=".code-mower/builder-runs/pr-${number}.cloud-event.json" comments=".code-mower/pr-comments.json" # The event payload carries the opener, the head and the labels, but diff --git a/tests/test_builder_lineage_entrypoints.py b/tests/test_builder_lineage_entrypoints.py index 9ce5d755..543cdfbf 100644 --- a/tests/test_builder_lineage_entrypoints.py +++ b/tests/test_builder_lineage_entrypoints.py @@ -910,16 +910,33 @@ def _step_script(self) -> str: for item in workflow["jobs"]["auto-record"]["steps"] if item.get("id") == "record" ) - return ( - step["run"] - .replace("${{ github.event.pull_request.number }}", str(PR)) - .replace("${{ github.token }}", "unused-in-this-test") + # The PR number now arrives as an environment value the script checks, + # not as an expression expanded into it, so nothing is substituted here. + return step["run"] + + def _workflow_env(self, *, authorities=AUTHORITY, variable="", template=None): + """The job environment GitHub composes from the *base* workflow file. + + The authority list is rendered into the workflow, so this is the whole + point of the base-controlled event: the job's inputs come from this + file, not from the pull request. + """ + + from code_mower import init + + rendered = init._render_workflow_template( + (template or self.TEMPLATE).read_text(encoding="utf-8"), + {"decision_authorities": authorities}, ) + env = dict(yaml.safe_load(rendered).get("env") or {}) + env["CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE"] = variable + env.pop("CODE_MOWER_PACKAGE_SPEC", None) + return {str(key): str(value) for key, value in env.items()} #: The job's own bound: MAX_PAGES pages of history plus one probe. MAX_REQUESTS = 21 - def _run_job(self, *, pages=None, raw=None, fail_from_page=None): + def _run_job(self, *, pages=None, raw=None, fail_from_page=None, job_env=None): """Execute the generated step with a page-aware fake `gh`. The fake answers each page request individually and records it, so the @@ -1006,8 +1023,8 @@ def _run_job(self, *, pages=None, raw=None, fail_from_page=None): "GITHUB_REPOSITORY": REPO, "GITHUB_EVENT_PATH": str(event_path), "GITHUB_OUTPUT": str(root / "github_output"), - "CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY, - "CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE": "", + "CODE_MOWER_PR_NUMBER": str(PR), + **(self._workflow_env() if job_env is None else dict(job_env)), }, capture_output=True, text=True, @@ -1155,6 +1172,127 @@ def test_the_comments_handed_on_are_one_flat_array(self): for entry in written: self.assertIsInstance(entry, dict) + def test_the_workflow_runs_from_the_base_branch_not_the_proposal(self): + """The authority list lives in this file, so the file must be trusted. + + Under `pull_request` GitHub runs the workflow as it exists on the + proposed revision, which would let a contributor edit their own copy + and name themselves an authority. `pull_request_target` runs the base + copy, in the base context. + """ + + for template in ( + ROOT / "templates/workflows/builder-provenance.yml.j2", + ROOT / "src/code_mower/templates/workflows/builder-provenance.yml.j2", + ): + with self.subTest(template=template.name): + from code_mower import init + + rendered = init._render_workflow_template( + template.read_text(encoding="utf-8"), + {"decision_authorities": AUTHORITY}, + ) + workflow = yaml.safe_load(rendered) + triggers = workflow[True] if True in workflow else workflow["on"] + self.assertEqual(list(triggers), ["pull_request_target"]) + self.assertEqual( + workflow["permissions"], + {"contents": "read", "pull-requests": "read"}, + "a base-context token stays read-only", + ) + # The base context is only safe while nothing from the + # proposal is fetched, built or executed. + self.assertNotIn("actions/checkout", rendered) + self.assertNotIn("code-mower.yml", rendered) + for step in workflow["jobs"]["auto-record"]["steps"]: + run = step.get("run", "") + self.assertNotIn("git ", run) + self.assertNotIn("requirements", run) + self.assertIn("pull_request_target", rendered) + + def test_a_proposed_workflow_and_config_cannot_change_who_is_trusted(self): + """The hostile-head case, end to end through real auto-record. + + The proposal carries its own copy of this workflow naming an outsider + as an authority, and its own `code-mower.yml` doing the same. The job + runs from the base copy, so neither is read: the outsider's marker + stays untrusted and the reviewed authority's is believed. + """ + + hostile = git_free_tempdir(self, "code-mower-hostile-head-") + (hostile / "code-mower.yml").write_text( + f"decisions:\n authorities:\n - {OUTSIDER}\n", encoding="utf-8" + ) + proposed = hostile / "builder-provenance.yml.j2" + proposed.write_text( + self.TEMPLATE.read_text(encoding="utf-8").replace( + "__DECISION_AUTHORITIES__", f'"{OUTSIDER}"' + ), + encoding="utf-8", + ) + # What the proposal *would* have supplied, had it been trusted. + self.assertEqual( + self._workflow_env(template=proposed, authorities=OUTSIDER)[ + "CODE_MOWER_DECISION_AUTHORITIES" + ], + OUTSIDER, + ) + + trusted = self._workflow_env(authorities=AUTHORITY) + self.assertEqual(trusted["CODE_MOWER_DECISION_AUTHORITIES"], AUTHORITY) + + outsider_marker = { + "user": {"login": OUTSIDER}, + "body": MARKER_BODY + + builder_lineage.lineage_comment_marker((takeover_episode(),)), + } + completed, artifact = self._run_job( + pages=[[outsider_marker]], job_env=trusted + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertNotEqual( + artifact["dimensions"]["builder_executor"], + "chatgpt-codex-connector", + "a proposed authority may not be believed", + ) + + completed, artifact = self._run_job( + pages=[[marker_comment()]], job_env=trusted + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual( + artifact["dimensions"]["builder_executor"], "chatgpt-codex-connector", + "the reviewed authority is still believed", + ) + + def test_the_repository_variable_still_overrides_the_reviewed_list(self): + """A repository setting is not part of any revision, so it still wins.""" + + env = self._workflow_env(authorities="someone-else", variable=AUTHORITY) + completed, artifact = self._run_job( + pages=[[marker_comment()]], job_env=env + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual( + artifact["dimensions"]["builder_executor"], "chatgpt-codex-connector" + ) + + def test_a_non_numeric_bound_target_refuses(self): + """Event data names the target; it never becomes part of a command.""" + + root = git_free_tempdir(self, "code-mower-bad-number-") + script = root / "record.sh" + script.write_text(self._step_script(), encoding="utf-8") + result = subprocess.run( + ["bash", str(script)], + cwd=str(root), + env={"PATH": os.environ.get("PATH", ""), "HOME": str(root), + "GITHUB_REPOSITORY": REPO, "CODE_MOWER_PR_NUMBER": "12; rm -rf /"}, + capture_output=True, text=True, timeout=60, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("numeric pull request", result.stderr) + def test_an_empty_history_still_records_the_opener(self): """Absence of evidence is not a failure -- only unreadability is.""" From 2c99b1b31740d72f82f9aabe79c986908f3383bf Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 16:38:09 -0700 Subject: [PATCH 19/25] Validate comment records, not just their shape A dict is not a comment. Every authoritative read established only that a page was a list of objects, so a present `body` that was a number or a `user` that was a string could still be stringified into an author or a marker GitHub never sent, or skipped into absence. The shared `require_comment_list` now checks the two fields lineage actually reads, and the lower `github_pr` transport, the SaaS labeler and the generated provenance script all go through it. `audit_labeler_lib.fetch_issue_comments` -- which the trailer labeler's own label decision consumes -- no longer applies `or []` or filters non-dicts away. GitHub's schema is the boundary, not a stricter invention of one: a comment from a deleted account carries `user: null` and `body` is optional, and both stay ordinary. Refs #963 Co-Authored-By: Claude Opus 5 --- src/code_mower/audit_labeler_lib.py | 25 ++- src/code_mower/builder_lineage.py | 31 ++++ src/code_mower/provider_runners/github_pr.py | 21 +-- src/code_mower/saas_reviewer_labeler.py | 19 ++- .../workflows/builder-provenance.yml.j2 | 17 ++ templates/workflows/builder-provenance.yml.j2 | 17 ++ tests/test_builder_lineage_entrypoints.py | 145 ++++++++++++++++++ tools/audit_labeler_lib.py | 25 ++- tools/builder_lineage.py | 31 ++++ 9 files changed, 302 insertions(+), 29 deletions(-) diff --git a/src/code_mower/audit_labeler_lib.py b/src/code_mower/audit_labeler_lib.py index cd10be15..8128443a 100644 --- a/src/code_mower/audit_labeler_lib.py +++ b/src/code_mower/audit_labeler_lib.py @@ -33,6 +33,7 @@ episodes_from_comment_body, branch_lane_from_identity, lanes_from_identity, + require_comment_list, resolve_identity_only, resolve_lineage, ) @@ -46,6 +47,7 @@ episodes_from_comment_body, branch_lane_from_identity, lanes_from_identity, + require_comment_list, resolve_identity_only, resolve_lineage, ) @@ -59,6 +61,7 @@ episodes_from_comment_body, branch_lane_from_identity, lanes_from_identity, + require_comment_list, resolve_identity_only, resolve_lineage, ) @@ -1155,13 +1158,23 @@ def fetch_issue_comments( "GET", f"/repos/{repo}/issues/{issue_number}/comments?per_page=100&page={page}", tokens=tokens, - ) or [] - if not isinstance(chunk, list): - raise RuntimeError("GitHub API issue comments returned a non-list response") - if not chunk: + ) + # Shape and the fields lineage reads are settled before anything else + # looks at the page. `or []` made `None`, `False` and `{}` -- every + # falsey successful response -- end the read, and filtering by + # `isinstance` dropped malformed records silently. Both report "there + # is nothing here" for "this could not be read", and this page feeds + # the trailer labeler's own label decision. + try: + page_comments = require_comment_list( + chunk, what=f"issue comments page {page} for {repo}#{issue_number}" + ) + except LineageError as exc: + raise RuntimeError(str(exc)) from None + if not page_comments: return comments - comments.extend(comment for comment in chunk if isinstance(comment, dict)) - if len(chunk) < 100: + comments.extend(dict(comment) for comment in page_comments) + if len(page_comments) < 100: return comments page += 1 raise IssueCommentPaginationLimitExceeded( diff --git a/src/code_mower/builder_lineage.py b/src/code_mower/builder_lineage.py index 4050fb63..bc36629d 100644 --- a/src/code_mower/builder_lineage.py +++ b/src/code_mower/builder_lineage.py @@ -724,9 +724,40 @@ def require_comment_list(value: Any, *, what: str) -> tuple[Mapping[str, Any], . for item in value: if not isinstance(item, Mapping): raise LineageError(f"{what} contains an entry that is not a comment") + _require_comment_record(item, what=what) return tuple(value) +def _require_comment_record(comment: Mapping[str, Any], *, what: str) -> None: + """The two fields lineage actually reads must be readable, or nothing is. + + Being a dict is not being a comment. The marker lives in ``body`` and trust + is decided from ``user.login``, so a present ``body`` that is a number, or + a ``user`` that is a string, or a ``login`` that is an object, is a record + whose meaning cannot be recovered. Coercing those with ``str()`` invents an + author or a body that GitHub never sent; skipping them drops the record. + Either way an unreadable history becomes an absent one -- and absence is + what admits a reviewer. + + GitHub's own schema is the boundary, not a stricter invention of one: a + comment from a deleted account really does carry ``"user": null``, and + ``body`` really is optional on some representations. Both are accepted and + simply name no author and no marker. + """ + + body = comment.get("body") + if body is not None and not isinstance(body, str): + raise LineageError(f"{what} contains a comment whose body is not text") + user = comment.get("user") + if user is None: + return + if not isinstance(user, Mapping): + raise LineageError(f"{what} contains a comment whose author is not an object") + login = user.get("login") + if login is not None and not isinstance(login, str): + raise LineageError(f"{what} contains a comment whose author login is not text") + + def branch_lane_from_identity( *, identity: Mapping[str, Any] | None, branch: str = "" ) -> str: diff --git a/src/code_mower/provider_runners/github_pr.py b/src/code_mower/provider_runners/github_pr.py index 0e008be0..f7b67631 100644 --- a/src/code_mower/provider_runners/github_pr.py +++ b/src/code_mower/provider_runners/github_pr.py @@ -131,16 +131,19 @@ def fetch_issue_comments( f"/repos/{repo}/issues/{issue_number}/comments?per_page={per_page}&page={page}", token=token, ) - if not isinstance(chunk, list): - raise ValueError( - f"GitHub issue comments page {page} for {repo}#{issue_number} " - f"was not a list" - ) - if any(not isinstance(comment, dict) for comment in chunk): - raise ValueError( - f"GitHub issue comments page {page} for {repo}#{issue_number} " - f"contains an entry that is not a comment" + # The same shared record contract every other authoritative read uses: + # a list of comments whose `body` and `user.login` are readable where + # present. A dict is not a comment -- a numeric body or an object login + # would be stringified into an author or a marker GitHub never sent. + from ..builder_lineage import LineageError, require_comment_list + + try: + require_comment_list( + chunk, + what=f"GitHub issue comments page {page} for {repo}#{issue_number}", ) + except LineageError as exc: + raise ValueError(str(exc)) from None if not chunk: return all_comments all_comments.extend(chunk) diff --git a/src/code_mower/saas_reviewer_labeler.py b/src/code_mower/saas_reviewer_labeler.py index 0b7d8602..cd2519f2 100644 --- a/src/code_mower/saas_reviewer_labeler.py +++ b/src/code_mower/saas_reviewer_labeler.py @@ -33,6 +33,7 @@ lineage_decision_authorities, lineage_marker_author_trust, load_json, + require_comment_list, sha_matches, ) else: @@ -53,6 +54,7 @@ lineage_decision_authorities, lineage_marker_author_trust, load_json, + require_comment_list, sha_matches, ) except ImportError: # pragma: no cover - direct `python tools/foo.py` execution @@ -72,6 +74,7 @@ lineage_decision_authorities, lineage_marker_author_trust, load_json, + require_comment_list, sha_matches, ) @@ -200,15 +203,15 @@ def fetch_issue_comments( f"{path}?per_page=100&page={page}", tokens=tokens, ) - if not isinstance(chunk, list) or any( - not isinstance(item, dict) for item in chunk - ): + # One shared record contract: the page is a list, every entry is a + # comment, and the fields lineage reads -- `body` and `user.login` -- + # are readable where they are present. + try: + require_comment_list(chunk, what=f"comment page {page}") + except LineageError as exc: raise GitHubRequestError( - "GET", - f"{path}?per_page=100&page={page}", - 0, - "comment page is not a list of comment objects", - ) + "GET", f"{path}?per_page=100&page={page}", 0, str(exc) + ) from None if not chunk: return all_comments all_comments.extend(chunk) diff --git a/src/code_mower/templates/workflows/builder-provenance.yml.j2 b/src/code_mower/templates/workflows/builder-provenance.yml.j2 index 3229ae39..4b8efcac 100644 --- a/src/code_mower/templates/workflows/builder-provenance.yml.j2 +++ b/src/code_mower/templates/workflows/builder-provenance.yml.j2 @@ -139,8 +139,25 @@ jobs: if len(payload) > PER_PAGE: refuse(f"page {index} of the comment history is over the page size") for entry in payload: + # A dict is not a comment. The marker lives in `body` and + # trust is decided from `user.login`, so a present field of + # the wrong type is a record whose meaning cannot be + # recovered -- and coercing it invents an author or a body + # GitHub never sent. `user: null` and an absent `body` are + # GitHub's own schema and stay ordinary. if not isinstance(entry, dict): refuse(f"page {index} of the comment history holds a non-object") + body = entry.get("body") + if body is not None and not isinstance(body, str): + refuse(f"page {index} holds a comment whose body is not text") + user = entry.get("user") + if user is None: + continue + if not isinstance(user, dict): + refuse(f"page {index} holds a comment whose author is not an object") + login = user.get("login") + if login is not None and not isinstance(login, str): + refuse(f"page {index} holds a comment whose author login is not text") return payload comments = [] diff --git a/templates/workflows/builder-provenance.yml.j2 b/templates/workflows/builder-provenance.yml.j2 index 3229ae39..4b8efcac 100644 --- a/templates/workflows/builder-provenance.yml.j2 +++ b/templates/workflows/builder-provenance.yml.j2 @@ -139,8 +139,25 @@ jobs: if len(payload) > PER_PAGE: refuse(f"page {index} of the comment history is over the page size") for entry in payload: + # A dict is not a comment. The marker lives in `body` and + # trust is decided from `user.login`, so a present field of + # the wrong type is a record whose meaning cannot be + # recovered -- and coercing it invents an author or a body + # GitHub never sent. `user: null` and an absent `body` are + # GitHub's own schema and stay ordinary. if not isinstance(entry, dict): refuse(f"page {index} of the comment history holds a non-object") + body = entry.get("body") + if body is not None and not isinstance(body, str): + refuse(f"page {index} holds a comment whose body is not text") + user = entry.get("user") + if user is None: + continue + if not isinstance(user, dict): + refuse(f"page {index} holds a comment whose author is not an object") + login = user.get("login") + if login is not None and not isinstance(login, str): + refuse(f"page {index} holds a comment whose author login is not text") return payload comments = [] diff --git a/tests/test_builder_lineage_entrypoints.py b/tests/test_builder_lineage_entrypoints.py index 543cdfbf..5a52a7ff 100644 --- a/tests/test_builder_lineage_entrypoints.py +++ b/tests/test_builder_lineage_entrypoints.py @@ -626,6 +626,33 @@ def test_an_untrusted_marker_is_discarded(self): self.assertNotEqual(payload.get("executor"), "chatgpt-codex-connector") +#: Records that are dicts but whose relevant fields cannot be read. A dict is +#: not a comment: the marker lives in `body` and trust is decided from +#: `user.login`, so a present field of the wrong type would be stringified into +#: an author or a body GitHub never sent. +MALFORMED_COMMENT_RECORDS = ( + {"user": {"login": AUTHORITY}, "body": 12345}, + {"user": {"login": AUTHORITY}, "body": {"text": "hi"}}, + {"user": {"login": AUTHORITY}, "body": ["hi"]}, + {"user": "codemower-ai", "body": "hi"}, + {"user": 7, "body": "hi"}, + {"user": ["codemower-ai"], "body": "hi"}, + {"user": {"login": {"name": AUTHORITY}}, "body": "hi"}, + {"user": {"login": 7}, "body": "hi"}, + {"user": {"login": [AUTHORITY]}, "body": "hi"}, +) + +#: GitHub's own schema, which must keep working: a comment from a deleted +#: account carries `user: null`, and `body` is optional on some +#: representations. Neither names an author or a marker, and neither is an +#: error. +VALID_COMMENT_RECORDS = ( + {"user": None, "body": "a deleted account said this"}, + {"user": {"login": AUTHORITY}}, + {"user": {"login": None}, "body": "hi"}, + {"user": {"login": AUTHORITY}, "body": "ordinary comment"}, +) + INVALID_COMMENT_RESPONSES = ( None, False, @@ -785,6 +812,124 @@ def request(method, path, **_kwargs): return request, seen + def test_the_lowest_transport_refuses_a_malformed_record(self): + from code_mower.provider_runners import github_pr + + for record in MALFORMED_COMMENT_RECORDS: + with self.subTest(record=record): + request, _ = self._gh_pages([record]) + with mock.patch.object(github_pr, "_gh_request", request): + with self.assertRaises(ValueError): + github_pr.fetch_issue_comments(REPO, PR, token="t") + + def test_the_lowest_transport_refuses_a_malformed_record_after_a_valid_page(self): + from code_mower.provider_runners import github_pr + + first = [marker_comment() for _ in range(100)] + for record in MALFORMED_COMMENT_RECORDS: + with self.subTest(record=record): + request, seen = self._gh_pages(first, [record]) + with mock.patch.object(github_pr, "_gh_request", request): + with self.assertRaises(ValueError): + github_pr.fetch_issue_comments(REPO, PR, token="t") + self.assertEqual(seen, [1, 2]) + + def test_the_lowest_transport_keeps_githubs_own_schema(self): + from code_mower.provider_runners import github_pr + + request, _ = self._gh_pages(list(VALID_COMMENT_RECORDS)) + with mock.patch.object(github_pr, "_gh_request", request): + comments = github_pr.fetch_issue_comments(REPO, PR, token="t") + self.assertEqual(len(comments), len(VALID_COMMENT_RECORDS)) + + def test_the_wrapper_launches_nothing_on_a_malformed_record(self): + from code_mower import claude_audit_pr + from code_mower.provider_runners import github_pr + + for record in MALFORMED_COMMENT_RECORDS: + with self.subTest(record=record): + request, _ = self._gh_pages([record]) + with mock.patch.object(github_pr, "_gh_request", request): + with self.assertRaises(RuntimeError) as raised: + claude_audit_pr._require_independent_review( + "claude", REPO, PR, + {"user": {"login": "a-human"}, + "head": {"ref": BRANCH, "sha": TAKEN}, + "labels": []}, + TAKEN, + authorities=(AUTHORITY,), + fetch_comments=lambda: github_pr.fetch_issue_comments( + REPO, PR, token="t" + ), + ) + self.assertIn("lineage_unreadable", str(raised.exception)) + + def test_the_saas_labeler_mutates_no_label_on_a_malformed_record(self): + for record in MALFORMED_COMMENT_RECORDS: + with self.subTest(record=record): + self.assertEqual( + self._labeler_applied(pages=[[record]]), [], + "a malformed record may move no label", + ) + + def test_the_saas_labeler_reads_githubs_own_schema_whole(self): + """Positive control, on valid fixtures only: nothing is dropped.""" + + valid = list(VALID_COMMENT_RECORDS) + [marker_comment()] + + def api(method, path, **_kwargs): + return valid if "page=1" in path else [] + + with mock.patch.object(labeler, "github_request_with_fallback", api): + comments = labeler.fetch_issue_comments(REPO, PR, tokens=(), page_cap=5) + self.assertEqual(len(comments), len(valid)) + # And it still reaches a decision without refusing the run. + self.assertEqual(self._labeler_applied(pages=[valid]), []) + + def _labeler_applied(self, *, pages): + applied = [] + + def api(method, path, **_kwargs): + if "/comments" in path: + index = int(re.search(r"[?&]page=(\d+)", path).group(1)) + return pages[index - 1] if index - 1 < len(pages) else [] + if re.search(r"/pulls/\d+$", path): + return _pull_request() + if "/commits/" in path: + return [{"number": PR}] + return [] + + root = git_free_tempdir(self, "code-mower-record-shape-") + event = { + "action": "completed", + "check_run": { + "status": "completed", "conclusion": "success", + "name": "greptile review", "app": {"slug": "greptile-apps"}, + "head_sha": TAKEN, "pull_requests": [{"number": PR}], + }, + } + event_path = Path(root) / "event.json" + event_path.write_text(json.dumps(event), encoding="utf-8") + env = { + "GITHUB_EVENT_PATH": str(event_path), + "GITHUB_REPOSITORY": REPO, + "GITHUB_EVENT_NAME": "check_run", + "GREPTILE_LABEL_TOKEN": "t", + "GITHUB_TOKEN": "t", + "CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY, + "CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE": "", + "DRY_RUN": "", + } + with mock.patch.dict(os.environ, env, clear=False), \ + mock.patch.object(labeler, "github_request_with_fallback", api), \ + mock.patch( + "code_mower.audit_labeler_lib.github_request_with_fallback", api), \ + mock.patch.object( + labeler, "_apply_or_log", lambda *a, **k: applied.append(a)), \ + mock.patch("sys.stdout", new_callable=_Capture): + self.assertEqual(labeler.main(["--adapter", "greptile"]), 0) + return applied + def test_the_lowest_transport_refuses_every_malformed_page(self): """The wrapper's own `fetch_issue_comments`, not a stubbed return.""" diff --git a/tools/audit_labeler_lib.py b/tools/audit_labeler_lib.py index cd10be15..8128443a 100644 --- a/tools/audit_labeler_lib.py +++ b/tools/audit_labeler_lib.py @@ -33,6 +33,7 @@ episodes_from_comment_body, branch_lane_from_identity, lanes_from_identity, + require_comment_list, resolve_identity_only, resolve_lineage, ) @@ -46,6 +47,7 @@ episodes_from_comment_body, branch_lane_from_identity, lanes_from_identity, + require_comment_list, resolve_identity_only, resolve_lineage, ) @@ -59,6 +61,7 @@ episodes_from_comment_body, branch_lane_from_identity, lanes_from_identity, + require_comment_list, resolve_identity_only, resolve_lineage, ) @@ -1155,13 +1158,23 @@ def fetch_issue_comments( "GET", f"/repos/{repo}/issues/{issue_number}/comments?per_page=100&page={page}", tokens=tokens, - ) or [] - if not isinstance(chunk, list): - raise RuntimeError("GitHub API issue comments returned a non-list response") - if not chunk: + ) + # Shape and the fields lineage reads are settled before anything else + # looks at the page. `or []` made `None`, `False` and `{}` -- every + # falsey successful response -- end the read, and filtering by + # `isinstance` dropped malformed records silently. Both report "there + # is nothing here" for "this could not be read", and this page feeds + # the trailer labeler's own label decision. + try: + page_comments = require_comment_list( + chunk, what=f"issue comments page {page} for {repo}#{issue_number}" + ) + except LineageError as exc: + raise RuntimeError(str(exc)) from None + if not page_comments: return comments - comments.extend(comment for comment in chunk if isinstance(comment, dict)) - if len(chunk) < 100: + comments.extend(dict(comment) for comment in page_comments) + if len(page_comments) < 100: return comments page += 1 raise IssueCommentPaginationLimitExceeded( diff --git a/tools/builder_lineage.py b/tools/builder_lineage.py index 4050fb63..bc36629d 100644 --- a/tools/builder_lineage.py +++ b/tools/builder_lineage.py @@ -724,9 +724,40 @@ def require_comment_list(value: Any, *, what: str) -> tuple[Mapping[str, Any], . for item in value: if not isinstance(item, Mapping): raise LineageError(f"{what} contains an entry that is not a comment") + _require_comment_record(item, what=what) return tuple(value) +def _require_comment_record(comment: Mapping[str, Any], *, what: str) -> None: + """The two fields lineage actually reads must be readable, or nothing is. + + Being a dict is not being a comment. The marker lives in ``body`` and trust + is decided from ``user.login``, so a present ``body`` that is a number, or + a ``user`` that is a string, or a ``login`` that is an object, is a record + whose meaning cannot be recovered. Coercing those with ``str()`` invents an + author or a body that GitHub never sent; skipping them drops the record. + Either way an unreadable history becomes an absent one -- and absence is + what admits a reviewer. + + GitHub's own schema is the boundary, not a stricter invention of one: a + comment from a deleted account really does carry ``"user": null``, and + ``body`` really is optional on some representations. Both are accepted and + simply name no author and no marker. + """ + + body = comment.get("body") + if body is not None and not isinstance(body, str): + raise LineageError(f"{what} contains a comment whose body is not text") + user = comment.get("user") + if user is None: + return + if not isinstance(user, Mapping): + raise LineageError(f"{what} contains a comment whose author is not an object") + login = user.get("login") + if login is not None and not isinstance(login, str): + raise LineageError(f"{what} contains a comment whose author login is not text") + + def branch_lane_from_identity( *, identity: Mapping[str, Any] | None, branch: str = "" ) -> str: From 42b5fcfa2db101e3e294ed02a667040a361b1ec3 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 16:49:38 -0700 Subject: [PATCH 20/25] Validate the raw comment source, and tell absence from a present null Direct auto-record. The CLI normalised its input before validating it: entries that were not objects were skipped and `_text()` ran over whatever was there, so a list-valued body or an object login became a plausible record and the clean tuple handed on afterwards had nothing left to detect. `builder auto-record` still emitted an attribution artifact for input the shared validator rejects. The source actually used -- the pull request's own comments, or an explicitly supplied list -- is now validated at that boundary, before normalisation, and a malformed one exits unreadable with no artifact written. Whichever source is present is the one checked; a malformed one is not silently passed over for the other. Presence is not value. The validator asked `get(...) is not None`, which accepted a `body` or a `login` that was *there* and held null as though the field had been omitted. Omitted optional fields stay ordinary; a field that is present and holds null or the wrong type has lost its meaning and is unreadable. The one meaningful null in GitHub's schema -- a whole author object, for a deleted account -- stays valid, and both author representations are recognised, `user` for REST and `author` for `gh ... --json comments`, so neither transport's records are validated against the other's shape. The corrected contract is applied once, in the shared validator, and reached by the lower direct-wrapper fetch, the SaaS labeler, the trailer labeler's own history read, direct auto-record and the generated provenance job, with the canonical, packaged and vendored mirrors synchronised. The trailer path now treats an unreadable page exactly as it already treats a truncated one: the latest verdict cannot be established, so no label moves. The CLI regression matrix drives `builder auto-record --pr-json --comments-json --output` over raw fixtures: wrong-type and present-null body and login, malformed non-null authors in both representations, a non-list source, a malformed record after valid data, a malformed pull request source that must not be skipped for the other, and -- on valid fixtures only -- GitHub's own schema, the gh author transport, a genuine empty history and no comments source at all. The trailer regressions run the real `main` over the lowest GitHub request and prove no label moves on an unreadable history while a valid one still decides. Refs #963 Co-Authored-By: Claude Opus 5 --- src/code_mower/builder_lineage.py | 36 +++-- src/code_mower/builder_runs.py | 27 +++- .../workflows/builder-provenance.yml.j2 | 25 ++-- src/code_mower/trailer_comment_labeler.py | 11 ++ templates/workflows/builder-provenance.yml.j2 | 25 ++-- tests/test_builder_lineage_entrypoints.py | 141 +++++++++++++++++- tests/test_trailer_lineage_history.py | 107 +++++++++++++ tools/builder_lineage.py | 36 +++-- 8 files changed, 361 insertions(+), 47 deletions(-) create mode 100644 tests/test_trailer_lineage_history.py diff --git a/src/code_mower/builder_lineage.py b/src/code_mower/builder_lineage.py index bc36629d..d565b2d9 100644 --- a/src/code_mower/builder_lineage.py +++ b/src/code_mower/builder_lineage.py @@ -708,6 +708,12 @@ def lanes_from_identity( return _lane(lowered.get(_text(author).lower())), label_lanes +#: The author field, by transport. GitHub REST names the commenter ``user``; +#: ``gh ... --json comments`` names it ``author``. Both are nullable for a +#: deleted account, and whichever one a payload carries is validated. +COMMENT_AUTHOR_FIELDS = ("user", "author") + + def require_comment_list(value: Any, *, what: str) -> tuple[Mapping[str, Any], ...]: """A successful comment read must be a complete list of comment objects. @@ -745,17 +751,27 @@ def _require_comment_record(comment: Mapping[str, Any], *, what: str) -> None: simply name no author and no marker. """ - body = comment.get("body") - if body is not None and not isinstance(body, str): + # Presence and value are different questions. An omitted optional field + # says nothing and is ordinary; a field that is *there* and holds null or + # the wrong type is a record whose meaning cannot be recovered. Only one + # null is meaningful in GitHub's schema -- a whole author object, for a + # comment whose account was deleted -- and that one stays valid. + if "body" in comment and not isinstance(comment["body"], str): raise LineageError(f"{what} contains a comment whose body is not text") - user = comment.get("user") - if user is None: - return - if not isinstance(user, Mapping): - raise LineageError(f"{what} contains a comment whose author is not an object") - login = user.get("login") - if login is not None and not isinstance(login, str): - raise LineageError(f"{what} contains a comment whose author login is not text") + for field in COMMENT_AUTHOR_FIELDS: + if field not in comment: + continue + author = comment[field] + if author is None: + continue + if not isinstance(author, Mapping): + raise LineageError( + f"{what} contains a comment whose author is not an object" + ) + if "login" in author and not isinstance(author["login"], str): + raise LineageError( + f"{what} contains a comment whose author login is not text" + ) def branch_lane_from_identity( diff --git a/src/code_mower/builder_runs.py b/src/code_mower/builder_runs.py index 9e79d63c..f8ecb4c9 100644 --- a/src/code_mower/builder_runs.py +++ b/src/code_mower/builder_runs.py @@ -267,15 +267,30 @@ def _comments_from_pr_payload( caller may hand the list in alongside. Both are normalised to the one shape every lineage reader consumes, so auto-record applies the same marker-trust rule as the gate rather than a transport-specific one. + + The source actually used is validated *before* it is normalised. This is + the direct CLI's own input boundary: normalising first skipped entries that + were not objects and ran `_text()` over whatever was there, so a list-valued + body or an object login became a plausible-looking record, and the + already-clean tuple handed on afterwards had nothing left to detect. An + unreadable comments source is refused here, before any attribution exists. """ - raw = pr.get("comments") - if not isinstance(raw, list): - raw = payload.get("comments") + from .builder_lineage import require_comment_list + + if "comments" in pr: + raw, source = pr["comments"], "pull request comments" + elif "comments" in payload: + raw, source = payload["comments"], "supplied comments" + else: + return () + # A source that is present but null carries no history to read; that is + # the ordinary "this payload has no comments" shape, not a malformed one. + if raw is None: + return () + validated = require_comment_list(raw, what=source) normalised: list[Mapping[str, Any]] = [] - for item in raw or (): - if not isinstance(item, Mapping): - continue + for item in validated: user = _record(item.get("user")) or _record(item.get("author")) or {} normalised.append( {"user": {"login": _text(user.get("login"))}, "body": _text(item.get("body"))} diff --git a/src/code_mower/templates/workflows/builder-provenance.yml.j2 b/src/code_mower/templates/workflows/builder-provenance.yml.j2 index 4b8efcac..52aac790 100644 --- a/src/code_mower/templates/workflows/builder-provenance.yml.j2 +++ b/src/code_mower/templates/workflows/builder-provenance.yml.j2 @@ -145,19 +145,24 @@ jobs: # recovered -- and coercing it invents an author or a body # GitHub never sent. `user: null` and an absent `body` are # GitHub's own schema and stay ordinary. + # Presence and value are different questions: an omitted + # optional field is ordinary, a present one holding null or + # the wrong type is unreadable. The one meaningful null is a + # whole author object, for a deleted account. if not isinstance(entry, dict): refuse(f"page {index} of the comment history holds a non-object") - body = entry.get("body") - if body is not None and not isinstance(body, str): + if "body" in entry and not isinstance(entry["body"], str): refuse(f"page {index} holds a comment whose body is not text") - user = entry.get("user") - if user is None: - continue - if not isinstance(user, dict): - refuse(f"page {index} holds a comment whose author is not an object") - login = user.get("login") - if login is not None and not isinstance(login, str): - refuse(f"page {index} holds a comment whose author login is not text") + for field in ("user", "author"): + if field not in entry: + continue + author = entry[field] + if author is None: + continue + if not isinstance(author, dict): + refuse(f"page {index} holds a comment whose author is not an object") + if "login" in author and not isinstance(author["login"], str): + refuse(f"page {index} holds a comment whose author login is not text") return payload comments = [] diff --git a/src/code_mower/trailer_comment_labeler.py b/src/code_mower/trailer_comment_labeler.py index e6f22a5f..115fee71 100644 --- a/src/code_mower/trailer_comment_labeler.py +++ b/src/code_mower/trailer_comment_labeler.py @@ -425,6 +425,17 @@ def main(argv: Optional[Sequence[str]] = None) -> int: f"warning: {exc}; skipping label update because latest verdict cannot be established", file=sys.stderr, ) + except RuntimeError as exc: + # A successful page that is not a readable comment history is + # the same problem as a truncated one, and is handled the same + # way: the latest verdict cannot be established, so no label + # moves. Reporting it as an absent history would let the run + # label on a history nobody could read. + comment_history_complete = False + print( + f"warning: {exc}; skipping label update because latest verdict cannot be established", + file=sys.stderr, + ) github_actions_workflows = tuple( parse_csv_set(os.environ.get("CODE_MOWER_GITHUB_ACTIONS_WORKFLOWS") or "") diff --git a/templates/workflows/builder-provenance.yml.j2 b/templates/workflows/builder-provenance.yml.j2 index 4b8efcac..52aac790 100644 --- a/templates/workflows/builder-provenance.yml.j2 +++ b/templates/workflows/builder-provenance.yml.j2 @@ -145,19 +145,24 @@ jobs: # recovered -- and coercing it invents an author or a body # GitHub never sent. `user: null` and an absent `body` are # GitHub's own schema and stay ordinary. + # Presence and value are different questions: an omitted + # optional field is ordinary, a present one holding null or + # the wrong type is unreadable. The one meaningful null is a + # whole author object, for a deleted account. if not isinstance(entry, dict): refuse(f"page {index} of the comment history holds a non-object") - body = entry.get("body") - if body is not None and not isinstance(body, str): + if "body" in entry and not isinstance(entry["body"], str): refuse(f"page {index} holds a comment whose body is not text") - user = entry.get("user") - if user is None: - continue - if not isinstance(user, dict): - refuse(f"page {index} holds a comment whose author is not an object") - login = user.get("login") - if login is not None and not isinstance(login, str): - refuse(f"page {index} holds a comment whose author login is not text") + for field in ("user", "author"): + if field not in entry: + continue + author = entry[field] + if author is None: + continue + if not isinstance(author, dict): + refuse(f"page {index} holds a comment whose author is not an object") + if "login" in author and not isinstance(author["login"], str): + refuse(f"page {index} holds a comment whose author login is not text") return payload comments = [] diff --git a/tests/test_builder_lineage_entrypoints.py b/tests/test_builder_lineage_entrypoints.py index 5a52a7ff..f9a91c82 100644 --- a/tests/test_builder_lineage_entrypoints.py +++ b/tests/test_builder_lineage_entrypoints.py @@ -640,6 +640,14 @@ def test_an_untrusted_marker_is_discarded(self): {"user": {"login": {"name": AUTHORITY}}, "body": "hi"}, {"user": {"login": 7}, "body": "hi"}, {"user": {"login": [AUTHORITY]}, "body": "hi"}, + # Present and null is not the same as omitted. A whole author object may + # be null -- the account was deleted -- but a null login inside a present + # author object, or a null body, is a field that lost its value. + {"user": {"login": None}, "body": "hi"}, + {"user": {"login": AUTHORITY}, "body": None}, + {"author": {"login": None}, "body": "hi"}, + {"author": "codemower-ai", "body": "hi"}, + {"author": {"login": 7}, "body": "hi"}, ) #: GitHub's own schema, which must keep working: a comment from a deleted @@ -649,7 +657,10 @@ def test_an_untrusted_marker_is_discarded(self): VALID_COMMENT_RECORDS = ( {"user": None, "body": "a deleted account said this"}, {"user": {"login": AUTHORITY}}, - {"user": {"login": None}, "body": "hi"}, + {"user": {}, "body": "an author object naming nobody"}, + {"body": "a record with no author field at all"}, + {"author": None, "body": "the gh/GraphQL transport, deleted account"}, + {"author": {"login": AUTHORITY}, "body": "the gh/GraphQL transport"}, {"user": {"login": AUTHORITY}, "body": "ordinary comment"}, ) @@ -1029,6 +1040,134 @@ def test_no_provider_is_launched_on_an_invalid_read(self): self.assertIn("lineage_unreadable", str(raised.exception)) +class TheDirectAutoRecordCliValidatesItsRawInput(unittest.TestCase): + """`builder auto-record` normalised before it validated. + + Its own input boundary skipped entries that were not objects and ran + `_text()` over whatever was there, so a list-valued body or an object login + became a plausible record -- and the clean tuple handed on afterwards had + nothing left to detect. The raw source is now checked first, and an + unreadable one produces no attribution artifact at all. + """ + + def _cli(self, *, comments=None, in_pr=False, omit=False): + """Run the real CLI over raw, un-normalised comment fixtures.""" + + from code_mower import builder_runs + + root = git_free_tempdir(self, "code-mower-auto-record-cli-") + pull_request = _pull_request() + pull_request["user"] = {"login": OPENER} + pull_request["labels"] = [{"name": "builder:codex"}] + if in_pr and not omit: + pull_request["comments"] = comments + pr_json = root / "event.json" + pr_json.write_text( + json.dumps({"pull_request": pull_request}), encoding="utf-8" + ) + argv = [ + "auto-record", + "--pr-json", str(pr_json), + "--repo", REPO, + "--output", str(root / "run.json"), + "--force", + "--json", + ] + if not in_pr and not omit: + comments_json = root / "comments.json" + comments_json.write_text(json.dumps(comments), encoding="utf-8") + argv[7:7] = ["--comments-json", str(comments_json)] + env = { + "CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY, + "CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE": "", + } + captured = _Capture() + errors = _Capture() + with mock.patch.dict(os.environ, env, clear=False), \ + mock.patch("sys.stdout", captured), \ + mock.patch("sys.stderr", errors): + code = builder_runs.main(argv) + artifact = root / "run.json" + return ( + code, + captured.text(), + errors.text(), + json.loads(artifact.read_text(encoding="utf-8")) + if artifact.is_file() + else None, + ) + + def _assert_refused(self, code, artifact, errors): + self.assertEqual(code, 1, errors) + self.assertIsNone(artifact, "an unreadable source may leave no artifact") + self.assertIn("comment", errors.lower()) + + def test_a_malformed_record_in_the_supplied_source_leaves_no_artifact(self): + for record in MALFORMED_COMMENT_RECORDS: + with self.subTest(record=record): + code, _, errors, artifact = self._cli(comments=[record]) + self._assert_refused(code, artifact, errors) + + def test_a_malformed_record_in_the_pull_request_source_leaves_no_artifact(self): + for record in MALFORMED_COMMENT_RECORDS: + with self.subTest(record=record): + code, _, errors, artifact = self._cli( + comments=[record], in_pr=True + ) + self._assert_refused(code, artifact, errors) + + def test_a_malformed_record_after_valid_data_leaves_no_artifact(self): + code, _, errors, artifact = self._cli( + comments=[marker_comment(), {"user": {"login": 7}, "body": "hi"}] + ) + self._assert_refused(code, artifact, errors) + + def test_a_non_list_source_leaves_no_artifact(self): + for source in ({}, False, "comments", 7, [marker_comment(), "text"]): + with self.subTest(source=source): + code, _, errors, artifact = self._cli(comments=source) + self._assert_refused(code, artifact, errors) + + def test_a_malformed_pull_request_source_is_not_skipped_for_the_other(self): + """The source actually present is the one validated.""" + + code, _, errors, artifact = self._cli(comments={}, in_pr=True) + self._assert_refused(code, artifact, errors) + + def test_githubs_own_schema_still_records(self): + code, out, errors, artifact = self._cli( + comments=list(VALID_COMMENT_RECORDS) + [marker_comment()] + ) + self.assertEqual(code, 0, errors) + self.assertIsNotNone(artifact) + self.assertEqual( + json.loads(out)["executor"], "chatgpt-codex-connector", + "a valid authority comment is still believed", + ) + + def test_the_gh_author_transport_still_records(self): + marker = { + "author": {"login": AUTHORITY}, + "body": MARKER_BODY + + builder_lineage.lineage_comment_marker((takeover_episode(),)), + } + code, out, errors, artifact = self._cli(comments=[marker], in_pr=True) + self.assertEqual(code, 0, errors) + self.assertIsNotNone(artifact) + self.assertEqual(json.loads(out)["executor"], "chatgpt-codex-connector") + + def test_a_genuinely_empty_history_still_records_the_opener(self): + code, out, errors, artifact = self._cli(comments=[]) + self.assertEqual(code, 0, errors) + self.assertIsNotNone(artifact) + self.assertEqual(json.loads(out)["executor"], "devin") + + def test_no_comments_source_at_all_still_records_the_opener(self): + code, out, errors, artifact = self._cli(omit=True) + self.assertEqual(code, 0, errors) + self.assertEqual(json.loads(out)["executor"], "devin") + + class TheGeneratedJobReadsTheWholeCommentHistory(unittest.TestCase): """Run the workflow's own recording step, with only `gh` replaced. diff --git a/tests/test_trailer_lineage_history.py b/tests/test_trailer_lineage_history.py new file mode 100644 index 00000000..09a9da81 --- /dev/null +++ b/tests/test_trailer_lineage_history.py @@ -0,0 +1,107 @@ +"""The actual trailer labeler route, over the lowest GitHub request. + +``trailer_comment_labeler.main`` fetches the verdict history itself and hands +it to ``lineage_context`` before deciding a label. That fetch used to apply +``or []`` and filter non-dicts away, so a falsey or malformed *successful* +page became an empty history -- and an empty history is an ordinary answer, so +the run went on to mutate a label anyway. These drive the real route and mock +only the lowest request, because validating a value handed straight to a +helper would walk past the normalisation that caused the problem. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from code_mower import audit_labeler_lib as labeler_lib # noqa: E402 +from code_mower import trailer_comment_labeler # noqa: E402 + +from test_trailer_comment_labeler import HEAD_SHA, _event # noqa: E402 + +MALFORMED_TRAILER_HISTORIES = ( + None, + False, + {}, + [{"user": {"login": "codex-audit-bot"}, "body": "hi"}, "not a comment"], + [{"user": {"login": "codex-audit-bot"}, "body": 12345}], + [{"user": {"login": "codex-audit-bot"}, "body": None}], + [{"user": "codex-audit-bot", "body": "hi"}], + [{"user": {"login": {"name": "codex-audit-bot"}}, "body": "hi"}], + [{"user": {"login": None}, "body": "hi"}], +) + + +def _verdict_body() -> str: + return ( + "Codex Audit - PASS\n" + f"Head SHA: `{HEAD_SHA}`\n" + "" + ) + + +def _trailer_main(monkeypatch, tmp_path, api): + """Run the real `main`; `fetch_issue_comments` is deliberately untouched.""" + + monkeypatch.delenv("CODEX_BOT_AUTHORS", raising=False) + event_path = tmp_path / "event.json" + event_path.write_text( + json.dumps(_event("codex-audit-bot", _verdict_body())), encoding="utf-8" + ) + applied: list = [] + monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_path)) + monkeypatch.setenv("GITHUB_REPOSITORY", "owner/repo") + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setattr( + trailer_comment_labeler, + "fetch_pull_request", + lambda *_args, **_kwargs: {"head": {"sha": HEAD_SHA}}, + ) + monkeypatch.setattr(labeler_lib, "github_request_with_fallback", api) + monkeypatch.setattr( + trailer_comment_labeler, + "apply_label_decision", + lambda repo, decision, **_kwargs: applied.append((repo, decision)), + ) + code = trailer_comment_labeler.main(["--lane", "codex"]) + return code, applied + + +@pytest.mark.parametrize("history", MALFORMED_TRAILER_HISTORIES) +def test_main_mutates_no_label_when_the_history_is_unreadable( + history, monkeypatch, tmp_path +) -> None: + _, applied = _trailer_main( + monkeypatch, tmp_path, lambda *_args, **_kwargs: history + ) + assert applied == [], "an unreadable history may move no label" + + +def test_main_reads_githubs_own_comment_schema(monkeypatch, tmp_path) -> None: + """Positive control, valid fixtures only: none of this is an error.""" + + valid = [ + {"user": None, "body": "a deleted account said this"}, + {"user": {"login": "someone"}}, + {"user": {"login": "codex-audit-bot"}, "body": _verdict_body()}, + ] + code, applied = _trailer_main( + monkeypatch, + tmp_path, + lambda method, path, **_kwargs: valid if "page=1" in path else [], + ) + assert code == 0 + assert applied, "a valid history still reaches a label decision" + + +def test_main_still_decides_on_a_genuinely_empty_history( + monkeypatch, tmp_path +) -> None: + code, applied = _trailer_main(monkeypatch, tmp_path, lambda *_a, **_k: []) + assert code == 0 + assert applied, "no comment history is ordinary; the event still decides" diff --git a/tools/builder_lineage.py b/tools/builder_lineage.py index bc36629d..d565b2d9 100644 --- a/tools/builder_lineage.py +++ b/tools/builder_lineage.py @@ -708,6 +708,12 @@ def lanes_from_identity( return _lane(lowered.get(_text(author).lower())), label_lanes +#: The author field, by transport. GitHub REST names the commenter ``user``; +#: ``gh ... --json comments`` names it ``author``. Both are nullable for a +#: deleted account, and whichever one a payload carries is validated. +COMMENT_AUTHOR_FIELDS = ("user", "author") + + def require_comment_list(value: Any, *, what: str) -> tuple[Mapping[str, Any], ...]: """A successful comment read must be a complete list of comment objects. @@ -745,17 +751,27 @@ def _require_comment_record(comment: Mapping[str, Any], *, what: str) -> None: simply name no author and no marker. """ - body = comment.get("body") - if body is not None and not isinstance(body, str): + # Presence and value are different questions. An omitted optional field + # says nothing and is ordinary; a field that is *there* and holds null or + # the wrong type is a record whose meaning cannot be recovered. Only one + # null is meaningful in GitHub's schema -- a whole author object, for a + # comment whose account was deleted -- and that one stays valid. + if "body" in comment and not isinstance(comment["body"], str): raise LineageError(f"{what} contains a comment whose body is not text") - user = comment.get("user") - if user is None: - return - if not isinstance(user, Mapping): - raise LineageError(f"{what} contains a comment whose author is not an object") - login = user.get("login") - if login is not None and not isinstance(login, str): - raise LineageError(f"{what} contains a comment whose author login is not text") + for field in COMMENT_AUTHOR_FIELDS: + if field not in comment: + continue + author = comment[field] + if author is None: + continue + if not isinstance(author, Mapping): + raise LineageError( + f"{what} contains a comment whose author is not an object" + ) + if "login" in author and not isinstance(author["login"], str): + raise LineageError( + f"{what} contains a comment whose author login is not text" + ) def branch_lane_from_identity( From d94827c53f5d5896a039dfe31128af8f3dc5a61d Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 16:58:20 -0700 Subject: [PATCH 21/25] Tell a REST comment count from a comment history A regression I introduced last round. REST states the *number* of comments under `pull_request.comments`; the history is a separate fetch. Selecting "whatever is under that key" therefore handed an integer to the comment-list validator, and every ordinary pull request -- count 0 as readily as count 7 -- exited unreadable with no attribution artifact, including ones carrying a proven takeover. The generated job passes the event payload and its separately fetched history side by side, so it hit this on every run. Source selection is now explicit. A caller that fetched the history and passed it with `--comments-json` has named the source, and nothing beside it may shadow it; the fetched list is kept apart from the payload rather than merged into it, so it can no longer be confused with the metadata it sits next to. With no explicit source, an embedded history is used only when the field actually holds one: an integer is a count, which is metadata rather than a malformed history, and is skipped. Anything else present under that name is the selected history and must be readable -- null, false, an object, a nested list and an invalid record all fail closed, where the previous round wrongly let a present null pass as absence and record an artifact. A field that is not there at all remains the ordinary no-history case. The generated-job regressions now use the event GitHub actually sends, with an integer count beside the fetched history, at counts 0, 1 and 7: with an empty fetched history the opener keeps the run, and with a trusted takeover history the verified current writer takes it. The direct CLI controls cover explicit precedence over both a count and an embedded list, a supplied empty history winning over an embedded takeover, counts alone, the supported gh embedded list, an omitted field, and eight present-but-unreadable histories -- each asserting exit 1 and no artifact. Refs #963 Co-Authored-By: Claude Opus 5 --- src/code_mower/builder_runs.py | 81 ++++++++--- tests/test_builder_lineage_entrypoints.py | 158 +++++++++++++++++++++- 2 files changed, 217 insertions(+), 22 deletions(-) diff --git a/src/code_mower/builder_runs.py b/src/code_mower/builder_runs.py index f8ecb4c9..d090b142 100644 --- a/src/code_mower/builder_runs.py +++ b/src/code_mower/builder_runs.py @@ -225,10 +225,14 @@ def load_pull_request_metadata( path: Path, *, repo: str = "", comments_path: Path | None = None ) -> PullRequestMetadata: payload = _load_json_object(path) - if comments_path is not None: - # A GitHub event payload never carries the pull request's comments, so - # the published lineage arrives as its own authenticated fetch. - payload = {**payload, "comments": _load_json_list(comments_path)} + # A GitHub event payload never carries the pull request's comments, so the + # published lineage arrives as its own authenticated fetch. It is kept + # apart from the payload rather than merged into it: REST states the + # *number* of comments under the same key, and a merged list could no + # longer be told from the metadata it sits beside. + explicit_comments = ( + _load_json_list(comments_path) if comments_path is not None else None + ) pr = _record(payload.get("pull_request")) or payload number = _text(pr.get("number")) or _text(payload.get("number")) url = _text(pr.get("html_url")) or _text(pr.get("url")) @@ -241,7 +245,7 @@ def load_pull_request_metadata( body=_text(pr.get("body")), head_sha=_text((_record(pr.get("head")) or {}).get("sha")), labels=_labels_from_pr_payload(pr), - comments=_comments_from_pr_payload(payload, pr), + comments=_comments_from_pr_payload(payload, pr, explicit=explicit_comments), ) @@ -257,8 +261,42 @@ def _labels_from_pr_payload(pr: Mapping[str, Any]) -> tuple[str, ...]: return tuple(names) -def _comments_from_pr_payload( +#: "This payload carries no comment history at all", which is not the same +#: answer as "it carries one that cannot be read". +_NO_HISTORY = object() + + +def _selected_embedded_history( payload: Mapping[str, Any], pr: Mapping[str, Any] +) -> tuple[Any, str]: + """The embedded comment history, told apart from REST's comment *count*. + + ``gh ... --json comments`` nests the history under the pull request. REST + puts an integer under the same name -- how many comments there are, with + the history fetched separately -- so a count is metadata, not a malformed + history, and must not shadow the fetched list or fail the run. Anything + else present under that name is a selected history and has to be readable: + null, false, an object and a nested list are all unreadable, not absent. + """ + + for container, source in ( + (pr, "the pull request's embedded comment history"), + (payload, "the event payload's embedded comment history"), + ): + if "comments" not in container: + continue + value = container["comments"] + if isinstance(value, int) and not isinstance(value, bool): + continue + return value, source + return _NO_HISTORY, "" + + +def _comments_from_pr_payload( + payload: Mapping[str, Any], + pr: Mapping[str, Any], + *, + explicit: Any = None, ) -> tuple[Mapping[str, Any], ...]: """Pull request comments from whichever transport supplied the payload. @@ -268,26 +306,27 @@ def _comments_from_pr_payload( every lineage reader consumes, so auto-record applies the same marker-trust rule as the gate rather than a transport-specific one. - The source actually used is validated *before* it is normalised. This is - the direct CLI's own input boundary: normalising first skipped entries that - were not objects and ran `_text()` over whatever was there, so a list-valued - body or an object login became a plausible-looking record, and the - already-clean tuple handed on afterwards had nothing left to detect. An - unreadable comments source is refused here, before any attribution exists. + The source actually used is selected explicitly, then validated *before* + it is normalised. This is the direct CLI's own input boundary: normalising + first skipped entries that were not objects and ran `_text()` over whatever + was there, so a list-valued body or an object login became a + plausible-looking record, and the already-clean tuple handed on afterwards + had nothing left to detect. An unreadable comments source is refused here, + before any attribution exists -- and a REST comment *count* is not one. """ from .builder_lineage import require_comment_list - if "comments" in pr: - raw, source = pr["comments"], "pull request comments" - elif "comments" in payload: - raw, source = payload["comments"], "supplied comments" + if explicit is not None: + # The caller fetched the history itself and said so. That is the + # selected source, and no field beside it may shadow it. + raw, source = explicit, "the supplied comment history" else: - return () - # A source that is present but null carries no history to read; that is - # the ordinary "this payload has no comments" shape, not a malformed one. - if raw is None: - return () + raw, source = _selected_embedded_history(payload, pr) + if raw is _NO_HISTORY: + # No history field at all. Ordinary: the run is attributed from + # the pull request's own metadata, as it always was. + return () validated = require_comment_list(raw, what=source) normalised: list[Mapping[str, Any]] = [] for item in validated: diff --git a/tests/test_builder_lineage_entrypoints.py b/tests/test_builder_lineage_entrypoints.py index f9a91c82..e010f3d4 100644 --- a/tests/test_builder_lineage_entrypoints.py +++ b/tests/test_builder_lineage_entrypoints.py @@ -664,6 +664,9 @@ def test_an_untrusted_marker_is_discarded(self): {"user": {"login": AUTHORITY}, "body": "ordinary comment"}, ) +#: "This fixture leaves the field out entirely", which no JSON value can say. +_OMITTED = object() + INVALID_COMMENT_RESPONSES = ( None, False, @@ -1167,6 +1170,114 @@ def test_no_comments_source_at_all_still_records_the_opener(self): self.assertEqual(code, 0, errors) self.assertEqual(json.loads(out)["executor"], "devin") + def _cli_with_embedded(self, embedded, *, explicit=None): + """Put a raw value under the pull request's own `comments` key.""" + + from code_mower import builder_runs + + root = git_free_tempdir(self, "code-mower-source-selection-") + pull_request = _pull_request() + pull_request["user"] = {"login": OPENER} + pull_request["labels"] = [{"name": "builder:codex"}] + if embedded is not _OMITTED: + pull_request["comments"] = embedded + pr_json = root / "event.json" + pr_json.write_text( + json.dumps({"pull_request": pull_request}), encoding="utf-8" + ) + argv = [ + "auto-record", "--pr-json", str(pr_json), "--repo", REPO, + "--output", str(root / "run.json"), "--force", "--json", + ] + if explicit is not None: + comments_json = root / "comments.json" + comments_json.write_text(json.dumps(explicit), encoding="utf-8") + argv[5:5] = ["--comments-json", str(comments_json)] + env = { + "CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY, + "CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE": "", + } + captured, errors = _Capture(), _Capture() + with mock.patch.dict(os.environ, env, clear=False), \ + mock.patch("sys.stdout", captured), \ + mock.patch("sys.stderr", errors): + code = builder_runs.main(argv) + artifact = root / "run.json" + return code, captured.text(), errors.text(), artifact.is_file() + + def test_a_rest_comment_count_is_metadata_not_a_history(self): + for count in (0, 1, 42): + with self.subTest(count=count): + code, out, errors, artifact = self._cli_with_embedded(count) + self.assertEqual(code, 0, errors) + self.assertTrue(artifact) + self.assertEqual(json.loads(out)["executor"], "devin") + + def test_the_supplied_history_wins_over_a_rest_count(self): + code, out, errors, artifact = self._cli_with_embedded( + 7, explicit=[marker_comment()] + ) + self.assertEqual(code, 0, errors) + self.assertTrue(artifact) + self.assertEqual(json.loads(out)["executor"], "chatgpt-codex-connector") + + def test_the_supplied_history_wins_over_an_embedded_list(self): + """Explicit selection is not overridden by what sits beside it.""" + + code, out, errors, artifact = self._cli_with_embedded( + [], explicit=[marker_comment()] + ) + self.assertEqual(code, 0, errors) + self.assertEqual(json.loads(out)["executor"], "chatgpt-codex-connector") + + def test_a_supplied_empty_history_wins_over_an_embedded_takeover(self): + code, out, errors, artifact = self._cli_with_embedded( + [marker_comment()], explicit=[] + ) + self.assertEqual(code, 0, errors) + self.assertEqual(json.loads(out)["executor"], "devin") + + def test_a_supported_embedded_gh_list_is_still_read(self): + marker = { + "author": {"login": AUTHORITY}, + "body": MARKER_BODY + + builder_lineage.lineage_comment_marker((takeover_episode(),)), + } + code, out, errors, artifact = self._cli_with_embedded([marker]) + self.assertEqual(code, 0, errors) + self.assertEqual(json.loads(out)["executor"], "chatgpt-codex-connector") + + def test_an_omitted_history_field_is_ordinary(self): + code, out, errors, artifact = self._cli_with_embedded(_OMITTED) + self.assertEqual(code, 0, errors) + self.assertTrue(artifact) + self.assertEqual(json.loads(out)["executor"], "devin") + + def test_a_present_but_malformed_embedded_history_fails_closed(self): + """Present-and-unreadable is not absent, whatever sits beside it.""" + + for embedded in ( + None, + False, + {}, + "comments", + [[marker_comment()]], + [marker_comment(), "text"], + [{"user": {"login": 7}, "body": "hi"}], + [{"user": {"login": AUTHORITY}, "body": None}], + ): + with self.subTest(embedded=embedded): + code, _, errors, artifact = self._cli_with_embedded(embedded) + self.assertEqual(code, 1, errors) + self.assertFalse(artifact, "no artifact on an unreadable history") + + def test_a_malformed_supplied_history_fails_closed_beside_a_valid_count(self): + code, _, errors, artifact = self._cli_with_embedded( + 3, explicit=[{"user": {"login": 7}, "body": "hi"}] + ) + self.assertEqual(code, 1, errors) + self.assertFalse(artifact) + class TheGeneratedJobReadsTheWholeCommentHistory(unittest.TestCase): """Run the workflow's own recording step, with only `gh` replaced. @@ -1220,7 +1331,15 @@ def _workflow_env(self, *, authorities=AUTHORITY, variable="", template=None): #: The job's own bound: MAX_PAGES pages of history plus one probe. MAX_REQUESTS = 21 - def _run_job(self, *, pages=None, raw=None, fail_from_page=None, job_env=None): + def _run_job( + self, + *, + pages=None, + raw=None, + fail_from_page=None, + job_env=None, + comment_count=0, + ): """Execute the generated step with a page-aware fake `gh`. The fake answers each page request individually and records it, so the @@ -1293,6 +1412,10 @@ def _run_job(self, *, pages=None, raw=None, fail_from_page=None, job_env=None): pull_request = _pull_request() pull_request["user"] = {"login": OPENER} pull_request["labels"] = [{"name": "builder:codex"}] + # What GitHub actually sends: `comments` is the *count*, and the + # history is the separate authenticated fetch this job makes. + pull_request["comments"] = comment_count + pull_request["review_comments"] = 0 event_path.write_text( json.dumps({"pull_request": pull_request}), encoding="utf-8" ) @@ -1577,6 +1700,39 @@ def test_a_non_numeric_bound_target_refuses(self): self.assertNotEqual(result.returncode, 0) self.assertIn("numeric pull request", result.stderr) + def test_a_rest_comment_count_does_not_shadow_the_fetched_history(self): + """What GitHub actually sends: `comments` is a number, not a history. + + The event's nested `comments` field states how many comments there + are; the history is the separate fetch this job makes. Reading that + number as the history refuses ordinary pull requests outright. + """ + + for count in (0, 1, 7): + with self.subTest(count=count, history="empty"): + completed, artifact = self._run_job( + pages=[[]], comment_count=count + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertIsNotNone(artifact, "a valid run must be attributed") + self.assertEqual( + artifact["dimensions"]["builder_executor"], "devin", + "no evidence, so the opener keeps the run", + ) + with self.subTest(count=count, history="takeover"): + completed, artifact = self._run_job( + pages=[[marker_comment()]], comment_count=count + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual( + artifact["dimensions"]["builder_executor"], + "chatgpt-codex-connector", + "the verified current writer takes the run", + ) + self.assertEqual( + artifact["dimensions"]["builder_current_writer"], "codex" + ) + def test_an_empty_history_still_records_the_opener(self): """Absence of evidence is not a failure -- only unreadability is.""" From 45dbc9d5b06808c402d36819915a7244af050abb Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 17:21:29 -0700 Subject: [PATCH 22/25] Validate gate comment pages, refuse an announced empty chain, run on unittest The generated gate read comment pages through the generic paginated flattener, which drops members it cannot use, and then `str(... or "")` over each body, which turns a present non-string into plausible text. A history nobody could read therefore reached the gate looking ordinary and lineage stayed readable. Comment pages are now validated under the shared record contract before anything flattens, filters or stringifies them, in the checked-in gate and both templates; an unreadable history fails the gate with the reason, and nothing downstream sees it. The generic flattener is untouched for timeline events, which are not comments and are never read for lineage. A trusted marker declaring `episodes: []` was returning no episodes, so announced lineage read as ordinary absence -- while the publisher refuses to publish zero episodes, making that marker a claim contradicting itself. The strict parser now refuses it, alone or mixed with valid history. A genuinely empty comment history and unrelated comments are unchanged. The trailer-history regressions are now unittest-native. CI runs `python -m unittest discover -s tests` without pytest, where a pytest-only module is not an error but silently undiscovered, so every case was missing rather than failing. Converted to TestCase/subTest with no pytest import, keeping the actual main -> lower request -> lineage_context -> label route and every malformed and positive case, and the conversion is proved: the module's imports are checked structurally, unittest's own loader discovers all four cases by name, and the suite it discovers is executed and asserted successful. Refs #963 Co-Authored-By: Claude Opus 5 --- .github/workflows/code-mower-gate.yml | 31 ++- src/code_mower/audit_labeler_lib.py | 30 +++ src/code_mower/builder_lineage.py | 7 + .../workflows/code-mower-gate.yml.j2 | 31 ++- templates/workflows/code-mower-gate.yml.j2 | 31 ++- tests/test_release_hygiene.py | 125 +++++++++- tests/test_trailer_lineage_history.py | 218 +++++++++++++----- tools/audit_labeler_lib.py | 30 +++ tools/builder_lineage.py | 7 + 9 files changed, 438 insertions(+), 72 deletions(-) diff --git a/.github/workflows/code-mower-gate.yml b/.github/workflows/code-mower-gate.yml index ce596e94..780d11c3 100644 --- a/.github/workflows/code-mower-gate.yml +++ b/.github/workflows/code-mower-gate.yml @@ -287,6 +287,7 @@ jobs: audit_run_in_flight_detail_for_lane, audit_verdict_newer_than_in_flight, episodes_from_comment_body, + flatten_paginated_comments, flatten_paginated_items, github_actions_comment_attested, latest_current_audit_verdict, @@ -301,6 +302,7 @@ jobs: audit_run_in_flight_detail_for_lane, audit_verdict_newer_than_in_flight, episodes_from_comment_body, + flatten_paginated_comments, flatten_paginated_items, github_actions_comment_attested, latest_current_audit_verdict, @@ -348,7 +350,26 @@ jobs: if isinstance(item, dict) } lanes = [lane for lane in lanes if isinstance(lane, dict)] - comments = flatten_paginated_items(comments_payload) + # Comment pages are validated under the shared record contract + # *before* anything flattens, filters or stringifies them. The + # generic flattener drops members it cannot use -- right for a mixed + # timeline, wrong for a comment history, where a dropped comment is a + # dropped marker -- and the `str(... or "")` below would turn a + # present non-string body into plausible text. A history nobody could + # read would otherwise reach the gate looking ordinary. Timeline + # events keep the generic flattener: they are not comments and are + # never read for lineage. + comment_history_readable = True + comment_history_problem = "" + try: + comments = flatten_paginated_comments(comments_payload) + except Exception as exc: + comment_history_readable = False + comment_history_problem = str(exc) + # The gate is already failing on this. Salvaging the readable + # members would hand records nobody could validate to every other + # consumer below, so nothing downstream sees this history at all. + comments = [] events = flatten_paginated_items(events_payload) audit_runs = audit_runs_payload if isinstance(audit_runs_payload, list) else [] configured_decision_authorities = ( @@ -452,7 +473,7 @@ jobs: if item.strip() } lineage_episodes = [] - lineage_readable = True + lineage_readable = comment_history_readable for comment in comments: comment_body = str(comment.get("body") or "") if LINEAGE_MARKER not in comment_body: @@ -565,7 +586,11 @@ jobs: ) if not lineage_readable: - emit("failure", "Code Mower builder contribution evidence is unreadable") + emit( + "failure", + "Code Mower builder contribution evidence is unreadable" + + (": " + comment_history_problem if comment_history_problem else ""), + ) elif builder_lineage.status == "conflict": emit("failure", "conflicting Code Mower builder identity: " + builder_lineage.owner_action) elif builder_lineage.status == "waiting": diff --git a/src/code_mower/audit_labeler_lib.py b/src/code_mower/audit_labeler_lib.py index 8128443a..5f7436c6 100644 --- a/src/code_mower/audit_labeler_lib.py +++ b/src/code_mower/audit_labeler_lib.py @@ -622,6 +622,36 @@ def flatten_paginated_items(payload: Any) -> list[dict[str, Any]]: return items +def flatten_paginated_comments(payload: Any) -> list[dict[str, Any]]: + """Flatten a paginated *comment* response under the shared record contract. + + :func:`flatten_paginated_items` is for timeline entries and stays as it is: + it drops members it cannot use, which is right for a mixed event stream and + wrong for a comment history. A dropped comment is a dropped marker, and the + marker proving a takeover is in the newest part of the history. So comment + pages are validated here -- before anything flattens, filters or + stringifies them -- and an unreadable page raises rather than shrinking. + + Genuinely empty pages, ``user: null`` and an omitted optional ``body`` are + GitHub's own schema and stay ordinary. + """ + + if not isinstance(payload, list): + raise LineageError("the comment history did not come back as a paginated array") + comments: list[dict[str, Any]] = [] + for index, page in enumerate(payload, start=1): + # A single-object page is the un-paginated shape the generic flattener + # already accepts; it is validated the same way. + members = [page] if isinstance(page, Mapping) else page + comments.extend( + dict(comment) + for comment in require_comment_list( + members, what=f"comment page {index}" + ) + ) + return comments + + def audit_comment_head_sha(body: str) -> str: match = HEAD_SHA_LINE_RE.search(body) return match.group(1) if match else "" diff --git a/src/code_mower/builder_lineage.py b/src/code_mower/builder_lineage.py index d565b2d9..2b28fe12 100644 --- a/src/code_mower/builder_lineage.py +++ b/src/code_mower/builder_lineage.py @@ -1125,4 +1125,11 @@ def episodes_from_comment_body(body: str) -> tuple[ContributionEpisode, ...]: items = payload.get("episodes") if not isinstance(items, list) or len(items) > MAX_EPISODES: raise LineageError("published builder lineage is unreadable") + if not items: + # A marker announces lineage. The publisher refuses to publish zero + # episodes, so a trusted marker carrying an empty chain is not a + # history that happens to be empty -- it is a claim that contradicts + # itself, and reading it as ordinary absence is how announced evidence + # disappears into the single-builder answer. + raise LineageError("published builder lineage declares no episodes") return tuple(episode_from_mapping(item) for item in items) diff --git a/src/code_mower/templates/workflows/code-mower-gate.yml.j2 b/src/code_mower/templates/workflows/code-mower-gate.yml.j2 index df7c711d..02b2e039 100644 --- a/src/code_mower/templates/workflows/code-mower-gate.yml.j2 +++ b/src/code_mower/templates/workflows/code-mower-gate.yml.j2 @@ -286,6 +286,7 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ audit_run_in_flight_detail_for_lane, audit_verdict_newer_than_in_flight, episodes_from_comment_body, + flatten_paginated_comments, flatten_paginated_items, github_actions_comment_attested, latest_current_audit_verdict, @@ -300,6 +301,7 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ audit_run_in_flight_detail_for_lane, audit_verdict_newer_than_in_flight, episodes_from_comment_body, + flatten_paginated_comments, flatten_paginated_items, github_actions_comment_attested, latest_current_audit_verdict, @@ -347,7 +349,26 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ if isinstance(item, dict) } lanes = [lane for lane in lanes if isinstance(lane, dict)] - comments = flatten_paginated_items(comments_payload) + # Comment pages are validated under the shared record contract + # *before* anything flattens, filters or stringifies them. The + # generic flattener drops members it cannot use -- right for a mixed + # timeline, wrong for a comment history, where a dropped comment is a + # dropped marker -- and the `str(... or "")` below would turn a + # present non-string body into plausible text. A history nobody could + # read would otherwise reach the gate looking ordinary. Timeline + # events keep the generic flattener: they are not comments and are + # never read for lineage. + comment_history_readable = True + comment_history_problem = "" + try: + comments = flatten_paginated_comments(comments_payload) + except Exception as exc: + comment_history_readable = False + comment_history_problem = str(exc) + # The gate is already failing on this. Salvaging the readable + # members would hand records nobody could validate to every other + # consumer below, so nothing downstream sees this history at all. + comments = [] events = flatten_paginated_items(events_payload) audit_runs = audit_runs_payload if isinstance(audit_runs_payload, list) else [] configured_decision_authorities = ( @@ -451,7 +472,7 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ if item.strip() } lineage_episodes = [] - lineage_readable = True + lineage_readable = comment_history_readable for comment in comments: comment_body = str(comment.get("body") or "") if LINEAGE_MARKER not in comment_body: @@ -564,7 +585,11 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ ) if not lineage_readable: - emit("failure", "Code Mower builder contribution evidence is unreadable") + emit( + "failure", + "Code Mower builder contribution evidence is unreadable" + + (": " + comment_history_problem if comment_history_problem else ""), + ) elif builder_lineage.status == "conflict": emit("failure", "conflicting Code Mower builder identity: " + builder_lineage.owner_action) elif builder_lineage.status == "waiting": diff --git a/templates/workflows/code-mower-gate.yml.j2 b/templates/workflows/code-mower-gate.yml.j2 index df7c711d..02b2e039 100644 --- a/templates/workflows/code-mower-gate.yml.j2 +++ b/templates/workflows/code-mower-gate.yml.j2 @@ -286,6 +286,7 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ audit_run_in_flight_detail_for_lane, audit_verdict_newer_than_in_flight, episodes_from_comment_body, + flatten_paginated_comments, flatten_paginated_items, github_actions_comment_attested, latest_current_audit_verdict, @@ -300,6 +301,7 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ audit_run_in_flight_detail_for_lane, audit_verdict_newer_than_in_flight, episodes_from_comment_body, + flatten_paginated_comments, flatten_paginated_items, github_actions_comment_attested, latest_current_audit_verdict, @@ -347,7 +349,26 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ if isinstance(item, dict) } lanes = [lane for lane in lanes if isinstance(lane, dict)] - comments = flatten_paginated_items(comments_payload) + # Comment pages are validated under the shared record contract + # *before* anything flattens, filters or stringifies them. The + # generic flattener drops members it cannot use -- right for a mixed + # timeline, wrong for a comment history, where a dropped comment is a + # dropped marker -- and the `str(... or "")` below would turn a + # present non-string body into plausible text. A history nobody could + # read would otherwise reach the gate looking ordinary. Timeline + # events keep the generic flattener: they are not comments and are + # never read for lineage. + comment_history_readable = True + comment_history_problem = "" + try: + comments = flatten_paginated_comments(comments_payload) + except Exception as exc: + comment_history_readable = False + comment_history_problem = str(exc) + # The gate is already failing on this. Salvaging the readable + # members would hand records nobody could validate to every other + # consumer below, so nothing downstream sees this history at all. + comments = [] events = flatten_paginated_items(events_payload) audit_runs = audit_runs_payload if isinstance(audit_runs_payload, list) else [] configured_decision_authorities = ( @@ -451,7 +472,7 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ if item.strip() } lineage_episodes = [] - lineage_readable = True + lineage_readable = comment_history_readable for comment in comments: comment_body = str(comment.get("body") or "") if LINEAGE_MARKER not in comment_body: @@ -564,7 +585,11 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ ) if not lineage_readable: - emit("failure", "Code Mower builder contribution evidence is unreadable") + emit( + "failure", + "Code Mower builder contribution evidence is unreadable" + + (": " + comment_history_problem if comment_history_problem else ""), + ) elif builder_lineage.status == "conflict": emit("failure", "conflicting Code Mower builder identity: " + builder_lineage.owner_action) elif builder_lineage.status == "waiting": diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index 5befc213..c15ea61c 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -101,6 +101,25 @@ def _reported_manifest_identity(manifest_bytes: bytes) -> dict: } +def _gate_takeover_episode(): + """One verified Devin -> Codex handoff, for marker fixtures.""" + + from code_mower import builder_lineage + + return builder_lineage.ContributionEpisode( + sequence=1, + kind=builder_lineage.HANDOFF_KIND, + repo="owner/repo", + pr_number=7, + branch="devin/topic", + source_lane="devin", + destination_lane="codex", + expected_head="a" * 40, + resulting_head="b" * 40, + writer_state="terminated", + ) + + class ReleaseHygieneTests(unittest.TestCase): def test_version_is_current_supervised_pilot_release(self) -> None: self.assertEqual(__version__, "1.4.0") @@ -1676,6 +1695,7 @@ def _run_gate_template_decision( lanes: list[dict[str, str]], labels: set[str], comments: list[dict[str, object]] | None = None, + comment_pages: object = None, events: list[dict[str, object]] | None = None, event_pages: list[list[dict[str, object]]] | None = None, head_sha: str = "a" * 40, @@ -1703,7 +1723,12 @@ def _run_gate_template_decision( json.dump([{"name": label} for label in sorted(labels)], handle) labels_path = handle.name with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle: - json.dump([comments or []], handle) + # `comment_pages` writes the raw paginated payload, so a test can + # hand the gate the shapes GitHub could actually return. + json.dump( + comment_pages if comment_pages is not None else [comments or []], + handle, + ) comments_path = handle.name with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle: json.dump(event_pages if event_pages is not None else [events or []], handle) @@ -1822,6 +1847,104 @@ def fake_github_request( def _bound_actions_body(self, body: str, *, comment_id: int = 1234) -> str: return provider_runners.bind_actions_run_comment_id(body, comment_id) + _GATE_LANES = [ + { + "id": "codex", + "display_name": "Codex", + "done": "codex-audit-done", + "blocked": "codex-audit-blocked", + "author_lane": "codex", + "builder_label": "builder:codex", + "bot_authors": "codex-audit-bot,codex-audit-bot[bot]", + } + ] + + def _gate_over_comment_pages(self, pages): + return self._run_gate_template_decision( + lanes=self._GATE_LANES, + labels={"builder:codex"}, + comment_pages=pages, + owner_login="owner", + ) + + def test_gate_refuses_a_comment_history_it_cannot_read(self) -> None: + """The rendered gate, over raw pages -- not a lower helper. + + The generic flattener drops members it cannot use and `str(... or "")` + turns a present non-string body into plausible text, so an unreadable + history reached the gate looking ordinary and lineage stayed readable. + """ + + marker_author = {"login": "owner"} + unreadable_pages = ( + {"comments": []}, + [{"body": "hi", "user": marker_author}, "not a comment"], + [[[{"body": "hi", "user": marker_author}]]], + [[{"body": 12345, "user": marker_author}]], + [[{"body": None, "user": marker_author}]], + [[{"body": "hi", "user": "owner"}]], + [[{"body": "hi", "user": {"login": 7}}]], + [[{"body": "hi", "user": {"login": None}}]], + ) + for pages in unreadable_pages: + with self.subTest(pages=pages): + result = self._gate_over_comment_pages(pages) + self.assertEqual(result["gate_state"], "failure", result) + self.assertIn( + "builder contribution evidence is unreadable", + result["gate_description"], + ) + + def test_gate_accepts_githubs_own_comment_schema(self) -> None: + """Positive control, valid fixtures only: none of this is unreadable.""" + + pages = [ + [ + {"user": None, "body": "a deleted account said this"}, + {"user": {"login": "someone"}}, + {"user": {"login": "owner"}, "body": "ordinary comment"}, + ], + [], + ] + result = self._gate_over_comment_pages(pages) + self.assertNotIn( + "unreadable", result["gate_description"], result + ) + + def test_gate_accepts_a_genuinely_empty_comment_history(self) -> None: + result = self._gate_over_comment_pages([[]]) + self.assertNotIn("unreadable", result["gate_description"], result) + + def test_gate_refuses_a_trusted_marker_declaring_no_episodes(self) -> None: + """An announced empty chain is not an ordinary absence of lineage.""" + + from code_mower import builder_lineage + + empty_marker = builder_lineage.lineage_comment_marker(()) + valid_marker = builder_lineage.lineage_comment_marker( + (_gate_takeover_episode(),) + ) + alone = [[{"user": {"login": "owner"}, "body": "Lineage\n\n" + empty_marker}]] + mixed = [ + [ + {"user": {"login": "owner"}, "body": "Lineage\n\n" + valid_marker}, + {"user": {"login": "owner"}, "body": "Lineage\n\n" + empty_marker}, + ] + ] + for label, pages in (("alone", alone), ("mixed", mixed)): + with self.subTest(case=label): + result = self._gate_over_comment_pages(pages) + self.assertEqual(result["gate_state"], "failure", result) + self.assertIn( + "builder contribution evidence is unreadable", + result["gate_description"], + ) + + def test_gate_reads_an_ordinary_unrelated_comment_history(self) -> None: + pages = [[{"user": {"login": "someone"}, "body": "looks good"}]] + result = self._gate_over_comment_pages(pages) + self.assertNotIn("unreadable", result["gate_description"], result) + def test_gate_decision_rejects_builder_exclusion_with_no_independent_lane( self, ) -> None: diff --git a/tests/test_trailer_lineage_history.py b/tests/test_trailer_lineage_history.py index 09a9da81..7523248e 100644 --- a/tests/test_trailer_lineage_history.py +++ b/tests/test_trailer_lineage_history.py @@ -7,17 +7,26 @@ the run went on to mutate a label anyway. These drive the real route and mock only the lowest request, because validating a value handed straight to a helper would walk past the normalisation that caused the problem. + +Written against ``unittest`` on purpose: CI runs these through +``python -m unittest discover -s tests``, where pytest is not installed, so a +pytest-only file would be silently undiscovered rather than loudly missing. """ from __future__ import annotations +import ast +import io import json +import os import sys +import tempfile +import unittest from pathlib import Path - -import pytest +from unittest import mock sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) from code_mower import audit_labeler_lib as labeler_lib # noqa: E402 from code_mower import trailer_comment_labeler # noqa: E402 @@ -45,63 +54,148 @@ def _verdict_body() -> str: ) -def _trailer_main(monkeypatch, tmp_path, api): - """Run the real `main`; `fetch_issue_comments` is deliberately untouched.""" - - monkeypatch.delenv("CODEX_BOT_AUTHORS", raising=False) - event_path = tmp_path / "event.json" - event_path.write_text( - json.dumps(_event("codex-audit-bot", _verdict_body())), encoding="utf-8" - ) - applied: list = [] - monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_path)) - monkeypatch.setenv("GITHUB_REPOSITORY", "owner/repo") - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setattr( - trailer_comment_labeler, - "fetch_pull_request", - lambda *_args, **_kwargs: {"head": {"sha": HEAD_SHA}}, - ) - monkeypatch.setattr(labeler_lib, "github_request_with_fallback", api) - monkeypatch.setattr( - trailer_comment_labeler, - "apply_label_decision", - lambda repo, decision, **_kwargs: applied.append((repo, decision)), - ) - code = trailer_comment_labeler.main(["--lane", "codex"]) - return code, applied - - -@pytest.mark.parametrize("history", MALFORMED_TRAILER_HISTORIES) -def test_main_mutates_no_label_when_the_history_is_unreadable( - history, monkeypatch, tmp_path -) -> None: - _, applied = _trailer_main( - monkeypatch, tmp_path, lambda *_args, **_kwargs: history - ) - assert applied == [], "an unreadable history may move no label" - - -def test_main_reads_githubs_own_comment_schema(monkeypatch, tmp_path) -> None: - """Positive control, valid fixtures only: none of this is an error.""" - - valid = [ - {"user": None, "body": "a deleted account said this"}, - {"user": {"login": "someone"}}, - {"user": {"login": "codex-audit-bot"}, "body": _verdict_body()}, - ] - code, applied = _trailer_main( - monkeypatch, - tmp_path, - lambda method, path, **_kwargs: valid if "page=1" in path else [], - ) - assert code == 0 - assert applied, "a valid history still reaches a label decision" - - -def test_main_still_decides_on_a_genuinely_empty_history( - monkeypatch, tmp_path -) -> None: - code, applied = _trailer_main(monkeypatch, tmp_path, lambda *_a, **_k: []) - assert code == 0 - assert applied, "no comment history is ordinary; the event still decides" +class TrailerHistoryMustBeReadable(unittest.TestCase): + """The real `main` -> lower request -> `lineage_context` -> label route.""" + + def _trailer_main(self, api): + """Run the real `main`; `fetch_issue_comments` is deliberately untouched.""" + + applied: list = [] + with tempfile.TemporaryDirectory() as tmp: + event_path = Path(tmp) / "event.json" + event_path.write_text( + json.dumps(_event("codex-audit-bot", _verdict_body())), + encoding="utf-8", + ) + env = { + "GITHUB_EVENT_PATH": str(event_path), + "GITHUB_REPOSITORY": "owner/repo", + "GITHUB_TOKEN": "token", + } + with mock.patch.dict(os.environ, env, clear=False), \ + mock.patch.object( + trailer_comment_labeler, + "fetch_pull_request", + lambda *_a, **_k: {"head": {"sha": HEAD_SHA}}, + ), \ + mock.patch.object( + labeler_lib, "github_request_with_fallback", api + ), \ + mock.patch.object( + trailer_comment_labeler, + "apply_label_decision", + lambda repo, decision, **_k: applied.append((repo, decision)), + ): + os.environ.pop("CODEX_BOT_AUTHORS", None) + code = trailer_comment_labeler.main(["--lane", "codex"]) + return code, applied + + def test_an_unreadable_history_moves_no_label(self): + for history in MALFORMED_TRAILER_HISTORIES: + with self.subTest(history=history): + _, applied = self._trailer_main(lambda *_a, **_k: history) + self.assertEqual( + applied, [], "an unreadable history may move no label" + ) + + def test_githubs_own_comment_schema_still_decides(self): + """Positive control, valid fixtures only: none of this is an error.""" + + valid = [ + {"user": None, "body": "a deleted account said this"}, + {"user": {"login": "someone"}}, + {"user": {"login": "codex-audit-bot"}, "body": _verdict_body()}, + ] + code, applied = self._trailer_main( + lambda method, path, **_k: valid if "page=1" in path else [] + ) + self.assertEqual(code, 0) + self.assertTrue(applied, "a valid history still reaches a label decision") + + def test_a_genuinely_empty_history_still_decides(self): + code, applied = self._trailer_main(lambda *_a, **_k: []) + self.assertEqual(code, 0) + self.assertTrue( + applied, "no comment history is ordinary; the event still decides" + ) + + def test_an_announced_empty_lineage_marker_is_not_absence(self): + """A trusted marker declaring no episodes contradicts itself.""" + + from code_mower import builder_lineage + + marker = builder_lineage.lineage_comment_marker(()) + history = [ + {"user": {"login": "codemower-ai"}, "body": "Lineage\n\n" + marker}, + {"user": {"login": "codex-audit-bot"}, "body": _verdict_body()}, + ] + with mock.patch.dict( + os.environ, {"CODE_MOWER_DECISION_AUTHORITIES": "codemower-ai"}, clear=False + ): + _, applied = self._trailer_main( + lambda method, path, **_k: history if "page=1" in path else [] + ) + self.assertEqual(applied, [], "announced lineage may not read as absence") + + +class TheseCasesRunWithoutPytest(unittest.TestCase): + """CI runs `python -m unittest discover -s tests`, with no pytest installed. + + A pytest-only module is not an error there -- it is silently undiscovered, + which is worse than a failure, because the coverage simply stops existing. + So the discovery CI performs is exercised here with unittest's own loader. + """ + + MODULE = "test_trailer_lineage_history" + + def test_the_module_imports_no_pytest(self): + """Checked structurally: a mention in prose is not a dependency.""" + + tree = ast.parse(Path(__file__).read_text(encoding="utf-8")) + imported: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imported.add(node.module.split(".")[0]) + self.assertNotIn("pytest", imported) + + def test_unittest_discovery_finds_every_case(self): + suite = unittest.TestLoader().discover( + str(Path(__file__).resolve().parent), + pattern=Path(__file__).name, + top_level_dir=str(Path(__file__).resolve().parent), + ) + found = {case.id().rsplit(".", 1)[-1] for case in _flatten(suite)} + for name in ( + "test_an_unreadable_history_moves_no_label", + "test_githubs_own_comment_schema_still_decides", + "test_a_genuinely_empty_history_still_decides", + "test_an_announced_empty_lineage_marker_is_not_absence", + ): + with self.subTest(case=name): + self.assertIn(name, found) + + def test_unittest_executes_the_cases_it_discovers(self): + # Loaded by name rather than by discovery, so this class -- and this + # very test -- stays out of the suite being run. + suite = unittest.TestLoader().loadTestsFromName( + f"{self.MODULE}.{TrailerHistoryMustBeReadable.__name__}" + ) + self.assertGreater(suite.countTestCases(), 0) + result = unittest.TextTestRunner( + stream=io.StringIO(), verbosity=0 + ).run(suite) + self.assertTrue(result.wasSuccessful(), result.errors + result.failures) + + +def _flatten(suite): + for item in suite: + if isinstance(item, unittest.TestSuite): + yield from _flatten(item) + else: + yield item + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tools/audit_labeler_lib.py b/tools/audit_labeler_lib.py index 8128443a..5f7436c6 100644 --- a/tools/audit_labeler_lib.py +++ b/tools/audit_labeler_lib.py @@ -622,6 +622,36 @@ def flatten_paginated_items(payload: Any) -> list[dict[str, Any]]: return items +def flatten_paginated_comments(payload: Any) -> list[dict[str, Any]]: + """Flatten a paginated *comment* response under the shared record contract. + + :func:`flatten_paginated_items` is for timeline entries and stays as it is: + it drops members it cannot use, which is right for a mixed event stream and + wrong for a comment history. A dropped comment is a dropped marker, and the + marker proving a takeover is in the newest part of the history. So comment + pages are validated here -- before anything flattens, filters or + stringifies them -- and an unreadable page raises rather than shrinking. + + Genuinely empty pages, ``user: null`` and an omitted optional ``body`` are + GitHub's own schema and stay ordinary. + """ + + if not isinstance(payload, list): + raise LineageError("the comment history did not come back as a paginated array") + comments: list[dict[str, Any]] = [] + for index, page in enumerate(payload, start=1): + # A single-object page is the un-paginated shape the generic flattener + # already accepts; it is validated the same way. + members = [page] if isinstance(page, Mapping) else page + comments.extend( + dict(comment) + for comment in require_comment_list( + members, what=f"comment page {index}" + ) + ) + return comments + + def audit_comment_head_sha(body: str) -> str: match = HEAD_SHA_LINE_RE.search(body) return match.group(1) if match else "" diff --git a/tools/builder_lineage.py b/tools/builder_lineage.py index d565b2d9..2b28fe12 100644 --- a/tools/builder_lineage.py +++ b/tools/builder_lineage.py @@ -1125,4 +1125,11 @@ def episodes_from_comment_body(body: str) -> tuple[ContributionEpisode, ...]: items = payload.get("episodes") if not isinstance(items, list) or len(items) > MAX_EPISODES: raise LineageError("published builder lineage is unreadable") + if not items: + # A marker announces lineage. The publisher refuses to publish zero + # episodes, so a trusted marker carrying an empty chain is not a + # history that happens to be empty -- it is a claim that contradicts + # itself, and reading it as ordinary absence is how announced evidence + # disappears into the single-builder answer. + raise LineageError("published builder lineage declares no episodes") return tuple(episode_from_mapping(item) for item in items) From 25b96689ab44f6c4c414cfc0844eba9da72e03c8 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 17:24:46 -0700 Subject: [PATCH 23/25] Bind the loop value in the trailer history lambda Ruff B023: the lambda closed over `history` rather than binding it. The call is eager inside the same iteration, so every case already saw the value it was written for -- but a closure that reads a loop variable is one refactor away from all nine cases testing the last one. Bound as a keyword-only default; behavior and every case are unchanged. Refs #963 Co-Authored-By: Claude Opus 5 --- tests/test_trailer_lineage_history.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_trailer_lineage_history.py b/tests/test_trailer_lineage_history.py index 7523248e..f04ee88d 100644 --- a/tests/test_trailer_lineage_history.py +++ b/tests/test_trailer_lineage_history.py @@ -93,7 +93,9 @@ def _trailer_main(self, api): def test_an_unreadable_history_moves_no_label(self): for history in MALFORMED_TRAILER_HISTORIES: with self.subTest(history=history): - _, applied = self._trailer_main(lambda *_a, **_k: history) + _, applied = self._trailer_main( + lambda *_a, _history=history, **_k: _history + ) self.assertEqual( applied, [], "an unreadable history may move no label" ) From d6c6d687fc5b1f741d2f99a54795943712debd07 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 17:30:48 -0700 Subject: [PATCH 24/25] Require every slurped comment page to be an array The gate fetches `gh api --paginate --slurp`, whose shape is a list of pages. `flatten_paginated_comments` wrapped a bare Mapping page as a one-comment page, copying the generic flattener's leniency into a place that cannot afford it: `[{}]`, `[{"comments": []}]` and `[[], {"items": []}]` were reinterpreted as comments with no body and no author, so a response nobody could read looked like an absent history and the gate waited for an audit instead of refusing. Record validation could not recover it, because the wrapper had already made the payload look well formed. Pages are now required to be arrays before any record is looked at, so a bare object, null, false or a string in that position is unreadable. Legitimate slurped shapes are unchanged: an empty payload, empty pages, several pages, and a trailing empty page all stay ordinary. The generic `flatten_paginated_items` is untouched for timeline events. Refs #963 Co-Authored-By: Claude Opus 5 --- src/code_mower/audit_labeler_lib.py | 18 ++++++++++++------ tests/test_release_hygiene.py | 27 +++++++++++++++++++++++++-- tools/audit_labeler_lib.py | 18 ++++++++++++------ 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/code_mower/audit_labeler_lib.py b/src/code_mower/audit_labeler_lib.py index 5f7436c6..30a0427e 100644 --- a/src/code_mower/audit_labeler_lib.py +++ b/src/code_mower/audit_labeler_lib.py @@ -640,14 +640,20 @@ def flatten_paginated_comments(payload: Any) -> list[dict[str, Any]]: raise LineageError("the comment history did not come back as a paginated array") comments: list[dict[str, Any]] = [] for index, page in enumerate(payload, start=1): - # A single-object page is the un-paginated shape the generic flattener - # already accepts; it is validated the same way. - members = [page] if isinstance(page, Mapping) else page + # Every page is an array. The gate fetches `gh api --paginate --slurp`, + # whose shape is a list of pages, so anything else in that position is + # not a page. Accepting a bare object as a one-comment page -- which is + # what the generic flattener does -- reinterprets `{}` or + # `{"comments": []}` as a comment with no body and no author, and a + # response nobody could read then looks like an absent history: the + # gate waits for an audit instead of refusing. Record validation cannot + # recover that, because by then the wrapper has already made the + # payload look well formed. + if not isinstance(page, list): + raise LineageError(f"comment page {index} is not an array of comments") comments.extend( dict(comment) - for comment in require_comment_list( - members, what=f"comment page {index}" - ) + for comment in require_comment_list(page, what=f"comment page {index}") ) return comments diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index c15ea61c..2e246d05 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -1879,6 +1879,16 @@ def test_gate_refuses_a_comment_history_it_cannot_read(self) -> None: unreadable_pages = ( {"comments": []}, [{"body": "hi", "user": marker_author}, "not a comment"], + # A bare object where a page belongs. `gh api --paginate --slurp` + # returns a list of pages, so these are not pages at all -- and + # read as one-comment pages they looked like an absent history, + # leaving the gate waiting for an audit instead of refusing. + [{}], + [{"comments": []}], + [[], {"items": []}], + [None], + [False], + ["page"], [[[{"body": "hi", "user": marker_author}]]], [[{"body": 12345, "user": marker_author}]], [[{"body": None, "user": marker_author}]], @@ -1912,8 +1922,21 @@ def test_gate_accepts_githubs_own_comment_schema(self) -> None: ) def test_gate_accepts_a_genuinely_empty_comment_history(self) -> None: - result = self._gate_over_comment_pages([[]]) - self.assertNotIn("unreadable", result["gate_description"], result) + """Valid slurped shapes stay ordinary, including empty pages.""" + + for pages in ( + [], + [[]], + [[], []], + [ + [{"user": {"login": "someone"}, "body": "first page"}], + [{"user": {"login": "someone"}, "body": "second page"}], + ], + [[{"user": {"login": "someone"}, "body": "only page"}], []], + ): + with self.subTest(pages=pages): + result = self._gate_over_comment_pages(pages) + self.assertNotIn("unreadable", result["gate_description"], result) def test_gate_refuses_a_trusted_marker_declaring_no_episodes(self) -> None: """An announced empty chain is not an ordinary absence of lineage.""" diff --git a/tools/audit_labeler_lib.py b/tools/audit_labeler_lib.py index 5f7436c6..30a0427e 100644 --- a/tools/audit_labeler_lib.py +++ b/tools/audit_labeler_lib.py @@ -640,14 +640,20 @@ def flatten_paginated_comments(payload: Any) -> list[dict[str, Any]]: raise LineageError("the comment history did not come back as a paginated array") comments: list[dict[str, Any]] = [] for index, page in enumerate(payload, start=1): - # A single-object page is the un-paginated shape the generic flattener - # already accepts; it is validated the same way. - members = [page] if isinstance(page, Mapping) else page + # Every page is an array. The gate fetches `gh api --paginate --slurp`, + # whose shape is a list of pages, so anything else in that position is + # not a page. Accepting a bare object as a one-comment page -- which is + # what the generic flattener does -- reinterprets `{}` or + # `{"comments": []}` as a comment with no body and no author, and a + # response nobody could read then looks like an absent history: the + # gate waits for an audit instead of refusing. Record validation cannot + # recover that, because by then the wrapper has already made the + # payload look well formed. + if not isinstance(page, list): + raise LineageError(f"comment page {index} is not an array of comments") comments.extend( dict(comment) - for comment in require_comment_list( - members, what=f"comment page {index}" - ) + for comment in require_comment_list(page, what=f"comment page {index}") ) return comments From 0706c53922f67168dfbb6d2a6493a031a9b0f205 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 14 Sep 2026 18:15:48 -0700 Subject: [PATCH 25/25] Close five lineage admission and label-mutation gaps Account aliases. The own-identity floor composed over raw configured keys, but account names are matched case-insensitively downstream, so `{"Codex[Bot]": "claude"}` survived beside a new canonical entry and which one won came down to insertion order -- an alias could outrank the canonical account and let a lane review its own diff. Account keys are normalised before the floor is applied, two spellings naming different lanes are refused as the one contradiction they are whichever order they were written in, and compatible aliases stay valid. The label floor and the branch contract are unchanged. Publication semantics. Idempotency and readback tested for the expected marker text somewhere in a body. A body holding that text beside a broken marker, or a second trusted comment carrying a different chain, therefore counted as "already published" and the builder label moved on a history consumers would not resolve the same way. Both now parse the complete trusted history under the strict framing rule and resolve it against the repository, pull request, branch and exact head; an unreadable or disagreeing history stops before anything is posted or reconciled. Valid trusted duplicates are still idempotent and an untrusted publisher is still refused. Raw publisher and status transports. Both read `gh pr view --json comments`, and both discarded authoritative history through `or []` and non-mapping filtering before anything could validate it. They now apply the shared record contract to the embedded list before normalising it -- distinct from the gate's slurped array of page arrays and from REST's comment count -- and status surfaces a malformed history as one bounded conflict rather than raising out of a run. `user: null`, an omitted body, a genuine empty list and both author transports are unchanged. Structural requeue. A requeue clears done and blocked, which is a label-state mutation, but both paths -- a missing review id and a failed inline-comment fetch -- mutated first and never parsed lineage at all. Lineage is now admitted once, before either path: a published history that cannot be read, or that does not settle at this head, changes no labels. An identity-only disagreement with no episodes is the ordinary no-lineage case and still requeues. Status branch provenance. The status projection resolved without the configured branch identity, so a `codex/` branch labelled `builder:claude` came back a sole Claude writer and would route a reviewer on it while the gate refused the same pull request. It now resolves under the same contract as the gate and the wrappers. Regressions run the actual consumers: both wrapper composition orders with no provider invoked on a conflict, the publisher over broken and conflicting public histories, the raw delivery transport, both Greptile event paths with unreadable and unresolved controls plus an ordinary requeue, and status through `builder_lineage_for` and `_summarize_pr`. Refs #963 Co-Authored-By: Claude Opus 5 --- src/code_mower/lane_delivery.py | 110 ++++++++-- src/code_mower/lane_status.py | 36 +++- src/code_mower/provider_runners/lineage.py | 51 ++++- src/code_mower/saas_reviewer_labeler.py | 80 +++++++ tests/test_builder_lineage_entrypoints.py | 232 +++++++++++++++++++++ tests/test_builder_lineage_integration.py | 94 +++++++++ tests/test_lane_status_branch_contract.py | 169 +++++++++++++++ 7 files changed, 750 insertions(+), 22 deletions(-) create mode 100644 tests/test_lane_status_branch_contract.py diff --git a/src/code_mower/lane_delivery.py b/src/code_mower/lane_delivery.py index ff607635..c29e62e4 100644 --- a/src/code_mower/lane_delivery.py +++ b/src/code_mower/lane_delivery.py @@ -1269,18 +1269,35 @@ def _gh_comment_bodies(repo: str, number: str) -> tuple[dict[str, Any], ...]: consumer reads is not a publication. """ + from . import builder_lineage + result = subprocess.check_output( ["gh", "pr", "view", number, "--repo", repo, "--json", "comments"], timeout=60, text=True, stderr=subprocess.DEVNULL, ) - payload = json.loads(result).get("comments") or [] + # `gh pr view --json comments` embeds one list under the pull request -- + # not the gate's slurped array of page arrays, and not REST's comment + # count. Whatever it is, it is validated before it is normalised: `or []` + # made a null or object response an empty history, and filtering + # non-objects out dropped records that could have carried the marker. + payload = json.loads(result).get("comments") + if payload is None: + raise LaneDeliveryError( + f"gh pr view returned no comments field for {repo}#{number}" + ) + validated = builder_lineage.require_comment_list( + payload, what=f"pull request comments for {repo}#{number}" + ) return tuple( { - "user": {"login": str(((item.get("author") or {}).get("login")) or "")}, + "user": { + "login": str( + ((item.get("author") or item.get("user") or {}).get("login")) or "" + ) + }, "body": str(item.get("body") or ""), } - for item in payload - if isinstance(item, dict) + for item in validated ) @@ -1357,20 +1374,76 @@ def publish_lineage_evidence( _assert_safe_metadata(json.loads(marker.split(None, 2)[2].rsplit("-->", 1)[0].strip()), path="lineage_marker") - def _readable_marker_present() -> bool: + def _published_episodes() -> tuple: + """Every episode the trusted published history actually carries. + + The expected marker text appearing *somewhere* is not a publication. + A body can hold that substring beside a broken marker, and two trusted + comments can each carry a different chain -- in both cases the history + consumers will read is not the one being published. So the trusted + comments are parsed under the strict framing rule and their episodes + are collected whole; an unreadable one raises rather than being + skipped past. + """ + + collected: list = [] for item in existing_bodies() or (): if isinstance(item, Mapping): login = str(((item.get("user") or {}).get("login")) or "") body = str(item.get("body") or "") else: login, body = "", str(item) - if marker not in body: + if builder_lineage.LINEAGE_MARKER not in body: continue - if trusted_author(login): - return True - return False + if not trusted_author(login): + continue + collected.extend(builder_lineage.episodes_from_comment_body(body)) + return tuple(collected) + + def _published_state() -> tuple[str, str]: + """``(state, detail)`` for the trusted history at this exact head.""" - if _readable_marker_present(): + try: + collected = _published_episodes() + except builder_lineage.LineageError as exc: + return "unreadable", str(exc) + if not collected: + return "absent", "" + resolved = builder_lineage.resolve_lineage( + repo=repo, pr_number=pr_number, branch=branch, head_sha=head_sha, + episodes=collected, opener_lane=opener_lane, label_lanes=label_lanes, + ) + if resolved.status != "resolved": + return "conflicting", resolved.reason + if ( + resolved.current_writer != lineage.current_writer + or resolved.episodes != lineage.episodes + or tuple(resolved.contributors) != tuple(lineage.contributors) + ): + return "conflicting", "published lineage describes a different chain" + return "published", "" + + state, detail = _published_state() + if state == "unreadable": + # Whatever is already public cannot be read. Adding to it would leave + # two histories nobody can reconcile, and moving the label would rest + # on the one that could not be read. + return {"published": False, "duplicate": False, + "reason": "existing_lineage_unreadable", "detail": detail, + "owner_action": ( + "the builder lineage already published on this pull " + "request cannot be read; correct or remove it before " + "publishing again" + )} + if state == "conflicting": + return {"published": False, "duplicate": False, + "reason": "existing_lineage_conflicts", "detail": detail, + "owner_action": ( + "the builder lineage already published on this pull " + "request describes a different chain at this head; " + "reconcile the two before publishing again" + )} + if state == "published": return {"published": False, "duplicate": True, "reason": "already_published"} publish( "Builder contribution lineage for this head, published so the gate and " @@ -1379,13 +1452,18 @@ def _readable_marker_present() -> bool: f"- contributors: {', '.join('`' + lane + '`' for lane in lineage.contributors)}\n" f"- head: `{lineage.head_sha}`\n\n" + marker ) - if not _readable_marker_present(): - # The comment went up under an account the consumers do not trust, so - # the evidence is unreadable to everyone who needs it. Reporting this - # as published would move the builder label onto lineage the gate - # cannot see -- the exact conflict this path prevents. + # The readback asks the same semantic question, not whether the text + # landed: what a consumer now resolves from the trusted history has to be + # the lineage that was just published. + state, detail = _published_state() + if state != "published": + # Either the comment went up under an account the consumers do not + # trust, or what is now public does not resolve to this chain. Either + # way the evidence is not readable as intended by everyone who needs + # it, and reporting it as published would move the builder label onto + # lineage the gate cannot see -- the exact conflict this path prevents. return {"published": False, "duplicate": False, - "reason": "publication_author_untrusted", + "reason": "publication_author_untrusted", "detail": detail, "owner_action": ( "publish builder lineage from a configured decision " "authority, or add the publishing account to them" diff --git a/src/code_mower/lane_status.py b/src/code_mower/lane_status.py index c1744f3c..4deb6825 100644 --- a/src/code_mower/lane_status.py +++ b/src/code_mower/lane_status.py @@ -270,13 +270,28 @@ def _lineage_comments(pr: Mapping[str, Any]) -> tuple[dict[str, Any], ...]: here keeps one marker-trust rule instead of one per transport. """ + from .builder_lineage import require_comment_list + + # Validated before it is normalised, like every other authoritative read: + # `or []` turned a null or object response into an empty history, and + # filtering non-mappings out dropped records that could have carried the + # marker. `gh pr view --json comments` embeds one list -- not the gate's + # slurped array of page arrays, and not REST's comment count -- and an + # unreadable one is surfaced to the caller rather than shrunk to absence. + payload = pr.get("comments") + if payload is None: + return () + validated = require_comment_list(payload, what="pull request comments") return tuple( { - "user": {"login": _text((item.get("author") or {}).get("login"))}, + "user": { + "login": _text( + (item.get("author") or item.get("user") or {}).get("login") + ) + }, "body": _text(item.get("body")), } - for item in (pr.get("comments") or []) - if isinstance(item, Mapping) + for item in validated ) @@ -298,6 +313,7 @@ def builder_lineage_for( author: str, state_dir: Path | None = None, comments: Sequence[Mapping[str, Any]] = (), + raw_comments: Any = None, ) -> dict[str, Any]: """Resolve recorded contribution lineage for one pull request at its head. @@ -319,6 +335,11 @@ def builder_lineage_for( identity = load_identity() authorities = decision_authorities_from_env() try: + # Normalised inside the same guard that already answers for unreadable + # evidence, so a malformed published history reaches the caller as one + # bounded conflict rather than as an exception out of a status run. + if raw_comments is not None: + comments = _lineage_comments({"comments": raw_comments}) episodes = trusted_episodes( repo, pr_number, @@ -355,6 +376,13 @@ def builder_lineage_for( episodes=episodes, opener_lane=opener_lane, label_lanes=label_lanes, + # The same identity contract the gate and the wrappers resolve under. + # Without it this projection would call a `codex/` branch labelled + # `builder:claude` a sole Claude writer and route a reviewer on that, + # while the gate refused the very same pull request. + branch_lane=lineage_module.branch_lane_from_identity( + identity=identity, branch=branch + ), ).as_dict() @@ -397,7 +425,7 @@ def _summarize_pr( head_sha=head_sha, labels=[name for names in labels.values() for name in names], author=_author(pr), - comments=_lineage_comments(pr), + raw_comments=pr.get("comments") or [], ), } diff --git a/src/code_mower/provider_runners/lineage.py b/src/code_mower/provider_runners/lineage.py index 40856b23..ba8a576d 100644 --- a/src/code_mower/provider_runners/lineage.py +++ b/src/code_mower/provider_runners/lineage.py @@ -122,7 +122,16 @@ def identity_with_lane_floor(identity: Mapping[str, Any] | None, lane: str) -> M labels = base.get("labels") authors = base.get("authors") merged_labels = dict(labels) if isinstance(labels, Mapping) else {} - merged_authors = dict(authors) if isinstance(authors, Mapping) else {} + # Account keys are matched case-insensitively downstream, so the floor has + # to compose over the *same* keys resolution will use. Flooring the raw key + # left `{"Codex[Bot]": "claude"}` untouched beside a new `codex[bot]` + # entry, and which of the two survived normalisation came down to + # insertion order -- an alias could quietly outrank the canonical account + # and let a lane review its own diff. Normalising first also makes two + # aliases that disagree visible as the contradiction they are. + merged_authors = _normalized_account_map( + authors if isinstance(authors, Mapping) else {} + ) if reviewer: # A floor, not a default. ``setdefault`` leaves a present-but-useless # mapping alone -- `{"builder:codex": ""}` keeps naming no lane -- and @@ -135,7 +144,9 @@ def identity_with_lane_floor(identity: Mapping[str, Any] | None, lane: str) -> M # believes something about its own identity that is not true. _claim_own_identity(merged_labels, f"builder:{reviewer}", reviewer, "label") for login in LANE_ACCOUNT_FLOOR.get(reviewer, ()): - _claim_own_identity(merged_authors, login, reviewer, "account") + _claim_own_identity( + merged_authors, _account_key(login), reviewer, "account" + ) # The floor raises the three fields it is responsible for and leaves the # rest of the deployment's contract intact. Rebuilding the mapping from # scratch dropped `branch_prefixes` and `require_verified_lineage`, so @@ -153,6 +164,42 @@ class ReviewerIdentityInvalid(RuntimeError): """The deployment's identity contract misnames the reviewer's own lane.""" +def _account_key(login: Any) -> str: + """The form account lookups actually use: trimmed and case-folded.""" + + return str(login or "").strip().lower() + + +def _normalized_account_map(authors: Mapping[str, Any]) -> dict: + """Account map keyed the way resolution reads it, aliases reconciled. + + Two spellings of one account are compatible when they name the same lane + and a contradiction when they do not -- and a contradiction has to be the + same contradiction whichever order the deployment wrote them in. Values + keep whatever the contract said; only the key is normalised. + """ + + normalized: dict = {} + for raw_key, value in authors.items(): + key = _account_key(raw_key) + if not key: + continue + if key in normalized: + first = str(normalized[key]).strip().lower() + second = str(value).strip().lower() + if first != second: + raise ReviewerIdentityInvalid( + f"reviewer_identity_invalid: the configured accounts name " + f"`{key}` as both `{first or 'nothing'}` and " + f"`{second or 'nothing'}`. Account names are matched " + f"case-insensitively, so these are one account with two " + f"answers; correct CODE_MOWER_AUTHOR_EXCLUSION_JSON." + ) + continue + normalized[key] = value + return normalized + + def _claim_own_identity( mapping: dict, key: str, reviewer: str, kind: str ) -> None: diff --git a/src/code_mower/saas_reviewer_labeler.py b/src/code_mower/saas_reviewer_labeler.py index cd2519f2..7e824c64 100644 --- a/src/code_mower/saas_reviewer_labeler.py +++ b/src/code_mower/saas_reviewer_labeler.py @@ -32,8 +32,10 @@ lineage_context, lineage_decision_authorities, lineage_marker_author_trust, + load_author_exclusion_config, load_json, require_comment_list, + resolve_builder_lineage, sha_matches, ) else: @@ -53,8 +55,10 @@ lineage_context, lineage_decision_authorities, lineage_marker_author_trust, + load_author_exclusion_config, load_json, require_comment_list, + resolve_builder_lineage, sha_matches, ) except ImportError: # pragma: no cover - direct `python tools/foo.py` execution @@ -73,8 +77,10 @@ lineage_context, lineage_decision_authorities, lineage_marker_author_trust, + load_author_exclusion_config, load_json, require_comment_list, + resolve_builder_lineage, sha_matches, ) @@ -255,6 +261,64 @@ def fetch_lineage_comments( ) +def structural_lineage_refusal( + *, + repo: str, + pr_number: int, + branch: str, + head_sha: Optional[str], + comments: Optional[Sequence[Mapping[str, Any]]], + labels: Sequence[str], + author: str, +) -> str: + """Why a structural requeue must not touch labels, or ``""`` when it may. + + A requeue removes the done and blocked labels. That is a label-state + mutation, so it answers to the same published-lineage contract a verdict + does: evidence nobody can read, or evidence that disagrees with itself at + this head, is not a basis for changing which lane is believed. Deciding + this *before* either requeue path is the point -- clearing the labels first + and resolving afterwards leaves the pull request already changed. + """ + + try: + lineage = lineage_context( + repo=repo, + pr_number=pr_number, + branch=branch, + head_sha=head_sha, + comments=comments, + trusted_author=lineage_marker_author_trust( + authorities=lineage_decision_authorities() + ), + ) + except LineageError: + return "published builder lineage is unreadable; leaving labels unchanged" + resolved = resolve_builder_lineage( + labels=list(labels), + author=author, + config=load_author_exclusion_config(), + repo=lineage.repo, + pr_number=lineage.pr_number, + branch=lineage.branch, + head_sha=lineage.head_sha, + episodes=lineage.episodes, + ) + if lineage.episodes and resolved.status != "resolved": + # Only a *published history* blocks the requeue, and only when it does + # not settle at this head: a chain that conflicts, or that describes + # some other head, is not a basis for deciding which lane is believed. + # An identity-only disagreement -- an opener and a label naming + # different lanes, with no episodes at all -- is the ordinary + # no-lineage case every other route already handles, and refusing it + # here would stop requeues that have nothing to do with lineage. + return ( + f"builder lineage is unresolved ({resolved.reason}); " + f"leaving labels unchanged" + ) + return "" + + def has_same_head_review( reviews: list[dict[str, Any]], *, @@ -887,6 +951,22 @@ def main(argv: Optional[Sequence[str]] = None) -> int: print(f"skip: could not fetch published builder lineage: {exc}") return 0 if adapter.requires_review_comments: + # Both requeue paths below mutate labels without consulting a + # verdict, so lineage is parsed and admitted here -- once, before + # either of them can clear done or blocked on evidence nobody + # could read. + structural_refusal = structural_lineage_refusal( + repo=repo, + pr_number=pr_number, + branch=head_branch, + head_sha=current_head_sha, + comments=lineage_comments, + labels=pr_labels, + author=pr_author, + ) + if structural_refusal: + print(f"skip: {structural_refusal}") + return 0 review = event.get("review") or {} review_id = review.get("id") if not review_id: diff --git a/tests/test_builder_lineage_entrypoints.py b/tests/test_builder_lineage_entrypoints.py index e010f3d4..20c28a80 100644 --- a/tests/test_builder_lineage_entrypoints.py +++ b/tests/test_builder_lineage_entrypoints.py @@ -177,6 +177,238 @@ def test_an_unreadable_readback_blocks_the_label(self): self.assertEqual(gh.label_commands, [], "no label may be reconciled") +class PublicationIsDecidedSemantically(unittest.TestCase): + """The expected text being present is not a verified publication. + + Idempotency and readback used a substring test, so a body holding that + text beside a broken marker -- or a second trusted comment carrying a + different chain -- counted as "already published", and the builder label + moved on a history consumers would not resolve the same way. + """ + + def _publish(self, comments, *, publish_as=AUTHORITY): + posted: list[str] = [] + existing = list(comments) + + def record(body): + posted.append(body) + existing.append({"user": {"login": publish_as}, "body": body}) + + result = lane_delivery.publish_lineage_evidence( + repo=REPO, pr_number=str(PR), branch=BRANCH, head_sha=TAKEN, + episodes=(takeover_episode(),), + opener_lane="devin", label_lanes=("codex",), + existing_bodies=lambda: list(existing), + publish=record, + trusted_author=reviewer_lineage.marker_author_trust((AUTHORITY,)), + ) + return result, posted + + def _valid_marker(self): + return MARKER_BODY + builder_lineage.lineage_comment_marker( + (takeover_episode(),) + ) + + def test_a_valid_trusted_duplicate_is_still_idempotent(self): + result, posted = self._publish( + [{"user": {"login": AUTHORITY}, "body": self._valid_marker()}] + ) + self.assertTrue(result["duplicate"]) + self.assertEqual(posted, []) + + def test_the_expected_text_beside_a_broken_marker_is_not_a_publication(self): + broken = self._valid_marker() + "\n\n