diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py index cb71de1d64..404f7f5ba5 100644 --- a/lib/crewai-tools/src/crewai_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/__init__.py @@ -32,6 +32,9 @@ from crewai_tools.tools.browserbase_load_tool.browserbase_load_tool import ( BrowserbaseLoadTool, ) +from crewai_tools.tools.chronoverify_image_verification_tool.chronoverify_image_verification_tool import ( + ChronoVerifyImageVerificationTool, +) from crewai_tools.tools.code_docs_search_tool.code_docs_search_tool import ( CodeDocsSearchTool, ) @@ -237,6 +240,7 @@ "BrightDataWebUnlockerTool", "BrowserbaseLoadTool", "CSVSearchTool", + "ChronoVerifyImageVerificationTool", "CodeDocsSearchTool", "ComposioTool", "ContextualAICreateAgentTool", diff --git a/lib/crewai-tools/src/crewai_tools/tools/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/__init__.py index 18bf4e5638..f84e9044b2 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/__init__.py @@ -21,6 +21,9 @@ from crewai_tools.tools.browserbase_load_tool.browserbase_load_tool import ( BrowserbaseLoadTool, ) +from crewai_tools.tools.chronoverify_image_verification_tool.chronoverify_image_verification_tool import ( + ChronoVerifyImageVerificationTool, +) from crewai_tools.tools.code_docs_search_tool.code_docs_search_tool import ( CodeDocsSearchTool, ) @@ -222,6 +225,7 @@ "BrightDataWebUnlockerTool", "BrowserbaseLoadTool", "CSVSearchTool", + "ChronoVerifyImageVerificationTool", "CodeDocsSearchTool", "ComposioTool", "ContextualAICreateAgentTool", diff --git a/lib/crewai-tools/src/crewai_tools/tools/chronoverify_image_verification_tool/README.md b/lib/crewai-tools/src/crewai_tools/tools/chronoverify_image_verification_tool/README.md new file mode 100644 index 0000000000..5c764e4212 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/chronoverify_image_verification_tool/README.md @@ -0,0 +1,84 @@ +# ChronoVerifyImageVerificationTool + +## Description + +This tool verifies when a photo was taken and its provenance using the [ChronoVerify](https://chronoverify.com) API. For each image it checks EXIF and XMP metadata, validates C2PA Content Credentials against the official trust lists, and runs classical pixel forensics, returning a single typed verdict with a 0 to 100 confidence score. + +The verdict is one of: + +| Verdict | Meaning | +|---|---| +| `provenance_confirmed` | Cryptographically valid C2PA Content Credentials from a signer on a recognised trust list | +| `consistent` | Metadata and pixel evidence agree with the claimed capture time and device | +| `inconclusive` | Not enough evidence to support or contradict the claim | +| `metadata_anomaly` | Metadata is missing, contradictory, or altered | +| `manipulation_indicated` | Pixel forensics or metadata indicate editing | + +The response also includes the extracted capture time, capture device, capture location, a `c2pa` block, and file integrity hashes. The full response schema is published at `https://chronoverify.com/v1/verify.schema.json`. + +**Scope**: ChronoVerify validates provenance. It is not a deepfake or AI-generation detector. Verdicts are investigative triage to support human review, not proof, and should not be the sole basis for any automated decision. + +## Installation + +Install the crewai_tools package: + +```shell +pip install 'crewai[tools]' +``` + +No extra dependencies are required. + +## Authentication (optional) + +The tool works without any credentials on ChronoVerify's free keyless path, which is rate limited per IP. + +For higher limits, set the `CHRONOVERIFY_API_KEY` environment variable. A free key (100 verifications per month, no card) can be requested with: + +```shell +curl -X POST https://chronoverify.com/v1/keys/free -d "email=you@example.com" +``` + +```shell +export CHRONOVERIFY_API_KEY='cv_live_...' +``` + +## Example + +```python +from crewai import Agent, Task +from crewai_tools import ChronoVerifyImageVerificationTool + +tool = ChronoVerifyImageVerificationTool() + +verifier = Agent( + role="Image Provenance Analyst", + goal="Verify when submitted photos were taken and where they came from", + backstory=( + "You examine image metadata, Content Credentials, and forensic " + "signals to assess whether a photo's claimed origin holds up." + ), + tools=[tool], + verbose=True, +) + +task = Task( + description=( + "Verify the capture time and provenance of the image at " + "https://example.com/photo.jpg and summarize the evidence." + ), + agent=verifier, + expected_output="A short provenance assessment citing the verdict and confidence.", +) +``` + +## Arguments + +- `image_path` (str, optional): Local path of the image file to verify. +- `image_url` (str, optional): Direct URL of the image to verify. Must be a direct link to the image bytes; redirects and hosts that block automated fetches fail, so prefer `image_path` when the file is available locally. + +Provide exactly one of the two. + +Constructor options: + +- `api_base_url` (str, optional): Base URL of the ChronoVerify API. Defaults to `https://chronoverify.com`. +- `request_timeout` (int, optional): Request timeout in seconds. Defaults to 120. diff --git a/lib/crewai-tools/src/crewai_tools/tools/chronoverify_image_verification_tool/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/chronoverify_image_verification_tool/__init__.py new file mode 100644 index 0000000000..d08fa8a016 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/chronoverify_image_verification_tool/__init__.py @@ -0,0 +1,8 @@ +"""ChronoVerify image verification tool for CrewAI.""" + +from crewai_tools.tools.chronoverify_image_verification_tool.chronoverify_image_verification_tool import ( + ChronoVerifyImageVerificationTool, +) + + +__all__ = ["ChronoVerifyImageVerificationTool"] diff --git a/lib/crewai-tools/src/crewai_tools/tools/chronoverify_image_verification_tool/chronoverify_image_verification_tool.py b/lib/crewai-tools/src/crewai_tools/tools/chronoverify_image_verification_tool/chronoverify_image_verification_tool.py new file mode 100644 index 0000000000..c944d12069 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/chronoverify_image_verification_tool/chronoverify_image_verification_tool.py @@ -0,0 +1,142 @@ +"""ChronoVerify image capture-time and provenance verification tool for CrewAI.""" + +import json +import os +from pathlib import Path + +from crewai.tools import BaseTool, EnvVar +from pydantic import BaseModel, Field +import requests + + +class ChronoVerifyImageVerificationToolSchema(BaseModel): + """Input for ChronoVerifyImageVerificationTool.""" + + image_path: str | None = Field( + default=None, + description=( + "Local filesystem path of the image file to verify. " + "Provide exactly one of image_path or image_url." + ), + ) + image_url: str | None = Field( + default=None, + description=( + "Direct URL of the image to verify. Must be a direct link to the " + "image bytes; redirects and hosts that block automated fetches fail, " + "so prefer image_path when the file is available locally." + ), + ) + + +class ChronoVerifyImageVerificationTool(BaseTool): + """Verify a photo's capture time and provenance with the ChronoVerify API. + + Sends the image to the ChronoVerify verification endpoint, which checks + EXIF and XMP metadata, validates C2PA Content Credentials against the + official trust lists, and runs classical pixel forensics. The response is + a typed verdict with a 0 to 100 confidence plus the extracted capture + time, capture device, capture location, C2PA details, and file hashes. + + ChronoVerify is not a deepfake or AI-generation detector. Verdicts are + investigative triage to support human review, not proof. + + An API key is optional. Without one, requests use the free keyless path, + which is rate limited per IP. Set ``CHRONOVERIFY_API_KEY`` to meter + requests against a key instead. + """ + + name: str = "ChronoVerify Image Verification" + description: str = ( + "Verify when a photo was taken and its provenance. Checks EXIF and XMP " + "metadata, validates C2PA Content Credentials against the official " + "trust lists, and runs pixel forensics, returning one verdict " + "(provenance_confirmed, consistent, inconclusive, metadata_anomaly, or " + "manipulation_indicated) with a 0 to 100 confidence, plus the extracted " + "capture time, capture device, and capture location. It does not detect " + "deepfakes or AI generation; verdicts are investigative triage to " + "support human review, not proof. Accepts a local image file path or a " + "direct image URL." + ) + args_schema: type[BaseModel] = ChronoVerifyImageVerificationToolSchema + api_base_url: str = Field( + default="https://chronoverify.com", + description="Base URL of the ChronoVerify API.", + ) + request_timeout: int = Field( + default=120, + description="Timeout in seconds for the verification request.", + ) + env_vars: list[EnvVar] = Field( + default_factory=lambda: [ + EnvVar( + name="CHRONOVERIFY_API_KEY", + description=( + "Optional ChronoVerify API key (cv_live_...). Without it, " + "requests use the free keyless path, which is rate limited " + "per IP. A free key is available at " + "https://chronoverify.com/v1/keys/free." + ), + required=False, + ), + ] + ) + + def _request_headers(self) -> dict[str, str]: + """Build request headers, adding auth only when an API key is set.""" + api_key = os.environ.get("CHRONOVERIFY_API_KEY") + if api_key: + return {"Authorization": f"Bearer {api_key}"} + return {} + + def _run( + self, + image_path: str | None = None, + image_url: str | None = None, + ) -> str: + """Verify an image and return the verification result as JSON text. + + Args: + image_path: Local path of the image file to verify. + image_url: Direct URL of the image to verify. + + Returns: + The ChronoVerify verification response serialized as JSON, or an + error message describing what went wrong. + """ + if not image_path and not image_url: + return "Error: provide either image_path or image_url." + if image_path and image_url: + return "Error: provide only one of image_path or image_url, not both." + + endpoint = f"{self.api_base_url.rstrip('/')}/v1/verify" + + try: + if image_path: + path = Path(image_path) + if not path.is_file(): + return f"Error: image file not found: {image_path}" + with path.open("rb") as image_file: + response = requests.post( + endpoint, + headers=self._request_headers(), + files={"file": (path.name, image_file)}, + timeout=self.request_timeout, + ) + else: + response = requests.post( + endpoint, + headers=self._request_headers(), + data={"url": image_url}, + timeout=self.request_timeout, + ) + response.raise_for_status() + return json.dumps(response.json(), indent=2) + except requests.exceptions.HTTPError as e: + status_code = e.response.status_code if e.response is not None else "?" + detail = e.response.text[:500] if e.response is not None else str(e) + return f"Error verifying image (HTTP {status_code}): {detail}" + except requests.exceptions.RequestException as e: + return f"Error communicating with the ChronoVerify API: {e!s}" + except ValueError as e: + return f"Error parsing the ChronoVerify API response: {e!s}" diff --git a/lib/crewai-tools/tests/tools/chronoverify_image_verification_tool_test.py b/lib/crewai-tools/tests/tools/chronoverify_image_verification_tool_test.py new file mode 100644 index 0000000000..1d9304eafb --- /dev/null +++ b/lib/crewai-tools/tests/tools/chronoverify_image_verification_tool_test.py @@ -0,0 +1,157 @@ +"""Tests for ChronoVerifyImageVerificationTool.""" + +import json +import os +from unittest.mock import MagicMock, patch + +from crewai_tools import ChronoVerifyImageVerificationTool +import pytest +import requests + + +REQUESTS_POST = ( + "crewai_tools.tools.chronoverify_image_verification_tool" + ".chronoverify_image_verification_tool.requests.post" +) + + +@pytest.fixture +def tool(): + return ChronoVerifyImageVerificationTool() + + +@pytest.fixture +def mock_verify_response(): + return { + "verdict": "consistent", + "confidence": 82, + "capture_time": { + "present": True, + "value": "2026-05-14T09:31:22+00:00", + "source": "exif", + }, + "capture_device": {"make": "Apple", "model": "iPhone 15 Pro"}, + "capture_location": {"present": False}, + "c2pa": {"present": False}, + "integrity": {"sha256": "a" * 64}, + } + + +def _mock_response(payload, status_code=200): + response = MagicMock() + response.status_code = status_code + response.json.return_value = payload + response.raise_for_status.return_value = None + return response + + +@patch(REQUESTS_POST) +def test_verify_by_url(mock_post, tool, mock_verify_response): + mock_post.return_value = _mock_response(mock_verify_response) + + result = tool._run(image_url="https://example.com/photo.jpg") + + parsed = json.loads(result) + assert parsed["verdict"] == "consistent" + assert parsed["confidence"] == 82 + + _, kwargs = mock_post.call_args + assert kwargs["data"] == {"url": "https://example.com/photo.jpg"} + assert mock_post.call_args[0][0] == "https://chronoverify.com/v1/verify" + + +@patch(REQUESTS_POST) +def test_verify_by_file(mock_post, tool, mock_verify_response, tmp_path): + mock_post.return_value = _mock_response(mock_verify_response) + image_file = tmp_path / "photo.jpg" + image_file.write_bytes(b"\xff\xd8\xff\xe0fake-jpeg-bytes") + + result = tool._run(image_path=str(image_file)) + + parsed = json.loads(result) + assert parsed["verdict"] == "consistent" + + _, kwargs = mock_post.call_args + assert "files" in kwargs + assert kwargs["files"]["file"][0] == "photo.jpg" + + +@patch(REQUESTS_POST) +def test_keyless_request_sends_no_auth_header(mock_post, tool, mock_verify_response): + mock_post.return_value = _mock_response(mock_verify_response) + + with patch.dict(os.environ, {}, clear=True): + tool._run(image_url="https://example.com/photo.jpg") + + _, kwargs = mock_post.call_args + assert "Authorization" not in kwargs["headers"] + + +@patch(REQUESTS_POST) +def test_api_key_sent_as_bearer_token(mock_post, tool, mock_verify_response): + mock_post.return_value = _mock_response(mock_verify_response) + + with patch.dict(os.environ, {"CHRONOVERIFY_API_KEY": "cv_live_test123"}): + tool._run(image_url="https://example.com/photo.jpg") + + _, kwargs = mock_post.call_args + assert kwargs["headers"]["Authorization"] == "Bearer cv_live_test123" + + +def test_missing_inputs_returns_error(tool): + result = tool._run() + assert "provide either image_path or image_url" in result + + +def test_both_inputs_returns_error(tool): + result = tool._run( + image_path="photo.jpg", image_url="https://example.com/photo.jpg" + ) + assert "only one of image_path or image_url" in result + + +def test_missing_file_returns_error(tool): + result = tool._run(image_path="does/not/exist.jpg") + assert "image file not found" in result + + +@patch(REQUESTS_POST) +def test_http_error_returns_error_message(mock_post, tool): + response = MagicMock() + response.status_code = 429 + response.text = "Rate limited" + response.raise_for_status.side_effect = requests.exceptions.HTTPError( + response=response + ) + mock_post.return_value = response + + result = tool._run(image_url="https://example.com/photo.jpg") + + assert "HTTP 429" in result + assert "Rate limited" in result + + +@patch(REQUESTS_POST, side_effect=requests.exceptions.ConnectionError("boom")) +def test_network_error_returns_error_message(mock_post, tool): + result = tool._run(image_url="https://example.com/photo.jpg") + assert "Error communicating with the ChronoVerify API" in result + + +@patch(REQUESTS_POST) +def test_custom_base_url(mock_post, mock_verify_response): + mock_post.return_value = _mock_response(mock_verify_response) + tool = ChronoVerifyImageVerificationTool(api_base_url="http://localhost:8000/") + + tool._run(image_url="https://example.com/photo.jpg") + + assert mock_post.call_args[0][0] == "http://localhost:8000/v1/verify" + + +@patch(REQUESTS_POST) +def test_run_method_invocation(mock_post, tool, mock_verify_response): + """Test invocation through .run(), which is how CrewAI calls tools.""" + mock_post.return_value = _mock_response(mock_verify_response) + + result = tool.run(image_url="https://example.com/photo.jpg") + + assert "consistent" in result diff --git a/lib/crewai-tools/tool.specs.json b/lib/crewai-tools/tool.specs.json index 795fa932c4..7ca4a0c4a6 100644 --- a/lib/crewai-tools/tool.specs.json +++ b/lib/crewai-tools/tool.specs.json @@ -3955,6 +3955,110 @@ "type": "object" } }, + { + "description": "Verify when a photo was taken and its provenance. Checks EXIF and XMP metadata, validates C2PA Content Credentials against the official trust lists, and runs pixel forensics, returning one verdict (provenance_confirmed, consistent, inconclusive, metadata_anomaly, or manipulation_indicated) with a 0 to 100 confidence, plus the extracted capture time, capture device, and capture location. It does not detect deepfakes or AI generation; verdicts are investigative triage to support human review, not proof. Accepts a local image file path or a direct image URL.", + "env_vars": [ + { + "default": null, + "description": "Optional ChronoVerify API key (cv_live_...). Without it, requests use the free keyless path, which is rate limited per IP. A free key is available at https://chronoverify.com/v1/keys/free.", + "name": "CHRONOVERIFY_API_KEY", + "required": false + } + ], + "humanized_name": "ChronoVerify Image Verification", + "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": "Verify a photo's capture time and provenance with the ChronoVerify API.\n\nSends the image to the ChronoVerify verification endpoint, which checks\nEXIF and XMP metadata, validates C2PA Content Credentials against the\nofficial trust lists, and runs classical pixel forensics. The response is\na typed verdict with a 0 to 100 confidence plus the extracted capture\ntime, capture device, capture location, C2PA details, and file hashes.\n\nChronoVerify is not a deepfake or AI-generation detector. Verdicts are\ninvestigative triage to support human review, not proof.\n\nAn API key is optional. Without one, requests use the free keyless path,\nwhich is rate limited per IP. Set ``CHRONOVERIFY_API_KEY`` to meter\nrequests against a key instead.", + "properties": { + "api_base_url": { + "default": "https://chronoverify.com", + "description": "Base URL of the ChronoVerify API.", + "title": "Api Base Url", + "type": "string" + }, + "request_timeout": { + "default": 120, + "description": "Timeout in seconds for the verification request.", + "title": "Request Timeout", + "type": "integer" + } + }, + "required": [], + "title": "ChronoVerifyImageVerificationTool", + "type": "object" + }, + "name": "ChronoVerifyImageVerificationTool", + "package_dependencies": [], + "run_params_schema": { + "description": "Input for ChronoVerifyImageVerificationTool.", + "properties": { + "image_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Local filesystem path of the image file to verify. Provide exactly one of image_path or image_url.", + "title": "Image Path" + }, + "image_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Direct URL of the image to verify. Must be a direct link to the image bytes; redirects and hosts that block automated fetches fail, so prefer image_path when the file is available locally.", + "title": "Image Url" + } + }, + "title": "ChronoVerifyImageVerificationToolSchema", + "type": "object" + } + }, { "description": "A tool that can be used to semantic search a query from a Code Docs content.", "env_vars": [],