diff --git a/CHANGELOG.md b/CHANGELOG.md index bb8425f..5ea6b1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to the AxonFlow Python SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.1.0] - 2026-03-14 + +### Added + +- `audit_tool_call()` — record non-LLM tool calls (API, MCP, function) in the audit trail. Returns audit ID, status, and timestamp. Requires Platform v5.1.0+ +- `get_audit_logs_by_tenant()` — retrieve audit logs for a tenant with optional pagination +- `search_audit_logs()` — search audit logs with filters (client ID, request type, limit) + +### Fixed + +- Telemetry pings now suppressed for localhost/127.0.0.1/::1 endpoints unless `telemetry_enabled` is explicitly set to `True`. Prevents telemetry noise during local development. + +--- + ## [4.0.0] - 2026-03-09 ### Breaking Changes diff --git a/axonflow/__init__.py b/axonflow/__init__.py index 6f8deeb..6991e48 100644 --- a/axonflow/__init__.py +++ b/axonflow/__init__.py @@ -92,6 +92,9 @@ FEATAssessment, FEATAssessmentStatus, FEATPillar, + Finding, + FindingSeverity, + FindingStatus, KillSwitch, KillSwitchEvent, KillSwitchEventType, @@ -128,7 +131,14 @@ CATEGORY_MEDIA_DOCUMENT, CATEGORY_MEDIA_PII, CATEGORY_MEDIA_SAFETY, + AuditLogEntry, + AuditQueryOptions, AuditResult, + AuditSearchRequest, + AuditSearchResponse, + AuditToolCallRequest, + AuditToolCallResponse, + AxonFlowConfig, Budget, BudgetAlert, BudgetAlertsResponse, @@ -145,15 +155,20 @@ ClientRequest, ClientResponse, CodeArtifact, + ConnectorHealthStatus, ConnectorInstallRequest, ConnectorMetadata, + ConnectorPolicyInfo, ConnectorResponse, CreateBudgetRequest, + DynamicPolicyInfo, + DynamicPolicyMatch, ExecutionDetail, ExecutionExportOptions, ExecutionMode, ExecutionSnapshot, ExecutionSummary, + ExfiltrationCheckInfo, ListBudgetsOptions, ListExecutionsOptions, ListExecutionsResponse, @@ -178,6 +193,7 @@ PolicyApprovalResult, PolicyEvaluationInfo, PolicyEvaluationResult, + PolicyMatchInfo, PricingInfo, PricingListResponse, RateLimitInfo, @@ -232,6 +248,7 @@ "SDKCompatibility", "HealthResponse", # Configuration + "AxonFlowConfig", "Mode", "RetryConfig", "CacheConfig", @@ -257,6 +274,8 @@ "ConnectorMetadata", "ConnectorInstallRequest", "ConnectorResponse", + "ConnectorHealthStatus", + "ConnectorPolicyInfo", # MCP Policy Check types "MCPCheckInputRequest", "MCPCheckInputResponse", @@ -279,8 +298,20 @@ # Gateway Mode types "RateLimitInfo", "PolicyApprovalResult", + "PolicyMatchInfo", + "ExfiltrationCheckInfo", + "DynamicPolicyMatch", + "DynamicPolicyInfo", "TokenUsage", "AuditResult", + # Audit Log Read types (Issue #878) + "AuditSearchRequest", + "AuditSearchResponse", + "AuditLogEntry", + "AuditQueryOptions", + # Audit Tool Call types (Issue #1260) + "AuditToolCallRequest", + "AuditToolCallResponse", # Execution Replay types "ExecutionSummary", "ExecutionSnapshot", @@ -393,6 +424,9 @@ "WebhookSubscription", "ListWebhooksResponse", # MAS FEAT Compliance types (Enterprise) + "FindingSeverity", + "FindingStatus", + "Finding", "MaterialityClassification", "SystemStatus", "FEATAssessmentStatus", diff --git a/axonflow/_version.py b/axonflow/_version.py index 4c122e6..74710ae 100644 --- a/axonflow/_version.py +++ b/axonflow/_version.py @@ -1,3 +1,3 @@ """Single source of truth for the AxonFlow SDK version.""" -__version__ = "4.0.0" +__version__ = "4.1.0" diff --git a/axonflow/client.py b/axonflow/client.py index 8a03472..f58ca4c 100644 --- a/axonflow/client.py +++ b/axonflow/client.py @@ -129,6 +129,8 @@ AuditResult, AuditSearchRequest, AuditSearchResponse, + AuditToolCallRequest, + AuditToolCallResponse, AxonFlowConfig, Budget, BudgetAlertsResponse, @@ -1783,6 +1785,71 @@ async def audit_llm_call( audit_id=response["audit_id"], ) + async def audit_tool_call( + self, + request: AuditToolCallRequest, + ) -> AuditToolCallResponse: + """Record a non-LLM tool call in the audit trail. + + Use this to audit tool invocations (MCP tools, API calls, function + calls) that are not LLM calls but should still appear in the audit + trail for governance and compliance. + + Args: + request: Tool call details including tool name, type, input/output, + and associated workflow/step information. + + Returns: + AuditToolCallResponse confirming the audit entry was recorded. + + Raises: + ValueError: If tool_name is empty. + AxonFlowError: If audit recording fails. + + Example: + >>> from axonflow.types import AuditToolCallRequest + >>> result = await client.audit_tool_call( + ... AuditToolCallRequest( + ... tool_name="getUserInfo", + ... tool_type="mcp", + ... workflow_id="wf_abc123", + ... success=True, + ... duration_ms=45, + ... ) + ... ) + >>> print(result.audit_id) + """ + if not request.tool_name or not request.tool_name.strip(): + msg = "tool_name is required and cannot be empty" + raise ValueError(msg) + + request_body = request.model_dump(by_alias=True, exclude_none=True) + + if self._config.debug: + self._logger.debug( + "Audit tool call request", + tool_name=request.tool_name, + tool_type=request.tool_type, + ) + + response = await self._request( + "POST", + "/api/v1/audit/tool-call", + json_data=request_body, + ) + + if self._config.debug: + self._logger.debug( + "Audit tool call complete", + audit_id=response.get("audit_id"), + ) + + return AuditToolCallResponse( + audit_id=response["audit_id"], + status=response["status"], + timestamp=response["timestamp"], + ) + # ========================================================================= # Audit Log Read Methods # ========================================================================= @@ -6197,6 +6264,13 @@ def audit_llm_call( ) ) + def audit_tool_call( + self, + request: AuditToolCallRequest, + ) -> AuditToolCallResponse: + """Record a non-LLM tool call in the audit trail.""" + return self._run_sync(self._async_client.audit_tool_call(request)) + # Policy CRUD sync wrappers def list_static_policies( diff --git a/axonflow/telemetry.py b/axonflow/telemetry.py index 1d678bd..c7b61e7 100644 --- a/axonflow/telemetry.py +++ b/axonflow/telemetry.py @@ -57,14 +57,52 @@ def _is_telemetry_enabled( return mode != "sandbox" -def _build_payload(mode: str) -> dict[str, object]: +def _detect_platform_version(endpoint: str) -> str | None: + """Detect platform version by calling the agent's /health endpoint. + + Returns the version string or None on any failure. + """ + try: + resp = httpx.get(f"{endpoint}/health", timeout=2) + if resp.status_code == _HTTP_OK: + body = resp.json() + version = body.get("version") + if isinstance(version, str) and version: + return version + except (httpx.HTTPError, OSError, ValueError, KeyError, TypeError, AttributeError): + pass + return None + + +def _is_localhost(endpoint: str) -> bool: + """Check whether the endpoint is a localhost address.""" + try: + from urllib.parse import urlparse # noqa: PLC0415 + + host = urlparse(endpoint).hostname or "" + except ValueError: + return False + else: + return host in ("localhost", "127.0.0.1", "::1") + + +def _normalize_arch(arch: str) -> str: + """Normalize architecture names to match other SDKs.""" + if arch == "aarch64": + return "arm64" + if arch == "x86_64": + return "x64" + return arch + + +def _build_payload(mode: str, platform_version: str | None = None) -> dict[str, object]: """Build the JSON payload for the checkpoint ping.""" return { "sdk": "python", "sdk_version": _SDK_VERSION, - "platform_version": None, - "os": platform.system(), - "arch": platform.machine(), + "platform_version": platform_version, + "os": platform.system().lower(), + "arch": _normalize_arch(platform.machine()), "runtime_version": platform.python_version(), "deployment_mode": mode, "features": [], @@ -72,14 +110,16 @@ def _build_payload(mode: str) -> dict[str, object]: } -def _do_ping(url: str, payload: dict[str, object], debug: bool) -> None: +def _do_ping(url: str, mode: str, endpoint: str, debug: bool) -> None: """Execute the HTTP POST (runs inside a daemon thread).""" try: + platform_version = _detect_platform_version(endpoint) if endpoint else None + payload = _build_payload(mode, platform_version) resp = httpx.post(url, json=payload, timeout=_TIMEOUT_SECONDS) if resp.status_code == _HTTP_OK: try: body = resp.json() - except (ValueError, KeyError): + except (ValueError, KeyError, TypeError, AttributeError): return latest = body.get("latest_version") if latest and latest != _SDK_VERSION: @@ -91,7 +131,7 @@ def _do_ping(url: str, payload: dict[str, object], debug: bool) -> None: ) if debug: logger.debug("Telemetry ping successful: %s", body) - except (httpx.HTTPError, OSError, ValueError): + except (httpx.HTTPError, OSError, ValueError, TypeError, AttributeError): # Silent failure -- never disrupt the caller. if debug: logger.debug("Telemetry ping failed (non-fatal)", exc_info=True) @@ -99,7 +139,7 @@ def _do_ping(url: str, payload: dict[str, object], debug: bool) -> None: def send_telemetry_ping( mode: str, - endpoint: str, # noqa: ARG001 kept for future platform_version detection + endpoint: str, telemetry_enabled: bool | None, has_credentials: bool = False, debug: bool = False, @@ -108,8 +148,8 @@ def send_telemetry_ping( Args: mode: SDK operation mode (``"production"`` or ``"sandbox"``). - endpoint: The AxonFlow agent endpoint (reserved for future - platform_version detection). + endpoint: The AxonFlow agent endpoint, used to detect the platform + version via ``/health``. telemetry_enabled: Explicit config override. ``None`` means use the mode-based default. has_credentials: Whether the client was initialized with credentials @@ -120,13 +160,16 @@ def send_telemetry_ping( if not _is_telemetry_enabled(mode, telemetry_enabled, has_credentials): return + # Suppress telemetry for localhost endpoints unless explicitly enabled. + if telemetry_enabled is not True and _is_localhost(endpoint): + return + logger.info( "AxonFlow: anonymous telemetry enabled. " "Opt out: AXONFLOW_TELEMETRY=off | https://docs.getaxonflow.com/telemetry" ) url = os.environ.get("AXONFLOW_CHECKPOINT_URL", "").strip() or _DEFAULT_CHECKPOINT_URL - payload = _build_payload(mode) - t = threading.Thread(target=_do_ping, args=(url, payload, debug), daemon=True) + t = threading.Thread(target=_do_ping, args=(url, mode, endpoint, debug), daemon=True) t.start() diff --git a/axonflow/types.py b/axonflow/types.py index c4df01e..b7125f7 100644 --- a/axonflow/types.py +++ b/axonflow/types.py @@ -1127,3 +1127,44 @@ class UpdateMediaGovernanceConfigRequest(BaseModel): CATEGORY_MEDIA_BIOMETRIC: str = "media-biometric" CATEGORY_MEDIA_DOCUMENT: str = "media-document" CATEGORY_MEDIA_PII: str = "media-pii" + + +# ========================================================================= +# Audit Tool Call Types +# ========================================================================= + + +class AuditToolCallRequest(BaseModel): + """Request to record a non-LLM tool call in the audit trail.""" + + model_config = ConfigDict(populate_by_name=True) + + tool_name: str = Field(description="Name of the tool that was called") + tool_type: str | None = Field( + default=None, description="Type of tool (e.g., mcp, api, function)" + ) + input: dict[str, Any] | None = Field(default=None, alias="input", description="Tool input data") + output: dict[str, Any] | None = Field( + default=None, alias="output", description="Tool output data" + ) + workflow_id: str | None = Field(default=None, description="Associated workflow ID") + step_id: str | None = Field(default=None, description="Associated step ID") + user_id: str | None = Field(default=None, description="User who triggered the tool call") + duration_ms: int | None = Field( + default=None, description="Duration of the tool call in milliseconds" + ) + policies_applied: list[str] | None = Field( + default=None, description="List of policies applied to this tool call" + ) + success: bool | None = Field(default=None, description="Whether the tool call succeeded") + error_message: str | None = Field( + default=None, description="Error message if the tool call failed" + ) + + +class AuditToolCallResponse(BaseModel): + """Response from recording a tool call audit entry.""" + + audit_id: str = Field(description="Unique ID for the audit entry") + status: str = Field(description="Recording status (e.g., recorded)") + timestamp: str = Field(description="Timestamp when the audit entry was recorded") diff --git a/pyproject.toml b/pyproject.toml index 583ea54..4540562 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "axonflow" -version = "4.0.0" +version = "4.1.0" description = "AxonFlow Python SDK - Enterprise AI Governance in 3 Lines of Code" readme = "README.md" license = {text = "MIT"} diff --git a/tests/test_audit_tool_call.py b/tests/test_audit_tool_call.py new file mode 100644 index 0000000..0cc73b4 --- /dev/null +++ b/tests/test_audit_tool_call.py @@ -0,0 +1,244 @@ +"""Tests for audit_tool_call method.""" + +from __future__ import annotations + +import pytest +from pytest_httpx import HTTPXMock + +from axonflow import AxonFlow +from axonflow.exceptions import AxonFlowError +from axonflow.types import AuditToolCallRequest, AuditToolCallResponse + + +class TestAuditToolCall: + """Tests for audit_tool_call method.""" + + @pytest.mark.asyncio + async def test_success_with_all_fields( + self, + client: AxonFlow, + httpx_mock: HTTPXMock, + ) -> None: + """Test successful audit with all fields populated.""" + httpx_mock.add_response( + status_code=201, + json={ + "audit_id": "aud_abc123", + "status": "recorded", + "timestamp": "2026-03-14T10:30:00Z", + }, + ) + + request = AuditToolCallRequest( + tool_name="getUserInfo", + tool_type="mcp", + input={"user_id": "u123"}, + output={"name": "Alice", "email": "alice@example.com"}, + workflow_id="wf_abc123", + step_id="step-3", + user_id="user@example.com", + duration_ms=45, + policies_applied=["pii"], + success=True, + error_message="", + ) + + result = await client.audit_tool_call(request) + + assert isinstance(result, AuditToolCallResponse) + assert result.audit_id == "aud_abc123" + assert result.status == "recorded" + assert result.timestamp == "2026-03-14T10:30:00Z" + + @pytest.mark.asyncio + async def test_success_required_only( + self, + client: AxonFlow, + httpx_mock: HTTPXMock, + ) -> None: + """Test successful audit with only required fields.""" + httpx_mock.add_response( + status_code=201, + json={ + "audit_id": "aud_minimal", + "status": "recorded", + "timestamp": "2026-03-14T11:00:00Z", + }, + ) + + request = AuditToolCallRequest(tool_name="simpleCheck") + + result = await client.audit_tool_call(request) + + assert result.audit_id == "aud_minimal" + assert result.status == "recorded" + + @pytest.mark.asyncio + async def test_empty_tool_name_raises( + self, + client: AxonFlow, + ) -> None: + """Test that empty tool_name raises ValueError.""" + request = AuditToolCallRequest(tool_name="") + + with pytest.raises(ValueError, match="tool_name is required"): + await client.audit_tool_call(request) + + @pytest.mark.asyncio + async def test_whitespace_tool_name_raises( + self, + client: AxonFlow, + ) -> None: + """Test that whitespace-only tool_name raises ValueError.""" + request = AuditToolCallRequest(tool_name=" ") + + with pytest.raises(ValueError, match="tool_name is required"): + await client.audit_tool_call(request) + + @pytest.mark.asyncio + async def test_server_error( + self, + client: AxonFlow, + httpx_mock: HTTPXMock, + ) -> None: + """Test that server errors are raised as AxonFlowError.""" + httpx_mock.add_response( + status_code=500, + json={"error": "internal server error"}, + ) + + request = AuditToolCallRequest(tool_name="getUserInfo") + + with pytest.raises(AxonFlowError): + await client.audit_tool_call(request) + + @pytest.mark.asyncio + async def test_400_error( + self, + client: AxonFlow, + httpx_mock: HTTPXMock, + ) -> None: + """Test that 400 errors are raised as AxonFlowError.""" + httpx_mock.add_response( + status_code=400, + json={"error": "invalid request"}, + ) + + request = AuditToolCallRequest(tool_name="getUserInfo") + + with pytest.raises(AxonFlowError): + await client.audit_tool_call(request) + + @pytest.mark.asyncio + async def test_excludes_none_fields_from_request( + self, + client: AxonFlow, + httpx_mock: HTTPXMock, + ) -> None: + """Test that None optional fields are excluded from the request body.""" + httpx_mock.add_response( + status_code=201, + json={ + "audit_id": "aud_sparse", + "status": "recorded", + "timestamp": "2026-03-14T12:00:00Z", + }, + ) + + request = AuditToolCallRequest( + tool_name="checkAccess", + tool_type="api", + success=True, + ) + + result = await client.audit_tool_call(request) + + assert result.audit_id == "aud_sparse" + + # Verify the request sent to the server excludes None fields + sent_request = httpx_mock.get_request() + assert sent_request is not None + import json + + body = json.loads(sent_request.content) + assert "tool_name" in body + assert "tool_type" in body + assert "success" in body + # None fields should not be present + assert "input" not in body + assert "output" not in body + assert "workflow_id" not in body + assert "step_id" not in body + assert "user_id" not in body + assert "duration_ms" not in body + assert "policies_applied" not in body + assert "error_message" not in body + + +class TestAuditToolCallTypes: + """Tests for AuditToolCallRequest and AuditToolCallResponse types.""" + + def test_request_model_validate(self) -> None: + """Test AuditToolCallRequest model validation.""" + request = AuditToolCallRequest.model_validate( + { + "tool_name": "myTool", + "tool_type": "mcp", + "input": {"key": "value"}, + "duration_ms": 100, + } + ) + + assert request.tool_name == "myTool" + assert request.tool_type == "mcp" + assert request.input == {"key": "value"} + assert request.duration_ms == 100 + assert request.output is None + assert request.workflow_id is None + + def test_response_model_validate(self) -> None: + """Test AuditToolCallResponse model validation.""" + response = AuditToolCallResponse.model_validate( + { + "audit_id": "aud_123", + "status": "recorded", + "timestamp": "2026-03-14T10:00:00Z", + } + ) + + assert response.audit_id == "aud_123" + assert response.status == "recorded" + assert response.timestamp == "2026-03-14T10:00:00Z" + + def test_request_serialization_excludes_none(self) -> None: + """Test that model_dump excludes None fields.""" + request = AuditToolCallRequest(tool_name="myTool") + data = request.model_dump(by_alias=True, exclude_none=True) + + assert data == {"tool_name": "myTool"} + + def test_request_serialization_includes_all(self) -> None: + """Test full serialization with all fields.""" + request = AuditToolCallRequest( + tool_name="myTool", + tool_type="mcp", + input={"a": 1}, + output={"b": 2}, + workflow_id="wf_1", + step_id="s_1", + user_id="u_1", + duration_ms=50, + policies_applied=["pii", "gdpr"], + success=False, + error_message="timeout", + ) + data = request.model_dump(by_alias=True, exclude_none=True) + + assert data["tool_name"] == "myTool" + assert data["tool_type"] == "mcp" + assert data["input"] == {"a": 1} + assert data["output"] == {"b": 2} + assert data["workflow_id"] == "wf_1" + assert data["policies_applied"] == ["pii", "gdpr"] + assert data["success"] is False + assert data["error_message"] == "timeout" diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 9aff2cc..10c48f7 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -15,6 +15,7 @@ _DEFAULT_CHECKPOINT_URL, _build_payload, _is_telemetry_enabled, + _normalize_arch, send_telemetry_ping, )