mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-05-06 01:32:36 +00:00
feat(integrations): add optional Signet integration
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 <joao@crewai.com>
This commit is contained in:
@@ -107,6 +107,9 @@ a2a = [
|
||||
file-processing = [
|
||||
"crewai-files",
|
||||
]
|
||||
signet = [
|
||||
"signet-auth>=0.5.0",
|
||||
]
|
||||
qdrant-edge = [
|
||||
"qdrant-edge-py>=0.6.0",
|
||||
]
|
||||
|
||||
6
lib/crewai/src/crewai/integrations/__init__.py
Normal file
6
lib/crewai/src/crewai/integrations/__init__.py
Normal file
@@ -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.
|
||||
"""
|
||||
83
lib/crewai/src/crewai/integrations/signet/__init__.py
Normal file
83
lib/crewai/src/crewai/integrations/signet/__init__.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Optional Signet integration for CrewAI.
|
||||
|
||||
`Signet <https://github.com/Prismer-AI/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)
|
||||
34
lib/crewai/src/crewai/integrations/signet/config.py
Normal file
34
lib/crewai/src/crewai/integrations/signet/config.py
Normal file
@@ -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
|
||||
324
lib/crewai/src/crewai/integrations/signet/listener.py
Normal file
324
lib/crewai/src/crewai/integrations/signet/listener.py
Normal file
@@ -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,
|
||||
}
|
||||
0
lib/crewai/tests/integrations/__init__.py
Normal file
0
lib/crewai/tests/integrations/__init__.py
Normal file
0
lib/crewai/tests/integrations/signet/__init__.py
Normal file
0
lib/crewai/tests/integrations/signet/__init__.py
Normal file
418
lib/crewai/tests/integrations/signet/test_signet_listener.py
Normal file
418
lib/crewai/tests/integrations/signet/test_signet_listener.py
Normal file
@@ -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
|
||||
20
uv.lock
generated
20
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user