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:
parent
bf1eed1a31
commit
ee41c53d1b
2 changed files with 271 additions and 0 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Reference in a new issue