This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
orchestrator/agent-team/tests/test_build_verify_subgraph.py
Adam Moussa 00c51192c8 fix(agent-team): remediate C1 security-review BLOCK (2 HIGH + MED/LOW)
High-recall /sh-security-review fan-out + proof-or-kill verifier found two
confirmed HIGH; both now closed (verified empirically against the working tree):

- LOGIC-RACE-01 (HIGH, CWE-835): the build-loop budget was structurally dead
  (verifier read a shared wiring-time VerifierConfig.build_loops, always 0, so
  the max_build_loops park never fired -> a perpetually-failing task looped
  BUILD->DISPATCH->VERIFY forever, force-pushing + firing a CI run each round).
  Threaded build_loops through durable PipelineState/TaskRecord; verifier reads
  state.get('build_loops',0), writes the incremented count back on each FAIL, and
  PARKS at max_build_loops. Parks after exactly N failures, never unbounded.
- SEC-01 (HIGH, CWE-532) + SEC-02 (MED, CWE-214): p3_rollback.sh echoed the live
  App JWT to stdout in default dry-run and passed it as a gh argv literal. Added
  redact_secrets (Bearer/Authorization/ghX_/PEM masking) through run_or_plan; the
  App uninstall now uses curl -H @<0600 tempfile> (JWT never on argv), shredded
  after. Empirical: app/incident/all dry-runs leak 0 JWT occurrences.
- SEC-03 (MED, CWE-798): assert_no_write_token now applies the PEM regex + the
  configured App-ID to env/config VALUES (not just files) — an App private key
  under a benign env name is caught.
- SEC-04 (LOW) + P3-IAC-08 (LOW): tightened the box GITHUB_TOKEN fallback /
  value-scan; staged-only WARN on the live workflow revert.

Suite: 1382 passed, ruff clean. Branch only; not merged/deployed.
NOTE: re-verifier flagged SEC-01 as open by grepping COMMITTED blobs (the fix was
uncommitted working-tree state); independently confirmed closed empirically.
2026-06-23 19:52:04 -04:00

629 lines
22 KiB
Python

"""Unit tests for agent_team.nodes.build_verify_subgraph (P3-INERT topology).
These tests prove the build -> verify subgraph TOPOLOGY is correctly inert:
* the BUILD node proposes a candidate diff via an INJECTED fake builder and
advances to VERIFY;
* the VERIFY node, fed a fake authenticated-pass ``ci_result``, routes to the
approved / PR terminus;
* the VERIFY node with the DEFAULT (None) fetcher — and with a failing fetcher —
BLOCKs and routes to PARKED, never fabricating a pass;
* an LLM fix-proposal can NEVER flip a failing verdict to pass (the gate is the
sole pass authority).
Everything is fully mocked; no SDK, no network, no live CI.
"""
from __future__ import annotations
import pytest
from agent_team.nodes import build_verify_subgraph as bvs
from agent_team.nodes import verifier as verifier_mod
from agent_team.nodes.build_verify_subgraph import (
APPROVED_ROUTE,
BUILD_ROUTE,
PARKED_ROUTE,
bind_ci_result_fetcher,
make_build_node,
make_verify_node,
route_after_verify,
)
from agent_team.nodes.verifier import VerifierConfig, set_fix_advisor
from agent_team.state_store import compute_content_hash
from agent_team.task_model import Phase, PipelineState, TaskStatus
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _diff_for(*paths: str) -> str:
"""Build a minimal in-scope unified diff touching ``paths``."""
chunks = []
for p in paths:
chunks.append(f"diff --git a/{p} b/{p}\n@@ -1 +1 @@\n-old\n+new\n")
return "".join(chunks)
def _hash(diff: str) -> str:
return compute_content_hash(diff.encode("utf-8"))
def _plan(scope: list[str]) -> dict:
return {
"title": "do the thing",
"scope": scope,
"phases": ["P1: edit", "P2: test"],
"approved": True,
}
def _build_state(plan: dict) -> PipelineState:
return {
"thread_id": "t1",
"status": TaskStatus.ACTIVE.value,
"current_phase": Phase.BUILD.value,
"plan": plan,
}
def _verify_state(diff: str) -> PipelineState:
return {
"thread_id": "t1",
"status": TaskStatus.ACTIVE.value,
"current_phase": Phase.VERIFY.value,
"candidate_diff": diff,
"diff_hash": _hash(diff),
"ci_results": None,
}
@pytest.fixture(autouse=True)
def _reset_advisor():
"""Restore the default null fix-advisor after each test."""
yield
set_fix_advisor(verifier_mod._null_advisor)
# --------------------------------------------------------------------------- #
# Route id parity with graph.py (topology contract)
# --------------------------------------------------------------------------- #
def test_route_ids_mirror_graph_by_value() -> None:
"""BUILD_ROUTE / PARKED_ROUTE must match graph.py by value (no import cycle)."""
from agent_team import graph
assert bvs.BUILD_ROUTE == graph.BUILD_ROUTE
assert bvs.PARKED_ROUTE == graph.PARKED_ROUTE
# APPROVED_ROUTE is the build->verify-specific PASS terminus.
assert APPROVED_ROUTE == "approved"
def test_integrate_note_documents_build_dispatch_verify_order() -> None:
"""The topology note records BUILD -> [DISPATCH] -> VERIFY (design §4 D1).
DISPATCH must precede VERIFY so it captures ``state["run_id"]`` before VERIFY
reads it. Pin the documented order so a future reorder back to the old
BUILD -> VERIFY -> DISPATCH topology is caught.
"""
note = bvs._INTEGRATE_NOTE
assert "BUILD -> [DISPATCH] -> VERIFY" in note
# DISPATCH appears before VERIFY in the recorded stage order.
assert note.index("DISPATCH") < note.index("VERIFY")
# --------------------------------------------------------------------------- #
# BUILD node: proposes a diff via an injected fake builder
# --------------------------------------------------------------------------- #
def test_build_node_proposes_diff_with_injected_builder() -> None:
diff = _diff_for("src/foo.py")
calls: list[dict] = []
def fake_builder(*, plan, config):
calls.append({"plan": plan, "config": config})
return diff
node = make_build_node(diff_builder=fake_builder)
plan = _plan(scope=["src"])
out = node(_build_state(plan))
# The injected builder was consulted with the approved plan.
assert len(calls) == 1
assert calls[0]["plan"] == plan
# Clean in-scope diff -> advance to VERIFY with the diff + integrity hash.
assert out["candidate_diff"] == diff
assert out["diff_hash"] == _hash(diff)
assert out["current_phase"] == Phase.VERIFY.value
assert out["status"] == TaskStatus.ACTIVE.value
assert "park_reason" not in out
def test_build_node_parks_on_trust_control_surface_violation() -> None:
"""A diff touching the denylist parks for human + GPT cross-review."""
diff = _diff_for(".github/workflows/ci.yml")
def fake_builder(*, plan, config):
return diff
node = make_build_node(diff_builder=fake_builder)
out = node(_build_state(_plan(scope=[".github"])))
assert out["current_phase"] == Phase.PARKED.value
assert out["status"] == TaskStatus.PARKED.value
assert "park_reason" in out
# --------------------------------------------------------------------------- #
# VERIFY node: authenticated pass -> approved/PR terminus
# --------------------------------------------------------------------------- #
def test_verify_pass_routes_to_approved() -> None:
diff = _diff_for("src/foo.py")
def pass_fetcher(state):
# A fake authenticated-pass CI result keyed to the expected run + hash.
return {"run_id": "r1", "conclusion": "success", "diff_hash": _hash(diff)}
node = make_verify_node(
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
ci_result_fetcher=pass_fetcher,
)
out = node(_verify_state(diff))
# Pure-code gate passed -> DONE / draft-PR terminus.
assert out["status"] == TaskStatus.DONE.value
assert out["current_phase"] == Phase.DONE.value
assert out["ci_results"]["gate_decision"] == "pass"
assert route_after_verify(out) == APPROVED_ROUTE
# --------------------------------------------------------------------------- #
# VERIFY node: INERT default (None) + failing fetcher -> BLOCK -> PARKED
# --------------------------------------------------------------------------- #
def test_verify_default_fetcher_blocks_and_parks() -> None:
"""No ci_result (the INERT default) -> BLOCK -> PARKED. Never a pass."""
diff = _diff_for("src/foo.py")
# No fetcher injected: the default returns None (pre-live-CI reality).
node = make_verify_node(VerifierConfig(expected_run_id="r1", allowed_scope=["src"]))
out = node(_verify_state(diff))
assert out["status"] == TaskStatus.PARKED.value
assert out["current_phase"] == Phase.PARKED.value
assert out["ci_results"]["gate_decision"] == "block"
assert route_after_verify(out) == PARKED_ROUTE
def test_verify_none_fetcher_explicit_blocks_and_parks() -> None:
"""An explicit fetcher returning None also fails safe to PARKED."""
diff = _diff_for("src/foo.py")
def none_fetcher(state):
return None
node = make_verify_node(
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
ci_result_fetcher=none_fetcher,
)
out = node(_verify_state(diff))
assert out["current_phase"] == Phase.PARKED.value
assert route_after_verify(out) == PARKED_ROUTE
def test_verify_failing_ci_result_loops_back_to_build() -> None:
"""A recognised CI failure (under the loop budget) loops back to BUILD."""
diff = _diff_for("src/foo.py")
def fail_fetcher(state):
return {"run_id": "r1", "conclusion": "failure", "diff_hash": _hash(diff)}
node = make_verify_node(
VerifierConfig(expected_run_id="r1", allowed_scope=["src"], build_loops=0),
ci_result_fetcher=fail_fetcher,
)
out = node(_verify_state(diff))
assert out["status"] == TaskStatus.ACTIVE.value
assert out["current_phase"] == Phase.BUILD.value
assert out["ci_results"]["gate_decision"] == "fail"
assert route_after_verify(out) == BUILD_ROUTE
# The recoverable loop persists the incremented DURABLE count into state.
assert out["build_loops"] == 1
def test_repeated_fail_through_wrapper_parks_after_max_build_loops() -> None:
"""A perpetually-FAILing task PARKS after exactly max_build_loops loops.
Drives the durable BUILD->DISPATCH->VERIFY budget through the subgraph
wrapper with ONE shared VerifierConfig (the wiring-time reality). The count
that bounds the loop is the per-task ``state["build_loops"]`` the node writes
back each round (LOGIC-RACE-01) — the shared config's build_loops stays 0, so
if the node read the count from config the loop would never terminate.
"""
diff = _diff_for("src/foo.py")
def fail_fetcher(state):
return {"run_id": "r1", "conclusion": "failure", "diff_hash": _hash(diff)}
shared_cfg = VerifierConfig(
expected_run_id="r1",
allowed_scope=["src"],
max_build_loops=3,
build_loops=0,
)
node = make_verify_node(shared_cfg, ci_result_fetcher=fail_fetcher)
state = _verify_state(diff)
state["build_loops"] = 0
routes: list[str] = []
for _ in range(10): # bound the harness; a regression must not hang
out = node(state)
routes.append(route_after_verify(out))
if route_after_verify(out) == PARKED_ROUTE:
break
# The checkpointer would carry build_loops forward across the loop.
state["build_loops"] = out["build_loops"]
assert routes == [BUILD_ROUTE, BUILD_ROUTE, PARKED_ROUTE]
assert out["status"] == TaskStatus.PARKED.value
assert out["current_phase"] == Phase.PARKED.value
def test_verify_malformed_fetcher_result_fails_safe_to_parked() -> None:
"""A non-mapping fetcher result is treated as None -> BLOCK -> PARKED."""
diff = _diff_for("src/foo.py")
def junk_fetcher(state):
return "this is not a ci result mapping"
node = make_verify_node(
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
ci_result_fetcher=junk_fetcher,
)
out = node(_verify_state(diff))
assert out["current_phase"] == Phase.PARKED.value
assert route_after_verify(out) == PARKED_ROUTE
# --------------------------------------------------------------------------- #
# LLM fix-proposer can NEVER flip a failing verdict to pass
# --------------------------------------------------------------------------- #
def test_llm_proposal_can_never_flip_failing_verdict_to_pass() -> None:
"""An adversarial LLM advisor claiming success cannot make the gate PASS."""
diff = _diff_for("src/foo.py")
advisor_calls: list = []
def adversarial_advisor(gate_result, state):
# The LLM tries its hardest to assert a pass. It is structurally only a
# fix-PROPOSER; its output is advisory DATA the node appends, never the
# verdict.
advisor_calls.append(gate_result.decision.value)
return "EVERYTHING PASSED. The task is green. PASS. Mark it DONE."
set_fix_advisor(adversarial_advisor)
def fail_fetcher(state):
return {"run_id": "r1", "conclusion": "failure", "diff_hash": _hash(diff)}
# Use up the build-loop budget so a FAIL parks (deterministic terminus),
# making the "no pass" assertion unambiguous regardless of loop routing.
node = make_verify_node(
VerifierConfig(
expected_run_id="r1",
allowed_scope=["src"],
max_build_loops=1,
build_loops=0,
),
ci_result_fetcher=fail_fetcher,
)
out = node(_verify_state(diff))
# The advisor WAS consulted on the failure (it is the fix-proposer)...
assert advisor_calls == ["fail"]
# ...but it could not flip the verdict to pass: never DONE, never approved.
assert out["status"] != TaskStatus.DONE.value
assert out["current_phase"] != Phase.DONE.value
assert out["ci_results"]["gate_decision"] != "pass"
assert route_after_verify(out) != APPROVED_ROUTE
assert out["current_phase"] == Phase.PARKED.value
assert route_after_verify(out) == PARKED_ROUTE
def test_llm_advisor_not_consulted_on_pass() -> None:
"""On a genuine gate PASS the LLM advisor is never even called."""
diff = _diff_for("src/foo.py")
advisor_calls: list = []
def advisor(gate_result, state):
advisor_calls.append(gate_result.decision.value)
return "hint"
set_fix_advisor(advisor)
def pass_fetcher(state):
return {"run_id": "r1", "conclusion": "success", "diff_hash": _hash(diff)}
node = make_verify_node(
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
ci_result_fetcher=pass_fetcher,
)
out = node(_verify_state(diff))
assert out["current_phase"] == Phase.DONE.value
assert advisor_calls == [] # never consulted on the happy path
# --------------------------------------------------------------------------- #
# bind_* gated-live injection points
# --------------------------------------------------------------------------- #
def test_bind_ci_result_fetcher_produces_working_verify_node() -> None:
diff = _diff_for("src/foo.py")
def pass_fetcher(state):
return {"run_id": "r1", "conclusion": "success", "diff_hash": _hash(diff)}
node = bind_ci_result_fetcher(
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
pass_fetcher,
)
out = node(_verify_state(diff))
assert route_after_verify(out) == APPROVED_ROUTE
def test_bind_diff_builder_produces_working_build_node() -> None:
diff = _diff_for("src/foo.py")
def fake_builder(*, plan, config):
return diff
node = bvs.bind_diff_builder(fake_builder)
out = node(_build_state(_plan(scope=["src"])))
assert out["candidate_diff"] == diff
assert out["current_phase"] == Phase.VERIFY.value
# --------------------------------------------------------------------------- #
# route_after_verify fail-safe on a missing / unknown phase
# --------------------------------------------------------------------------- #
def test_route_after_verify_parks_on_missing_phase() -> None:
assert route_after_verify({}) == PARKED_ROUTE
assert route_after_verify({"current_phase": "intake"}) == PARKED_ROUTE
def test_verify_node_does_not_mutate_caller_state() -> None:
"""The wrapper merges ci_result into a COPY, never the caller's state."""
diff = _diff_for("src/foo.py")
state = _verify_state(diff)
state["ci_results"] = None
sentinel = state["ci_results"]
def pass_fetcher(s):
return {"run_id": "r1", "conclusion": "success", "diff_hash": _hash(diff)}
node = make_verify_node(
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
ci_result_fetcher=pass_fetcher,
)
node(state)
# Caller's state is untouched (the node wrote into a dict copy).
assert state["ci_results"] is sentinel
# --------------------------------------------------------------------------- #
# Per-task run-id binding through the subgraph wrapper (design §4 Decision 4)
# --------------------------------------------------------------------------- #
def test_verify_node_binds_to_per_task_state_run_id() -> None:
"""make_verify_node gates against state["run_id"], not a static config id.
A single VerifierConfig with no expected_run_id is shared; the per-task
state["run_id"] supplies the binding the fetched CI conclusion must match.
"""
diff = _diff_for("src/foo.py")
def fetcher_keyed_to_task(s):
# The (real) fetcher keys its conclusion to the task's dispatched run id.
return {
"run_id": s["run_id"],
"conclusion": "success",
"diff_hash": _hash(diff),
}
node = make_verify_node(
VerifierConfig(expected_run_id=None, allowed_scope=["src"]),
ci_result_fetcher=fetcher_keyed_to_task,
)
state = _verify_state(diff)
state["run_id"] = "dispatched-123"
out = node(state)
assert out["status"] == TaskStatus.DONE.value
assert out["review_verdicts"][0]["run_id"] == "dispatched-123"
def test_verify_node_blocks_substituted_run_id_through_wrapper() -> None:
"""A fetched CI result keyed to a DIFFERENT run id than state["run_id"] blocks."""
diff = _diff_for("src/foo.py")
def substituting_fetcher(s):
# CI result grafted from another task (run id mismatch) with success.
return {
"run_id": "someone-elses-run",
"conclusion": "success",
"diff_hash": _hash(diff),
}
node = make_verify_node(
VerifierConfig(expected_run_id=None, allowed_scope=["src"]),
ci_result_fetcher=substituting_fetcher,
)
state = _verify_state(diff)
state["run_id"] = "my-run"
out = node(state)
assert out["status"] == TaskStatus.PARKED.value
assert out["ci_results"]["gate_decision"] == "block"
def test_verify_node_blocks_when_no_run_id_anywhere() -> None:
"""No state run_id and no config fallback -> BLOCK/park, never a vacuous pass."""
diff = _diff_for("src/foo.py")
def pass_fetcher(s):
return {"run_id": "r1", "conclusion": "success", "diff_hash": _hash(diff)}
node = make_verify_node(
VerifierConfig(expected_run_id=None, allowed_scope=["src"]),
ci_result_fetcher=pass_fetcher,
)
out = node(_verify_state(diff)) # no state["run_id"]
assert out["status"] == TaskStatus.PARKED.value
assert out["ci_results"]["gate_decision"] == "block"
# --------------------------------------------------------------------------- #
# Async CI-wait: VERIFY suspends via interrupt() on an in-progress run (§4 Dec 2)
# --------------------------------------------------------------------------- #
def test_verify_suspends_on_in_progress_run_then_resumes_to_gate() -> None:
"""With a dispatched run_id and a not-yet-terminal fetch, VERIFY interrupts;
once the CI-watcher resumes it, the re-fetched terminal result is gated."""
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command
diff = _diff_for("src/foo.py")
# The fetcher is "in progress" on the first call, then terminal on the second
# (mirroring a real run that concluded while the task was suspended).
calls = {"n": 0}
def progressing_fetcher(state):
calls["n"] += 1
if calls["n"] == 1:
return None # still running -> VERIFY must suspend
return {
"run_id": state["run_id"],
"conclusion": "success",
"diff_hash": _hash(diff),
}
node = make_verify_node(
VerifierConfig(expected_run_id=None, allowed_scope=["src"]),
ci_result_fetcher=progressing_fetcher,
)
builder = StateGraph(PipelineState)
builder.add_node("verify", node)
builder.add_edge(START, "verify")
builder.add_edge("verify", END)
graph = builder.compile(checkpointer=MemorySaver())
cfg = {"configurable": {"thread_id": "tw1"}}
state = _verify_state(diff)
state["run_id"] = "dispatched-77"
first = graph.invoke(state, cfg)
# The task suspended at VERIFY's interrupt (awaiting CI) rather than gating.
assert "__interrupt__" in first
assert calls["n"] == 1
# The CI-watcher resumes the task once the run terminated.
out = graph.invoke(Command(resume={"awaiting_ci": "done"}), cfg)
# On resume the node re-fetched the now-terminal result and the gate PASSed.
assert calls["n"] == 2
assert out["status"] == TaskStatus.DONE.value
assert out["current_phase"] == Phase.DONE.value
def test_verify_spurious_resume_still_non_terminal_parks_never_passes() -> None:
"""A spurious resume (re-fetch STILL non-terminal) must fail closed.
The CI-watcher resumes VERIFY on what it believes is a terminal conclusion,
but the authenticated re-fetch is the source of truth. If that re-fetch is
STILL None (a spurious / premature resume, or a run that flapped back to
in-progress), the node must NOT vacuously pass: it falls through to the gate,
which — with a dispatched run_id but no terminal result — BLOCKs and parks.
"""
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command
diff = _diff_for("src/foo.py")
# The fetch is non-terminal on EVERY call (the resume was spurious — the run
# never actually concluded).
calls = {"n": 0}
def never_terminal_fetcher(state):
calls["n"] += 1
return None
node = make_verify_node(
VerifierConfig(expected_run_id=None, allowed_scope=["src"]),
ci_result_fetcher=never_terminal_fetcher,
)
builder = StateGraph(PipelineState)
builder.add_node("verify", node)
builder.add_edge(START, "verify")
builder.add_edge("verify", END)
graph = builder.compile(checkpointer=MemorySaver())
cfg = {"configurable": {"thread_id": "spurious-1"}}
state = _verify_state(diff)
state["run_id"] = "dispatched-99"
first = graph.invoke(state, cfg)
# First pass: in-progress fetch -> VERIFY suspends awaiting CI.
assert "__interrupt__" in first
assert calls["n"] == 1
# The watcher resumes, but the authenticated re-fetch is STILL non-terminal.
out = graph.invoke(Command(resume={"awaiting_ci": "spurious"}), cfg)
# Re-fetched again on resume (the node replays from its start, so it fetches,
# the interrupt returns the resume value rather than re-suspending, then it
# re-fetches once more) — every fetch is non-terminal.
assert calls["n"] > 1
# ...and with no terminal result the gate BLOCKs and the task PARKS — never a
# vacuous pass.
assert out["status"] == TaskStatus.PARKED.value
assert out["current_phase"] == Phase.PARKED.value
assert out["ci_results"]["gate_decision"] == "block"
assert route_after_verify(out) == PARKED_ROUTE
def test_verify_no_run_id_does_not_suspend_and_parks() -> None:
"""The INERT/no-run path (no state run_id) never suspends: a None fetch flows
straight to the gate, which BLOCKs and parks (existing behavior preserved)."""
diff = _diff_for("src/foo.py")
node = make_verify_node(
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
ci_result_fetcher=lambda s: None,
)
out = node(_verify_state(diff)) # no state["run_id"] -> no interrupt
assert out["current_phase"] == Phase.PARKED.value
assert route_after_verify(out) == PARKED_ROUTE