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_builders.py

475 lines
16 KiB
Python
Raw Normal View History

Add Plane-2 leaf scaffold (pipeline graph, nodes, HITL, transports, CI) Consolidates the 18 leaf modules from the r720-plane2-scaffold workflow onto the foundation commit. Full suite: 535 passed, 1 skipped; ruff + format clean. Built (pre-deployment scaffold only — nothing provisioned/enabled): - LangGraph pipeline graph.py (INTAKE->CLARIFY->PLAN, interrupt()/resume, checkpointer-injectable) - nodes: clarifier (98% gate), planner, review_loop (GPT-4.1), builders->candidate diff, verifier - §3.3.1 HITL: ledger ops, resume_worker, deadline_timer, recovery sweep, responder - transports: slack / github / claude_code adapters - ci_gate (pure-code pass/fail), operator_cli, run-team.py entry, P1 sim harness - ci/agent-team-apply-verify.yml (split untrusted/privileged jobs) — authored, disabled KNOWN OPEN FINDINGS (verifier/cross-review, not yet fixed — see follow-up): - builders denylist: 4 execution-proven bypasses (delete, mode-change, copy-to, out-of-scope delete) - §3.3.1 CAS: BEGIN IMMEDIATE outside try/except; shared-connection txn nesting unsafe under concurrency - operator_cli: missing re-deliver/force-resume; audit-after-mutate ordering gap - ci yaml: GPT-4.1 cross-review PASS w/ 4 FIX items (symlink path escape, etc.) - P1 sim harness models the ledger layer, not real LangGraph interrupt/resume; P1 exit criteria not yet truly proven Deploy-gated (NOT done): IAM/step-ca/Roles Anywhere/confluence-bot provisioning, /sh-security-review sign-off, live Slack/CI, rsync, live dry-runs, Adam approval.
2026-06-17 14:17:44 -04:00
"""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
Resolve security-review BLOCK: CI-guard bypasses, denylist parity, force-resume Addresses the confirmed findings from /sh-security-review + the GPT-4.1 cross-review of the Plane-2 scaffold. Full suite: 589 passed; ruff clean. FIXED (proven-exploitable): - CI-guard denylist bypass (HIGH): Python fnmatch '**/' is non-recursive, so root-level template.yaml/*.tf/cdk.json/*.pem/*.key/*-stack.* evaded the trust-control surface. Replaced fnmatch with a recursive, case-insensitive glob->regex matcher. (verified: fnmatch('template.yaml','**/template.yaml')==False) - CI-guard scope bypass (HIGH): a '**' declared_scope made every path in-scope. Scope is now concrete-prefix confinement (reduces a glob to its leading metacharacter-free segments; '**' -> empty -> dropped -> unscoped reject). - Box-side vs CI denylist divergence (MED): builders.py _DENY_PATTERNS now covers Terraform, *.pem/*.key, CDK stack files, .github/actions, *iam*, bare policy*.json (case-insensitive), matching the CI surface. - force-resume was backwards (MED): it superseded the answered row recovery resumes from, making a stuck task permanently un-resumable while printing success. Now re-opens an EXPIRED (parked) question via a new reopen_question CAS helper; never supersedes an answered row; honest exit codes. - operator attribution (MED): run-team.py --operator defaulted to "" -> now the OS login, so destructive actions are always attributable. - audit-log append race (MED): replaced read-modify-rewrite (lost records under concurrent operators) with an O_APPEND single-line write, mode 600 enforced. - lstrip("ab/") path-mangling in the symlink error path -> regex prefix strip. Regression tests added across test_ci_gate_workflow / test_builders / test_run_team / test_schema. Design-level findings (resume-worker durability, egress breadth, answered_at ordering, DB-swap TOCTOU, diff-hash threat-model) are pre-deployment / P1-build-proper and recorded with written justification in agent-team/.security-review/suppressions.json; CI README diff-hash wording made honest.
2026-06-17 15:15:50 -04:00
# --------------------------------------------------------------------------- #
# Box-side / CI-side denylist parity (security-review: box-side was narrower)
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"path",
[
"infra/main.tf",
"infra/app-stack.ts",
"infra/app_stack.py",
"secrets/deploy.pem",
"signing.key",
"config/policy.json",
"roles/myiam.json",
".github/actions/build/action.yml",
],
)
def test_box_side_denylist_covers_ci_surface(path: str) -> None:
# These IaC/key families were caught by the CI guard but slipped the box-side
# scan. The box is the first backstop; it must reject them too (no auto-build).
diff = (
f"diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n"
"@@ -1 +1 @@\n-x\n+y\n"
)
scope = [path.split("/")[0], "."]
v = scan_trust_control_surface(diff, scope=scope)
assert v, f"expected {path} to be denied box-side"
Add Plane-2 leaf scaffold (pipeline graph, nodes, HITL, transports, CI) Consolidates the 18 leaf modules from the r720-plane2-scaffold workflow onto the foundation commit. Full suite: 535 passed, 1 skipped; ruff + format clean. Built (pre-deployment scaffold only — nothing provisioned/enabled): - LangGraph pipeline graph.py (INTAKE->CLARIFY->PLAN, interrupt()/resume, checkpointer-injectable) - nodes: clarifier (98% gate), planner, review_loop (GPT-4.1), builders->candidate diff, verifier - §3.3.1 HITL: ledger ops, resume_worker, deadline_timer, recovery sweep, responder - transports: slack / github / claude_code adapters - ci_gate (pure-code pass/fail), operator_cli, run-team.py entry, P1 sim harness - ci/agent-team-apply-verify.yml (split untrusted/privileged jobs) — authored, disabled KNOWN OPEN FINDINGS (verifier/cross-review, not yet fixed — see follow-up): - builders denylist: 4 execution-proven bypasses (delete, mode-change, copy-to, out-of-scope delete) - §3.3.1 CAS: BEGIN IMMEDIATE outside try/except; shared-connection txn nesting unsafe under concurrency - operator_cli: missing re-deliver/force-resume; audit-after-mutate ordering gap - ci yaml: GPT-4.1 cross-review PASS w/ 4 FIX items (symlink path escape, etc.) - P1 sim harness models the ledger layer, not real LangGraph interrupt/resume; P1 exit criteria not yet truly proven Deploy-gated (NOT done): IAM/step-ca/Roles Anywhere/confluence-bot provisioning, /sh-security-review sign-off, live Slack/CI, rsync, live dry-runs, Adam approval.
2026-06-17 14:17:44 -04:00
# --------------------------------------------------------------------------- #
# 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")