- BLOCK: cap candidate diff at 40 KB in the dispatcher (the diff rides a base64 workflow_dispatch input; GitHub caps inputs at ~64 KB so an oversized diff cannot dispatch at all) + a defense-in-depth decoded-size bound in materialize. - FIX: harden the draft-PR HEAD_BRANCH guard to reject leading/trailing slash, '..' segments, and '//' (CWE-88 git ref-traversal), not just bad charset. - NIT: document the mandatory invariants on gate-and-pr (required-reviewer environment must stay; runs-on must stay GitHub-hosted). - QUESTION (lockfiles): answered in-code — the build-test sandbox is credential-less + egress-blocked, so lockfile-postinstall RCE is contained. Tests added for all guards. 1042 tests, ruff clean, YAML valid.
179 lines
6 KiB
Python
179 lines
6 KiB
Python
"""Unit tests for agent_team.dispatcher — the trusted apply-path transport (§4.3).
|
|
|
|
Fully hermetic: the branch-push and workflow-dispatch side effects are injected
|
|
fakes, so no git, no ``gh``, and no network are exercised. The tests pin the
|
|
pure input-assembly + validation contract and the push-before-dispatch order.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
|
|
import pytest
|
|
|
|
from agent_team.dispatcher import (
|
|
MAX_DIFF_BYTES,
|
|
DispatcherError,
|
|
DispatchInputs,
|
|
build_dispatch_inputs,
|
|
dispatch_apply_verify,
|
|
head_branch_for,
|
|
)
|
|
from agent_team.state_store import compute_content_hash
|
|
|
|
TASK = "0a1b2c3d4e5f6071"
|
|
DIFF = "diff --git a/README.md b/README.md\n--- a/README.md\n+++ b/README.md\n@@ -1 +1,2 @@\n title\n+added line\n"
|
|
SCOPE = "README.md\ndocs/**"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# build_dispatch_inputs (pure)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_build_dispatch_inputs_binds_hash_and_b64() -> None:
|
|
di = build_dispatch_inputs(task_id=TASK, diff_text=DIFF, declared_scope=SCOPE)
|
|
# expected_diff_hash is the plain sha256 the workflow's sha256sum reproduces.
|
|
assert di.expected_diff_hash == compute_content_hash(DIFF.encode("utf-8"))
|
|
# diff_b64 round-trips back to the exact diff bytes.
|
|
assert base64.b64decode(di.diff_b64).decode("utf-8") == DIFF
|
|
assert di.head_branch == f"agent-team/apply/{TASK}"
|
|
assert di.diff_artifact_name == f"agent-team-diff-{TASK}"
|
|
assert di.declared_scope == SCOPE
|
|
|
|
|
|
def test_as_inputs_keys_match_the_six_workflow_inputs() -> None:
|
|
di = build_dispatch_inputs(task_id=TASK, diff_text=DIFF, declared_scope=SCOPE)
|
|
assert set(di.as_inputs()) == {
|
|
"task_id",
|
|
"diff_artifact_name",
|
|
"expected_diff_hash",
|
|
"declared_scope",
|
|
"diff_b64",
|
|
"head_branch",
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("bad_diff", ["", " ", "\n\n"])
|
|
def test_empty_diff_rejected(bad_diff: str) -> None:
|
|
with pytest.raises(DispatcherError):
|
|
build_dispatch_inputs(task_id=TASK, diff_text=bad_diff, declared_scope=SCOPE)
|
|
|
|
|
|
def test_oversized_diff_rejected() -> None:
|
|
# The diff rides a base64 workflow_dispatch input (GitHub ~64 KB cap); a diff
|
|
# over MAX_DIFF_BYTES must fail closed in the dispatcher, not be dispatched.
|
|
big = "diff --git a/x b/x\n" + "+" + ("x" * (MAX_DIFF_BYTES + 1)) + "\n"
|
|
with pytest.raises(DispatcherError):
|
|
build_dispatch_inputs(task_id=TASK, diff_text=big, declared_scope=SCOPE)
|
|
|
|
|
|
@pytest.mark.parametrize("bad_scope", ["", " "])
|
|
def test_empty_scope_rejected(bad_scope: str) -> None:
|
|
# An empty declared scope would let a diff touch ANY path — fail closed.
|
|
with pytest.raises(DispatcherError):
|
|
build_dispatch_inputs(task_id=TASK, diff_text=DIFF, declared_scope=bad_scope)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad_task", ["", "has space", "semi;colon", "../escape", "a/b", "x" * 201]
|
|
)
|
|
def test_unsafe_task_id_rejected(bad_task: str) -> None:
|
|
with pytest.raises(DispatcherError):
|
|
head_branch_for(bad_task)
|
|
with pytest.raises(DispatcherError):
|
|
build_dispatch_inputs(task_id=bad_task, diff_text=DIFF, declared_scope=SCOPE)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# dispatch_apply_verify (injected seams)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class _Recorder:
|
|
def __init__(self) -> None:
|
|
self.calls: list[dict] = []
|
|
|
|
|
|
def test_dispatch_pushes_then_fires_with_correct_inputs() -> None:
|
|
order: list[str] = []
|
|
pushed = _Recorder()
|
|
fired = _Recorder()
|
|
|
|
def pusher(*, owner, repo, base, head_branch, diff_text):
|
|
order.append("push")
|
|
pushed.calls.append(
|
|
{
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"base": base,
|
|
"head": head_branch,
|
|
"diff": diff_text,
|
|
}
|
|
)
|
|
|
|
def dispatcher(*, owner, repo, inputs, ref):
|
|
order.append("dispatch")
|
|
fired.calls.append({"owner": owner, "repo": repo, "inputs": inputs, "ref": ref})
|
|
|
|
di = dispatch_apply_verify(
|
|
owner="Sea-Haven-Industries",
|
|
repo="orchestrator",
|
|
task_id=TASK,
|
|
diff_text=DIFF,
|
|
declared_scope=SCOPE,
|
|
pusher=pusher,
|
|
dispatcher=dispatcher,
|
|
)
|
|
|
|
assert isinstance(di, DispatchInputs)
|
|
# Branch is pushed BEFORE the workflow is dispatched (the draft-PR step opens
|
|
# against an already-pushed --head).
|
|
assert order == ["push", "dispatch"]
|
|
assert pushed.calls[0]["head"] == f"agent-team/apply/{TASK}"
|
|
assert pushed.calls[0]["diff"] == DIFF
|
|
# The dispatch carries all six inputs, including the b64 diff + head branch.
|
|
inputs = fired.calls[0]["inputs"]
|
|
assert inputs["head_branch"] == f"agent-team/apply/{TASK}"
|
|
assert base64.b64decode(inputs["diff_b64"]).decode("utf-8") == DIFF
|
|
assert inputs["expected_diff_hash"] == compute_content_hash(DIFF.encode("utf-8"))
|
|
assert fired.calls[0]["ref"] == "main"
|
|
|
|
|
|
def test_dispatch_does_not_fire_if_push_fails() -> None:
|
|
fired = _Recorder()
|
|
|
|
def failing_pusher(**_kw):
|
|
raise RuntimeError("push failed")
|
|
|
|
def dispatcher(**kw):
|
|
fired.calls.append(kw)
|
|
|
|
with pytest.raises(RuntimeError):
|
|
dispatch_apply_verify(
|
|
owner="o",
|
|
repo="r",
|
|
task_id=TASK,
|
|
diff_text=DIFF,
|
|
declared_scope=SCOPE,
|
|
pusher=failing_pusher,
|
|
dispatcher=dispatcher,
|
|
)
|
|
# A failed push must NOT dispatch a run (no orphan run against a missing head).
|
|
assert fired.calls == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"owner,repo", [("", "r"), ("o", ""), ("bad owner", "r"), ("o", "r/x")]
|
|
)
|
|
def test_unsafe_owner_repo_rejected(owner: str, repo: str) -> None:
|
|
with pytest.raises(DispatcherError):
|
|
dispatch_apply_verify(
|
|
owner=owner,
|
|
repo=repo,
|
|
task_id=TASK,
|
|
diff_text=DIFF,
|
|
declared_scope=SCOPE,
|
|
pusher=lambda **_k: None,
|
|
dispatcher=lambda **_k: None,
|
|
)
|