From ee41c53d1b78aabea9f840c2a7b7285a1eaa4a2e Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Thu, 18 Jun 2026 16:04:10 -0400 Subject: [PATCH] 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. --- agent-team/run-team.py | 115 ++++++++++++++++++++++ agent-team/tests/test_run_team.py | 156 ++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) diff --git a/agent-team/run-team.py b/agent-team/run-team.py index 120bb18..13a4de2 100644 --- a/agent-team/run-team.py +++ b/agent-team/run-team.py @@ -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 diff --git a/agent-team/tests/test_run_team.py b/agent-team/tests/test_run_team.py index 20abfb1..86b3574 100644 --- a/agent-team/tests/test_run_team.py +++ b/agent-team/tests/test_run_team.py @@ -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()