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.
This commit is contained in:
Adam Moussa 2026-06-18 16:04:10 -04:00
parent bf1eed1a31
commit ee41c53d1b
2 changed files with 271 additions and 0 deletions

View file

@ -46,6 +46,11 @@ Subcommands (P1 surface):
severity threshold (default ``high``), de-dup, and start one remediation task
per unique finding via the committed coordinator intake entry. Reads local
report JSON only; no CI, OIDC, network, or git/patch apply. Opt-in/inert.
* ``fix`` — plan a Plane-1 Tier-3 dep-bump fix for one confirmed dependency-cve
finding (spec via Claude + minimal bump patch via DeepSeek) and, in
``--dry-run``, print the spec + patch + the org-CI ``workflow_dispatch`` inputs
WITHOUT dispatching. Opt-in/inert: it binds no dispatcher and holds no write
token; live dispatch is provisioning-gated (the P3-live apply/verify surface).
Exit codes: ``0`` success, ``1`` operational failure (e.g. row not found, the
compare-and-set lost the race), ``2`` usage error (argparse).
@ -755,6 +760,82 @@ def _cmd_intake_checker(args: argparse.Namespace, *, out: Any) -> int:
return 0
def _load_finding_for_fix(args: argparse.Namespace) -> dict[str, Any]:
"""Load the single confirmed dependency-cve finding to fix from a report file.
Reads the ``dependency-cve.json`` report (the checker's output shape) at
``--report`` and selects the finding by ``--finding-id``. Returns the finding
mapping. Raises :class:`SystemExit` on any load/selection error so the CLI
fails loudly rather than dispatching against a half-resolved finding. Pure
read; no network, no CI, no git.
"""
report_path = Path(args.report)
try:
raw = report_path.read_text(encoding="utf-8")
except OSError as exc:
raise SystemExit(f"cannot read finding report {report_path}: {exc}") from exc
try:
report = json.loads(raw)
except ValueError as exc:
raise SystemExit(
f"finding report {report_path} is not valid JSON: {exc}"
) from exc
findings = report.get("findings") if isinstance(report, dict) else None
if not isinstance(findings, list):
raise SystemExit(
f"finding report {report_path} has no 'findings' array (got "
f"{type(report).__name__})"
)
matches = [
f for f in findings if isinstance(f, dict) and f.get("id") == args.finding_id
]
if not matches:
raise SystemExit(f"no finding with id {args.finding_id!r} in {report_path}")
if len(matches) > 1:
raise SystemExit(
f"ambiguous: {len(matches)} findings share id {args.finding_id!r} in "
f"{report_path}"
)
return matches[0]
def _cmd_fix(args: argparse.Namespace, *, out: Any) -> int:
"""Plan a Plane-1 dep-bump fix and show what it WOULD dispatch (§7 Phase 5).
The Tier-3 fixer front door (design §4 fixer row, §3.3.2). Loads ONE confirmed
``dependency-cve`` finding from the report, asks the fixer to produce a fix
spec (Claude) + a minimal bump patch (DeepSeek via the orchestrator) + the CI
``workflow_dispatch`` inputs, and — in ``--dry-run`` (the only mode wired
here) — PRINTS the spec, patch, and dispatch inputs without dispatching
anything.
OPT-IN / INERT: this command never dispatches. It binds NO workflow dispatcher
(the box holds no write token, D2), so even an ``ok`` plan only prints. Live
dispatch is a provisioning-time wiring of the trusted apply path's dispatcher,
deliberately not reachable from this CLI. A non-fixable finding prints the
fail-safe reason and exits non-zero.
Returns ``0`` when a fix plan was produced (dry-run printed), ``1`` when the
finding is not fixable (fail-safe; nothing planned).
"""
from agent_team.nodes.fixer import describe_plan, plan_fix
if not args.dry_run:
# Live dispatch is provisioning-gated and not wired into the CLI; refuse
# to run without --dry-run rather than silently doing nothing.
raise SystemExit(
"fix supports only --dry-run in this build (live dispatch is "
"provisioning-gated; the box holds no write token, D2). Re-run with "
"--dry-run to see what it WOULD dispatch."
)
finding = _load_finding_for_fix(args)
plan = plan_fix(finding, task_id=args.task_id)
print(describe_plan(plan), file=out)
return 0 if plan.ok else 1
def _cmd_force_resume(args: argparse.Namespace, *, out: Any) -> int:
"""Force-resume a parked task's question (destructive; audit-logged).
@ -1064,6 +1145,40 @@ def build_parser() -> argparse.ArgumentParser:
help="use a non-posting transport (no token needed; ingest still runs)",
)
p_intake_checker.set_defaults(func=_cmd_intake_checker)
p_fix = sub.add_parser(
"fix",
help=(
"plan a Plane-1 dep-bump fix for a confirmed dependency-cve finding "
"and (dry-run) show what it WOULD dispatch to org CI"
),
)
p_fix.add_argument(
"--report",
required=True,
help="path to the dependency-cve.json finding report to read the finding from",
)
p_fix.add_argument(
"--finding-id",
required=True,
dest="finding_id",
help="the finding id (report findings[].id) to fix",
)
p_fix.add_argument(
"--task-id",
required=True,
dest="task_id",
help="pipeline task id (provenance; becomes the CI dispatch task_id)",
)
p_fix.add_argument(
"--dry-run",
action="store_true",
dest="dry_run",
help=(
"show the spec + patch + dispatch inputs WITHOUT dispatching "
"(the only supported mode; live dispatch is provisioning-gated)"
),
)
p_fix.set_defaults(func=_cmd_fix)
return parser

View file

@ -867,3 +867,159 @@ def test_build_transport_dry_run_returns_dry_run_transport(cli: ModuleType) -> N
deadline="2026-06-18T00:00:00+00:00",
)
assert ref == "dry-run:q1"
# --------------------------------------------------------------------------- #
# fix subcommand (Plane-1 Tier-3 fixer dry-run; §7 Phase 5)
# --------------------------------------------------------------------------- #
def _write_dep_report(path: Path, finding_id: str = "r-vuln-1") -> Path:
"""Write a minimal dependency-cve.json report with one confirmed finding."""
report = {
"checker": "dependency-cve",
"findings": [
{
"repo": "r",
"id": finding_id,
"title": "requests 2.19.0 is vulnerable (CVE-2018-18074)",
"severity": "high",
"category": "other",
"check": "vulnerable-dependency",
"status": "confirmed",
"proof": {
"package": "requests",
"version": "2.19.0",
"advisory_id": "CVE-2018-18074",
"summary": "leaks auth on redirect",
"fixed_version": "2.20.0",
},
}
],
}
path.write_text(json.dumps(report), encoding="utf-8")
return path
def test_fix_dry_run_prints_plan_and_dispatches_nothing(
cli: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``fix --dry-run`` plans a fix (fake model seams) and prints what it WOULD
dispatch — without firing a workflow."""
from agent_team.nodes import fixer as fixer_mod
report = _write_dep_report(tmp_path / "dependency-cve.json")
# Inject fake spec + build seams so no Claude/DeepSeek/network is touched.
bump = (
"diff --git a/requirements.txt b/requirements.txt\n"
"--- a/requirements.txt\n"
"+++ b/requirements.txt\n"
"@@ -1,1 +1,1 @@\n"
"-requests==2.19.0\n"
"+requests==2.20.0\n"
)
real_plan_fix = fixer_mod.plan_fix
def _patched_plan_fix(finding: Any, **kw: Any) -> Any:
kw.setdefault("spec", lambda _f: "bump it")
kw.setdefault("build", lambda _i: bump)
return real_plan_fix(finding, **kw)
monkeypatch.setattr(fixer_mod, "plan_fix", _patched_plan_fix)
out = io.StringIO()
rc = cli.main(
[
"fix",
"--report",
str(report),
"--finding-id",
"r-vuln-1",
"--task-id",
"t-cli-1",
"--dry-run",
],
out=out,
)
assert rc == 0
text = out.getvalue()
assert "workflow_dispatch inputs" in text
assert "candidate-diff-t-cli-1" in text
assert "DRY-RUN: nothing dispatched" in text
def test_fix_requires_dry_run(cli: ModuleType, tmp_path: Path) -> None:
"""Without --dry-run the fix command refuses (live dispatch is gated)."""
report = _write_dep_report(tmp_path / "dependency-cve.json")
with pytest.raises(SystemExit):
cli.main(
[
"fix",
"--report",
str(report),
"--finding-id",
"r-vuln-1",
"--task-id",
"t",
],
out=io.StringIO(),
)
def test_fix_unknown_finding_id_exits(cli: ModuleType, tmp_path: Path) -> None:
report = _write_dep_report(tmp_path / "dependency-cve.json")
with pytest.raises(SystemExit):
cli.main(
[
"fix",
"--report",
str(report),
"--finding-id",
"does-not-exist",
"--task-id",
"t",
"--dry-run",
],
out=io.StringIO(),
)
def test_fix_non_fixable_finding_returns_one(
cli: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A finding that is not a confirmed dependency-cve yields a fail-safe plan
(rc=1) and dispatches nothing."""
path = tmp_path / "dependency-cve.json"
report = {
"findings": [
{
"repo": "r",
"id": "r-not-dep",
"title": "x",
"severity": "high",
"category": "injection",
"check": "sqli",
"status": "confirmed",
"proof": {"input": "x", "outcome": "y"},
}
]
}
path.write_text(json.dumps(report), encoding="utf-8")
out = io.StringIO()
rc = cli.main(
[
"fix",
"--report",
str(path),
"--finding-id",
"r-not-dep",
"--task-id",
"t",
"--dry-run",
],
out=out,
)
assert rc == 1
assert "FIX NOT PLANNED" in out.getvalue()