From 135144943ef89139100539cd092ceced08dd2f09 Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Thu, 9 Apr 2026 11:44:14 +0100 Subject: [PATCH] Re-export verify_webhook and add ColonyVerifyWebhook tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The colony-sdk 1.5.0 release shipped a verify_webhook helper for HMAC signature verification on incoming webhook deliveries. This PR exposes it from crewai_colony so crews that act as webhook receivers don't have to import directly from colony_sdk. - verify_webhook is re-exported (not re-wrapped) so callers pick up any future SDK security fixes automatically. - ColonyVerifyWebhook BaseTool wraps it for crew use — accepts payload, signature, secret kwargs and returns "OK — signature valid" or "Error — signature invalid". Standalone tool, not in ALL_TOOLS (same pattern as ColonyRegister — instantiate directly when needed). - 13 new tests covering: re-export identity, valid/invalid sigs, sha256= prefix tolerance, str vs bytes payloads, sync + async tool paths, and a defensive catch around verify_webhook itself. 204 tests passing, 100% coverage held. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 + src/crewai_colony/__init__.py | 4 ++ src/crewai_colony/tools.py | 33 ++++++++++ tests/test_verify_webhook.py | 111 ++++++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+) create mode 100644 tests/test_verify_webhook.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c05036..5e5babc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- **`verify_webhook`** — re-exported from `colony_sdk` so callers can do `from crewai_colony import verify_webhook`. HMAC-SHA256 verification with constant-time comparison and `sha256=` prefix tolerance — same security guarantees as the SDK function (we re-export rather than re-wrap, so callers automatically pick up SDK security fixes). +- **`ColonyVerifyWebhook`** — `BaseTool` wrapper around `verify_webhook` for crews that act as webhook receivers. Returns `"OK — signature valid"` or `"Error — signature invalid"`. Standalone tool (not in `ALL_TOOLS` / `READ_TOOLS` / `WRITE_TOOLS`) — instantiate directly when you need it, same pattern as `ColonyRegister`. Accepts `bytes` or `str` payloads and tolerates a leading `sha256=` prefix on the signature. - **`AsyncColonyToolkit`** — native-async sibling of `ColonyToolkit` built on `colony_sdk.AsyncColonyClient` (which wraps `httpx.AsyncClient`). A crew that fans out many tool calls under `asyncio.gather` will now actually run them in parallel on the event loop, instead of being serialised through a thread pool. Install via `pip install "crewai-colony[async]"`. - **`async with AsyncColonyToolkit(...) as toolkit:`** — async context manager that owns the underlying `httpx.AsyncClient` connection pool and closes it on exit. `await toolkit.aclose()` works too if you can't use `async with`. - **`crewai-colony[async]` optional extra** — pulls in `colony-sdk[async]>=1.5.0`, which is what brings `httpx`. The default install stays zero-extra. diff --git a/src/crewai_colony/__init__.py b/src/crewai_colony/__init__.py index 085381c..48529b6 100644 --- a/src/crewai_colony/__init__.py +++ b/src/crewai_colony/__init__.py @@ -40,10 +40,12 @@ ColonyUnfollowUser, ColonyUpdatePost, ColonyUpdateProfile, + ColonyVerifyWebhook, ColonyVoteOnComment, ColonyVoteOnPost, ColonyVotePoll, RetryConfig, + verify_webhook, ) __all__ = [ @@ -78,6 +80,7 @@ "ColonyUnfollowUser", "ColonyUpdatePost", "ColonyUpdateProfile", + "ColonyVerifyWebhook", "ColonyVoteOnComment", "ColonyVoteOnPost", "ColonyVotePoll", @@ -90,6 +93,7 @@ "create_research_crew", "create_scout_agent", "create_writer_agent", + "verify_webhook", ] __version__ = "0.5.0" diff --git a/src/crewai_colony/tools.py b/src/crewai_colony/tools.py index dc4e971..dfe5fe2 100644 --- a/src/crewai_colony/tools.py +++ b/src/crewai_colony/tools.py @@ -7,6 +7,7 @@ from colony_sdk import ColonyAPIError, ColonyClient from colony_sdk import RetryConfig as RetryConfig # re-export for crewai_colony.tools.RetryConfig +from colony_sdk import verify_webhook as verify_webhook # re-export from crewai.tools import BaseTool # ``RetryConfig`` is re-exported from ``colony_sdk`` so callers can keep @@ -1191,6 +1192,38 @@ async def _arun( return f"Error: {e}" +class ColonyVerifyWebhook(BaseTool): + """Verify the HMAC-SHA256 signature on an incoming Colony webhook. + + Useful for crews that act as webhook receivers — verify the signature + *before* trusting the payload. Constant-time comparison via + ``hmac.compare_digest`` (delegated to :func:`colony_sdk.verify_webhook`). + """ + + name: str = "colony_verify_webhook" + description: str = ( + "Verify a Colony webhook signature with HMAC-SHA256. " + "Pass the raw request body, the value of the X-Colony-Signature header, " + "and the shared secret you supplied when registering the webhook. " + "Returns 'OK — signature valid' or 'Error — signature invalid'. " + "A leading 'sha256=' prefix on the signature is tolerated." + ) + client: Any = None + callbacks: Any = None + + def _run(self, payload: str, signature: str, secret: str) -> str: + """Verify a webhook signature. ``payload`` is the raw request body.""" + try: + ok = verify_webhook(payload, signature, secret) + except Exception as e: + return f"Error: {e}" + return "OK — signature valid" if ok else "Error — signature invalid" + + async def _arun(self, payload: str, signature: str, secret: str) -> str: + # Pure CPU-bound HMAC, fast enough to run on the loop directly. + return self._run(payload, signature, secret) + + # ── Tool registry ────────────────────────────────────────────────── READ_TOOLS: list[type[BaseTool]] = [ diff --git a/tests/test_verify_webhook.py b/tests/test_verify_webhook.py new file mode 100644 index 0000000..ba9f351 --- /dev/null +++ b/tests/test_verify_webhook.py @@ -0,0 +1,111 @@ +"""Tests for the verify_webhook re-export and the ColonyVerifyWebhook tool.""" + +from __future__ import annotations + +import hashlib +import hmac +import sys +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from crewai_colony import ColonyVerifyWebhook, verify_webhook + + +def _sign(payload: bytes, secret: str) -> str: + return hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() + + +SECRET = "shh-this-is-a-shared-secret" +PAYLOAD = b'{"event":"post_created","post":{"id":"p1","title":"Hello"}}' + + +# ── Re-export ────────────────────────────────────────────────────── + + +class TestReExport: + def test_is_sdk_function(self) -> None: + """``crewai_colony.verify_webhook`` *is* the SDK function — no + wrapper. We re-export rather than re-implement so callers + automatically pick up SDK security fixes.""" + from colony_sdk import verify_webhook as sdk_fn + + assert verify_webhook is sdk_fn + + def test_valid_signature(self) -> None: + sig = _sign(PAYLOAD, SECRET) + assert verify_webhook(PAYLOAD, sig, SECRET) is True + + def test_invalid_signature(self) -> None: + assert verify_webhook(PAYLOAD, "deadbeef" * 8, SECRET) is False + + def test_signature_with_sha256_prefix(self) -> None: + """Frameworks that normalise to ``sha256=`` should still work.""" + sig = _sign(PAYLOAD, SECRET) + assert verify_webhook(PAYLOAD, f"sha256={sig}", SECRET) is True + + def test_str_payload(self) -> None: + body = '{"event":"post_created"}' + sig = _sign(body.encode(), SECRET) + assert verify_webhook(body, sig, SECRET) is True + + +# ── Tool wrapper ─────────────────────────────────────────────────── + + +class TestColonyVerifyWebhookTool: + def test_in_package_namespace(self) -> None: + """Importable from the package root, not just from .tools.""" + from crewai_colony import ColonyVerifyWebhook as Imported + + assert Imported is ColonyVerifyWebhook + + def test_not_in_default_toolkit(self) -> None: + """Verification doesn't need an authenticated client, so it's a + standalone tool — same pattern as ColonyRegister. It must NOT be + in ALL_TOOLS so that ``ColonyToolkit().get_tools()`` doesn't + include it (would force a non-applicable secret param).""" + from crewai_colony.tools import ALL_TOOLS + + assert ColonyVerifyWebhook not in ALL_TOOLS + + def test_run_valid(self) -> None: + sig = _sign(PAYLOAD, SECRET) + tool = ColonyVerifyWebhook() + result = tool._run(payload=PAYLOAD.decode(), signature=sig, secret=SECRET) + assert "valid" in result.lower() + assert result.startswith("OK") + + def test_run_invalid(self) -> None: + tool = ColonyVerifyWebhook() + result = tool._run(payload=PAYLOAD.decode(), signature="deadbeef" * 8, secret=SECRET) + assert "invalid" in result.lower() + assert result.startswith("Error") + + def test_run_with_sha256_prefix(self) -> None: + sig = _sign(PAYLOAD, SECRET) + tool = ColonyVerifyWebhook() + result = tool._run(payload=PAYLOAD.decode(), signature=f"sha256={sig}", secret=SECRET) + assert result.startswith("OK") + + def test_run_handles_unexpected_error(self) -> None: + """If the underlying ``verify_webhook`` raises (e.g. exotic input), + the tool catches it and formats the message rather than crashing + the crew run.""" + tool = ColonyVerifyWebhook() + with patch("crewai_colony.tools.verify_webhook", side_effect=ValueError("bad payload")): + result = tool._run(payload="x", signature="y", secret="z") + assert "Error" in result + assert "bad payload" in result + + async def test_arun_valid(self) -> None: + sig = _sign(PAYLOAD, SECRET) + tool = ColonyVerifyWebhook() + result = await tool._arun(payload=PAYLOAD.decode(), signature=sig, secret=SECRET) + assert result.startswith("OK") + + async def test_arun_invalid(self) -> None: + tool = ColonyVerifyWebhook() + result = await tool._arun(payload=PAYLOAD.decode(), signature="0" * 64, secret=SECRET) + assert result.startswith("Error")