* feat(agent-team): Plane-1 Tier-3 fixer — dependency-cve finding -> patch + CI dispatch (opt-in/inert)
The fixer (design §4 fixer row, §7 Phase 5, §3.3.2) takes a CONFIRMED,
low-risk dependency-cve finding (the narrowest fix class) and produces:
* a fix SPEC (Claude, via the §3.1 billing seam), and
* a minimal bump PATCH (DeepSeek fast_coder, via the orchestrator run.py
path that builders_llm uses),
records the candidate diff + its content-hash, and emits the org-CI
workflow_dispatch inputs (task_id / diff_artifact_name / expected_diff_hash /
declared_scope) for the gate-passed P3-live apply/verify surface.
INERT / opt-in / fail-safe, mirroring build_verify_wiring:
* plan_fix dispatches NOTHING; dispatch_fix has NO default dispatcher
(the box holds no write token, D2) so an un-wired call can never fire a
workflow.
* no git/patch/subprocess/fs-write in executable code — the patch is emitted
as diff TEXT only; CI applies it and opens a DRAFT PR, the box never
applies/pushes/merges.
* untrusted-patch hygiene: the generated diff is confined box-side to the
single dependency manifest (declared_scope) and rejected via
ci_gate.denylist_violations if it escapes scope or touches the
trust-control surface — defense-in-depth with the CI guard.
* bad/ambiguous findings (wrong check/status/category, missing
package/fixed_version, ambiguous fixed_version, unparseable/empty diff)
yield a FAILED no-op plan, never a fabricated fix.
29 new pytest tests under agent-team/tests/test_fixer.py.
* feat(agent-team): run-team.py 'fix --dry-run' subcommand for the Plane-1 fixer
Adds the fixer front door to the operator CLI: load one confirmed
dependency-cve finding from a dependency-cve.json report (--report
--finding-id), plan the fix, and in --dry-run print the spec + patch + the
org-CI workflow_dispatch inputs WITHOUT dispatching anything.
Opt-in/inert: the command binds NO workflow dispatcher and holds no write
token, so even an ok plan only prints; live dispatch is provisioning-gated
(refuses to run without --dry-run). A non-fixable finding prints the
fail-safe reason and exits 1.
4 new pytest tests under agent-team/tests/test_run_team.py.
492 lines
22 KiB
Python
492 lines
22 KiB
Python
"""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)
|