"""Unit tests for the Plane-2 builders node (design §3.3.2, §7.1 P3). Covers the two box-side halves of the §3.3.2 trust boundary this leaf owns: the trust-control-surface denylist scan (boundary #2) and the diff integrity hash (boundary #3), plus the LangGraph node's clean-vs-park state transitions. The tests import the committed foundation contracts (``billing``, ``state_store``, ``task_model``) verbatim and assert the leaf builds on them without redefining them. """ from __future__ import annotations import pytest from agent_team import billing from agent_team.billing import BillingMode, ClaudeResult from agent_team.nodes import builders from agent_team.nodes.builders import ( BuildError, TrustBoundaryViolation, build_candidate_diff, builders_node, default_diff_builder, iter_diff_target_paths, scan_trust_control_surface, ) from agent_team.state_store import compute_content_hash from agent_team.task_model import Phase, TaskStatus # --------------------------------------------------------------------------- # # Diff fixtures # --------------------------------------------------------------------------- # CLEAN_DIFF = """diff --git a/src/app.py b/src/app.py --- a/src/app.py +++ b/src/app.py @@ -1,2 +1,2 @@ -old = 1 +new = 2 """ NEW_FILE_DIFF = """diff --git a/src/util/helpers.py b/src/util/helpers.py new file mode 100644 --- /dev/null +++ b/src/util/helpers.py @@ -0,0 +1 @@ +def f(): ... """ WORKFLOW_DIFF = """diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1 +1 @@ -on: push +on: [push, pull_request_target] """ def _scope_plan(diff_builder, scope=("src",), **extra): """Build a plan dict whose declared scope is ``scope``.""" plan = {"title": "t", "scope": list(scope), "phases": ["p1"]} plan.update(extra) return plan @pytest.fixture(autouse=True) def _restore_invoker(): """Restore the billing invoker after each test (shared module state).""" original = billing._invoker yield billing._invoker = original # --------------------------------------------------------------------------- # # Foundation imports are used verbatim (no redefinition) # --------------------------------------------------------------------------- # def test_imports_foundation_contracts_verbatim() -> None: # The leaf imports, not redefines, the foundation symbols. assert builders.compute_content_hash is compute_content_hash assert builders.claude_invoke is billing.claude_invoke assert builders.Phase is Phase assert builders.TaskStatus is TaskStatus # --------------------------------------------------------------------------- # # default_diff_builder uses the §3.1 billing seam # --------------------------------------------------------------------------- # def test_default_diff_builder_calls_claude_invoke() -> None: seen: dict = {} def fake(prompt: str, *, mode: BillingMode, **kw): seen["prompt"] = prompt return ClaudeResult(text=CLEAN_DIFF, mode=mode) billing.set_invoker(fake) out = default_diff_builder(plan={"title": "x", "scope": ["src"]}, config=None) assert out == CLEAN_DIFF # The plan title and scope are surfaced into the build prompt. assert "x" in seen["prompt"] assert "src" in seen["prompt"] def test_default_diff_builder_fails_loud_when_unwired() -> None: # Foundation contract: the seam raises until an invoker is bound. billing._invoker = billing._unconfigured_invoker with pytest.raises(RuntimeError): default_diff_builder(plan={"title": "x"}, config=None) # --------------------------------------------------------------------------- # # Diff parsing / canonicalization # --------------------------------------------------------------------------- # def test_iter_targets_reads_plus_header() -> None: targets = iter_diff_target_paths(CLEAN_DIFF) assert [t.path for t in targets] == ["src/app.py"] def test_iter_targets_ignores_dev_null_source() -> None: targets = iter_diff_target_paths(NEW_FILE_DIFF) assert [t.path for t in targets] == ["src/util/helpers.py"] def test_iter_targets_canonicalizes_dot_segments() -> None: diff = "+++ b/src/./sub/../app.py\n" targets = iter_diff_target_paths(diff) assert [t.path for t in targets] == ["src/app.py"] def test_iter_targets_dedupes() -> None: diff = "+++ b/src/app.py\n+++ b/src/app.py\n" assert len(iter_diff_target_paths(diff)) == 1 # --------------------------------------------------------------------------- # # Denylist: each surface (§3.3.2 boundary #2) # --------------------------------------------------------------------------- # def test_scan_clean_diff_is_empty() -> None: assert scan_trust_control_surface(CLEAN_DIFF, scope=["src"]) == [] def test_scan_flags_github_workflow() -> None: v = scan_trust_control_surface(WORKFLOW_DIFF, scope=["src", ".github"]) assert len(v) == 1 assert ".github/workflows" in v[0].reason @pytest.mark.parametrize( "path", [ "infra/template.yaml", "samconfig.toml".replace("toml", "yaml"), "service/serverless.yml", "cdk.json", "iam/read-policy.json", "policies/deploy.policy.yaml", "CODEOWNERS", ".github/CODEOWNERS", ".github/dependabot.yml", ".github/settings.yml", ], ) def test_scan_flags_trust_control_surface(path: str) -> None: diff = f"+++ b/{path}\n" # Declare a wide scope so the only possible failure is the denylist itself. v = scan_trust_control_surface(diff, scope=[path.split("/")[0], "."]) assert v, f"expected {path} to be denied" assert v[0].path == path def test_scan_denylist_beats_scope() -> None: # A workflow file inside the declared scope is still denied. v = scan_trust_control_surface(WORKFLOW_DIFF, scope=[".github"]) assert len(v) == 1 assert "workflow" in v[0].reason # --------------------------------------------------------------------------- # # Scope enforcement # --------------------------------------------------------------------------- # def test_scan_flags_out_of_scope_path() -> None: diff = "+++ b/other/module.py\n" v = scan_trust_control_surface(diff, scope=["src"]) assert len(v) == 1 assert "declared scope" in v[0].reason def test_empty_scope_rejects_everything() -> None: # An undeclared scope is a hard stop, not a wildcard. v = scan_trust_control_surface(CLEAN_DIFF, scope=[]) assert len(v) == 1 assert "declared scope" in v[0].reason def test_scope_prefix_match_is_boundary_safe() -> None: # "src" must not accidentally allow "srcfoo/...". diff = "+++ b/srcfoo/app.py\n" v = scan_trust_control_surface(diff, scope=["src"]) assert len(v) == 1 assert "declared scope" in v[0].reason # --------------------------------------------------------------------------- # # Renames cannot launder a forbidden destination # --------------------------------------------------------------------------- # def test_rename_into_denied_path_is_flagged() -> None: diff = ( "diff --git a/src/app.py b/.github/workflows/evil.yml\n" "similarity index 100%\n" "rename from src/app.py\n" "rename to .github/workflows/evil.yml\n" ) v = scan_trust_control_surface(diff, scope=["src", ".github"]) assert len(v) == 1 assert v[0].path == ".github/workflows/evil.yml" assert v[0].rename_from == "src/app.py" def test_rename_into_out_of_scope_is_flagged() -> None: diff = "rename from src/app.py\nrename to other/app.py\n" v = scan_trust_control_surface(diff, scope=["src"]) assert len(v) == 1 assert v[0].path == "other/app.py" assert v[0].rename_from == "src/app.py" # --------------------------------------------------------------------------- # # Deletes / mode-changes / copies cannot bypass the scan # (regression: header-only sections carry their path in ``diff --git``, not the # ``+++ b/`` body line, so a ``+++``-only scan missed all four of these) # --------------------------------------------------------------------------- # def test_delete_of_denied_path_is_flagged() -> None: diff = ( "diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml\n" "deleted file mode 100644\n" "--- a/.github/workflows/ci.yml\n" "+++ /dev/null\n" "@@ -1 +0,0 @@\n" "-on: push\n" ) v = scan_trust_control_surface(diff, scope=["src", ".github"]) assert len(v) == 1 assert v[0].path == ".github/workflows/ci.yml" assert "workflow" in v[0].reason def test_mode_change_only_on_denied_path_is_flagged() -> None: # A chmod with no +++ line at all — only the diff --git header exists. diff = ( "diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml\n" "old mode 100644\n" "new mode 100755\n" ) v = scan_trust_control_surface(diff, scope=["src", ".github"]) assert len(v) == 1 assert v[0].path == ".github/workflows/ci.yml" assert "workflow" in v[0].reason def test_copy_into_denied_path_is_flagged() -> None: # git ``copy to`` (not ``rename to``) must not launder a forbidden dest. diff = ( "diff --git a/src/x.py b/.github/workflows/evil.yml\n" "similarity index 100%\n" "copy from src/x.py\n" "copy to .github/workflows/evil.yml\n" ) v = scan_trust_control_surface(diff, scope=["src", ".github"]) assert [x.path for x in v] == [".github/workflows/evil.yml"] assert v[0].rename_from == "src/x.py" assert "workflow" in v[0].reason def test_out_of_scope_delete_is_flagged() -> None: diff = ( "diff --git a/secret/key.py b/secret/key.py\n" "deleted file mode 100644\n" "--- a/secret/key.py\n" "+++ /dev/null\n" "@@ -1 +0,0 @@\n" "-KEY = 1\n" ) v = scan_trust_control_surface(diff, scope=["src"]) assert len(v) == 1 assert v[0].path == "secret/key.py" assert "scope" in v[0].reason # --------------------------------------------------------------------------- # # Unsafe paths (absolute / parent-escaping / indirection) # --------------------------------------------------------------------------- # @pytest.mark.parametrize( "raw", [ "/etc/passwd", "../../../etc/passwd", "..", "src/../../escape.py", "c:/windows/system32", # absolute-ish via backslash normalization below ], ) def test_scan_flags_unsafe_paths(raw: str) -> None: diff = f"+++ b/{raw}\n" v = scan_trust_control_surface(diff, scope=["src", "."]) assert v, f"expected {raw} to be flagged" assert any("unsafe" in x.reason or "scope" in x.reason for x in v) def test_backslash_separator_is_normalized() -> None: # A Windows separator must not smuggle a denied path past the POSIX matcher. diff = "+++ b/.github\\workflows\\ci.yml\n" v = scan_trust_control_surface(diff, scope=[".github"]) assert len(v) == 1 assert "workflow" in v[0].reason # --------------------------------------------------------------------------- # # build_candidate_diff: hashing (§3.3.2 boundary #3) + errors # --------------------------------------------------------------------------- # def _stub_builder(diff: str): def _b(*, plan, config): return diff return _b def test_build_hash_matches_foundation_primitive() -> None: plan = _scope_plan(None) outcome = build_candidate_diff(plan, builder=_stub_builder(CLEAN_DIFF)) assert outcome.diff == CLEAN_DIFF assert outcome.diff_hash == compute_content_hash(CLEAN_DIFF.encode("utf-8")) assert outcome.clean is True def test_build_clean_outcome_has_no_violations() -> None: plan = _scope_plan(None) outcome = build_candidate_diff(plan, builder=_stub_builder(NEW_FILE_DIFF)) assert outcome.violations == [] assert outcome.clean is True def test_build_violation_still_hashes() -> None: plan = _scope_plan(None, scope=("src", ".github")) outcome = build_candidate_diff(plan, builder=_stub_builder(WORKFLOW_DIFF)) # Hash recorded for provenance even though the diff is rejected. assert outcome.diff_hash == compute_content_hash(WORKFLOW_DIFF.encode("utf-8")) assert outcome.clean is False def test_build_rejects_non_mapping_plan() -> None: with pytest.raises(BuildError): build_candidate_diff(["not", "a", "mapping"], builder=_stub_builder(CLEAN_DIFF)) def test_build_rejects_empty_diff() -> None: plan = _scope_plan(None) with pytest.raises(BuildError): build_candidate_diff(plan, builder=_stub_builder(" \n ")) def test_build_uses_default_builder_when_none() -> None: billing.set_invoker( lambda prompt, *, mode, **kw: ClaudeResult(text=CLEAN_DIFF, mode=mode) ) outcome = build_candidate_diff(_scope_plan(None)) assert outcome.diff == CLEAN_DIFF assert outcome.clean is True # --------------------------------------------------------------------------- # # builders_node: state transitions # --------------------------------------------------------------------------- # def test_node_clean_advances_to_verify() -> None: state = {"plan": _scope_plan(None)} update = builders_node(state, builder=_stub_builder(CLEAN_DIFF)) assert update["candidate_diff"] == CLEAN_DIFF assert update["diff_hash"] == compute_content_hash(CLEAN_DIFF.encode("utf-8")) assert update["current_phase"] == Phase.VERIFY.value assert update["status"] == TaskStatus.ACTIVE.value assert "park_reason" not in update def test_node_violation_parks_for_human_review() -> None: state = {"plan": _scope_plan(None, scope=("src", ".github"))} update = builders_node(state, builder=_stub_builder(WORKFLOW_DIFF)) # Diff + hash recorded for provenance/ALARM, but parked — never auto-built. assert update["candidate_diff"] == WORKFLOW_DIFF assert update["current_phase"] == Phase.PARKED.value assert update["status"] == TaskStatus.PARKED.value assert "cross-review" in update["park_reason"] assert "workflow" in update["park_reason"] def test_node_out_of_scope_parks() -> None: diff = "+++ b/other/x.py\n" state = {"plan": _scope_plan(None, scope=("src",))} update = builders_node(state, builder=_stub_builder(diff)) assert update["status"] == TaskStatus.PARKED.value assert "declared scope" in update["park_reason"] def test_node_requires_plan() -> None: with pytest.raises(BuildError): builders_node({}, builder=_stub_builder(CLEAN_DIFF)) def test_node_uses_default_builder_and_config() -> None: captured: dict = {} def fake(prompt: str, *, mode: BillingMode, **kw): captured["mode"] = mode return ClaudeResult(text=CLEAN_DIFF, mode=mode) billing.set_invoker(fake) state = {"plan": _scope_plan(None)} update = builders_node(state, config={"billing_mode": "api"}) assert update["current_phase"] == Phase.VERIFY.value # The config-selected billing mode reached the seam. assert captured["mode"] is BillingMode.API def test_violation_dataclass_shape() -> None: v = TrustBoundaryViolation(path="a", reason="b", rename_from="c") assert (v.path, v.reason, v.rename_from) == ("a", "b", "c")