From 3c9ee28d23b8371a35890589deee5b0ad08cef7c Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:37:25 -0400 Subject: [PATCH] fix: gate hosted webhook actions Signed-off-by: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> --- deploy/coven-github/coven_github_adapter.py | 34 ++- .../coven-github/test_coven_github_adapter.py | 204 ++++++++++++++++++ 2 files changed, 233 insertions(+), 5 deletions(-) diff --git a/deploy/coven-github/coven_github_adapter.py b/deploy/coven-github/coven_github_adapter.py index 884d2d2..4acdb6b 100644 --- a/deploy/coven-github/coven_github_adapter.py +++ b/deploy/coven-github/coven_github_adapter.py @@ -290,6 +290,11 @@ def trigger_enabled(policy, trigger): return trigger in set(enabled or []) +def event_trigger_key(event_name, payload): + action = str(payload.get("action") or "").strip() + return "{}.{}".format(event_name, action) if action else event_name + + def build_task_from_event(event_name, delivery_id, payload, policy): repository = payload.get("repository") or {} installation = payload.get("installation") or {} @@ -315,10 +320,12 @@ def build_task_from_event(event_name, delivery_id, payload, policy): if event_name == "issue_comment": issue = payload.get("issue") or {} comment = payload.get("comment") or {} + if payload.get("action") != "created": + return ignored(base, "unsupported_issue_comment_action") if not mentioned(comment.get("body"), policy): return ignored(base, "issue_comment_without_mention") - if not trigger_enabled(policy, "issue_mention"): - return ignored(base, "issue_mention_not_enabled") + if not trigger_enabled(policy, "issue_comment.created"): + return ignored(base, "issue_comment_not_enabled") if issue.get("pull_request"): base.update( { @@ -352,9 +359,11 @@ def build_task_from_event(event_name, delivery_id, payload, policy): if event_name == "pull_request_review_comment": comment = payload.get("comment") or {} pull_request = payload.get("pull_request") or {} + if payload.get("action") != "created": + return ignored(base, "unsupported_pr_review_comment_action") if not mentioned(comment.get("body"), policy): return ignored(base, "pr_review_comment_without_mention") - if not trigger_enabled(policy, "pr_review_comment"): + if not trigger_enabled(policy, "pull_request_review_comment.created"): return ignored(base, "pr_review_comment_not_enabled") base.update( { @@ -380,13 +389,13 @@ def build_task_from_event(event_name, delivery_id, payload, policy): action = payload.get("action") if action not in ("assigned", "labeled"): return ignored(base, "unsupported_issue_action") - if action == "assigned" and not trigger_enabled(policy, "issue_assigned"): + if action == "assigned" and not trigger_enabled(policy, "issues.assigned"): return ignored(base, "issue_assigned_not_enabled") if action == "assigned" and not issue_assigned_to_bot(issue, policy): return ignored(base, "issue_assigned_to_unmanaged_user") if action == "labeled" and not labels_include_trigger(issue.get("labels"), policy): return ignored(base, "issue_label_not_enabled") - if action == "labeled" and not trigger_enabled(policy, "issue_label"): + if action == "labeled" and not trigger_enabled(policy, "issues.labeled"): return ignored(base, "issue_label_not_enabled") base.update( { @@ -532,6 +541,21 @@ def route_delivery(event_name, delivery_id, payload, debug): "reason": "no_policy_for_installation_repo", } + trigger = event_trigger_key(event_name, payload) + if not trigger_enabled(policy, trigger): + delivery["state"] = "ignored" + delivery["routing_result"] = "trigger_not_enabled" + delivery["installation_id"] = installation_id or delivery.get("installation_id") + delivery["repository_id"] = repo_id or delivery.get("repository_id") + write_json_atomic(delivery_file, delivery) + return { + "ok": True, + "action": "ignored", + "delivery_id": delivery_id, + "reason": "trigger_not_enabled", + "trigger": trigger, + } + task = build_task_from_event(event_name, delivery_id, payload, policy) task["policy_snapshot"] = { "enabled_triggers": policy.get("enabled_triggers") or [], diff --git a/deploy/coven-github/test_coven_github_adapter.py b/deploy/coven-github/test_coven_github_adapter.py index aead618..346823d 100644 --- a/deploy/coven-github/test_coven_github_adapter.py +++ b/deploy/coven-github/test_coven_github_adapter.py @@ -41,6 +41,210 @@ def test_route_signed_delivery_reports_missing_secret(self): self.assertEqual(result["status"], 500) self.assertIn("GITHUB_WEBHOOK_SECRET", result["error"]) + def test_route_delivery_enforces_exact_trigger_policy(self): + adapter = load_adapter() + policy = { + "enabled_triggers": [ + "issues.labeled", + "issue_comment.created", + "pull_request_review_comment.created", + ], + "trigger_labels": ["coven:fix"], + "bot_usernames": ["coven-cody[bot]"], + "familiar": { + "id": "cody", + "display_name": "Cody", + "model": "openai/gpt-5.5", + "skills": [], + }, + "publication": {"mode": "record_only"}, + } + + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) + adapter.DELIVERIES_DIR = state_dir / "deliveries" + adapter.TASKS_DIR = state_dir / "tasks" + adapter.repo_policy = lambda payload: ("123456", "987654321", policy) + ran_tasks = [] + adapter.run_task = lambda task_id, debug: ran_tasks.append(task_id) + + result = adapter.route_delivery( + "issues", + "delivery-issue-opened", + { + "action": "opened", + "installation": {"id": 123456}, + "repository": { + "id": 987654321, + "full_name": "OpenCoven/example", + "clone_url": "https://github.com/OpenCoven/example.git", + "default_branch": "main", + }, + "issue": { + "number": 43, + "title": "Installer is slow", + "body": "A diagnostic issue, not a bot task.", + "labels": [], + "assignees": [], + }, + }, + lambda message: None, + ) + + self.assertEqual(result["action"], "ignored") + self.assertEqual(result["reason"], "trigger_not_enabled") + self.assertEqual(result["trigger"], "issues.opened") + self.assertTrue(adapter.delivery_path("delivery-issue-opened").exists()) + self.assertFalse(adapter.task_path("delivery-issue-opened").exists()) + self.assertEqual(ran_tasks, []) + + allowed = adapter.route_delivery( + "issues", + "delivery-issue-labeled", + { + "action": "labeled", + "installation": {"id": 123456}, + "repository": { + "id": 987654321, + "full_name": "OpenCoven/example", + "clone_url": "https://github.com/OpenCoven/example.git", + "default_branch": "main", + }, + "issue": { + "number": 44, + "title": "Fix it", + "body": "Please fix it.", + "labels": [{"name": "coven:fix"}], + }, + }, + lambda message: None, + ) + + self.assertEqual(allowed["action"], "accepted") + self.assertTrue(adapter.task_path("delivery-issue-labeled").exists()) + self.assertEqual(ran_tasks, ["delivery-issue-labeled"]) + + def test_event_trigger_key_preserves_actionless_events(self): + adapter = load_adapter() + + self.assertEqual(adapter.event_trigger_key("push", {}), "push") + self.assertEqual( + adapter.event_trigger_key("issues", {"action": "labeled"}), + "issues.labeled", + ) + + def test_webhook_trigger_names_cover_all_changed_routes(self): + adapter = load_adapter() + common = { + "installation": {"id": 123456}, + "repository": { + "id": 987654321, + "full_name": "OpenCoven/example", + "clone_url": "https://github.com/OpenCoven/example.git", + "default_branch": "main", + }, + } + familiar = { + "id": "cody", + "display_name": "Cody", + "model": "openai/gpt-5.5", + "skills": [], + } + cases = ( + ( + "issue_comment", + { + **common, + "action": "created", + "issue": {"number": 45}, + "comment": {"body": "@coven-cody[bot] please help"}, + }, + "issue_comment.created", + "issue_mention", + ), + ( + "pull_request_review_comment", + { + **common, + "action": "created", + "pull_request": {"number": 46}, + "comment": {"body": "@coven-cody[bot] please fix this"}, + }, + "pull_request_review_comment.created", + "pr_review_comment", + ), + ( + "issues", + { + **common, + "action": "assigned", + "issue": { + "number": 47, + "assignee": {"login": "coven-cody[bot]"}, + }, + }, + "issues.assigned", + "issue_assigned", + ), + ) + + for event_name, payload, enabled_trigger, expected_trigger in cases: + with self.subTest(event_name=event_name): + task = adapter.build_task_from_event( + event_name, + "delivery-{}".format(event_name), + payload, + { + "enabled_triggers": [enabled_trigger], + "bot_usernames": ["coven-cody[bot]"], + "familiar": familiar, + "publication": {"mode": "record_only"}, + }, + ) + + self.assertEqual(task["state"], "queued") + self.assertEqual(task["trigger"], expected_trigger) + + def test_labeled_issue_uses_webhook_trigger_name_policy(self): + adapter = load_adapter() + + task = adapter.build_task_from_event( + "issues", + "delivery-issue-labeled", + { + "action": "labeled", + "installation": {"id": 123456}, + "repository": { + "id": 987654321, + "full_name": "OpenCoven/example", + "clone_url": "https://github.com/OpenCoven/example.git", + "default_branch": "main", + }, + "issue": { + "number": 44, + "title": "Fix it", + "body": "Please fix it.", + "labels": [{"name": "coven:fix"}], + }, + }, + { + "enabled_triggers": ["issues.labeled"], + "trigger_labels": ["coven:fix"], + "bot_usernames": ["coven-cody[bot]"], + "familiar": { + "id": "cody", + "display_name": "Cody", + "model": "openai/gpt-5.5", + "skills": [], + }, + "publication": {"mode": "record_only"}, + }, + ) + + self.assertEqual(task["state"], "queued") + self.assertEqual(task["trigger"], "issue_assigned") + self.assertEqual(task["task"]["kind"], "fix_issue") + def test_prepare_review_context_rejects_stale_pr_head_evidence(self): adapter = load_adapter()