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_gate_workflow.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

211 lines
7.7 KiB
Python

"""Tests for the embedded guard logic in ``ci/agent-team-apply-verify.yml``.
The §3.3.2 trust-boundary guard (diff-integrity hash, the trust-control-surface
denylist, the symlink-escape reject, declared-scope enforcement) lives as an
inline Python heredoc inside the CI workflow, so it cannot be imported directly.
These tests extract that script from the YAML and execute it as the workflow
does — via env vars and a diff file — asserting the exit code for good and
adversarial diffs. This backs the workflow's correctness claim with a real,
runnable suite instead of an "verified during authoring" assertion, and guards
the symlink / non-UTF-8 / header-only-section fixes against regression.
It does NOT enable, provision, or run the workflow itself; it only exercises the
pure-code gate the workflow embeds.
"""
from __future__ import annotations
import hashlib
import os
import subprocess
import sys
from pathlib import Path
import pytest
_WORKFLOW = Path(__file__).resolve().parents[1] / "ci" / "agent-team-apply-verify.yml"
def _extract_guard_script() -> str:
"""Pull the first ``python3 - <<'PY' ... PY`` heredoc (the guard) from the YAML.
The body is indented to sit under the YAML ``run:`` block; we strip the
common 10-space lead so it is valid module source.
"""
lines = _WORKFLOW.read_text(encoding="utf-8").splitlines()
start = end = None
for i, line in enumerate(lines):
if start is None and line.strip() == "python3 - <<'PY'":
start = i + 1
elif start is not None and line.strip() == "PY":
end = i
break
assert start is not None and end is not None, "guard heredoc not found"
body = lines[start:end]
return "\n".join(ln[10:] if ln.startswith(" " * 10) else ln for ln in body)
@pytest.fixture(scope="module")
def guard_script(tmp_path_factory: pytest.TempPathFactory) -> Path:
path = tmp_path_factory.mktemp("guard") / "guard.py"
path.write_text(_extract_guard_script(), encoding="utf-8")
return path
def _run_guard(
guard_script: Path,
tmp_path: Path,
diff: str | bytes,
scope: str,
*,
bad_hash: bool = False,
) -> int:
raw = diff.encode("utf-8") if isinstance(diff, str) else diff
diff_path = tmp_path / "candidate.diff"
diff_path.write_bytes(raw)
expected = "deadbeef" if bad_hash else hashlib.sha256(raw).hexdigest()
env = dict(
os.environ,
DIFF_PATH=str(diff_path),
EXPECTED_DIFF_HASH=expected,
DECLARED_SCOPE=scope,
)
result = subprocess.run(
[sys.executable, str(guard_script)], env=env, capture_output=True, text=True
)
return result.returncode
CLEAN = (
"diff --git a/src/app.py b/src/app.py\n"
"--- a/src/app.py\n+++ b/src/app.py\n@@ -1 +1 @@\n-x\n+y\n"
)
SYMLINK = (
"diff --git a/src/link b/src/link\nnew file mode 120000\n"
"--- /dev/null\n+++ b/src/link\n@@ -0,0 +1 @@\n+../.github/workflows\n"
)
# A diff that CHANGES an existing regular file into a symlink (mode 100644 ->
# 120000). The escape vector is the same: the link target redirects later writes
# into a denied path that textual matching cannot see. Must be rejected too.
SYMLINK_MODE_CHANGE = (
"diff --git a/src/existing b/src/existing\n"
"old mode 100644\nnew mode 120000\n"
"--- a/src/existing\n+++ b/src/existing\n@@ -1 +1 @@\n-regular contents\n"
"+../.github/workflows\n"
)
WORKFLOW_DELETE = (
"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"
)
COPY_TO_DENIED = (
"diff --git a/src/x.py b/.github/workflows/evil.yml\nsimilarity index 100%\n"
"copy from src/x.py\ncopy to .github/workflows/evil.yml\n"
)
NON_UTF8 = (
b"diff --git a/src/app.py b/src/app.py\n--- a/src/app.py\n"
b"+++ b/src/app.py\n@@ -1 +1 @@\n-x\n+\xff\xfe\n"
)
def test_clean_in_scope_diff_passes(guard_script: Path, tmp_path: Path) -> None:
assert _run_guard(guard_script, tmp_path, CLEAN, "src/**") == 0
def test_hash_mismatch_fails(guard_script: Path, tmp_path: Path) -> None:
assert _run_guard(guard_script, tmp_path, CLEAN, "src/**", bad_hash=True) == 2
def test_workflow_delete_is_rejected(guard_script: Path, tmp_path: Path) -> None:
# Header-only section (delete) caught via diff --git, not a +++ body line.
assert (
_run_guard(guard_script, tmp_path, WORKFLOW_DELETE, "src/**\n.github/**") == 4
)
def test_copy_into_denied_path_is_rejected(guard_script: Path, tmp_path: Path) -> None:
assert _run_guard(guard_script, tmp_path, COPY_TO_DENIED, "src/**\n.github/**") == 4
def test_symlink_addition_is_rejected(guard_script: Path, tmp_path: Path) -> None:
# The symlink-escape vector: rejected outright (exit 7).
assert _run_guard(guard_script, tmp_path, SYMLINK, "src/**") == 7
def test_symlink_mode_change_is_rejected(guard_script: Path, tmp_path: Path) -> None:
# A regular file FLIPPED to a symlink (mode 120000) is the same escape vector
# and must also be rejected (exit 7), not just brand-new symlinks.
assert _run_guard(guard_script, tmp_path, SYMLINK_MODE_CHANGE, "src/**") == 7
def test_non_utf8_diff_fails_closed(guard_script: Path, tmp_path: Path) -> None:
assert _run_guard(guard_script, tmp_path, NON_UTF8, "src/**") == 8
def test_out_of_scope_path_is_rejected(guard_script: Path, tmp_path: Path) -> None:
assert _run_guard(guard_script, tmp_path, CLEAN, "other/**") == 6
def test_unscoped_diff_is_rejected(guard_script: Path, tmp_path: Path) -> None:
assert _run_guard(guard_script, tmp_path, CLEAN, "") == 5
def test_escaping_scope_entries_are_dropped(guard_script: Path, tmp_path: Path) -> None:
# A parent-escaping scope entry must not widen coverage; it is dropped, so a
# diff under it is treated as unscoped.
assert _run_guard(guard_script, tmp_path, CLEAN, "../../etc") == 5
# --- security-review regressions: denylist & scope matcher (was fnmatch) ----
def _modify(path: str) -> str:
return f"diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n@@ -1 +1 @@\n-x\n+y\n"
@pytest.mark.parametrize(
"root_path",
[
"template.yaml",
"main.tf",
"cdk.json",
"policy.json",
"id.pem",
"signing.key",
"app-stack.ts",
"infra_stack.py",
],
)
def test_root_level_iac_is_denied(
guard_script: Path, tmp_path: Path, root_path: str
) -> None:
# Regression: Python fnmatch '**/' is non-recursive, so root-level IaC/secret
# files slipped the denylist. The glob->regex matcher must reject them (exit 4).
assert _run_guard(guard_script, tmp_path, _modify(root_path), ".") == 4
@pytest.mark.parametrize(
"cased_path", ["Template.YAML", "Main.TF", ".github/Workflows/ci.yml"]
)
def test_denylist_is_case_insensitive(
guard_script: Path, tmp_path: Path, cased_path: str
) -> None:
# A case variant of a trust-control filename must not evade the gate.
assert _run_guard(guard_script, tmp_path, _modify(cased_path), ".") == 4
def test_scope_double_star_cannot_widen_to_whole_tree(
guard_script: Path, tmp_path: Path
) -> None:
# Regression: a '**' scope entry made in_scope() true for every path. It must
# reduce to the empty (root) prefix and be dropped -> unscoped (exit 5).
assert _run_guard(guard_script, tmp_path, _modify("any/deep/file.py"), "**") == 5
def test_scope_glob_reduces_to_concrete_prefix(
guard_script: Path, tmp_path: Path
) -> None:
# A legitimate 'src/**' scope still confines to the src/ prefix: in-scope
# passes, a sibling path is rejected.
assert _run_guard(guard_script, tmp_path, _modify("src/app.py"), "src/**") == 0
assert _run_guard(guard_script, tmp_path, _modify("other/app.py"), "src/**") == 6