Resolves three execution-proven verifier findings from the scaffold review. Full suite: 548 passed, 1 skipped (stable across repeated runs); ruff clean. builders denylist (§3.3.2 #2): scan was +++-only and missed header-only sections. Now section-driven off `diff --git a/<src> b/<dest>`, catching the 4 proven bypasses — delete of a denied path, mode-change-only, `copy to` a denied path, out-of-scope delete (regression tests for each). §3.3.1 compare-and-set concurrency: BEGIN IMMEDIATE moved inside guarded retry; each CAS now runs on its own connection (shared sqlite3.Connection cannot hold two transactions, and is unsafe for concurrent use even for reads). connect() stashes the db path on a Connection subclass so the path is derived by a thread-safe attribute read, not a PRAGMA on the shared conn; busy_timeout set before the WAL pragma. Added shared-connection concurrent regression tests (distinct + same question) — previously raised "transaction within a transaction". operator CLI (run-team.py): added the design-named re-deliver and force-resume verbs (were missing); audit now records the attempt BEFORE the mutation and the outcome after, so a ledger mutation can never land without a trail; main() catches OSError instead of leaving an uncaught traceback on audit-write failure.
444 lines
15 KiB
Python
444 lines
15 KiB
Python
"""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")
|