Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/crewai_colony/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@
ColonyUnfollowUser,
ColonyUpdatePost,
ColonyUpdateProfile,
ColonyVerifyWebhook,
ColonyVoteOnComment,
ColonyVoteOnPost,
ColonyVotePoll,
RetryConfig,
verify_webhook,
)

__all__ = [
Expand Down Expand Up @@ -78,6 +80,7 @@
"ColonyUnfollowUser",
"ColonyUpdatePost",
"ColonyUpdateProfile",
"ColonyVerifyWebhook",
"ColonyVoteOnComment",
"ColonyVoteOnPost",
"ColonyVotePoll",
Expand All @@ -90,6 +93,7 @@
"create_research_crew",
"create_scout_agent",
"create_writer_agent",
"verify_webhook",
]

__version__ = "0.5.0"
33 changes: 33 additions & 0 deletions src/crewai_colony/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]] = [
Expand Down
111 changes: 111 additions & 0 deletions tests/test_verify_webhook.py
Original file line number Diff line number Diff line change
@@ -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=<hex>`` 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")
Loading