* 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.
357 lines
14 KiB
Python
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")
|