From 53c6ae4c5e1945eed309bbfe36359c47459ba2b3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 05:56:27 +0000 Subject: [PATCH] feat(integrations): add optional Signet integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in `crewai.integrations.signet` module that produces Ed25519-signed Signet receipts for every governed action emitted on the CrewAI event bus: structured tool calls, MCP tool executions, and A2A delegations. - Lazy-imports `signet-auth` with a clear ImportError if the `crewai[signet]` extra is not installed. - Pairs started/completed events via `started_event_id` so each receipt covers both the input and the output of a single action. - Adds configuration surfaces (audit log, policy attestation, per-surface toggles) via `SignetConfig`. - Ships tests covering all three event surfaces with a mocked SigningAgent. Closes #5568 Co-Authored-By: João --- lib/crewai/pyproject.toml | 3 + .../src/crewai/integrations/__init__.py | 6 + .../crewai/integrations/signet/__init__.py | 83 ++++ .../src/crewai/integrations/signet/config.py | 34 ++ .../crewai/integrations/signet/listener.py | 324 ++++++++++++++ lib/crewai/tests/integrations/__init__.py | 0 .../tests/integrations/signet/__init__.py | 0 .../signet/test_signet_listener.py | 418 ++++++++++++++++++ uv.lock | 20 +- 9 files changed, 886 insertions(+), 2 deletions(-) create mode 100644 lib/crewai/src/crewai/integrations/__init__.py create mode 100644 lib/crewai/src/crewai/integrations/signet/__init__.py create mode 100644 lib/crewai/src/crewai/integrations/signet/config.py create mode 100644 lib/crewai/src/crewai/integrations/signet/listener.py create mode 100644 lib/crewai/tests/integrations/__init__.py create mode 100644 lib/crewai/tests/integrations/signet/__init__.py create mode 100644 lib/crewai/tests/integrations/signet/test_signet_listener.py diff --git a/lib/crewai/pyproject.toml b/lib/crewai/pyproject.toml index 4956c81f0..9bdfa4f57 100644 --- a/lib/crewai/pyproject.toml +++ b/lib/crewai/pyproject.toml @@ -107,6 +107,9 @@ a2a = [ file-processing = [ "crewai-files", ] +signet = [ + "signet-auth>=0.5.0", +] qdrant-edge = [ "qdrant-edge-py>=0.6.0", ] diff --git a/lib/crewai/src/crewai/integrations/__init__.py b/lib/crewai/src/crewai/integrations/__init__.py new file mode 100644 index 000000000..ed9fd507f --- /dev/null +++ b/lib/crewai/src/crewai/integrations/__init__.py @@ -0,0 +1,6 @@ +"""First-party integrations for CrewAI. + +Each subpackage is opt-in and must lazily import any third-party dependencies +so that importing ``crewai.integrations`` has no runtime cost for users who +have not installed the corresponding extra. +""" diff --git a/lib/crewai/src/crewai/integrations/signet/__init__.py b/lib/crewai/src/crewai/integrations/signet/__init__.py new file mode 100644 index 000000000..9ed1c09a4 --- /dev/null +++ b/lib/crewai/src/crewai/integrations/signet/__init__.py @@ -0,0 +1,83 @@ +"""Optional Signet integration for CrewAI. + +`Signet `_ produces Ed25519-signed, +hash-chained receipts for AI agent tool calls. This integration registers a +:class:`BaseEventListener` that signs a receipt for every governed action +(structured tool, MCP tool execution, A2A delegation) using the paired +``Started``/``Completed`` events emitted by the CrewAI event bus. + +The integration is installed as an optional extra:: + + pip install 'crewai[signet]' + +Then enabled with a single call:: + + from crewai.integrations.signet import install + + listener = install(key_name="my-crew-agent") + +After installation, every tool call, MCP tool execution, and A2A delegation +produces a signed receipt stored on the returned listener and (optionally) +appended to a local hash-chained audit log by ``signet-auth``. +""" + +from __future__ import annotations + +from crewai.integrations.signet.config import SignetConfig +from crewai.integrations.signet.listener import Receipt, SignetEventListener + + +__all__ = ["Receipt", "SignetConfig", "SignetEventListener", "install"] + + +def install( + key_name: str, + *, + owner: str | None = None, + audit: bool = True, + policy_path: str | None = None, + create_if_missing: bool = True, + tool_events: bool = True, + mcp_events: bool = True, + a2a_events: bool = True, + signing_agent: object | None = None, +) -> SignetEventListener: + """Install the Signet event listener on the CrewAI event bus. + + Args: + key_name: Signet ``SigningAgent`` identity name. If the identity does + not exist yet and ``create_if_missing=True`` (the default), a new + Ed25519 keypair is created and stored under ``~/.signet/keys/``. + owner: Optional owner string used when creating a new identity. + audit: If ``True``, ``signet-auth`` appends every receipt to its local + hash-chained audit log at ``~/.signet/audit/``. + policy_path: Optional path to a Signet policy file that is co-signed + with every receipt. + create_if_missing: If ``True``, create the identity on first use when + no matching key is found. If ``False``, load the existing key only + and raise if it cannot be found. + tool_events: If ``True``, sign structured tool calls + (``tool_usage_started`` / ``tool_usage_finished``). + mcp_events: If ``True``, sign MCP tool executions + (``mcp_tool_execution_started`` / ``mcp_tool_execution_completed``). + a2a_events: If ``True``, sign A2A delegations + (``a2a_delegation_started`` / ``a2a_delegation_completed``). + signing_agent: Optional pre-built ``SigningAgent`` (or a test double + exposing a ``sign(action, params=...)`` method). When provided, + ``signet-auth`` is not imported and the extra is not required. + + Returns: + The registered :class:`SignetEventListener`. Receipts can be inspected + via ``listener.receipts``. + """ + config = SignetConfig( + key_name=key_name, + owner=owner, + audit=audit, + policy_path=policy_path, + create_if_missing=create_if_missing, + tool_events=tool_events, + mcp_events=mcp_events, + a2a_events=a2a_events, + ) + return SignetEventListener(config=config, signing_agent=signing_agent) diff --git a/lib/crewai/src/crewai/integrations/signet/config.py b/lib/crewai/src/crewai/integrations/signet/config.py new file mode 100644 index 000000000..9ca6fbbbc --- /dev/null +++ b/lib/crewai/src/crewai/integrations/signet/config.py @@ -0,0 +1,34 @@ +"""Configuration for the optional Signet integration.""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class SignetConfig(BaseModel): + """User-facing configuration for the Signet listener. + + Attributes: + key_name: Signet ``SigningAgent`` identity name. + owner: Optional owner string used when creating a new identity. + audit: Whether signet-auth should append receipts to its hash-chained + audit log. + policy_path: Optional path to a Signet policy file that is co-signed + with every receipt. + create_if_missing: Whether to create the identity on first use when no + matching key is found. + tool_events: Whether to sign structured tool calls. + mcp_events: Whether to sign MCP tool executions. + a2a_events: Whether to sign A2A delegations. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + key_name: str = Field(..., min_length=1) + owner: str | None = None + audit: bool = True + policy_path: str | None = None + create_if_missing: bool = True + tool_events: bool = True + mcp_events: bool = True + a2a_events: bool = True diff --git a/lib/crewai/src/crewai/integrations/signet/listener.py b/lib/crewai/src/crewai/integrations/signet/listener.py new file mode 100644 index 000000000..c394d2fb0 --- /dev/null +++ b/lib/crewai/src/crewai/integrations/signet/listener.py @@ -0,0 +1,324 @@ +"""Signet event listener that signs paired CrewAI action events. + +The listener subscribes to the paired ``Started``/``Completed`` events emitted +for structured tool calls, MCP tool executions, and A2A delegations. When a +``Completed`` event fires, it correlates the two payloads via the CrewAI event +scope (``event.started_event_id``) and produces a single Ed25519-signed Signet +receipt covering the input and the output. + +The ``signet_auth`` dependency is **lazy-imported**. If the ``crewai[signet]`` +extra is not installed and no ``signing_agent`` is injected, a clear +:class:`ImportError` is raised the first time a matching event fires. Users +who do not opt in pay no import cost. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import threading +from typing import TYPE_CHECKING, Any, Protocol, TypeVar, runtime_checkable + +from crewai.events.base_event_listener import BaseEventListener +from crewai.events.types.a2a_events import ( + A2ADelegationCompletedEvent, + A2ADelegationStartedEvent, +) +from crewai.events.types.mcp_events import ( + MCPToolExecutionCompletedEvent, + MCPToolExecutionFailedEvent, + MCPToolExecutionStartedEvent, +) +from crewai.events.types.tool_usage_events import ( + ToolUsageErrorEvent, + ToolUsageFinishedEvent, + ToolUsageStartedEvent, +) +from crewai.integrations.signet.config import SignetConfig + + +if TYPE_CHECKING: + from crewai.events.base_events import BaseEvent + from crewai.events.event_bus import CrewAIEventsBus + + +_StartedEventT = TypeVar("_StartedEventT") + + +_SIGNET_INSTALL_HINT: str = ( + "The Signet integration requires the `signet-auth` package. Install the " + "optional extra with `pip install 'crewai[signet]'` or inject a " + "`signing_agent` with a `.sign(action, params=...)` method." +) + + +@runtime_checkable +class SigningAgentProtocol(Protocol): + """Minimal protocol satisfied by ``signet_auth.SigningAgent``. + + Any object exposing a ``sign(action, params=...)`` method returning a + receipt (typically a mapping) is accepted. This keeps the listener + decoupled from ``signet-auth`` for testing and for alternative backends. + """ + + def sign( + self, action: str, *, params: dict[str, Any] + ) -> Any: # pragma: no cover - protocol + ... + + +@dataclass +class Receipt: + """A signed receipt produced by the listener. + + Attributes: + kind: One of ``"tool"``, ``"mcp_tool"``, ``"a2a_delegation"``. + action: Action name used when signing (e.g. the tool name). + payload: Canonical dict passed to the signing agent covering both the + input (from the ``Started`` event) and the output (from the + ``Completed`` event). + receipt: The raw object returned by the signing agent. + error: ``True`` if the receipt was produced from an error/failed event. + """ + + kind: str + action: str + payload: dict[str, Any] + receipt: Any + error: bool = False + + +class SignetEventListener(BaseEventListener): + """Event listener that produces Signet receipts for governed actions. + + Args: + config: :class:`SignetConfig` controlling which event surfaces are + signed and how the ``SigningAgent`` is built. + signing_agent: Optional pre-built signing agent. When provided the + ``signet_auth`` package is not imported. Must expose + ``sign(action, params=...)``. + """ + + verbose: bool = False + + def __init__( + self, + config: SignetConfig, + *, + signing_agent: Any | None = None, + ) -> None: + self.config = config + self._injected_signing_agent = signing_agent + self._signing_agent: Any | None = signing_agent + self._pending: dict[str, BaseEvent] = {} + self._pending_lock = threading.Lock() + self.receipts: list[Receipt] = [] + self._receipts_lock = threading.Lock() + super().__init__() + + def _get_signing_agent(self) -> Any: + """Return the active signing agent, lazily building one if needed.""" + if self._signing_agent is not None: + return self._signing_agent + try: + from signet_auth import SigningAgent # type: ignore[import-not-found] + except ImportError as exc: # pragma: no cover - exercised via stubbed test + raise ImportError(_SIGNET_INSTALL_HINT) from exc + + kwargs: dict[str, Any] = {} + if self.config.audit: + kwargs["audit"] = True + if self.config.policy_path is not None: + kwargs["policy_path"] = self.config.policy_path + + if self.config.create_if_missing and hasattr(SigningAgent, "create"): + create_kwargs = dict(kwargs) + if self.config.owner is not None: + create_kwargs["owner"] = self.config.owner + self._signing_agent = SigningAgent.create( + self.config.key_name, **create_kwargs + ) + else: + self._signing_agent = SigningAgent(self.config.key_name, **kwargs) + return self._signing_agent + + def setup_listeners(self, crewai_event_bus: CrewAIEventsBus) -> None: + """Register handlers for each enabled event surface.""" + if self.config.tool_events: + self._register_tool_handlers(crewai_event_bus) + if self.config.mcp_events: + self._register_mcp_handlers(crewai_event_bus) + if self.config.a2a_events: + self._register_a2a_handlers(crewai_event_bus) + + def _register_tool_handlers(self, bus: CrewAIEventsBus) -> None: + @bus.on(ToolUsageStartedEvent) + def _on_tool_start(source: Any, event: ToolUsageStartedEvent) -> None: + self._remember_start(event) + + @bus.on(ToolUsageFinishedEvent) + def _on_tool_finish(source: Any, event: ToolUsageFinishedEvent) -> None: + started = self._consume_start(event.started_event_id, ToolUsageStartedEvent) + if started is None: + return + payload = _tool_payload(started, output=event.output, error=None) + self._sign_and_record("tool", started.tool_name, payload, error=False) + + @bus.on(ToolUsageErrorEvent) + def _on_tool_error(source: Any, event: ToolUsageErrorEvent) -> None: + started = self._consume_start(event.started_event_id, ToolUsageStartedEvent) + if started is None: + return + payload = _tool_payload(started, output=None, error=str(event.error)) + self._sign_and_record("tool", started.tool_name, payload, error=True) + + def _register_mcp_handlers(self, bus: CrewAIEventsBus) -> None: + @bus.on(MCPToolExecutionStartedEvent) + def _on_mcp_start(source: Any, event: MCPToolExecutionStartedEvent) -> None: + self._remember_start(event) + + @bus.on(MCPToolExecutionCompletedEvent) + def _on_mcp_complete( + source: Any, event: MCPToolExecutionCompletedEvent + ) -> None: + started = self._consume_start( + event.started_event_id, MCPToolExecutionStartedEvent + ) + if started is None: + return + payload = _mcp_payload(started, result=event.result, error=None) + self._sign_and_record("mcp_tool", started.tool_name, payload, error=False) + + @bus.on(MCPToolExecutionFailedEvent) + def _on_mcp_failed(source: Any, event: MCPToolExecutionFailedEvent) -> None: + started = self._consume_start( + event.started_event_id, MCPToolExecutionStartedEvent + ) + if started is None: + return + payload = _mcp_payload(started, result=None, error=event.error) + self._sign_and_record("mcp_tool", started.tool_name, payload, error=True) + + def _register_a2a_handlers(self, bus: CrewAIEventsBus) -> None: + @bus.on(A2ADelegationStartedEvent) + def _on_a2a_start(source: Any, event: A2ADelegationStartedEvent) -> None: + self._remember_start(event) + + @bus.on(A2ADelegationCompletedEvent) + def _on_a2a_complete(source: Any, event: A2ADelegationCompletedEvent) -> None: + started = self._consume_start( + event.started_event_id, A2ADelegationStartedEvent + ) + if started is None: + return + payload = _a2a_payload(started, completed=event) + action = f"a2a:{started.agent_id}" + is_error = event.status.lower() not in {"completed", "ok", "success"} + self._sign_and_record("a2a_delegation", action, payload, error=is_error) + + def _remember_start(self, event: BaseEvent) -> None: + with self._pending_lock: + self._pending[event.event_id] = event + + def _consume_start( + self, + started_event_id: str | None, + expected_type: type[_StartedEventT], + ) -> _StartedEventT | None: + if not started_event_id: + return None + with self._pending_lock: + started = self._pending.pop(started_event_id, None) + if not isinstance(started, expected_type): + return None + return started + + def _sign_and_record( + self, + kind: str, + action: str, + payload: dict[str, Any], + *, + error: bool, + ) -> None: + agent = self._get_signing_agent() + receipt = agent.sign(action, params=payload) + with self._receipts_lock: + self.receipts.append( + Receipt( + kind=kind, + action=action, + payload=payload, + receipt=receipt, + error=error, + ) + ) + + +def _tool_payload( + started: ToolUsageStartedEvent, + *, + output: Any, + error: str | None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "kind": "tool", + "tool_name": started.tool_name, + "tool_class": started.tool_class, + "tool_args": started.tool_args, + "agent_id": started.agent_id, + "agent_role": started.agent_role, + "task_id": started.task_id, + "started_event_id": started.event_id, + } + if error is None: + payload["output"] = output + else: + payload["error"] = error + return payload + + +def _mcp_payload( + started: MCPToolExecutionStartedEvent, + *, + result: Any, + error: str | None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "kind": "mcp_tool", + "tool_name": started.tool_name, + "tool_args": started.tool_args, + "server_name": started.server_name, + "server_url": started.server_url, + "transport_type": started.transport_type, + "agent_id": started.agent_id, + "agent_role": started.agent_role, + "task_id": started.task_id, + "started_event_id": started.event_id, + } + if error is None: + payload["result"] = result + else: + payload["error"] = error + return payload + + +def _a2a_payload( + started: A2ADelegationStartedEvent, + *, + completed: A2ADelegationCompletedEvent, +) -> dict[str, Any]: + return { + "kind": "a2a_delegation", + "endpoint": started.endpoint, + "task_description": started.task_description, + "a2a_agent_id": started.agent_id, + "a2a_agent_name": started.a2a_agent_name, + "context_id": started.context_id, + "is_multiturn": started.is_multiturn, + "turn_number": started.turn_number, + "skill_id": started.skill_id, + "started_event_id": started.event_id, + "status": completed.status, + "result": completed.result, + "error": completed.error, + } diff --git a/lib/crewai/tests/integrations/__init__.py b/lib/crewai/tests/integrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lib/crewai/tests/integrations/signet/__init__.py b/lib/crewai/tests/integrations/signet/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lib/crewai/tests/integrations/signet/test_signet_listener.py b/lib/crewai/tests/integrations/signet/test_signet_listener.py new file mode 100644 index 000000000..9b503af2f --- /dev/null +++ b/lib/crewai/tests/integrations/signet/test_signet_listener.py @@ -0,0 +1,418 @@ +"""Tests for the optional Signet integration. + +These tests use a lightweight ``FakeSigningAgent`` that satisfies the same +contract as ``signet_auth.SigningAgent`` so the listener can be exercised +without installing the ``crewai[signet]`` extra. +""" + +from __future__ import annotations + +import sys +import types +from typing import Any + +import pytest + +from crewai.events.event_bus import crewai_event_bus +from crewai.events.types.a2a_events import ( + A2ADelegationCompletedEvent, + A2ADelegationStartedEvent, +) +from crewai.events.types.mcp_events import ( + MCPToolExecutionCompletedEvent, + MCPToolExecutionFailedEvent, + MCPToolExecutionStartedEvent, +) +from crewai.events.types.tool_usage_events import ( + ToolUsageErrorEvent, + ToolUsageFinishedEvent, + ToolUsageStartedEvent, +) +from crewai.integrations.signet import SignetConfig, SignetEventListener, install +from crewai.integrations.signet.listener import _SIGNET_INSTALL_HINT, Receipt + + +class FakeSigningAgent: + """Test double matching the ``signet_auth.SigningAgent`` contract.""" + + def __init__(self, name: str = "fake") -> None: + self.name = name + self.calls: list[tuple[str, dict[str, Any]]] = [] + + def sign(self, action: str, *, params: dict[str, Any]) -> dict[str, Any]: + self.calls.append((action, params)) + return { + "action": action, + "params": params, + "signature": f"sig-{len(self.calls)}", + "signed_by": self.name, + } + + +def _install(**kwargs: Any) -> tuple[SignetEventListener, FakeSigningAgent]: + agent = FakeSigningAgent() + listener = install(key_name="test-key", signing_agent=agent, **kwargs) + return listener, agent + + +def _wait() -> None: + """Block until all pending event handlers have finished.""" + crewai_event_bus.flush(timeout=5.0) + + +def _emit_tool_pair( + tool_name: str = "some_tool", + *, + with_error: bool = False, + output: Any = "ok", +) -> tuple[ToolUsageStartedEvent, ToolUsageFinishedEvent | ToolUsageErrorEvent]: + started = ToolUsageStartedEvent( + tool_name=tool_name, + tool_args={"x": 1}, + tool_class="SomeTool", + agent_id="agent-1", + agent_role="analyst", + ) + crewai_event_bus.emit(source=None, event=started) + + finished: ToolUsageFinishedEvent | ToolUsageErrorEvent + if with_error: + finished = ToolUsageErrorEvent( + tool_name=tool_name, + tool_args={"x": 1}, + tool_class="SomeTool", + agent_id="agent-1", + agent_role="analyst", + error="boom", + started_event_id=started.event_id, + ) + else: + from datetime import datetime, timezone + + now = datetime.now(timezone.utc) + finished = ToolUsageFinishedEvent( + tool_name=tool_name, + tool_args={"x": 1}, + tool_class="SomeTool", + agent_id="agent-1", + agent_role="analyst", + started_at=now, + finished_at=now, + output=output, + started_event_id=started.event_id, + ) + crewai_event_bus.emit(source=None, event=finished) + _wait() + return started, finished + + +def _emit_mcp_pair( + *, + with_error: bool = False, + tool_name: str = "mcp_echo", +) -> tuple[ + MCPToolExecutionStartedEvent, + MCPToolExecutionCompletedEvent | MCPToolExecutionFailedEvent, +]: + started = MCPToolExecutionStartedEvent( + server_name="server-a", + server_url="http://localhost:8080", + transport_type="http", + tool_name=tool_name, + tool_args={"q": "hi"}, + ) + crewai_event_bus.emit(source=None, event=started) + + completed: MCPToolExecutionCompletedEvent | MCPToolExecutionFailedEvent + if with_error: + completed = MCPToolExecutionFailedEvent( + server_name="server-a", + tool_name=tool_name, + tool_args={"q": "hi"}, + error="server crashed", + started_event_id=started.event_id, + ) + else: + completed = MCPToolExecutionCompletedEvent( + server_name="server-a", + tool_name=tool_name, + tool_args={"q": "hi"}, + result={"echo": "hi"}, + started_event_id=started.event_id, + ) + crewai_event_bus.emit(source=None, event=completed) + _wait() + return started, completed + + +def _emit_a2a_pair( + *, + status: str = "completed", +) -> tuple[A2ADelegationStartedEvent, A2ADelegationCompletedEvent]: + started = A2ADelegationStartedEvent( + endpoint="https://remote/agent", + task_description="summarize", + agent_id="remote-agent-1", + context_id="ctx-1", + ) + crewai_event_bus.emit(source=None, event=started) + + completed = A2ADelegationCompletedEvent( + status=status, + result="done" if status == "completed" else None, + error=None if status == "completed" else "refused", + context_id="ctx-1", + endpoint="https://remote/agent", + started_event_id=started.event_id, + ) + crewai_event_bus.emit(source=None, event=completed) + _wait() + return started, completed + + +class TestSignetConfig: + def test_defaults(self) -> None: + cfg = SignetConfig(key_name="k") + assert cfg.key_name == "k" + assert cfg.audit is True + assert cfg.create_if_missing is True + assert cfg.tool_events and cfg.mcp_events and cfg.a2a_events + + def test_key_name_required(self) -> None: + with pytest.raises(ValueError): + SignetConfig(key_name="") + + def test_frozen(self) -> None: + cfg = SignetConfig(key_name="k") + with pytest.raises(Exception): + cfg.key_name = "other" # type: ignore[misc] + + +class TestToolSigning: + def test_signs_one_receipt_per_tool_call(self) -> None: + with crewai_event_bus.scoped_handlers(): + listener, agent = _install() + started, finished = _emit_tool_pair(output={"answer": 42}) + + assert len(listener.receipts) == 1 + rec = listener.receipts[0] + assert isinstance(rec, Receipt) + assert rec.kind == "tool" + assert rec.action == "some_tool" + assert rec.error is False + assert rec.payload["tool_name"] == "some_tool" + assert rec.payload["tool_args"] == {"x": 1} + assert rec.payload["output"] == {"answer": 42} + assert rec.payload["started_event_id"] == started.event_id + assert rec.payload["agent_id"] == "agent-1" + assert rec.receipt["signature"] == "sig-1" + assert agent.calls[0][0] == "some_tool" + + def test_signs_error_event(self) -> None: + with crewai_event_bus.scoped_handlers(): + listener, _ = _install() + _emit_tool_pair(with_error=True) + + assert len(listener.receipts) == 1 + rec = listener.receipts[0] + assert rec.error is True + assert rec.payload["error"] == "boom" + assert "output" not in rec.payload + + def test_finished_without_started_is_skipped(self) -> None: + with crewai_event_bus.scoped_handlers(): + listener, _ = _install() + from datetime import datetime, timezone + + now = datetime.now(timezone.utc) + orphan = ToolUsageFinishedEvent( + tool_name="orphan", + tool_args={}, + started_at=now, + finished_at=now, + output="x", + started_event_id="does-not-exist", + ) + crewai_event_bus.emit(source=None, event=orphan) + + assert listener.receipts == [] + + def test_pairs_are_independent_across_calls(self) -> None: + with crewai_event_bus.scoped_handlers(): + listener, _ = _install() + _emit_tool_pair(tool_name="t1", output="a") + _emit_tool_pair(tool_name="t2", output="b") + + actions = [r.action for r in listener.receipts] + outputs = [r.payload["output"] for r in listener.receipts] + assert actions == ["t1", "t2"] + assert outputs == ["a", "b"] + + +class TestMCPSigning: + def test_signs_mcp_tool_execution(self) -> None: + with crewai_event_bus.scoped_handlers(): + listener, _ = _install() + _emit_mcp_pair() + + assert len(listener.receipts) == 1 + rec = listener.receipts[0] + assert rec.kind == "mcp_tool" + assert rec.action == "mcp_echo" + assert rec.payload["server_name"] == "server-a" + assert rec.payload["result"] == {"echo": "hi"} + assert rec.error is False + + def test_signs_mcp_failure(self) -> None: + with crewai_event_bus.scoped_handlers(): + listener, _ = _install() + _emit_mcp_pair(with_error=True) + + assert len(listener.receipts) == 1 + rec = listener.receipts[0] + assert rec.error is True + assert rec.payload["error"] == "server crashed" + + +class TestA2ASigning: + def test_signs_successful_a2a_delegation(self) -> None: + with crewai_event_bus.scoped_handlers(): + listener, _ = _install() + _emit_a2a_pair(status="completed") + + assert len(listener.receipts) == 1 + rec = listener.receipts[0] + assert rec.kind == "a2a_delegation" + assert rec.action == "a2a:remote-agent-1" + assert rec.payload["status"] == "completed" + assert rec.payload["endpoint"] == "https://remote/agent" + assert rec.error is False + + def test_flags_failed_a2a_delegation_as_error(self) -> None: + with crewai_event_bus.scoped_handlers(): + listener, _ = _install() + _emit_a2a_pair(status="failed") + + assert len(listener.receipts) == 1 + assert listener.receipts[0].error is True + assert listener.receipts[0].payload["status"] == "failed" + + +class TestSurfaceToggles: + def test_disabling_tool_events_skips_tool_signing(self) -> None: + with crewai_event_bus.scoped_handlers(): + listener, _ = _install(tool_events=False) + _emit_tool_pair() + _emit_mcp_pair() + + assert len(listener.receipts) == 1 + assert listener.receipts[0].kind == "mcp_tool" + + def test_disabling_mcp_events_skips_mcp_signing(self) -> None: + with crewai_event_bus.scoped_handlers(): + listener, _ = _install(mcp_events=False) + _emit_tool_pair() + _emit_mcp_pair() + + assert len(listener.receipts) == 1 + assert listener.receipts[0].kind == "tool" + + def test_disabling_a2a_events_skips_a2a_signing(self) -> None: + with crewai_event_bus.scoped_handlers(): + listener, _ = _install(a2a_events=False) + _emit_a2a_pair() + + assert listener.receipts == [] + + +class TestLazyImport: + def test_missing_signet_auth_raises_clear_import_error( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """When no signing_agent is injected and signet_auth isn't installed, + ``_get_signing_agent`` must raise a clear ImportError — not at import + or registration time. + """ + monkeypatch.setitem(sys.modules, "signet_auth", None) + + with crewai_event_bus.scoped_handlers(): + listener = install(key_name="real-key") + assert listener.receipts == [] + with pytest.raises(ImportError, match="signet-auth"): + listener._get_signing_agent() + + def test_builds_signing_agent_from_fake_signet_auth( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """If signet_auth is importable, SigningAgent.create is used with the + config-derived kwargs.""" + captured: dict[str, Any] = {} + + class _SigningAgent: + def __init__(self, name: str, **kwargs: Any) -> None: + self.name = name + self.kwargs = kwargs + + @classmethod + def create(cls, name: str, **kwargs: Any) -> "_SigningAgent": + captured["create"] = (name, kwargs) + return cls(name, **kwargs) + + def sign(self, action: str, *, params: dict[str, Any]) -> dict[str, Any]: + return {"action": action, "params": params, "by": self.name} + + fake_module = types.ModuleType("signet_auth") + fake_module.SigningAgent = _SigningAgent # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "signet_auth", fake_module) + + with crewai_event_bus.scoped_handlers(): + listener = install( + key_name="team-agent", + owner="alice", + audit=True, + policy_path="/tmp/policy.yaml", + ) + agent = listener._get_signing_agent() + + assert captured["create"][0] == "team-agent" + assert captured["create"][1] == { + "audit": True, + "policy_path": "/tmp/policy.yaml", + "owner": "alice", + } + assert isinstance(agent, _SigningAgent) + # Subsequent calls must reuse the same instance. + assert listener._get_signing_agent() is agent + + def test_loads_existing_identity_when_create_if_missing_false( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """When ``create_if_missing=False`` the listener instantiates + ``SigningAgent`` directly instead of calling ``create``.""" + init_args: dict[str, Any] = {} + + class _SigningAgent: + def __init__(self, name: str, **kwargs: Any) -> None: + init_args["name"] = name + init_args["kwargs"] = kwargs + + def sign(self, action: str, *, params: dict[str, Any]) -> dict[str, Any]: + return {"action": action, "params": params} + + fake_module = types.ModuleType("signet_auth") + fake_module.SigningAgent = _SigningAgent # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "signet_auth", fake_module) + + with crewai_event_bus.scoped_handlers(): + listener = install( + key_name="existing-agent", + create_if_missing=False, + audit=False, + ) + listener._get_signing_agent() + + assert init_args["name"] == "existing-agent" + assert init_args["kwargs"] == {} + + def test_hint_message_mentions_extra(self) -> None: + assert "crewai[signet]" in _SIGNET_INSTALL_HINT diff --git a/uv.lock b/uv.lock index 7451cfa17..c60c32f18 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-04-18T07:00:00Z" +exclude-newer = "2026-04-18T00:00:00Z" [manifest] members = [ @@ -1330,6 +1330,9 @@ qdrant = [ qdrant-edge = [ { name = "qdrant-edge-py" }, ] +signet = [ + { name = "signet-auth" }, +] tools = [ { name = "crewai-tools" }, ] @@ -1387,6 +1390,7 @@ requires-dist = [ { name = "qdrant-client", extras = ["fastembed"], marker = "extra == 'qdrant'", specifier = "~=1.14.3" }, { name = "qdrant-edge-py", marker = "extra == 'qdrant-edge'", specifier = ">=0.6.0" }, { name = "regex", specifier = "~=2026.1.15" }, + { name = "signet-auth", marker = "extra == 'signet'", specifier = ">=0.5.0" }, { name = "textual", specifier = ">=7.5.0" }, { name = "tiktoken", marker = "extra == 'embeddings'", specifier = "~=0.8.0" }, { name = "tokenizers", specifier = ">=0.21,<1" }, @@ -1395,7 +1399,7 @@ requires-dist = [ { name = "uv", specifier = "~=0.11.6" }, { name = "voyageai", marker = "extra == 'voyageai'", specifier = "~=0.3.5" }, ] -provides-extras = ["a2a", "anthropic", "aws", "azure-ai-inference", "bedrock", "docling", "embeddings", "file-processing", "google-genai", "litellm", "mem0", "openpyxl", "pandas", "qdrant", "qdrant-edge", "tools", "voyageai", "watson"] +provides-extras = ["a2a", "anthropic", "aws", "azure-ai-inference", "bedrock", "docling", "embeddings", "file-processing", "google-genai", "litellm", "mem0", "openpyxl", "pandas", "qdrant", "qdrant-edge", "signet", "tools", "voyageai", "watson"] [[package]] name = "crewai-devtools" @@ -8095,6 +8099,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "signet-auth" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/09/68036ddb2d00985d1081f205e2480368ed28294a864bc87e44796ec6685b/signet_auth-0.9.0.tar.gz", hash = "sha256:c650db7d16236448234a2d356245e19bdb5b48fe78cc3436311db8b002867885", size = 88768, upload-time = "2026-04-13T09:33:56.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/8d/800651df03709729ae52c89310c71eaf3aea4efadb183e67a9d7f002cba6/signet_auth-0.9.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:013152b26415ceb89cf2969e69a03aa254328f2d7f656a080842c967e2c7d1e1", size = 1730524, upload-time = "2026-04-13T09:33:49.261Z" }, + { url = "https://files.pythonhosted.org/packages/78/61/59f87d18c76f9dff467b62a48afade139460c090f91ad13c56cb52dd2b46/signet_auth-0.9.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:4c3ce87dc6265993ddc4f56d6ef5ad867b66d74cc8713c23f1fe47d6317b1d55", size = 1614461, upload-time = "2026-04-13T09:33:51.016Z" }, + { url = "https://files.pythonhosted.org/packages/39/a4/b0d887fda7e25647629be600cd38e8f1fc129f6bc4c5bdefdac61531a915/signet_auth-0.9.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2950761b4199d764902520749c60d668ae7ade91de8347e89253a94f102c94d", size = 1802423, upload-time = "2026-04-13T09:33:53.338Z" }, + { url = "https://files.pythonhosted.org/packages/e4/60/71f8884256523a57646fd0e216b7a267cae69e4751f4bf0e5703bd9b1d5f/signet_auth-0.9.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2139507c8c0d44b74fa6d73f1812446223c1fa381f0d2e811a9a411302378deb", size = 1859359, upload-time = "2026-04-13T09:33:55.506Z" }, +] + [[package]] name = "singlestoredb" version = "1.16.9"