diff --git a/.github/workflows/agent-completion-enforcement.yml b/.github/workflows/agent-completion-enforcement.yml index 1c4fd1b5d..17500b376 100644 --- a/.github/workflows/agent-completion-enforcement.yml +++ b/.github/workflows/agent-completion-enforcement.yml @@ -44,7 +44,13 @@ jobs: if test -s trusted-report.json; then python3 scripts/ci/agent_completion_enforcement.py trusted-report.json .github/agent-lock/trusted-publishers.json "$head" "$PR" > enforcement-verdict.json else - printf '%s\n' '{"conclusion":"failure","reason":"missing_trusted_publication","details":{}}' > enforcement-verdict.json + # If the policy is unprovisioned (all allowlists empty), yield a neutral advisory + # rather than a hard failure so PRs are not blocked while the GitHub App is not yet configured. + if python3 scripts/ci/agent_completion_enforcement.py --check-provisioned .github/agent-lock/trusted-publishers.json; then + printf '%s\n' '{"conclusion":"failure","reason":"missing_trusted_publication","details":{}}' > enforcement-verdict.json + else + printf '%s\n' '{"conclusion":"neutral","reason":"trust_policy_unprovisioned","details":{}}' > enforcement-verdict.json + fi fi - name: Publish the required head-bound Check run if: always() @@ -71,9 +77,22 @@ jobs: } catch (error) { core.warning(error.message); } - const conclusion = verdict.conclusion === 'success' - ? 'success' - : 'failure'; + let conclusion; + if (verdict.conclusion === 'success') { + conclusion = 'success'; + } else if (verdict.conclusion === 'neutral') { + conclusion = 'neutral'; + } else { + conclusion = 'failure'; + } + let title; + if (conclusion === 'success') { + title = 'Trusted evidence verified'; + } else if (conclusion === 'neutral') { + title = 'Trusted publisher not yet configured (advisory)'; + } else { + title = 'Trusted evidence blocked'; + } const summary = JSON.stringify(verdict); await github.rest.checks.create({ owner: context.repo.owner, @@ -83,12 +102,10 @@ jobs: status: 'completed', conclusion, output: { - title: conclusion === 'success' - ? 'Trusted evidence verified' - : 'Trusted evidence blocked', + title, summary: summary.slice(0, 60000) } }); - if (conclusion !== 'success') { + if (conclusion !== 'success' && conclusion !== 'neutral') { core.setFailed(verdict.reason || 'trusted evidence blocked'); } diff --git a/scripts/ci/agent_completion_enforcement.py b/scripts/ci/agent_completion_enforcement.py index f746c6f20..09293c4c1 100644 --- a/scripts/ci/agent_completion_enforcement.py +++ b/scripts/ci/agent_completion_enforcement.py @@ -21,6 +21,25 @@ def verdict(reason: str, **details: Any) -> dict[str, Any]: return {"conclusion": "failure", "reason": reason, "details": details} +def neutral_verdict(reason: str, **details: Any) -> dict[str, Any]: + return {"conclusion": "neutral", "reason": reason, "details": details} + + +def is_provisioned(policy: Any) -> bool: + """Return True only when all three allowlists are non-empty. + + The three required allowlists are trusted_check_app_slugs, + trusted_label_actors, and trusted_human_exemption_actors. + """ + if not isinstance(policy, dict): + return False + return bool( + policy.get("trusted_check_app_slugs") and + policy.get("trusted_label_actors") and + policy.get("trusted_human_exemption_actors") + ) + + def verify(payload: Any, policy: Any, head_sha: str, pull_number: int) -> dict[str, Any]: if not isinstance(payload, dict) or not isinstance(policy, dict): return verdict("invalid_payload") @@ -34,8 +53,8 @@ def verify(payload: Any, policy: Any, head_sha: str, pull_number: int) -> dict[s if not all(isinstance(value, list) and all(isinstance(item, str) and item for item in value) for value in (apps, labels, exemptions)): return verdict("invalid_trust_policy") - if not apps or not labels or not exemptions: - return verdict("trust_policy_unprovisioned") + if not is_provisioned(policy): + return neutral_verdict("trust_policy_unprovisioned") required = {"schema_version", "pull_number", "head_sha", "publisher", "applicability", "label_authorization", "focused_tests", "agent_events"} if set(payload) != required or payload.get("schema_version") != 1: return verdict("invalid_report_schema") @@ -78,8 +97,13 @@ def verify(payload: Any, policy: Any, head_sha: str, pull_number: int) -> dict[s def main() -> int: + if len(sys.argv) == 3 and sys.argv[1] == "--check-provisioned": + policy_path = sys.argv[2] + provisioned = is_provisioned(json.loads(Path(policy_path).read_text())) + return 0 if provisioned else 1 if len(sys.argv) != 5: - raise SystemExit("usage: verifier REPORT POLICY HEAD_SHA PULL_NUMBER") + raise SystemExit("usage: verifier REPORT POLICY HEAD_SHA PULL_NUMBER\n" + " verifier --check-provisioned POLICY") report, policy, head, pull = sys.argv[1:] result = verify(json.loads(Path(report).read_text()), json.loads(Path(policy).read_text()), head, int(pull)) print(json.dumps(result, sort_keys=True)) diff --git a/tests/unit/test_agent_completion_enforcement.py b/tests/unit/test_agent_completion_enforcement.py index 722a5cdd8..c37e1a7ca 100644 --- a/tests/unit/test_agent_completion_enforcement.py +++ b/tests/unit/test_agent_completion_enforcement.py @@ -17,7 +17,9 @@ def test_accepts_head_bound_trusted_report(self): def test_unprovisioned_policy_blocks(self): policy = dict(POLICY, trusted_check_app_slugs=[]) - self.assertEqual(verify(report(), policy, HEAD, 9)["reason"], "trust_policy_unprovisioned") + result = verify(report(), policy, HEAD, 9) + self.assertEqual(result["reason"], "trust_policy_unprovisioned") + self.assertEqual(result["conclusion"], "neutral") def test_stale_report_blocks(self): body = report(); body["head_sha"] = "b" * 40