diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py index 12011ce394..b5c93d3d15 100644 --- a/lib/crewai-tools/src/crewai_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/__init__.py @@ -160,6 +160,7 @@ from crewai_tools.tools.selenium_scraping_tool.selenium_scraping_tool import ( SeleniumScrapingTool, ) +from crewai_tools.tools.signatrust_tool.signatrust_tool import SignatrustTool from crewai_tools.tools.serpapi_tool.serpapi_google_search_tool import ( SerpApiGoogleSearchTool, ) @@ -301,6 +302,7 @@ "ScrapegraphScrapeToolSchema", "ScrapflyScrapeWebsiteTool", "SeleniumScrapingTool", + "SignatrustTool", "SerpApiGoogleSearchTool", "SerpApiGoogleShoppingTool", "SerperDevTool", diff --git a/lib/crewai-tools/src/crewai_tools/tools/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/__init__.py index 18bf4e5638..883c362ebc 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/__init__.py @@ -148,6 +148,7 @@ from crewai_tools.tools.selenium_scraping_tool.selenium_scraping_tool import ( SeleniumScrapingTool, ) +from crewai_tools.tools.signatrust_tool.signatrust_tool import SignatrustTool from crewai_tools.tools.serpapi_tool.serpapi_google_search_tool import ( SerpApiGoogleSearchTool, ) @@ -283,6 +284,7 @@ "ScrapegraphScrapeToolSchema", "ScrapflyScrapeWebsiteTool", "SeleniumScrapingTool", + "SignatrustTool", "SerpApiGoogleSearchTool", "SerpApiGoogleShoppingTool", "SerperDevTool", diff --git a/lib/crewai-tools/src/crewai_tools/tools/signatrust_tool/README.md b/lib/crewai-tools/src/crewai_tools/tools/signatrust_tool/README.md new file mode 100644 index 0000000000..9fcf4c4055 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/signatrust_tool/README.md @@ -0,0 +1,99 @@ +# SignatrustTool Documentation + +## Description + +`SignatrustTool` lets your CrewAI agents generate, verify, and retrieve +**AI Decision Receipts** from [Signatrust](https://signatrust.net) — tamper-evident, +cryptographically signed (Ed25519) records of the decisions your agents make. + +This is useful for **verifiable accountability and auditability** of AI-assisted +decisions (compliance reviews, approvals, financial actions, content moderation, +etc.). By default, only a SHA-256 hash of the decision payload is stored +server-side, so the tool is privacy-first. + +The tool supports three operations: + +- `generate` — create a new signed Decision Receipt for an agent decision +- `verify` — verify the cryptographic integrity of an existing receipt by id +- `get` — retrieve a stored receipt by id + +## Installation + +```shell +pip install 'crewai[tools]' +``` + +Then set your Signatrust API key: + +```shell +export SIGNATRUST_API_KEY="sk_live_..." +``` + +Self-hosted deployments can point the tool at their own endpoint via `base_url`. + +## Example + +```python +from crewai import Agent, Task, Crew +from crewai_tools import SignatrustTool + +signatrust = SignatrustTool() # reads SIGNATRUST_API_KEY from the environment + +auditor = Agent( + role="Compliance Auditor", + goal="Record every approval decision as a verifiable Decision Receipt", + backstory="You ensure all AI-assisted decisions are cryptographically logged.", + tools=[signatrust], + verbose=True, +) + +task = Task( + description=( + "A loan application was approved by the model gpt-4o under the " + "'KYC-2024' and 'AML-Tier1' policies, with human review. " + "Generate a Signatrust Decision Receipt for this decision." + ), + expected_output="The receipt id and its verification status.", + agent=auditor, +) + +crew = Crew(agents=[auditor], tasks=[task]) +crew.kickoff() +``` + +### Direct usage + +```python +from crewai_tools import SignatrustTool + +tool = SignatrustTool() + +# Generate +print(tool.run( + operation="generate", + agent_name="loan-bot", + action="approve_application", + decision="approved", + model="gpt-4o", + policies=["KYC-2024", "AML-Tier1"], + human_review=True, +)) + +# Verify +print(tool.run(operation="verify", receipt_id="rcpt_123")) + +# Retrieve +print(tool.run(operation="get", receipt_id="rcpt_123")) +``` + +## Required environment variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `SIGNATRUST_API_KEY` | Yes | Your Signatrust API key (`sk_live_...`). | + +## Links + +- Website: https://signatrust.net +- Source code: https://github.com/abokenan444/Signatrust +- Standalone package: `pip install crewai-signatrust` (also available) diff --git a/lib/crewai-tools/src/crewai_tools/tools/signatrust_tool/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/signatrust_tool/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/crewai-tools/src/crewai_tools/tools/signatrust_tool/signatrust_tool.py b/lib/crewai-tools/src/crewai_tools/tools/signatrust_tool/signatrust_tool.py new file mode 100644 index 0000000000..5502d452b8 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/signatrust_tool/signatrust_tool.py @@ -0,0 +1,212 @@ +""" +Signatrust tool for CrewAI. + +Wraps the Signatrust REST API so agents can generate, verify, and retrieve +cryptographically signed **AI Decision Receipts** — tamper-evident records of +decisions made by AI agents. + +Learn more: https://signatrust.net +Source: https://github.com/abokenan444/Signatrust +""" + +from __future__ import annotations + +import asyncio +import json +import os +from typing import Any, Dict, List, Optional, Type + +from crewai.tools import BaseTool, EnvVar +from pydantic import BaseModel, Field + +DEFAULT_BASE_URL = "https://signatrust.net/api/v1" +DEFAULT_TIMEOUT = 30 + + +class SignatrustToolInput(BaseModel): + """Input schema for :class:`SignatrustTool`.""" + + operation: str = Field( + "generate", + description=( + "Operation to perform. One of: 'generate' (create a new Decision " + "Receipt), 'verify' (verify an existing receipt by id), or 'get' " + "(retrieve a receipt by id)." + ), + ) + agent_name: Optional[str] = Field( + None, + description="Name/identifier of the AI agent making the decision (generate).", + ) + action: Optional[str] = Field( + None, + description="The action being recorded, e.g. 'approve_transaction' (generate).", + ) + decision: Optional[str] = Field( + None, + description="The decision/outcome to record, e.g. 'approved' (generate).", + ) + model: Optional[str] = Field( + None, + description="Model that produced the decision, e.g. 'gpt-4o' (generate).", + ) + policies: Optional[List[str]] = Field( + None, + description="Policies/rules applied while making the decision (generate).", + ) + human_review: Optional[bool] = Field( + None, + description="Whether a human reviewed/approved the decision (generate).", + ) + metadata: Optional[Dict[str, Any]] = Field( + None, + description="Optional additional structured context (generate).", + ) + receipt_id: Optional[str] = Field( + None, + description="Receipt id, required for the 'verify' and 'get' operations.", + ) + + +class SignatrustTool(BaseTool): + """Generate, verify, and retrieve Signatrust AI Decision Receipts. + + Signatrust produces tamper-evident, cryptographically signed (Ed25519) + receipts for decisions made by AI agents, enabling verifiable accountability + and auditability. By default only a SHA-256 hash of the decision payload is + stored server-side (privacy-first). + + Requires the ``SIGNATRUST_API_KEY`` environment variable (or pass + ``api_key=`` directly). Self-hosted deployments can override ``base_url``. + """ + + name: str = "Signatrust Decision Receipt" + description: str = ( + "Create, verify, or retrieve cryptographically signed AI Decision " + "Receipts via Signatrust. Use 'generate' to record an agent decision, " + "'verify' to check a receipt's integrity, and 'get' to fetch a receipt " + "by id. Returns a JSON string." + ) + args_schema: Type[BaseModel] = SignatrustToolInput + + env_vars: List[EnvVar] = [ + EnvVar( + name="SIGNATRUST_API_KEY", + description="API key for the Signatrust service (sk_live_...).", + required=True, + ), + ] + package_dependencies: List[str] = ["requests"] + + api_key: Optional[str] = None + base_url: str = DEFAULT_BASE_URL + timeout: int = DEFAULT_TIMEOUT + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + # Lazy dependency check to keep the base install light. + try: + import requests # noqa: F401 + except Exception as exc: # pragma: no cover - import guard + raise ImportError( + "Missing optional dependency 'requests'. Install with:\n" + " pip install requests\n" + ) from exc + + if not self.api_key: + self.api_key = os.environ.get("SIGNATRUST_API_KEY") + if not self.api_key: + raise ValueError( + "A Signatrust API key is required. Set the SIGNATRUST_API_KEY " + "environment variable or pass api_key= to SignatrustTool." + ) + + # -- internal helpers ------------------------------------------------- + + def _headers(self) -> Dict[str, str]: + return { + "X-API-Key": self.api_key or "", + "Content-Type": "application/json", + "Accept": "application/json", + } + + def _generate(self, payload: Dict[str, Any]) -> Dict[str, Any]: + import requests + + body = {k: v for k, v in payload.items() if v is not None} + resp = requests.post( + f"{self.base_url}/receipts", + headers=self._headers(), + json=body, + timeout=self.timeout, + ) + resp.raise_for_status() + return resp.json() + + def _verify(self, receipt_id: str) -> Dict[str, Any]: + import requests + + resp = requests.get( + f"{self.base_url}/receipts/{receipt_id}/verify", + headers=self._headers(), + timeout=self.timeout, + ) + resp.raise_for_status() + return resp.json() + + def _get(self, receipt_id: str) -> Dict[str, Any]: + import requests + + resp = requests.get( + f"{self.base_url}/receipts/{receipt_id}", + headers=self._headers(), + timeout=self.timeout, + ) + resp.raise_for_status() + return resp.json() + + # -- execution -------------------------------------------------------- + + def _run(self, **kwargs: Any) -> str: + operation = (kwargs.get("operation") or "generate").strip().lower() + + try: + if operation == "generate": + result = self._generate( + { + "agent_name": kwargs.get("agent_name"), + "action": kwargs.get("action"), + "decision": kwargs.get("decision"), + "model": kwargs.get("model"), + "policies": kwargs.get("policies"), + "human_review": kwargs.get("human_review"), + "metadata": kwargs.get("metadata"), + } + ) + elif operation in {"verify", "get"}: + receipt_id = kwargs.get("receipt_id") + if not receipt_id: + return ( + f"Error: 'receipt_id' is required for the '{operation}' " + "operation." + ) + result = ( + self._verify(receipt_id) + if operation == "verify" + else self._get(receipt_id) + ) + else: + return ( + f"Error: unknown operation '{operation}'. Use 'generate', " + "'verify', or 'get'." + ) + # sort_keys makes the serialized payload stable across runs and + # across upstream services that may emit JSON in different orders. + return json.dumps(result, ensure_ascii=False, sort_keys=True) + except Exception as exc: # noqa: BLE001 - return friendly message + return f"Signatrust request failed: {exc}" + + async def _arun(self, **kwargs: Any) -> str: + # The underlying client uses blocking `requests`; offload to a worker + # thread so async callers don't stall the event loop. + return await asyncio.to_thread(self._run, **kwargs) diff --git a/lib/crewai-tools/tests/tools/signatrust_tool_test.py b/lib/crewai-tools/tests/tools/signatrust_tool_test.py new file mode 100644 index 0000000000..e2049c5345 --- /dev/null +++ b/lib/crewai-tools/tests/tools/signatrust_tool_test.py @@ -0,0 +1,144 @@ +import asyncio +import json +import threading +from unittest.mock import MagicMock, patch + +import pytest + +from crewai_tools.tools.signatrust_tool.signatrust_tool import SignatrustTool + + +@pytest.fixture +def signatrust_tool(): + return SignatrustTool(api_key="sk_test_123") + + +def test_requires_api_key(monkeypatch): + monkeypatch.delenv("SIGNATRUST_API_KEY", raising=False) + with pytest.raises(ValueError): + SignatrustTool() + + +def test_initialization_with_env(monkeypatch): + monkeypatch.setenv("SIGNATRUST_API_KEY", "sk_env_456") + tool = SignatrustTool() + assert tool.api_key == "sk_env_456" + assert tool.base_url == "https://signatrust.net/api/v1" + + +def test_custom_base_url(): + tool = SignatrustTool(api_key="sk_test_123", base_url="https://self.hosted/api/v1") + assert tool.base_url == "https://self.hosted/api/v1" + + +@patch("requests.post") +def test_generate(mock_post, signatrust_tool): + mock_resp = MagicMock() + mock_resp.json.return_value = {"id": "rcpt_1", "status": "signed"} + mock_resp.raise_for_status.return_value = None + mock_post.return_value = mock_resp + + out = signatrust_tool.run( + operation="generate", + agent_name="bot", + action="approve", + decision="approved", + model="gpt-4o", + human_review=True, + ) + data = json.loads(out) + assert data["id"] == "rcpt_1" + assert data["status"] == "signed" + # API key passed via header + _, kwargs = mock_post.call_args + assert kwargs["headers"]["X-API-Key"] == "sk_test_123" + # None fields stripped from body + assert "metadata" not in kwargs["json"] + + +@patch("requests.get") +def test_verify(mock_get, signatrust_tool): + mock_resp = MagicMock() + mock_resp.json.return_value = {"id": "rcpt_1", "valid": True} + mock_resp.raise_for_status.return_value = None + mock_get.return_value = mock_resp + + out = signatrust_tool.run(operation="verify", receipt_id="rcpt_1") + data = json.loads(out) + assert data["valid"] is True + assert mock_get.call_args[0][0].endswith("/receipts/rcpt_1/verify") + + +@patch("requests.get") +def test_get(mock_get, signatrust_tool): + mock_resp = MagicMock() + mock_resp.json.return_value = {"id": "rcpt_1", "action": "approve"} + mock_resp.raise_for_status.return_value = None + mock_get.return_value = mock_resp + + out = signatrust_tool.run(operation="get", receipt_id="rcpt_1") + data = json.loads(out) + assert data["id"] == "rcpt_1" + assert mock_get.call_args[0][0].endswith("/receipts/rcpt_1") + + +def test_verify_requires_receipt_id(signatrust_tool): + out = signatrust_tool.run(operation="verify") + assert "receipt_id" in out + + +def test_unknown_operation(signatrust_tool): + out = signatrust_tool.run(operation="frobnicate") + assert "unknown operation" in out.lower() + + +@patch("requests.post") +def test_arun_non_blocking(mock_post, signatrust_tool): + """`_arun` must offload the blocking HTTP call to a worker thread.""" + main_thread_id = threading.get_ident() + call_thread_ids: list[int] = [] + + def fake_post(*args, **kwargs): + call_thread_ids.append(threading.get_ident()) + mock_resp = MagicMock() + mock_resp.json.return_value = {"id": "rcpt_async", "status": "signed"} + mock_resp.raise_for_status.return_value = None + return mock_resp + + mock_post.side_effect = fake_post + + out = asyncio.run( + signatrust_tool._arun( + operation="generate", + agent_name="bot", + action="approve", + decision="approved", + ) + ) + data = json.loads(out) + assert data["id"] == "rcpt_async" + assert data["status"] == "signed" + assert len(call_thread_ids) == 1, "requests.post should be invoked exactly once" + assert call_thread_ids[0] != main_thread_id, ( + "_arun must offload the blocking HTTP call to a worker thread; " + "got call on the main thread (event loop would block)." + ) + + +@patch("requests.get") +def test_serialized_output_is_deterministic(mock_get, signatrust_tool): + """Equivalent payloads must serialize to the same JSON string regardless of upstream key order.""" + payload_keys_order_a = {"id": "rcpt_1", "valid": True, "trust": 90} + payload_keys_order_b = {"trust": 90, "valid": True, "id": "rcpt_1"} + + outputs = [] + for payload in (payload_keys_order_a, payload_keys_order_b): + mock_resp = MagicMock() + mock_resp.json.return_value = payload + mock_resp.raise_for_status.return_value = None + mock_get.return_value = mock_resp + outputs.append(signatrust_tool.run(operation="verify", receipt_id="rcpt_1")) + + assert outputs[0] == outputs[1] + # And keys are sorted alphabetically. + assert outputs[0] == '{"id": "rcpt_1", "trust": 90, "valid": true}' diff --git a/lib/crewai-tools/tool.specs.json b/lib/crewai-tools/tool.specs.json index 795fa932c4..f44757a2e9 100644 --- a/lib/crewai-tools/tool.specs.json +++ b/lib/crewai-tools/tool.specs.json @@ -23252,6 +23252,210 @@ "type": "object" } }, + { + "description": "Create, verify, or retrieve cryptographically signed AI Decision Receipts via Signatrust. Use 'generate' to record an agent decision, 'verify' to check a receipt's integrity, and 'get' to fetch a receipt by id. Returns a JSON string.", + "env_vars": [ + { + "default": null, + "description": "API key for the Signatrust service (sk_live_...).", + "name": "SIGNATRUST_API_KEY", + "required": true + } + ], + "humanized_name": "Signatrust Decision Receipt", + "init_params_schema": { + "$defs": { + "EnvVar": { + "properties": { + "default": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Default" + }, + "description": { + "title": "Description", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "required": { + "default": true, + "title": "Required", + "type": "boolean" + } + }, + "required": [ + "name", + "description" + ], + "title": "EnvVar", + "type": "object" + } + }, + "description": "Generate, verify, and retrieve Signatrust AI Decision Receipts.\n\nSignatrust produces tamper-evident, cryptographically signed (Ed25519)\nreceipts for decisions made by AI agents, enabling verifiable accountability\nand auditability. By default only a SHA-256 hash of the decision payload is\nstored server-side (privacy-first).\n\nRequires the ``SIGNATRUST_API_KEY`` environment variable (or pass\n``api_key=`` directly). Self-hosted deployments can override ``base_url``.", + "properties": { + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Key" + }, + "base_url": { + "default": "https://signatrust.net/api/v1", + "title": "Base Url", + "type": "string" + }, + "timeout": { + "default": 30, + "title": "Timeout", + "type": "integer" + } + }, + "required": [], + "title": "SignatrustTool", + "type": "object" + }, + "name": "SignatrustTool", + "package_dependencies": [ + "requests" + ], + "run_params_schema": { + "description": "Input schema for :class:`SignatrustTool`.", + "properties": { + "action": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The action being recorded, e.g. 'approve_transaction' (generate).", + "title": "Action" + }, + "agent_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name/identifier of the AI agent making the decision (generate).", + "title": "Agent Name" + }, + "decision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The decision/outcome to record, e.g. 'approved' (generate).", + "title": "Decision" + }, + "human_review": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Whether a human reviewed/approved the decision (generate).", + "title": "Human Review" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional additional structured context (generate).", + "title": "Metadata" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Model that produced the decision, e.g. 'gpt-4o' (generate).", + "title": "Model" + }, + "operation": { + "default": "generate", + "description": "Operation to perform. One of: 'generate' (create a new Decision Receipt), 'verify' (verify an existing receipt by id), or 'get' (retrieve a receipt by id).", + "title": "Operation", + "type": "string" + }, + "policies": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Policies/rules applied while making the decision (generate).", + "title": "Policies" + }, + "receipt_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Receipt id, required for the 'verify' and 'get' operations.", + "title": "Receipt Id" + } + }, + "title": "SignatrustToolInput", + "type": "object" + } + }, { "description": "A tool that can be used to semantic search a query from a database.", "env_vars": [