diff --git a/agent-team/agent_team/dispatcher.py b/agent-team/agent_team/dispatcher.py index 75f9fc6..bc6a960 100644 --- a/agent-team/agent_team/dispatcher.py +++ b/agent-team/agent_team/dispatcher.py @@ -253,14 +253,16 @@ def _default_branch_pusher() -> BranchPusher: check=True, capture_output=True, ) + # --index applies AND stages exactly the diff's changes (incl. new + # files) and NOTHING else — so the committed head tree is precisely + # base+diff, never stray untracked worktree content. This keeps the + # PR head bound to the same bytes CI hash-verified (LOGIC-1). No + # separate `git add -A` (which would stage unrelated content). subprocess.run( - ["git", "-C", str(clone), "apply", str(diff_path)], + ["git", "-C", str(clone), "apply", "--index", str(diff_path)], check=True, capture_output=True, ) - subprocess.run( - ["git", "-C", str(clone), "add", "-A"], check=True, capture_output=True - ) subprocess.run( [ "git", diff --git a/agent-team/ci/agent-team-apply-verify.yml b/agent-team/ci/agent-team-apply-verify.yml index d74aaca..96f3ad3 100644 --- a/agent-team/ci/agent-team-apply-verify.yml +++ b/agent-team/ci/agent-team-apply-verify.yml @@ -531,6 +531,52 @@ jobs: sys.exit(main()) PY + - name: Gate-weakening check (§4.5; a diff cannot disable its own checks) + env: + DIFF_PATH: ./_incoming/candidate.diff + run: | + set -euo pipefail + # SECURITY (§4.5): enforce gate-weakening on the LIVE PR-opening path, + # not only box-side. A diff that ADDS a lint/type/coverage/security + # suppression or a test skip/xfail could make build-test pass falsely; + # such a diff is escalated to a human, never auto-built. Mirrors + # agent_team.ci_gate._GATE_WEAKENING_MARKERS. Reads the diff as DATA. + python3 - <<'PY' + import os + import re + import sys + + markers = ( + "noqa", + "type: ignore", + "type:ignore", + "pragma: no cover", + "pragma: no-cover", + "nosec", + "nosemgrep", + "--no-verify", + ) + skip_re = re.compile( + r"@(?:pytest\.mark\.(?:skip|xfail)|unittest\.skip\w*)\b" + r"|\bpytest\.(?:skip|xfail)\s*\(" + r"|\.skipTest\s*\(" + ) + text = open(os.environ["DIFF_PATH"], encoding="utf-8", errors="replace").read() + viol = [] + for line in text.splitlines(): + if line.startswith("+") and not line.startswith("+++"): + content = line[1:] + low = content.lower() + if any(m in low for m in markers) or skip_re.search(content): + viol.append(content.strip()[:120]) + if viol: + print("::error::gate-weakening: a diff cannot add suppressions/skips that disable its own checks") + for v in viol[:20]: + print(f" + {v}") + sys.exit(1) + print("no gate-weakening markers in the diff") + PY + # ─────────────────────────────────────────────────────────────────────────── # JOB 2 — build-test (boundary 1). UNTRUSTED execution. This is the ONLY job # that applies + runs the patch. It is credential-less: contents:read only, no @@ -637,20 +683,32 @@ jobs: with: python-version: "3.12" - - name: Install + build + test (untrusted; result is non-authoritative) + - name: Install + build + test (untrusted; exit code IS authoritative) id: run run: | set -euo pipefail - # Placeholder build/test for the narrowest task class (dep bump / - # single-file fix). At deploy time this is parameterized per target - # repo. Exit code is what matters; any file the patch writes is - # ignored by the authoritative gate (boundary 4). + # Build/test for the narrowest task class (dep bump / single-file fix). + # SECURITY (boundary 4): this step's EXIT CODE is the authoritative + # build-test conclusion the pure-code gate keys on + # (needs.build-test.result). It must therefore FAIL on a red build/test + # — a previous `|| echo` swallowed ruff/pytest failures, which (now that + # the apply path is flipped live) would open draft PRs on red builds. + # The tool installs are best-effort (|| true), but ruff + pytest run + # AUTHORITATIVELY under `set -e` so a real failure fails the job. + # + # DEPLOY: the build/test invocation is parameterized per target repo at + # provisioning (a repo with no pytest suite needs its own command). Until + # then this generic ruff+pytest is the gate; a target whose tests need a + # different runner MUST be wired before it is dispatched. if [ -f requirements.txt ]; then python3 -m pip install --quiet -r requirements.txt || true fi python3 -m pip install --quiet ruff pytest || true - ruff check . || echo "ruff non-zero (recorded, non-authoritative)" - pytest -q || echo "pytest non-zero (recorded, non-authoritative)" + ruff check . + # pytest exit 5 == "no tests collected"; treat ONLY that as non-fatal + # (a fix with no test suite), every other non-zero is an authoritative + # failure. Never blanket-swallow. + pytest -q || { rc=$?; [ "$rc" -eq 5 ] || exit "$rc"; echo "no tests collected (exit 5)"; } - name: Post-build denied-path check (build hook may not write the trust surface) if: always() diff --git a/agent-team/tests/test_apply_verify_workflow_hardening.py b/agent-team/tests/test_apply_verify_workflow_hardening.py index 12fd29c..41d06b9 100644 --- a/agent-team/tests/test_apply_verify_workflow_hardening.py +++ b/agent-team/tests/test_apply_verify_workflow_hardening.py @@ -528,3 +528,32 @@ def test_materialize_bounds_decoded_diff_size() -> None: assert steps for s in steps: assert "too large" in s["run"], "materialize must bound the decoded diff size" + + +def test_build_test_step_does_not_swallow_failures() -> None: + # LOGIC-2: the build/test exit code is the authoritative build-test + # conclusion the gate keys on, so ruff/pytest must NOT be swallowed by a + # `|| echo ... non-authoritative` (which would open draft PRs on red builds). + steps = [ + s for _, s in _iter_steps(_doc()) if s.get("run") and "ruff check" in s["run"] + ] + assert steps, "expected the build/test step" + for s in steps: + run = s["run"] + assert 'echo "ruff non-zero' not in run, "ruff failure must not be swallowed" + assert 'echo "pytest non-zero' not in run, ( + "pytest failure must not be swallowed" + ) + # ruff runs authoritatively under set -e (bare invocation). + assert re.search(r"^\s*ruff check \.\s*$", run, re.MULTILINE), ( + "ruff check must run authoritatively (no failure swallow)" + ) + + +def test_guard_runs_gate_weakening_check_on_live_path() -> None: + # LOGIC-3: §4.5 enforced on the LIVE PR-opening path (guard), not only box-side. + guard_steps = _doc()["jobs"]["guard"]["steps"] + names = [s.get("name", "").lower() for s in guard_steps] + assert any("gate-weakening" in n for n in names), ( + "guard must run a gate-weakening check so the live path enforces §4.5" + )