From 9726642e72e3cd5822ef9d97c0b9e1ac5dfd96a0 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sat, 14 Mar 2026 12:51:53 +0530 Subject: [PATCH 01/14] fix: detect platform version in telemetry ping - Call /health endpoint to detect platform version before sending telemetry ping (2s timeout, silent failure on any error) - Platform version is included in the checkpoint payload when available - endpoint parameter is no longer unused (was reserved for this purpose) --- axonflow/telemetry.py | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/axonflow/telemetry.py b/axonflow/telemetry.py index 1d678bd..9ce1713 100644 --- a/axonflow/telemetry.py +++ b/axonflow/telemetry.py @@ -57,12 +57,29 @@ 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): + pass + return None + + +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, + "platform_version": platform_version, "os": platform.system(), "arch": platform.machine(), "runtime_version": platform.python_version(), @@ -72,9 +89,14 @@ 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, payload: dict[str, object], debug: bool, endpoint: str = "") -> None: """Execute the HTTP POST (runs inside a daemon thread).""" try: + # Detect platform version before sending + if endpoint: + pv = _detect_platform_version(endpoint) + if pv: + payload["platform_version"] = pv resp = httpx.post(url, json=payload, timeout=_TIMEOUT_SECONDS) if resp.status_code == _HTTP_OK: try: @@ -99,7 +121,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 +130,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 @@ -128,5 +150,5 @@ def send_telemetry_ping( 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, payload, debug, endpoint), daemon=True) t.start() From 927e5232de94b38f8228616c7b58b144d552470a Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sat, 14 Mar 2026 13:48:40 +0530 Subject: [PATCH 02/14] fix: normalize os to lowercase in telemetry payload Report 'darwin' instead of 'Darwin' for consistency across SDKs. --- axonflow/telemetry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/axonflow/telemetry.py b/axonflow/telemetry.py index 9ce1713..6301c96 100644 --- a/axonflow/telemetry.py +++ b/axonflow/telemetry.py @@ -80,7 +80,7 @@ def _build_payload(mode: str, platform_version: str | None = None) -> dict[str, "sdk": "python", "sdk_version": _SDK_VERSION, "platform_version": platform_version, - "os": platform.system(), + "os": platform.system().lower(), "arch": platform.machine(), "runtime_version": platform.python_version(), "deployment_mode": mode, From c5b2445ec1bc5161c8f57c72cf531e04ac3edf4b Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sat, 14 Mar 2026 14:06:03 +0530 Subject: [PATCH 03/14] fix: guard _detect_platform_version against non-dict JSON responses Add TypeError and AttributeError to exception tuple so non-object /health responses (arrays, strings) don't escape and kill the telemetry thread before POST. --- axonflow/telemetry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/axonflow/telemetry.py b/axonflow/telemetry.py index 6301c96..3e9e712 100644 --- a/axonflow/telemetry.py +++ b/axonflow/telemetry.py @@ -69,7 +69,7 @@ def _detect_platform_version(endpoint: str) -> str | None: version = body.get("version") if isinstance(version, str) and version: return version - except (httpx.HTTPError, OSError, ValueError, KeyError): + except (httpx.HTTPError, OSError, ValueError, KeyError, TypeError, AttributeError): pass return None From ddd5eac0c38e0993afa7e181d14c0adb9ab3e586 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sat, 14 Mar 2026 14:17:12 +0530 Subject: [PATCH 04/14] fix: normalize arch values and eliminate thread-safety issue in payload construction --- axonflow/telemetry.py | 27 ++++++++++++++++----------- tests/test_telemetry.py | 1 + 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/axonflow/telemetry.py b/axonflow/telemetry.py index 3e9e712..c8e1eea 100644 --- a/axonflow/telemetry.py +++ b/axonflow/telemetry.py @@ -74,6 +74,15 @@ def _detect_platform_version(endpoint: str) -> str | None: return None +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 { @@ -81,7 +90,7 @@ def _build_payload(mode: str, platform_version: str | None = None) -> dict[str, "sdk_version": _SDK_VERSION, "platform_version": platform_version, "os": platform.system().lower(), - "arch": platform.machine(), + "arch": _normalize_arch(platform.machine()), "runtime_version": platform.python_version(), "deployment_mode": mode, "features": [], @@ -89,19 +98,16 @@ def _build_payload(mode: str, platform_version: str | None = None) -> dict[str, } -def _do_ping(url: str, payload: dict[str, object], debug: bool, endpoint: str = "") -> None: +def _do_ping(url: str, mode: str, endpoint: str, debug: bool) -> None: """Execute the HTTP POST (runs inside a daemon thread).""" try: - # Detect platform version before sending - if endpoint: - pv = _detect_platform_version(endpoint) - if pv: - payload["platform_version"] = pv + 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: @@ -113,7 +119,7 @@ def _do_ping(url: str, payload: dict[str, object], debug: bool, endpoint: str = ) 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) @@ -148,7 +154,6 @@ def send_telemetry_ping( ) 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, endpoint), daemon=True) + t = threading.Thread(target=_do_ping, args=(url, mode, endpoint, debug), daemon=True) t.start() 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, ) From 027ad70d3c72b209c8fc05d60f3bb9504261ed8f Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sat, 14 Mar 2026 16:40:57 +0530 Subject: [PATCH 05/14] feat: add search_audit_logs and get_audit_logs_by_tenant methods (#878) Export AuditSearchRequest, AuditSearchResponse, AuditLogEntry, and AuditQueryOptions from the package __init__.py. The types, client methods, and tests were already implemented but not publicly exported. --- axonflow/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/axonflow/__init__.py b/axonflow/__init__.py index 6f8deeb..8489cce 100644 --- a/axonflow/__init__.py +++ b/axonflow/__init__.py @@ -128,7 +128,11 @@ CATEGORY_MEDIA_DOCUMENT, CATEGORY_MEDIA_PII, CATEGORY_MEDIA_SAFETY, + AuditLogEntry, + AuditQueryOptions, AuditResult, + AuditSearchRequest, + AuditSearchResponse, Budget, BudgetAlert, BudgetAlertsResponse, @@ -281,6 +285,11 @@ "PolicyApprovalResult", "TokenUsage", "AuditResult", + # Audit Log Read types (Issue #878) + "AuditSearchRequest", + "AuditSearchResponse", + "AuditLogEntry", + "AuditQueryOptions", # Execution Replay types "ExecutionSummary", "ExecutionSnapshot", From 0b663c215cd548323a55ed76d34451e261b8955a Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sat, 14 Mar 2026 16:47:24 +0530 Subject: [PATCH 06/14] feat: add audit_tool_call method (#1260) Add audit_tool_call SDK method to record non-LLM tool calls (MCP tools, API calls, function calls) in the audit trail. Posts to POST /api/v1/audit/tool-call with AuditToolCallRequest and returns AuditToolCallResponse. --- axonflow/__init__.py | 5 + axonflow/client.py | 73 ++++++++++ axonflow/types.py | 33 +++++ tests/test_audit_tool_call.py | 244 ++++++++++++++++++++++++++++++++++ 4 files changed, 355 insertions(+) create mode 100644 tests/test_audit_tool_call.py diff --git a/axonflow/__init__.py b/axonflow/__init__.py index 8489cce..964d469 100644 --- a/axonflow/__init__.py +++ b/axonflow/__init__.py @@ -133,6 +133,8 @@ AuditResult, AuditSearchRequest, AuditSearchResponse, + AuditToolCallRequest, + AuditToolCallResponse, Budget, BudgetAlert, BudgetAlertsResponse, @@ -290,6 +292,9 @@ "AuditSearchResponse", "AuditLogEntry", "AuditQueryOptions", + # Audit Tool Call types (Issue #1260) + "AuditToolCallRequest", + "AuditToolCallResponse", # Execution Replay types "ExecutionSummary", "ExecutionSnapshot", diff --git a/axonflow/client.py b/axonflow/client.py index 8a03472..a4e0e33 100644 --- a/axonflow/client.py +++ b/axonflow/client.py @@ -129,6 +129,8 @@ AuditResult, AuditSearchRequest, AuditSearchResponse, + AuditToolCallRequest, + AuditToolCallResponse, AxonFlowConfig, Budget, BudgetAlertsResponse, @@ -1783,6 +1785,70 @@ 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(): + raise ValueError("tool_name is required and cannot be empty") + + 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 +6263,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/types.py b/axonflow/types.py index c4df01e..40d9c1d 100644 --- a/axonflow/types.py +++ b/axonflow/types.py @@ -1127,3 +1127,36 @@ 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/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" From 17382f997d3da5ab7316c3e4565b27fd363e0c68 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sat, 14 Mar 2026 16:51:24 +0530 Subject: [PATCH 07/14] fix: export all public types from package entry point Add 10 missing type exports (AxonFlowConfig, ConnectorHealthStatus, PolicyMatchInfo, ExfiltrationCheckInfo, DynamicPolicyMatch, DynamicPolicyInfo, ConnectorPolicyInfo, FindingSeverity, FindingStatus, Finding) to both imports and __all__ in axonflow/__init__.py. --- axonflow/__init__.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/axonflow/__init__.py b/axonflow/__init__.py index 964d469..8396c63 100644 --- a/axonflow/__init__.py +++ b/axonflow/__init__.py @@ -92,6 +92,9 @@ FEATAssessment, FEATAssessmentStatus, FEATPillar, + Finding, + FindingSeverity, + FindingStatus, KillSwitch, KillSwitchEvent, KillSwitchEventType, @@ -135,6 +138,7 @@ AuditSearchResponse, AuditToolCallRequest, AuditToolCallResponse, + AxonFlowConfig, Budget, BudgetAlert, BudgetAlertsResponse, @@ -151,10 +155,15 @@ ClientRequest, ClientResponse, CodeArtifact, + ConnectorHealthStatus, ConnectorInstallRequest, ConnectorMetadata, + ConnectorPolicyInfo, ConnectorResponse, CreateBudgetRequest, + DynamicPolicyInfo, + DynamicPolicyMatch, + ExfiltrationCheckInfo, ExecutionDetail, ExecutionExportOptions, ExecutionMode, @@ -184,6 +193,7 @@ PolicyApprovalResult, PolicyEvaluationInfo, PolicyEvaluationResult, + PolicyMatchInfo, PricingInfo, PricingListResponse, RateLimitInfo, @@ -238,6 +248,7 @@ "SDKCompatibility", "HealthResponse", # Configuration + "AxonFlowConfig", "Mode", "RetryConfig", "CacheConfig", @@ -263,6 +274,8 @@ "ConnectorMetadata", "ConnectorInstallRequest", "ConnectorResponse", + "ConnectorHealthStatus", + "ConnectorPolicyInfo", # MCP Policy Check types "MCPCheckInputRequest", "MCPCheckInputResponse", @@ -285,6 +298,10 @@ # Gateway Mode types "RateLimitInfo", "PolicyApprovalResult", + "PolicyMatchInfo", + "ExfiltrationCheckInfo", + "DynamicPolicyMatch", + "DynamicPolicyInfo", "TokenUsage", "AuditResult", # Audit Log Read types (Issue #878) @@ -407,6 +424,9 @@ "WebhookSubscription", "ListWebhooksResponse", # MAS FEAT Compliance types (Enterprise) + "FindingSeverity", + "FindingStatus", + "Finding", "MaterialityClassification", "SystemStatus", "FEATAssessmentStatus", From 484a5bcddaa7109f7b842e61eca12bfc592b92f6 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sat, 14 Mar 2026 17:47:45 +0530 Subject: [PATCH 08/14] fix: suppress telemetry for localhost endpoints When the SDK endpoint is localhost, 127.0.0.1, or ::1, telemetry pings are now suppressed unless telemetry_enabled is explicitly set to True. Prevents telemetry leaks during local development. --- axonflow/telemetry.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/axonflow/telemetry.py b/axonflow/telemetry.py index c8e1eea..68f66f2 100644 --- a/axonflow/telemetry.py +++ b/axonflow/telemetry.py @@ -74,6 +74,16 @@ def _detect_platform_version(endpoint: str) -> str | None: return None +def _is_localhost(endpoint: str) -> bool: + """Check whether the endpoint is a localhost address.""" + try: + from urllib.parse import urlparse + host = urlparse(endpoint).hostname or "" + return host in ("localhost", "127.0.0.1", "::1") + except Exception: + return False + + def _normalize_arch(arch: str) -> str: """Normalize architecture names to match other SDKs.""" if arch == "aarch64": @@ -148,6 +158,10 @@ 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" From 769348455e03bf7f520cab774f4a2245e7677b6f Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sat, 14 Mar 2026 18:02:25 +0530 Subject: [PATCH 09/14] chore: bump version to 4.1.0 --- axonflow/_version.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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"} From f36685b886e1135d656802399d8386e40e9e8918 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sat, 14 Mar 2026 18:17:40 +0530 Subject: [PATCH 10/14] docs: add v4.1.0 changelog entry --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb8425f..3b581c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 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 call audit entries (API calls, MCP executions, function invocations) for compliance tracking. Requires Platform v5.1.0+. + +### 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 From 0d6f4329544b106eabf5743dd2a93d0a72af2992 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sat, 14 Mar 2026 22:46:40 +0530 Subject: [PATCH 11/14] fix: resolve ruff lint errors in new audit and telemetry code - Sort imports in __init__.py (I001) - Extract string literal from ValueError (EM101) - Narrow blind Exception catch to ValueError (BLE001) - Move return to else block (TRY300) - Break long Field descriptions to stay under 100 chars (E501) --- axonflow/__init__.py | 2 +- axonflow/client.py | 3 ++- axonflow/telemetry.py | 8 +++++--- axonflow/types.py | 20 +++++++++++++++----- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/axonflow/__init__.py b/axonflow/__init__.py index 8396c63..6991e48 100644 --- a/axonflow/__init__.py +++ b/axonflow/__init__.py @@ -163,12 +163,12 @@ CreateBudgetRequest, DynamicPolicyInfo, DynamicPolicyMatch, - ExfiltrationCheckInfo, ExecutionDetail, ExecutionExportOptions, ExecutionMode, ExecutionSnapshot, ExecutionSummary, + ExfiltrationCheckInfo, ListBudgetsOptions, ListExecutionsOptions, ListExecutionsResponse, diff --git a/axonflow/client.py b/axonflow/client.py index a4e0e33..f58ca4c 100644 --- a/axonflow/client.py +++ b/axonflow/client.py @@ -1820,7 +1820,8 @@ async def audit_tool_call( >>> print(result.audit_id) """ if not request.tool_name or not request.tool_name.strip(): - raise ValueError("tool_name is required and cannot be empty") + msg = "tool_name is required and cannot be empty" + raise ValueError(msg) request_body = request.model_dump(by_alias=True, exclude_none=True) diff --git a/axonflow/telemetry.py b/axonflow/telemetry.py index 68f66f2..c7b61e7 100644 --- a/axonflow/telemetry.py +++ b/axonflow/telemetry.py @@ -77,11 +77,13 @@ def _detect_platform_version(endpoint: str) -> str | None: def _is_localhost(endpoint: str) -> bool: """Check whether the endpoint is a localhost address.""" try: - from urllib.parse import urlparse + from urllib.parse import urlparse # noqa: PLC0415 + host = urlparse(endpoint).hostname or "" - return host in ("localhost", "127.0.0.1", "::1") - except Exception: + except ValueError: return False + else: + return host in ("localhost", "127.0.0.1", "::1") def _normalize_arch(arch: str) -> str: diff --git a/axonflow/types.py b/axonflow/types.py index 40d9c1d..89cc4ca 100644 --- a/axonflow/types.py +++ b/axonflow/types.py @@ -1140,18 +1140,28 @@ class AuditToolCallRequest(BaseModel): 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") + 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") + 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") + error_message: str | None = Field( + default=None, description="Error message if the tool call failed" + ) class AuditToolCallResponse(BaseModel): From 019db9038ee17b17f76556e7f219ff472bb1ee2f Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sat, 14 Mar 2026 22:52:51 +0530 Subject: [PATCH 12/14] docs: add v4.1.0 changelog entry --- CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b581c0..968b004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,13 @@ 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 +## [4.1.0] - Unreleased ### Added -- **`audit_tool_call()`**: Record non-LLM tool call audit entries (API calls, MCP executions, function invocations) for compliance tracking. Requires Platform v5.1.0+. +- `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 From 05f633f1e50cb38fe951fd2bcc1021ab6d7d9eb9 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sat, 14 Mar 2026 22:56:46 +0530 Subject: [PATCH 13/14] docs: set v4.1.0 release date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 968b004..5ea6b1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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] - Unreleased +## [4.1.0] - 2026-03-14 ### Added From 3f93d1f44e212995244464adef932f81ef9e0d76 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sat, 14 Mar 2026 23:44:08 +0530 Subject: [PATCH 14/14] fix: ruff format types.py --- axonflow/types.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/axonflow/types.py b/axonflow/types.py index 89cc4ca..b7125f7 100644 --- a/axonflow/types.py +++ b/axonflow/types.py @@ -1143,9 +1143,7 @@ class AuditToolCallRequest(BaseModel): 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" - ) + 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" )