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_ci_fetcher.py
Adam Moussa 3d97139300
feat(agent-team): P3-live CI apply/verify hardening + ci_fetcher (gate-passed, provisioning-gated) (#17)
* feat(agent-team): read-only CI-result fetcher for P3 verify gate (opt-in, inert)

ci_fetcher.py: fail-closed CiResultFetcher reading the GitHub Actions run
conclusion via a read-only PAT (AGENT_TEAM_CI_READ_TOKEN→GITHUB_TOKEN), returns
{run_id,conclusion,diff_hash} or None on any error. Data-fetcher only — ci_gate
owns the verdict; never writes, no OIDC/AWS, never reads patch artifacts.
coordinator gains opt-in gated_build_verify_wiring() composing it via
bind_ci_result_fetcher; NOT wired into the default run-team.py path. 20 tests.

* harden(agent-team): P3 apply/verify workflow — GitHub App token, CWE-94, fail-closed

Decision-1 auth model: gate-and-pr uses a GitHub App installation token
(pull-requests:write) behind the agent-apply environment; ALL OIDC/id-token/AWS
removed. Hardening: task_id env-indirection (CWE-94 — GitHub expands ${{ }} into
the run shell before exec, so %s/quoting is insufficient); run-id pinning on both
download-artifact; post-build denied-path check (build-hook writes into denied
paths fail the job); empty-hash fail-closed in BOTH the embedded gate (fixed a
real ''=='' pass bug) and ci_gate.py. App-token + draft-PR steps stay if:${{ false }}
until provisioning (App + environment + branch protection). +17 tests.

* harden(agent-team): apply P3-live security-gate fixes (GPT-4.1 xreview + sh-security-review)

BLOCK-1/FIX-4: gate-and-pr re-comments pull-requests:write + environment:agent-apply
(provisioning-time uncomment) and gains needs.guard/build-test=='success' job guard —
zero privilege until provisioning. BLOCK-2/3+FIX-5: ci_fetcher validates run_id (^[0-9]{1,20}$),
owner/repo (^[A-Za-z0-9_.-]{1,100}$), and fetched_id (int) — fail closed, no SSRF/path
injection. FIX-1: conclusion allowlist. FIX-3: api_root removed from public builder (no
injectable endpoint). INJ-02: post-build denied-path check uses NUL-delimited git output +
explicit rename parsing, no backslash mangling, non-UTF8=violation. INJ-03: all three trust-
control denylists unified to one 22-entry union + drift-guard test. Q1: documented run_id/
diff_hash trust source (dispatcher/ledger only). 884 tests, ruff clean. Privileged steps stay
if:${{ false }} until provisioning.

* build(security-review): prune .claude worktrees from deterministic scanners

Agent worktrees under .claude/worktrees/ are full repo copies; the cfn-lint
find|xargs template scan overflowed ('command line cannot be assembled') and the
pre-push hook fail-closed to BLOCK whenever a worktree was present. Prune .claude
in the cfn-lint find + semgrep/checkov excludes, and gitignore .claude/ so it is
never scanned or committed. Unblocks main-tree pushes during parallel agent work.
2026-06-18 15:53:26 -04:00

357 lines
14 KiB
Python

"""Unit tests for agent_team.ci_fetcher — the read-only, fail-closed CI fetcher.
The fetcher is a pure DATA seam: on a clean authenticated run it returns the
``{run_id, conclusion, diff_hash}`` mapping the pure-code gate consumes; on ANY
error (404 / auth fail / malformed body / timeout / missing run_id / missing
token) it returns ``None`` so the gate BLOCKs (fail-closed). It NEVER derives a
verdict and NEVER writes. Everything is mocked with a fake HTTP client; no
network, no real token.
"""
from __future__ import annotations
import pytest
from agent_team.ci_fetcher import (
CI_READ_TOKEN_ENV,
build_ci_result_fetcher,
fetch_ci_result,
)
class _FakeResponse:
def __init__(self, status: int, body):
self.status_code = status
self._body = body
def json(self):
if isinstance(self._body, Exception):
raise self._body
return self._body
class _FakeClient:
"""A requests-like client recording calls; raises only write verbs if asked."""
def __init__(self, response=None, raise_exc=None):
self._response = response
self._raise = raise_exc
self.calls: list[tuple[str, dict]] = []
def get(self, url, *, timeout=None):
self.calls.append((url, {"timeout": timeout}))
if self._raise is not None:
raise self._raise
return self._response
# If the fetcher ever tried to write, these would record it — they must not
# be called (the fetcher is read-only).
def post(self, *a, **k): # pragma: no cover - must never be called
raise AssertionError("ci_fetcher must never POST")
def patch(self, *a, **k): # pragma: no cover - must never be called
raise AssertionError("ci_fetcher must never PATCH")
def _state(run_id="123", diff_hash="abc123"):
return {"run_id": run_id, "diff_hash": diff_hash}
# --------------------------------------------------------------------------- #
# Success path
# --------------------------------------------------------------------------- #
def test_success_returns_correct_mapping() -> None:
client = _FakeClient(
_FakeResponse(200, {"id": 123, "conclusion": "success", "status": "completed"})
)
out = fetch_ci_result(_state(), owner="o", repo="r", client=client)
assert out == {"run_id": "123", "conclusion": "success", "diff_hash": "abc123"}
# It hit the runs endpoint for the right run, read-only.
assert client.calls[0][0].endswith("/repos/o/r/actions/runs/123")
def test_failure_conclusion_is_echoed_not_judged() -> None:
# The fetcher returns the raw conclusion; it does NOT decide pass/fail.
client = _FakeClient(_FakeResponse(200, {"id": 5, "conclusion": "failure"}))
out = fetch_ci_result(_state(run_id="5"), owner="o", repo="r", client=client)
assert out is not None
assert out["conclusion"] == "failure" # echoed verbatim, no verdict derived
def test_diff_hash_echoed_from_state() -> None:
client = _FakeClient(_FakeResponse(200, {"id": 9, "conclusion": "success"}))
out = fetch_ci_result(
{"run_id": "9", "diff_hash": "DEADBEEF"}, owner="o", repo="r", client=client
)
assert out["diff_hash"] == "DEADBEEF"
def test_run_id_read_from_state_drives_url() -> None:
client = _FakeClient(_FakeResponse(200, {"id": 777, "conclusion": "success"}))
fetch_ci_result(_state(run_id="777"), owner="acme", repo="svc", client=client)
assert client.calls[0][0].endswith("/repos/acme/svc/actions/runs/777")
# --------------------------------------------------------------------------- #
# Fail-closed paths (every error -> None)
# --------------------------------------------------------------------------- #
def test_404_fails_closed() -> None:
client = _FakeClient(_FakeResponse(404, {"message": "Not Found"}))
assert fetch_ci_result(_state(), owner="o", repo="r", client=client) is None
def test_auth_fail_401_fails_closed() -> None:
client = _FakeClient(_FakeResponse(401, {"message": "Bad credentials"}))
assert fetch_ci_result(_state(), owner="o", repo="r", client=client) is None
def test_forbidden_403_fails_closed() -> None:
client = _FakeClient(_FakeResponse(403, {"message": "forbidden"}))
assert fetch_ci_result(_state(), owner="o", repo="r", client=client) is None
def test_malformed_body_fails_closed() -> None:
client = _FakeClient(_FakeResponse(200, ValueError("not json")))
assert fetch_ci_result(_state(), owner="o", repo="r", client=client) is None
def test_non_object_body_fails_closed() -> None:
client = _FakeClient(_FakeResponse(200, ["not", "a", "dict"]))
assert fetch_ci_result(_state(), owner="o", repo="r", client=client) is None
def test_missing_conclusion_fails_closed() -> None:
# In-progress run: conclusion is None -> not an authenticated verdict.
client = _FakeClient(_FakeResponse(200, {"id": 1, "conclusion": None}))
assert (
fetch_ci_result(_state(run_id="1"), owner="o", repo="r", client=client) is None
)
def test_timeout_fails_closed() -> None:
client = _FakeClient(raise_exc=TimeoutError("timed out"))
assert fetch_ci_result(_state(), owner="o", repo="r", client=client) is None
def test_connection_error_fails_closed() -> None:
client = _FakeClient(raise_exc=ConnectionError("refused"))
assert fetch_ci_result(_state(), owner="o", repo="r", client=client) is None
def test_missing_run_id_fails_closed() -> None:
client = _FakeClient(_FakeResponse(200, {"id": 1, "conclusion": "success"}))
assert (
fetch_ci_result({"diff_hash": "x"}, owner="o", repo="r", client=client) is None
)
# And it never even made a request.
assert client.calls == []
def test_empty_run_id_fails_closed() -> None:
client = _FakeClient(_FakeResponse(200, {"id": 1, "conclusion": "success"}))
assert (
fetch_ci_result(
{"run_id": "", "diff_hash": "x"}, owner="o", repo="r", client=client
)
is None
)
assert client.calls == []
# --------------------------------------------------------------------------- #
# Missing token (no injected client) -> fails closed (does NOT raise)
# --------------------------------------------------------------------------- #
def test_missing_token_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(CI_READ_TOKEN_ENV, raising=False)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
# No client injected -> it must resolve a token; none set -> None (no raise).
assert fetch_ci_result(_state(), owner="o", repo="r") is None
# --------------------------------------------------------------------------- #
# Read-only contract: never derives a verdict, never writes
# --------------------------------------------------------------------------- #
def test_fetcher_never_returns_a_verdict_field() -> None:
client = _FakeClient(_FakeResponse(200, {"id": 2, "conclusion": "failure"}))
out = fetch_ci_result(_state(run_id="2"), owner="o", repo="r", client=client)
assert out is not None
# The mapping is DATA only — there is no gate_decision / passed / verdict.
assert set(out.keys()) == {"run_id", "conclusion", "diff_hash"}
assert "passed" not in out
assert "gate_decision" not in out
def test_build_ci_result_fetcher_returns_state_callable() -> None:
client = _FakeClient(_FakeResponse(200, {"id": 3, "conclusion": "success"}))
fetcher = build_ci_result_fetcher(owner="o", repo="r", client=client)
out = fetcher({"run_id": "3", "diff_hash": "h"})
assert out == {"run_id": "3", "conclusion": "success", "diff_hash": "h"}
def test_build_ci_result_fetcher_fails_closed_on_error() -> None:
client = _FakeClient(_FakeResponse(500, {"message": "boom"}))
fetcher = build_ci_result_fetcher(owner="o", repo="r", client=client)
assert fetcher({"run_id": "3", "diff_hash": "h"}) is None
# --------------------------------------------------------------------------- #
# BLOCK-2: run_id validation (fail-closed; never reaches the URL)
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"bad_run_id",
[
"abc", # non-numeric
"12a", # mixed
"../runs/999", # path traversal
"1 2", # whitespace
"1" * 21, # too long (> 20 digits)
"-1", # sign
"0x10", # hex
],
)
def test_invalid_run_id_fails_closed(bad_run_id: str) -> None:
client = _FakeClient(_FakeResponse(200, {"id": 1, "conclusion": "success"}))
assert (
fetch_ci_result(
{"run_id": bad_run_id, "diff_hash": "x"},
owner="o",
repo="r",
client=client,
)
is None
)
# It never made a request: rejected before URL construction.
assert client.calls == []
def test_valid_numeric_run_id_is_accepted() -> None:
client = _FakeClient(_FakeResponse(200, {"id": 42, "conclusion": "success"}))
out = fetch_ci_result(_state(run_id="42"), owner="o", repo="r", client=client)
assert out is not None and out["run_id"] == "42"
# --------------------------------------------------------------------------- #
# BLOCK-3: owner/repo validation (fail-closed; never reaches the URL)
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"owner,repo",
[
("o/../x", "r"), # traversal in owner
("o", "r/runs/9"), # extra segments in repo
("o ", "r"), # whitespace
("o", ""), # empty repo
("", "r"), # empty owner
("o", "r#frag"), # url metachar
("o?", "r"), # query metachar
],
)
def test_invalid_owner_repo_fails_closed(owner: str, repo: str) -> None:
client = _FakeClient(_FakeResponse(200, {"id": 1, "conclusion": "success"}))
assert fetch_ci_result(_state(), owner=owner, repo=repo, client=client) is None
assert client.calls == []
def test_valid_owner_repo_with_dots_dashes_underscores() -> None:
client = _FakeClient(_FakeResponse(200, {"id": 1, "conclusion": "success"}))
out = fetch_ci_result(
_state(run_id="1"), owner="my-org.x", repo="repo_name.v2", client=client
)
assert out is not None
assert client.calls[0][0].endswith("/repos/my-org.x/repo_name.v2/actions/runs/1")
# --------------------------------------------------------------------------- #
# FIX-1: conclusion allowlist (unrecognised -> fail-closed)
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"conclusion",
[
"success",
"failure",
"cancelled",
"skipped",
"timed_out",
"action_required",
"neutral",
"stale",
"startup_failure",
],
)
def test_allowlisted_conclusions_pass(conclusion: str) -> None:
client = _FakeClient(_FakeResponse(200, {"id": 1, "conclusion": conclusion}))
out = fetch_ci_result(_state(run_id="1"), owner="o", repo="r", client=client)
assert out is not None and out["conclusion"] == conclusion
def test_conclusion_is_normalized_lowercase() -> None:
client = _FakeClient(_FakeResponse(200, {"id": 1, "conclusion": "SUCCESS"}))
out = fetch_ci_result(_state(run_id="1"), owner="o", repo="r", client=client)
assert out is not None and out["conclusion"] == "success"
@pytest.mark.parametrize("bad", ["bogus", "passed", "won", "", "in_progress"])
def test_unrecognised_conclusion_fails_closed(bad: str) -> None:
client = _FakeClient(_FakeResponse(200, {"id": 1, "conclusion": bad}))
assert (
fetch_ci_result(_state(run_id="1"), owner="o", repo="r", client=client) is None
)
# --------------------------------------------------------------------------- #
# FIX-5: fetched_id validation (no arbitrary-string laundering)
# --------------------------------------------------------------------------- #
def test_integer_fetched_id_is_used() -> None:
client = _FakeClient(_FakeResponse(200, {"id": 999, "conclusion": "success"}))
out = fetch_ci_result(_state(run_id="1"), owner="o", repo="r", client=client)
assert out is not None and out["run_id"] == "999"
def test_numeric_string_fetched_id_is_used() -> None:
client = _FakeClient(_FakeResponse(200, {"id": "888", "conclusion": "success"}))
out = fetch_ci_result(_state(run_id="1"), owner="o", repo="r", client=client)
assert out is not None and out["run_id"] == "888"
@pytest.mark.parametrize(
"bad_id",
["../evil", "abc", "9a", True, {"x": 1}, ["1"], 1.5],
)
def test_non_integer_fetched_id_falls_back_to_validated_run_id(bad_id) -> None:
client = _FakeClient(_FakeResponse(200, {"id": bad_id, "conclusion": "success"}))
out = fetch_ci_result(_state(run_id="7"), owner="o", repo="r", client=client)
# Falls back to the already-validated requested run_id, never laundering the id.
assert out is not None and out["run_id"] == "7"
def test_missing_fetched_id_falls_back_to_run_id() -> None:
client = _FakeClient(_FakeResponse(200, {"conclusion": "success"}))
out = fetch_ci_result(_state(run_id="55"), owner="o", repo="r", client=client)
assert out is not None and out["run_id"] == "55"
# --------------------------------------------------------------------------- #
# FIX-3: production builder does not accept a caller-overridable api_root
# --------------------------------------------------------------------------- #
def test_build_ci_result_fetcher_rejects_api_root_kwarg() -> None:
# The production builder must NOT expose api_root (so the host cannot be
# redirected from the coordinator/production wiring).
with pytest.raises(TypeError):
build_ci_result_fetcher(owner="o", repo="r", api_root="https://evil.example")