"""Plane-1 Tier-3 fixer — confirmed finding -> fix patch -> CI draft-PR dispatch. Design refs: ``docs/r720-agent-team-design.md`` §4 (Tier-3 fixer row), §6.2, §7 Phase 5, §3.3.2 (the untrusted-patch trust boundary the fixer feeds). WHAT IT DOES ============ The fixer takes a **CONFIRMED, low-risk Plane-1 finding** (the NARROWEST class: a ``dependency-cve`` vulnerable-pin finding), produces a fix **spec** (Claude, via the §3.1 billing seam) and a minimal fix **patch** (DeepSeek ``fast_coder``, via the local orchestrator ``run.py`` — exactly the path :mod:`agent_team.nodes.builders_llm` uses), records the candidate diff + its content-hash, and emits the **dispatch request** for the org CI apply/verify workflow (``ci/agent-team-apply-verify.yml``, the gate-passed P3-live surface). The box stays READ-ONLY (D2/D11): it emits a patch + a dispatch request as DATA. CI applies the patch and opens a DRAFT PR; the box NEVER applies the patch to the local tree, NEVER pushes, NEVER holds a standing write token, and NEVER merges. ============================ INERT / OPT-IN / FAIL-SAFE ==================== This module mirrors the opt-in/inert discipline of :func:`agent_team.coordinator.build_verify_wiring` / :func:`agent_team.coordinator.gated_build_verify_wiring`: it is **not** wired into the default ``run-team.py`` ``serve`` path, and produces a fix plan WITHOUT dispatching unless a live dispatcher is explicitly bound. * **No live dispatch by default.** :func:`plan_fix` produces a :class:`FixPlan` (spec + patch + dispatch inputs) as DATA and dispatches NOTHING. Dispatch happens only through :func:`dispatch_fix` with an explicitly-supplied :data:`WorkflowDispatcher` callable — there is no default dispatcher (the default is ``None`` -> raise), so an un-wired call can never fire a workflow. * **No local mutation.** The patch is produced as diff TEXT via a subprocess that only ASKS the model for the diff. There is NO ``git apply``/``patch``/ ``git``/``write_text``/``open(..., "w")`` path anywhere in this module — by construction the fixer cannot mutate the working tree or push. * **Fail-safe.** A finding that is not a confirmed, unambiguous dependency-cve fix (wrong check/status/category, missing package/fixed_version, an ambiguous or multi-manifest scope, an unparseable/empty model diff, or a diff that strays outside the declared single-manifest scope or touches the trust-control surface) yields a FAILED :class:`FixPlan` (``ok is False``, no patch) — never a fabricated fix and never a dispatch. UNTRUSTED-PATCH HYGIENE (§3.3.2) ================================ A generated patch is UNTRUSTED model output. The fixer does NOT trust it: it flows through the SAME P3-live boundary the builders feed — ``expected_diff_hash`` binding (boundary #3), the declared-scope confinement + trust-control denylist (boundary #2), and the credential-less apply/draft-PR CI (boundaries #1/#4/#5). The fixer's job is to (a) confine ``declared_scope`` to the single dependency manifest the finding names, and (b) reject — box-side, before any dispatch — any diff that escapes that scope or hits the denylist, so the CI guard's identical check is defense-in-depth, not the only line. This module imports the committed contracts verbatim and redefines none of them. """ from __future__ import annotations from collections.abc import Callable, Mapping from dataclasses import dataclass, field from typing import Any from agent_team.billing import BillingMode, claude_invoke from agent_team.ci_gate import denylist_violations from agent_team.nodes.builders_llm import BuildCallable, default_build from agent_team.state_store import compute_content_hash __all__ = [ "FixDispatchInputs", "FixPlan", "SpecCallable", "WorkflowDispatcher", "describe_plan", "dispatch_fix", "is_fixable_dependency_finding", "plan_fix", ] # The CI workflow_dispatch input contract reused VERBATIM from the gate-passed # P3-live surface (ci/agent-team-apply-verify.yml `on.workflow_dispatch.inputs`). # The artifact named by ``diff_artifact_name`` MUST contain a file # ``candidate.diff`` whose sha256 equals ``expected_diff_hash`` (the guard job # re-hashes it). The trusted apply path (which owns the GitHub App write token) # uploads the artifact + dispatches; the box only EMITS these inputs (it has no # write token, D2). CANDIDATE_DIFF_FILENAME = "candidate.diff" # A dependency-cve finding's identifying contract (security-review/checkers/ # dependency-cve.sh add_finding). We bind the fixer to EXACTLY this class — the # narrowest, lowest-risk fix per the design — and refuse anything else. _FIX_CHECK = "vulnerable-dependency" _FIX_STATUS = "confirmed" _FIX_CATEGORY = "other" # The single manifest file the fixer is allowed to scope a fix to. We start with # PyPI/requirements.txt ONLY (the narrowest manifest: one ``name==version`` pin # per line). Other ecosystems (npm lockfiles, NuGet, poetry lockfiles) carry # resolved transitive trees / integrity hashes a one-line bump cannot safely # edit, so the fixer scopes to this file and they stay human-routed. _PYPI_MANIFEST = "requirements.txt" @dataclass class FixDispatchInputs: """The exact ``workflow_dispatch`` inputs for ci/agent-team-apply-verify.yml. Field names match the workflow's ``on.workflow_dispatch.inputs`` keys 1:1 so a dispatcher can pass :meth:`as_inputs` straight through. ``declared_scope`` is newline-separated globs (the workflow splits on newlines); the fixer confines it to the single manifest the finding names so the CI guard's boundary-2 scope check confines the apply to exactly that file. """ task_id: str diff_artifact_name: str expected_diff_hash: str declared_scope: str def as_inputs(self) -> dict[str, str]: """Return the inputs as the workflow's string->string dispatch map.""" return { "task_id": self.task_id, "diff_artifact_name": self.diff_artifact_name, "expected_diff_hash": self.expected_diff_hash, "declared_scope": self.declared_scope, } @dataclass class FixPlan: """A fix proposed as DATA — spec + patch + dispatch inputs (never applied here). This is what :func:`plan_fix` returns and what a dry-run prints. On success it carries everything CI needs (the diff text to upload as ``candidate.diff``, its content-hash, and the dispatch inputs); on failure it is a NO-OP marked ``ok is False`` with a ``reason`` and no patch — never a fabricated fix. Attributes: ok: ``True`` only for a confirmed dependency-cve finding that produced a valid, in-scope, denylist-clean one-line bump diff. reason: Human-readable explanation when not ``ok`` (empty on success). finding_id: The source finding's id (provenance; echoed even on failure). spec: The Claude-authored fix spec (the intent / what to change). diff: The candidate unified diff (empty on failure). diff_hash: sha256 of ``diff`` via :func:`agent_team.state_store.compute_content_hash` (always computed, including over the empty diff, so the record shape is uniform). manifest_path: The single dependency manifest the fix is scoped to. dispatch: The CI ``workflow_dispatch`` inputs (``None`` on failure). declared_scope: The canonical scope prefixes (the single manifest). """ ok: bool reason: str = "" finding_id: str = "" spec: str = "" diff: str = "" diff_hash: str = "" manifest_path: str = "" dispatch: FixDispatchInputs | None = None declared_scope: list[str] = field(default_factory=list) @classmethod def failed(cls, reason: str, *, finding_id: str = "") -> "FixPlan": """Build a FAILED no-op plan (no patch, no dispatch) the caller rejects.""" return cls( ok=False, reason=reason, finding_id=finding_id, diff="", diff_hash=compute_content_hash(b""), dispatch=None, ) # The injectable spec seam: given the finding, return the Claude-authored fix # spec text. Default (:func:`_default_spec`) routes to the §3.1 billing seam # (Claude). Tests pass a fake. Kept separate from the patch (DeepSeek) seam so # the design's "spec (Claude) + patch (DeepSeek)" split is explicit. SpecCallable = Callable[[Mapping[str, Any]], str] # The injectable workflow dispatcher: given the dispatch inputs + the candidate # diff content, fire the org CI ``workflow_dispatch`` and return an opaque # dispatch ref. The box has NO write token, so there is deliberately NO default — # :func:`dispatch_fix` requires an explicitly-bound dispatcher (provisioning # supplies the trusted apply path's dispatcher). An un-wired call can therefore # never fire a workflow. WorkflowDispatcher = Callable[["FixDispatchInputs", str], str] def is_fixable_dependency_finding(finding: Any) -> tuple[bool, str]: """Return ``(fixable, reason)`` for a candidate Plane-1 finding (fail-safe). A finding is fixable by THIS module only when it is an unambiguous, confirmed ``dependency-cve`` vulnerable-pin finding whose proof carries the package, the vulnerable version, and a single concrete ``fixed_version`` to bump to. Any deviation (wrong shape/check/status/category, missing fields, an ambiguous fixed version) returns ``(False, why)`` so the caller refuses rather than guessing. This is a pure predicate (no I/O). """ if not isinstance(finding, Mapping): return False, f"finding must be a mapping, got {type(finding).__name__}" check = finding.get("check") if check != _FIX_CHECK: return False, f"not a dependency-cve finding (check={check!r})" status = finding.get("status") if status != _FIX_STATUS: return False, f"finding is not confirmed (status={status!r})" category = finding.get("category") if category != _FIX_CATEGORY: return False, f"unexpected finding category {category!r}" proof = finding.get("proof") if not isinstance(proof, Mapping): return False, "finding has no structured proof block" package = proof.get("package") version = proof.get("version") fixed = proof.get("fixed_version") if not _nonempty_str(package): return False, "proof.package is missing/empty" if not _nonempty_str(version): return False, "proof.version is missing/empty" if not _nonempty_str(fixed): return False, "proof.fixed_version is missing/empty (nothing to bump to)" # An ambiguous fixed version (a range/list, not one concrete pin) is NOT a # safe one-line bump — refuse it (the OSV checker emits a single fixed event, # but a comma/space/range here means we cannot construct ``pkg==fixed``). if any(c in str(fixed) for c in (" ", ",", "<", ">", "*")): return False, f"ambiguous fixed_version {fixed!r}; not a single concrete pin" # Same defensive parse on the vulnerable version: a non-concrete current pin # means there is no single ``pkg==version`` line to rewrite. if any(c in str(version) for c in (" ", ",", "<", ">", "*")): return False, f"ambiguous version {version!r}; not a single concrete pin" if str(version) == str(fixed): return False, "vulnerable version equals fixed_version; nothing to bump" return True, "" def _nonempty_str(value: Any) -> bool: """True if ``value`` is a non-empty, non-whitespace string.""" return isinstance(value, str) and bool(value.strip()) def _default_spec(finding: Mapping[str, Any]) -> str: """Default :data:`SpecCallable`: author the fix spec via Claude (§3.1 seam). Routes to :func:`agent_team.billing.claude_invoke` (the design's "spec = Claude" half). The spec describes the intended minimal change — bump the one vulnerable pin to the advisory's fixed version, touching only the manifest. The model output is treated as untrusted text and is NEVER the authority for what gets dispatched (that is the deterministic patch + hash + scope below); the spec is provenance / the instruction the patch step renders from. """ proof = finding.get("proof", {}) package = proof.get("package") version = proof.get("version") fixed = proof.get("fixed_version") advisory = proof.get("advisory_id", "") repo = finding.get("repo", "") prompt = ( "You are authoring a minimal, low-risk dependency-bump fix spec for a " "confirmed vulnerable pinned dependency. State ONLY the single change: " f"in repo {repo!r}, bump the pinned dependency {package}=={version} to " f"{package}=={fixed} (advisory {advisory}). The change MUST touch only " "the dependency manifest and nothing else. Reply with a one-paragraph " "spec, no code." ) result = claude_invoke(prompt, mode=BillingMode.SUBSCRIPTION) return result.text def _render_patch_instruction(finding: Mapping[str, Any], *, manifest_path: str) -> str: """Render the dep-bump into a mechanical-edit instruction for fast_coder. Pure string assembly (no I/O) so the instruction shape is unit-testable. The instruction tells DeepSeek to emit ONLY a single unified diff that bumps the one pin in ``manifest_path`` — the box-side scope + denylist check in :func:`plan_fix` is the real enforcement, but reinforcing it in the prompt keeps the model on-task. """ proof = finding.get("proof", {}) package = proof.get("package") version = proof.get("version") fixed = proof.get("fixed_version") advisory = proof.get("advisory_id", "") return ( "You are performing a single mechanical dependency bump. Emit ONLY a " "git unified diff (no prose, no explanation, no code fences). Change " f"EXACTLY one line in `{manifest_path}`: the pinned requirement " f"`{package}=={version}` must become `{package}=={fixed}` (security " f"advisory {advisory}). Touch ONLY `{manifest_path}` and ONLY that one " "pin line — do not reformat, reorder, or edit any other line or file. " f"Both the `---`/`+++` headers must reference `{manifest_path}`." ) def _extract_diff(text: str) -> str | None: """Extract a unified diff from UNTRUSTED model output, or ``None`` (fail-safe). Delegates to :func:`agent_team.nodes.builders_llm._extract_diff` so the fixer and the builders share one defensive parser (framing lines, code fences, header validation). Pure text inspection — it never executes/applies anything. """ from agent_team.nodes.builders_llm import _extract_diff as _b_extract return _b_extract(text) def plan_fix( finding: Any, *, task_id: str, spec: SpecCallable | None = None, build: BuildCallable | None = None, artifact_prefix: str = "candidate-diff", ) -> FixPlan: """Plan a dep-bump fix for ``finding`` as DATA (spec + patch + dispatch inputs). Validates the finding (:func:`is_fixable_dependency_finding`), authors the fix spec (Claude via ``spec``, default :func:`_default_spec`), produces the bump patch (DeepSeek via ``build``, default :func:`agent_team.nodes.builders_llm.default_build`), parses the UNTRUSTED diff, confines ``declared_scope`` to the single manifest, and rejects any diff that escapes that scope or hits the trust-control denylist. Returns a :class:`FixPlan`. DISPATCHES NOTHING. The returned plan carries the CI ``workflow_dispatch`` inputs (:class:`FixDispatchInputs`); firing them is :func:`dispatch_fix`'s job and requires an explicitly-bound dispatcher. FAIL-SAFE (never fabricate a fix, never escape scope): a non-fixable finding, a build error, an unparseable/empty diff, or a diff that strays outside the single-manifest scope / touches the denylist all yield ``FixPlan.failed(...)`` — an ``ok is False`` no-op with no patch and no dispatch. """ finding_id = finding.get("id", "") if isinstance(finding, Mapping) else "" fixable, why = is_fixable_dependency_finding(finding) if not fixable: return FixPlan.failed( f"finding is not a fixable dependency-cve: {why}", finding_id=finding_id ) if not _nonempty_str(task_id): return FixPlan.failed("task_id is missing/empty", finding_id=finding_id) # The single manifest the fix is confined to. PyPI/requirements.txt only (the # narrowest manifest); other ecosystems are out of scope for this phase. manifest_path = _PYPI_MANIFEST declared_scope = [manifest_path] # 1) Spec (Claude). Untrusted text used only as the instruction/provenance. spec_fn: SpecCallable = spec if spec is not None else _default_spec try: spec_text = spec_fn(finding) except Exception as exc: # noqa: BLE001 - any spec failure fails SAFE return FixPlan.failed( f"fix-spec generation failed: {type(exc).__name__}", finding_id=finding_id ) if not _nonempty_str(spec_text): spec_text = "" # a missing spec is non-fatal; the patch is authoritative # 2) Patch (DeepSeek fast_coder via the orchestrator run.py). instruction = _render_patch_instruction(finding, manifest_path=manifest_path) build_fn: BuildCallable = build if build is not None else default_build try: raw = build_fn(instruction) except Exception as exc: # noqa: BLE001 - any build failure fails SAFE return FixPlan.failed( f"patch generation failed: {type(exc).__name__}", finding_id=finding_id ) if not isinstance(raw, str) or not raw.strip(): return FixPlan.failed( "patch generator produced empty output", finding_id=finding_id ) diff = _extract_diff(raw) if diff is None: return FixPlan.failed( "patch output is not a usable unified diff", finding_id=finding_id ) # 3) UNTRUSTED-PATCH HYGIENE (§3.3.2). Box-side enforcement BEFORE any # dispatch: the generated diff must stay inside the single declared manifest # scope and must not touch the trust-control surface. # ci_gate.denylist_violations runs the SAME denylist + scope check the CI # guard re-runs (defense-in-depth). violations = denylist_violations(diff, allowed_scope=declared_scope) if violations: return FixPlan.failed( "generated patch escapes the declared single-manifest scope or hits " f"the trust-control denylist: {'; '.join(violations)}", finding_id=finding_id, ) # 4) Diff integrity hash (boundary #3) — the value CI's guard re-hashes and # the ledger records, so the apply/verify hash binding holds. diff_hash = compute_content_hash(diff.encode("utf-8")) dispatch = FixDispatchInputs( task_id=task_id, diff_artifact_name=f"{artifact_prefix}-{task_id}", expected_diff_hash=diff_hash, # newline-separated globs (the workflow splits on newlines). One entry: # the single manifest, so the CI guard confines the apply to exactly it. declared_scope="\n".join(declared_scope), ) return FixPlan( ok=True, reason="", finding_id=finding_id, spec=spec_text, diff=diff, diff_hash=diff_hash, manifest_path=manifest_path, dispatch=dispatch, declared_scope=declared_scope, ) def dispatch_fix(plan: FixPlan, *, dispatcher: WorkflowDispatcher | None) -> str: """Fire the CI ``workflow_dispatch`` for an ``ok`` :class:`FixPlan` (OPT-IN). The ONLY live side-effect path. It is fail-closed and opt-in: * a non-``ok`` plan (or one missing dispatch inputs) raises ``ValueError`` — a failed/no-op plan is NEVER dispatched; * ``dispatcher`` has NO default. The box holds no write token, so dispatching requires the trusted apply path's dispatcher to be explicitly bound at provisioning. ``dispatcher=None`` raises :class:`RuntimeError`, so an un-wired call can never fire a workflow (mirrors the build_verify wiring staying opt-in / off in the default serve path). On success it forwards the candidate-diff content (to be uploaded as ``candidate.diff``) and the dispatch inputs to the dispatcher and returns its opaque dispatch ref. This module itself performs NO network, NO git, and NO write — the dispatcher (provisioned separately, with the GitHub App token) owns the artifact upload + the actual ``workflow_dispatch`` call + the draft PR. """ if not plan.ok or plan.dispatch is None: raise ValueError( f"refusing to dispatch a non-ok fix plan (reason={plan.reason!r})" ) if dispatcher is None: raise RuntimeError( "no workflow dispatcher bound; the box has no write token, so dispatch " "requires the trusted apply path's dispatcher to be explicitly supplied " "(opt-in/inert by default — nothing is dispatched without it)" ) return dispatcher(plan.dispatch, plan.diff) def describe_plan(plan: FixPlan) -> str: """Render a human-readable dry-run summary of a :class:`FixPlan` (no I/O). Used by the ``fix --dry-run`` CLI to show what WOULD be dispatched (the spec, the patch, and the CI ``workflow_dispatch`` inputs) without dispatching. Pure string assembly so it is unit-testable. """ lines: list[str] = [] lines.append(f"finding: {plan.finding_id or '(unknown)'}") if not plan.ok: lines.append(f"FIX NOT PLANNED (fail-safe): {plan.reason}") lines.append("dispatch: NONE") return "\n".join(lines) lines.append(f"manifest (declared scope): {plan.manifest_path}") lines.append(f"diff content-hash (sha256): {plan.diff_hash}") lines.append("") lines.append("--- fix spec (Claude) ---") lines.append(plan.spec or "(no spec)") lines.append("") lines.append("--- candidate patch (DeepSeek; NOT applied locally) ---") lines.append(plan.diff) lines.append("") lines.append("--- CI workflow_dispatch inputs (would dispatch) ---") if plan.dispatch is not None: for key, value in plan.dispatch.as_inputs().items(): lines.append(f" {key}: {value!r}") lines.append("") lines.append( "DRY-RUN: nothing dispatched. CI (agent-team-apply-verify.yml) would " "apply the patch and open a DRAFT PR; the box never applies/pushes/merges." ) return "\n".join(lines)