nat-engine 1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (299) hide show
  1. mannf/__init__.py +33 -0
  2. mannf/__main__.py +10 -0
  3. mannf/_version.py +8 -0
  4. mannf/agents/__init__.py +7 -0
  5. mannf/agents/analyzer_agent.py +9 -0
  6. mannf/agents/base.py +9 -0
  7. mannf/agents/bdi_agent.py +9 -0
  8. mannf/agents/belief_state.py +9 -0
  9. mannf/agents/coordinator_agent.py +9 -0
  10. mannf/agents/executor_agent.py +9 -0
  11. mannf/agents/monitor_agent.py +9 -0
  12. mannf/agents/oracle_agent.py +9 -0
  13. mannf/agents/planner_agent.py +9 -0
  14. mannf/agents/test_agent.py +9 -0
  15. mannf/anomaly/__init__.py +7 -0
  16. mannf/anomaly/enhanced_detector.py +9 -0
  17. mannf/cli.py +9 -0
  18. mannf/core/__init__.py +26 -0
  19. mannf/core/agents/__init__.py +52 -0
  20. mannf/core/agents/accessibility_scanner_agent.py +245 -0
  21. mannf/core/agents/analyzer_agent.py +224 -0
  22. mannf/core/agents/autonomous_loop_agent.py +1086 -0
  23. mannf/core/agents/autonomous_loop_models.py +62 -0
  24. mannf/core/agents/autonomous_run_differ.py +427 -0
  25. mannf/core/agents/base.py +128 -0
  26. mannf/core/agents/bdi_agent.py +330 -0
  27. mannf/core/agents/belief_state.py +202 -0
  28. mannf/core/agents/browser_coordinator_agent.py +224 -0
  29. mannf/core/agents/browser_executor_agent.py +410 -0
  30. mannf/core/agents/coordinator_agent.py +262 -0
  31. mannf/core/agents/executor_agent.py +222 -0
  32. mannf/core/agents/monitor_agent.py +188 -0
  33. mannf/core/agents/oracle_agent.py +150 -0
  34. mannf/core/agents/performance_testing_agent.py +279 -0
  35. mannf/core/agents/planner_agent.py +128 -0
  36. mannf/core/agents/test_agent.py +249 -0
  37. mannf/core/agents/visual_regression_agent.py +311 -0
  38. mannf/core/agents/web_crawler_agent.py +510 -0
  39. mannf/core/agents/worker_pool.py +366 -0
  40. mannf/core/anomaly/__init__.py +14 -0
  41. mannf/core/anomaly/enhanced_detector.py +541 -0
  42. mannf/core/browser/__init__.py +63 -0
  43. mannf/core/browser/accessibility_scanner.py +424 -0
  44. mannf/core/browser/discovery_model.py +178 -0
  45. mannf/core/browser/dom_snapshot.py +349 -0
  46. mannf/core/browser/ingestor_bridge.py +371 -0
  47. mannf/core/browser/performance_metrics.py +217 -0
  48. mannf/core/browser/reflection_analyzer.py +442 -0
  49. mannf/core/browser/scenario_generator.py +1100 -0
  50. mannf/core/browser/security_scenario_generator.py +695 -0
  51. mannf/core/browser/visual_comparer.py +159 -0
  52. mannf/core/diagnostics/__init__.py +28 -0
  53. mannf/core/diagnostics/failure_clusterer.py +211 -0
  54. mannf/core/diagnostics/flake_detector.py +233 -0
  55. mannf/core/diagnostics/root_cause_analyzer.py +273 -0
  56. mannf/core/distributed/__init__.py +16 -0
  57. mannf/core/distributed/endpoint.py +139 -0
  58. mannf/core/distributed/system_under_test.py +207 -0
  59. mannf/core/functional_orchestrator.py +428 -0
  60. mannf/core/messaging/__init__.py +11 -0
  61. mannf/core/messaging/bus.py +113 -0
  62. mannf/core/messaging/messages.py +89 -0
  63. mannf/core/nat_orchestrator.py +342 -0
  64. mannf/core/neural/__init__.py +183 -0
  65. mannf/core/orchestrator.py +272 -0
  66. mannf/core/prioritization/__init__.py +17 -0
  67. mannf/core/prioritization/adaptive_controller.py +509 -0
  68. mannf/core/prioritization/belief_prioritizer.py +231 -0
  69. mannf/core/prioritization/risk_scorer.py +430 -0
  70. mannf/core/reporting/__init__.py +12 -0
  71. mannf/core/reporting/unified_report.py +664 -0
  72. mannf/core/testing/__init__.py +17 -0
  73. mannf/core/testing/adaptive_controller.py +149 -0
  74. mannf/core/testing/models.py +179 -0
  75. mannf/core/validation/__init__.py +10 -0
  76. mannf/core/validation/self_validation_runner.py +180 -0
  77. mannf/dashboard/__init__.py +7 -0
  78. mannf/dashboard/app.py +9 -0
  79. mannf/dashboard/models.py +9 -0
  80. mannf/dashboard/static/index.html +2538 -0
  81. mannf/dashboard/telemetry.py +9 -0
  82. mannf/distributed/__init__.py +7 -0
  83. mannf/distributed/endpoint.py +9 -0
  84. mannf/distributed/system_under_test.py +9 -0
  85. mannf/healing/__init__.py +7 -0
  86. mannf/healing/graphql_schema_diff.py +9 -0
  87. mannf/healing/healer.py +9 -0
  88. mannf/healing/models.py +9 -0
  89. mannf/healing/schema_diff.py +9 -0
  90. mannf/integrations/__init__.py +7 -0
  91. mannf/integrations/auth.py +9 -0
  92. mannf/integrations/graphql_parser.py +9 -0
  93. mannf/integrations/graphql_sut.py +9 -0
  94. mannf/integrations/http_sut.py +9 -0
  95. mannf/integrations/openapi_parser.py +9 -0
  96. mannf/integrations/postman_parser.py +9 -0
  97. mannf/llm/__init__.py +7 -0
  98. mannf/llm/anthropic_provider.py +9 -0
  99. mannf/llm/base.py +9 -0
  100. mannf/llm/config.py +9 -0
  101. mannf/llm/factory.py +9 -0
  102. mannf/llm/openai_provider.py +9 -0
  103. mannf/llm/prompts.py +9 -0
  104. mannf/messaging/__init__.py +7 -0
  105. mannf/messaging/bus.py +9 -0
  106. mannf/messaging/messages.py +9 -0
  107. mannf/nat_orchestrator.py +9 -0
  108. mannf/neural/__init__.py +7 -0
  109. mannf/orchestrator.py +9 -0
  110. mannf/prioritization/__init__.py +7 -0
  111. mannf/prioritization/adaptive_controller.py +9 -0
  112. mannf/prioritization/belief_prioritizer.py +9 -0
  113. mannf/prioritization/risk_scorer.py +9 -0
  114. mannf/product/__init__.py +29 -0
  115. mannf/product/admin/__init__.py +3 -0
  116. mannf/product/admin/routes.py +514 -0
  117. mannf/product/auth/__init__.py +5 -0
  118. mannf/product/auth/saml.py +212 -0
  119. mannf/product/billing/__init__.py +5 -0
  120. mannf/product/billing/audit.py +160 -0
  121. mannf/product/billing/feature_gates.py +180 -0
  122. mannf/product/billing/metering.py +179 -0
  123. mannf/product/billing/notifications.py +181 -0
  124. mannf/product/billing/plans.py +133 -0
  125. mannf/product/billing/rate_limits.py +35 -0
  126. mannf/product/billing/stripe_billing.py +906 -0
  127. mannf/product/billing/tenant_auth.py +233 -0
  128. mannf/product/billing/tenant_manager.py +873 -0
  129. mannf/product/cli.py +3900 -0
  130. mannf/product/cli_admin.py +408 -0
  131. mannf/product/dashboard/__init__.py +61 -0
  132. mannf/product/dashboard/app.py +3567 -0
  133. mannf/product/dashboard/models.py +460 -0
  134. mannf/product/dashboard/static/index.html +6347 -0
  135. mannf/product/dashboard/static/manifest.json +25 -0
  136. mannf/product/dashboard/static/pwa-icon-192.png +0 -0
  137. mannf/product/dashboard/static/pwa-icon-512.png +0 -0
  138. mannf/product/dashboard/static/sw.js +64 -0
  139. mannf/product/dashboard/telemetry.py +547 -0
  140. mannf/product/database.py +145 -0
  141. mannf/product/demo.py +844 -0
  142. mannf/product/doctor.py +509 -0
  143. mannf/product/exporters/__init__.py +65 -0
  144. mannf/product/exporters/azuredevops_exporter.py +257 -0
  145. mannf/product/exporters/base.py +307 -0
  146. mannf/product/exporters/bugzilla_exporter.py +200 -0
  147. mannf/product/exporters/dedup.py +275 -0
  148. mannf/product/exporters/finding_adapter.py +216 -0
  149. mannf/product/exporters/github_exporter.py +197 -0
  150. mannf/product/exporters/gitlab_exporter.py +215 -0
  151. mannf/product/exporters/jira_exporter.py +180 -0
  152. mannf/product/exporters/linear_exporter.py +195 -0
  153. mannf/product/exporters/loader.py +233 -0
  154. mannf/product/exporters/pagerduty_exporter.py +363 -0
  155. mannf/product/exporters/sentry_exporter.py +322 -0
  156. mannf/product/exporters/servicenow_exporter.py +240 -0
  157. mannf/product/exporters/shortcut_exporter.py +231 -0
  158. mannf/product/exporters/webhook_exporter.py +383 -0
  159. mannf/product/formatters/__init__.py +18 -0
  160. mannf/product/formatters/allure_formatter.py +161 -0
  161. mannf/product/formatters/ctrf_formatter.py +149 -0
  162. mannf/product/healing/__init__.py +30 -0
  163. mannf/product/healing/graphql_schema_diff.py +152 -0
  164. mannf/product/healing/healer.py +141 -0
  165. mannf/product/healing/models.py +175 -0
  166. mannf/product/healing/schema_diff.py +251 -0
  167. mannf/product/ingestors/__init__.py +77 -0
  168. mannf/product/ingestors/base.py +256 -0
  169. mannf/product/ingestors/bgstm_ingestor.py +764 -0
  170. mannf/product/ingestors/curl_ingestor.py +1019 -0
  171. mannf/product/ingestors/cypress_ingestor.py +487 -0
  172. mannf/product/ingestors/gherkin_ingestor.py +967 -0
  173. mannf/product/ingestors/graphql_ingestor.py +845 -0
  174. mannf/product/ingestors/grpc_ingestor.py +591 -0
  175. mannf/product/ingestors/har_ingestor.py +976 -0
  176. mannf/product/ingestors/loader.py +284 -0
  177. mannf/product/ingestors/models.py +146 -0
  178. mannf/product/ingestors/openapi_ingestor.py +606 -0
  179. mannf/product/ingestors/playwright_ingestor.py +449 -0
  180. mannf/product/ingestors/postman_ingestor.py +631 -0
  181. mannf/product/ingestors/traffic_ingestor.py +679 -0
  182. mannf/product/ingestors/websocket_ingestor.py +526 -0
  183. mannf/product/integrations/__init__.py +21 -0
  184. mannf/product/integrations/auth.py +190 -0
  185. mannf/product/integrations/graphql_parser.py +436 -0
  186. mannf/product/integrations/graphql_sut.py +247 -0
  187. mannf/product/integrations/grpc_sut.py +469 -0
  188. mannf/product/integrations/http_sut.py +237 -0
  189. mannf/product/integrations/kafka_adapter.py +342 -0
  190. mannf/product/integrations/openapi_parser.py +513 -0
  191. mannf/product/integrations/postman_parser.py +467 -0
  192. mannf/product/integrations/webhook_receiver.py +344 -0
  193. mannf/product/integrations/websocket_sut.py +434 -0
  194. mannf/product/llm/__init__.py +25 -0
  195. mannf/product/llm/anthropic_provider.py +94 -0
  196. mannf/product/llm/base.py +267 -0
  197. mannf/product/llm/config.py +48 -0
  198. mannf/product/llm/factory.py +42 -0
  199. mannf/product/llm/openai_provider.py +93 -0
  200. mannf/product/llm/prompts.py +403 -0
  201. mannf/product/llm/root_cause_service.py +311 -0
  202. mannf/product/llm/test_plan_models.py +78 -0
  203. mannf/product/metrics.py +149 -0
  204. mannf/product/middleware/__init__.py +3 -0
  205. mannf/product/middleware/audit_middleware.py +112 -0
  206. mannf/product/middleware/tenant_isolation.py +114 -0
  207. mannf/product/models.py +347 -0
  208. mannf/product/notifications/__init__.py +24 -0
  209. mannf/product/notifications/dispatcher.py +411 -0
  210. mannf/product/onboarding.py +190 -0
  211. mannf/product/orchestration/__init__.py +39 -0
  212. mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
  213. mannf/product/orchestration/pipeline.py +401 -0
  214. mannf/product/orchestrator.py +987 -0
  215. mannf/product/orchestrator_models.py +269 -0
  216. mannf/product/regression/__init__.py +36 -0
  217. mannf/product/regression/differ.py +172 -0
  218. mannf/product/regression/masking.py +100 -0
  219. mannf/product/regression/models.py +232 -0
  220. mannf/product/regression/recorder.py +124 -0
  221. mannf/product/regression/replayer.py +168 -0
  222. mannf/product/reports/__init__.py +10 -0
  223. mannf/product/reports/pdf.py +132 -0
  224. mannf/product/scheduling/__init__.py +57 -0
  225. mannf/product/scheduling/cron_utils.py +251 -0
  226. mannf/product/scheduling/engine.py +473 -0
  227. mannf/product/scheduling/models.py +86 -0
  228. mannf/product/scheduling/queue.py +894 -0
  229. mannf/product/scheduling/store.py +235 -0
  230. mannf/product/security/__init__.py +21 -0
  231. mannf/product/security/belief_guided.py +143 -0
  232. mannf/product/security/checks/__init__.py +55 -0
  233. mannf/product/security/checks/base.py +69 -0
  234. mannf/product/security/checks/bfla.py +77 -0
  235. mannf/product/security/checks/bola.py +77 -0
  236. mannf/product/security/checks/bopla.py +80 -0
  237. mannf/product/security/checks/broken_auth.py +86 -0
  238. mannf/product/security/checks/graphql_security.py +299 -0
  239. mannf/product/security/checks/inventory.py +70 -0
  240. mannf/product/security/checks/misconfig.py +158 -0
  241. mannf/product/security/checks/resource_consumption.py +70 -0
  242. mannf/product/security/checks/sensitive_flows.py +80 -0
  243. mannf/product/security/checks/ssrf.py +101 -0
  244. mannf/product/security/checks/unsafe_consumption.py +120 -0
  245. mannf/product/security/models.py +92 -0
  246. mannf/product/security/plugin_loader.py +182 -0
  247. mannf/product/security/reporter.py +92 -0
  248. mannf/product/security/scanner.py +183 -0
  249. mannf/product/server.py +6220 -0
  250. mannf/product/setup_wizard.py +873 -0
  251. mannf/product/status.py +404 -0
  252. mannf/product/storage/__init__.py +10 -0
  253. mannf/product/storage/artifact_store.py +343 -0
  254. mannf/product/telemetry.py +300 -0
  255. mannf/product/uninstall.py +169 -0
  256. mannf/product/upgrade.py +139 -0
  257. mannf/product/weights/__init__.py +13 -0
  258. mannf/product/weights/blob_store.py +299 -0
  259. mannf/product/weights/factory.py +42 -0
  260. mannf/product/weights/registry.py +159 -0
  261. mannf/product/weights/store.py +210 -0
  262. mannf/regression/__init__.py +7 -0
  263. mannf/regression/differ.py +9 -0
  264. mannf/regression/masking.py +9 -0
  265. mannf/regression/models.py +9 -0
  266. mannf/regression/recorder.py +9 -0
  267. mannf/regression/replayer.py +9 -0
  268. mannf/security/__init__.py +7 -0
  269. mannf/security/belief_guided.py +9 -0
  270. mannf/security/checks/__init__.py +7 -0
  271. mannf/security/checks/base.py +9 -0
  272. mannf/security/checks/bfla.py +9 -0
  273. mannf/security/checks/bola.py +9 -0
  274. mannf/security/checks/bopla.py +9 -0
  275. mannf/security/checks/broken_auth.py +9 -0
  276. mannf/security/checks/graphql_security.py +9 -0
  277. mannf/security/checks/inventory.py +9 -0
  278. mannf/security/checks/misconfig.py +9 -0
  279. mannf/security/checks/resource_consumption.py +9 -0
  280. mannf/security/checks/sensitive_flows.py +9 -0
  281. mannf/security/checks/ssrf.py +9 -0
  282. mannf/security/checks/unsafe_consumption.py +9 -0
  283. mannf/security/models.py +9 -0
  284. mannf/security/reporter.py +9 -0
  285. mannf/security/scanner.py +9 -0
  286. mannf/server.py +9 -0
  287. mannf/testing/__init__.py +7 -0
  288. mannf/testing/adaptive_controller.py +9 -0
  289. mannf/testing/models.py +9 -0
  290. mannf/weights/__init__.py +7 -0
  291. mannf/weights/registry.py +9 -0
  292. mannf/weights/store.py +9 -0
  293. nat_engine-1.dist-info/METADATA +555 -0
  294. nat_engine-1.dist-info/RECORD +299 -0
  295. nat_engine-1.dist-info/WHEEL +5 -0
  296. nat_engine-1.dist-info/entry_points.txt +4 -0
  297. nat_engine-1.dist-info/licenses/LICENSE +651 -0
  298. nat_engine-1.dist-info/licenses/NOTICE +178 -0
  299. nat_engine-1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,311 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+ # Commercial licensing available — see COMMERCIAL_LICENSE.md.
5
+
6
+ """Reusable LLM-powered root cause analysis service (Phase 10.3).
7
+
8
+ Provides :class:`RootCauseSuggestion` (Pydantic model) and
9
+ :class:`ScanRootCauseAnalyzer` — a standalone async service that can be called
10
+ from any scan pipeline (API security scans, browser functional tests, or the
11
+ autonomous loop) to generate LLM root cause explanations for failures.
12
+
13
+ Feature gating
14
+ --------------
15
+ The analyzer honours the ``llm_root_cause`` quota from
16
+ :mod:`mannf.product.billing.feature_gates`:
17
+
18
+ - ``free``: disabled (0)
19
+ - ``pro``: top 5 findings/scan
20
+ - ``team`` / ``enterprise``: unlimited (-1)
21
+
22
+ Callers should sort failures by severity **before** calling
23
+ :meth:`ScanRootCauseAnalyzer.analyze_failures` and pass only the subset they
24
+ are allowed to analyze. The service itself does not enforce the quota — that
25
+ is the responsibility of the caller (typically the server endpoint or
26
+ orchestrator integration).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import logging
33
+ from typing import Any, Dict, List, Optional
34
+
35
+ from pydantic import BaseModel, Field
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Extended prompt template — works with API/security/functional failures
41
+ # ---------------------------------------------------------------------------
42
+
43
+ _EXTENDED_ROOT_CAUSE_PROMPT = """\
44
+ You are an expert software reliability engineer specialising in web application \
45
+ test automation and root cause analysis.
46
+
47
+ A failure has been detected. Using the information below, identify the most \
48
+ likely root cause and suggest concrete debugging steps.
49
+
50
+ Failure details:
51
+ Type: {failure_type}
52
+ URL / Endpoint: {url}
53
+ Error / Description: {error}
54
+ Severity: {severity}
55
+ HTTP Status Code: {status_code}
56
+ HTTP Response Body (truncated): {response_body}
57
+ Security Finding ID: {check_id}
58
+ Security Finding Title: {title}
59
+ CWE: {cwe_id}
60
+ Evidence: {evidence}
61
+ Remediation hint: {remediation}
62
+ Additional context: {context}
63
+
64
+ Based on the above, provide a structured JSON response with the following fields:
65
+ - "likely_cause": one-sentence description of the most probable root cause
66
+ - "suggested_fix": concise actionable suggestion for where to start debugging
67
+ - "confidence": float 0.0–1.0 indicating your confidence in this root cause \
68
+ (use lower values when limited context is available)
69
+ - "files_to_check": list of source file paths or component names most likely \
70
+ to contain the defect (empty list when unknown)
71
+
72
+ Return ONLY the JSON object with no surrounding markdown or explanation."""
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Pydantic model
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ class RootCauseSuggestion(BaseModel):
81
+ """Structured LLM root cause suggestion for a single failure.
82
+
83
+ Compatible with any failure format: API scan findings, browser test
84
+ failures, or security findings.
85
+ """
86
+
87
+ failure_id: str = Field("", description="Identifier of the failure (task_id, finding id, etc.).")
88
+ failure_type: str = Field("unknown", description="'security', 'functional', or 'autonomous'.")
89
+ url: str = Field("", description="URL or endpoint where the failure was detected.")
90
+ error: str = Field("", description="Error message or failure description.")
91
+ severity: str = Field("", description="Severity level: 'critical', 'high', 'medium', 'low'.")
92
+ likely_cause: str = Field(..., description="One-sentence description of the most probable root cause.")
93
+ suggested_fix: str = Field(..., description="Concise actionable debugging suggestion.")
94
+ confidence: float = Field(..., ge=0.0, le=1.0, description="LLM confidence in this root cause (0–1).")
95
+ files_to_check: List[str] = Field(default_factory=list, description="Source files or components to inspect.")
96
+ quota_limited: bool = Field(False, description="True when this finding was not analyzed due to plan quota.")
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Analyzer service
101
+ # ---------------------------------------------------------------------------
102
+
103
+
104
+ class ScanRootCauseAnalyzer:
105
+ """Reusable async service that calls the LLM to explain scan failures.
106
+
107
+ Parameters
108
+ ----------
109
+ llm_provider:
110
+ An initialised :class:`~mannf.product.llm.base.LLMProvider` instance.
111
+ When ``None``, :meth:`analyze_failures` returns an empty list.
112
+ quota:
113
+ Maximum number of failures to analyze in one call. ``-1`` means
114
+ unlimited. ``0`` means disabled. Corresponds to the
115
+ ``llm_root_cause`` feature gate value.
116
+ """
117
+
118
+ def __init__(
119
+ self,
120
+ llm_provider: Optional[Any] = None,
121
+ quota: int = -1,
122
+ ) -> None:
123
+ self._provider = llm_provider
124
+ self._quota = quota
125
+
126
+ # ------------------------------------------------------------------
127
+ # Public API
128
+ # ------------------------------------------------------------------
129
+
130
+ async def analyze_failures(
131
+ self,
132
+ failures: List[Dict[str, Any]],
133
+ context: Optional[Dict[str, Any]] = None,
134
+ ) -> List[RootCauseSuggestion]:
135
+ """Analyze a list of failures and return root cause suggestions.
136
+
137
+ Parameters
138
+ ----------
139
+ failures:
140
+ List of failure dicts. Each dict should contain as many of
141
+ the following keys as available:
142
+
143
+ - ``id`` / ``failure_id`` / ``task_id`` — unique identifier
144
+ - ``type`` / ``failure_type`` — ``"security"``, ``"functional"``, etc.
145
+ - ``url`` — URL or endpoint path
146
+ - ``error`` / ``description`` — error message
147
+ - ``severity`` — ``"critical"`` / ``"high"`` / ``"medium"`` / ``"low"``
148
+ - ``status_code`` — HTTP status code (for API failures)
149
+ - ``response_body`` — truncated HTTP response body
150
+ - ``check_id`` — security check identifier
151
+ - ``title`` — finding title
152
+ - ``cwe_id`` — CWE identifier
153
+ - ``evidence`` — security evidence string
154
+ - ``remediation`` — remediation hint
155
+ context:
156
+ Optional dict with additional context shared across all failures
157
+ (e.g., ``{"api_base_url": "...", "scan_type": "security"}``).
158
+
159
+ Returns
160
+ -------
161
+ list[RootCauseSuggestion]
162
+ One suggestion per analyzed failure. Failures that hit the quota
163
+ wall are returned with ``quota_limited=True`` and placeholder text.
164
+ Returns an empty list when LLM is unavailable or quota is 0.
165
+ """
166
+ if self._provider is None or self._quota == 0:
167
+ return []
168
+
169
+ if not failures:
170
+ return []
171
+
172
+ ctx = context or {}
173
+ results: List[RootCauseSuggestion] = []
174
+
175
+ for idx, failure in enumerate(failures):
176
+ failure_id = (
177
+ failure.get("id")
178
+ or failure.get("failure_id")
179
+ or failure.get("task_id")
180
+ or str(idx)
181
+ )
182
+ severity = failure.get("severity", "")
183
+ url = failure.get("url", failure.get("endpoint", ""))
184
+ failure_type = failure.get("type", failure.get("failure_type", "unknown"))
185
+
186
+ # Quota enforcement: beyond the allowed N, return placeholder entries
187
+ if self._quota != -1 and idx >= self._quota:
188
+ results.append(
189
+ RootCauseSuggestion(
190
+ failure_id=str(failure_id),
191
+ failure_type=failure_type,
192
+ url=url,
193
+ error=failure.get("error", failure.get("description", "")),
194
+ severity=severity,
195
+ likely_cause="Analysis skipped — plan quota reached.",
196
+ suggested_fix="Upgrade your plan to analyze all findings.",
197
+ confidence=0.0,
198
+ files_to_check=[],
199
+ quota_limited=True,
200
+ )
201
+ )
202
+ continue
203
+
204
+ suggestion = await self._analyze_one(failure, ctx)
205
+ if suggestion is not None:
206
+ results.append(suggestion)
207
+
208
+ return results
209
+
210
+ # ------------------------------------------------------------------
211
+ # Internals
212
+ # ------------------------------------------------------------------
213
+
214
+ async def _analyze_one(
215
+ self,
216
+ failure: Dict[str, Any],
217
+ context: Dict[str, Any],
218
+ ) -> Optional[RootCauseSuggestion]:
219
+ """Analyze a single failure dict; returns ``None`` on LLM error."""
220
+ failure_id = (
221
+ failure.get("id")
222
+ or failure.get("failure_id")
223
+ or failure.get("task_id")
224
+ or ""
225
+ )
226
+ failure_type = failure.get("type", failure.get("failure_type", "unknown"))
227
+ url = failure.get("url", failure.get("endpoint", ""))
228
+ error = failure.get("error", failure.get("description", ""))
229
+ severity = failure.get("severity", "")
230
+ status_code = str(failure.get("status_code", ""))
231
+ response_body = (failure.get("response_body") or "")[:500]
232
+ check_id = failure.get("check_id", "")
233
+ title = failure.get("title", "")
234
+ cwe_id = failure.get("cwe_id", "")
235
+ evidence = (failure.get("evidence") or "")[:500]
236
+ remediation = failure.get("remediation", "")
237
+
238
+ extra_ctx = dict(context)
239
+ extra_ctx.update(failure.get("context", {}))
240
+ context_str = json.dumps(extra_ctx) if extra_ctx else "{}"
241
+
242
+ prompt = _EXTENDED_ROOT_CAUSE_PROMPT.format(
243
+ failure_type=failure_type,
244
+ url=url,
245
+ error=error,
246
+ severity=severity,
247
+ status_code=status_code,
248
+ response_body=response_body,
249
+ check_id=check_id,
250
+ title=title,
251
+ cwe_id=cwe_id,
252
+ evidence=evidence,
253
+ remediation=remediation,
254
+ context=context_str,
255
+ )
256
+
257
+ try:
258
+ raw = await self._call_llm(prompt)
259
+ parsed = self._parse_llm_json(raw)
260
+ except Exception as exc: # noqa: BLE001
261
+ logger.warning("ScanRootCauseAnalyzer: LLM call failed for %s — %s", failure_id, exc)
262
+ return None
263
+
264
+ return RootCauseSuggestion(
265
+ failure_id=str(failure_id),
266
+ failure_type=failure_type,
267
+ url=url,
268
+ error=error,
269
+ severity=severity,
270
+ likely_cause=str(parsed.get("likely_cause", "Unable to determine root cause")),
271
+ suggested_fix=str(parsed.get("suggested_fix", "Investigate the error manually")),
272
+ confidence=float(max(0.0, min(1.0, parsed.get("confidence", 0.0)))),
273
+ files_to_check=list(parsed.get("files_to_check", [])),
274
+ quota_limited=False,
275
+ )
276
+
277
+ async def _call_llm(self, prompt: str) -> str:
278
+ """Invoke the LLM provider's generate method."""
279
+ provider = self._provider
280
+ if hasattr(provider, "generate"):
281
+ return await provider.generate(prompt)
282
+ if hasattr(provider, "acomplete"):
283
+ return await provider.acomplete(prompt)
284
+ if hasattr(provider, "complete"):
285
+ result = provider.complete(prompt)
286
+ if hasattr(result, "__await__"):
287
+ return await result
288
+ return result
289
+ raise AttributeError("LLM provider has no 'generate', 'acomplete', or 'complete' method")
290
+
291
+ @staticmethod
292
+ def _parse_llm_json(raw: str) -> Dict[str, Any]:
293
+ """Extract the JSON object from the LLM response string."""
294
+ text = raw.strip()
295
+ if text.startswith("```"):
296
+ lines = text.splitlines()
297
+ inner = [ln for ln in lines[1:] if ln.strip() != "```"]
298
+ text = "\n".join(inner).strip()
299
+ try:
300
+ return json.loads(text)
301
+ except json.JSONDecodeError:
302
+ pass
303
+ # Fallback: find the first {...} block
304
+ start = text.find("{")
305
+ end = text.rfind("}")
306
+ if start != -1 and end != -1 and end > start:
307
+ try:
308
+ return json.loads(text[start : end + 1])
309
+ except json.JSONDecodeError:
310
+ pass
311
+ return {}
@@ -0,0 +1,78 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+ # Commercial licensing available — see COMMERCIAL_LICENSE.md.
5
+
6
+ """Pydantic models for the LLM-generated test plan feature (Phase 10.1)."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime, timezone
11
+ from enum import Enum
12
+ from typing import List, Optional
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+
17
+ class PlanStatus(str, Enum):
18
+ draft = "draft"
19
+ approved = "approved"
20
+ executed = "executed"
21
+
22
+
23
+ class Priority(str, Enum):
24
+ critical = "critical"
25
+ high = "high"
26
+ medium = "medium"
27
+ low = "low"
28
+
29
+
30
+ class EndpointGroup(BaseModel):
31
+ """A logical group of related API endpoints within a test plan."""
32
+
33
+ name: str = Field(..., description="Human-readable group name (e.g. 'Authentication', 'User CRUD').")
34
+ endpoints: List[str] = Field(default_factory=list, description="List of 'METHOD /path' strings in this group.")
35
+ priority: Priority = Field(Priority.medium, description="Risk-based priority for this group.")
36
+ risk_assessment: str = Field("", description="Brief description of the risk profile for this group.")
37
+ suggested_strategies: List[str] = Field(
38
+ default_factory=list,
39
+ description="Recommended test strategies (e.g. 'boundary value', 'auth bypass').",
40
+ )
41
+ edge_cases: List[str] = Field(
42
+ default_factory=list,
43
+ description="Notable edge cases or business logic scenarios to cover.",
44
+ )
45
+
46
+
47
+ class PlanApproval(BaseModel):
48
+ """Records user review/approval of a test plan."""
49
+
50
+ approved_by: str = Field("", description="Username or identifier of the approver.")
51
+ approved_at: Optional[str] = Field(None, description="ISO-8601 timestamp of approval.")
52
+ excluded_groups: List[str] = Field(
53
+ default_factory=list,
54
+ description="Names of endpoint groups that were excluded from execution.",
55
+ )
56
+
57
+
58
+ class TestPlan(BaseModel):
59
+ """Top-level container for an LLM-generated API test plan."""
60
+
61
+ id: str = Field(..., description="Unique plan identifier (UUID).")
62
+ status: PlanStatus = Field(PlanStatus.draft, description="Lifecycle state of the plan.")
63
+ created_at: str = Field(
64
+ default_factory=lambda: datetime.now(timezone.utc).isoformat(),
65
+ description="ISO-8601 creation timestamp.",
66
+ )
67
+ spec_source: str = Field("", description="Original spec file name or URL.")
68
+ endpoint_groups: List[EndpointGroup] = Field(
69
+ default_factory=list,
70
+ description="Priority-ranked groups of related endpoints.",
71
+ )
72
+ estimated_coverage: Optional[float] = Field(
73
+ None,
74
+ ge=0.0,
75
+ le=1.0,
76
+ description="Estimated test coverage as a fraction (0–1), if the LLM provided one.",
77
+ )
78
+ approval: Optional[PlanApproval] = Field(None, description="Approval record, set when the plan is approved.")
@@ -0,0 +1,149 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+ # Commercial licensing available — see COMMERCIAL_LICENSE.md.
5
+
6
+ """Custom OpenTelemetry metrics for NAT scan lifecycle events.
7
+
8
+ Public API
9
+ ----------
10
+ - :func:`record_scan_start` — call when a scan begins.
11
+ - :func:`record_scan_end` — call when a scan finishes (any terminal status).
12
+
13
+ All functions are safe no-ops when ``opentelemetry-api`` is not installed or
14
+ when no metrics exporter is configured.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from typing import Any, Optional
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # No-op stubs used when OTel is unavailable
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ class _NoOpCounter:
30
+ def add(self, amount: float, attributes: Optional[dict] = None) -> None:
31
+ pass
32
+
33
+
34
+ class _NoOpHistogram:
35
+ def record(self, amount: float, attributes: Optional[dict] = None) -> None:
36
+ pass
37
+
38
+
39
+ class _NoOpUpDownCounter:
40
+ def add(self, amount: float, attributes: Optional[dict] = None) -> None:
41
+ pass
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Meter + instruments (initialised lazily)
46
+ # ---------------------------------------------------------------------------
47
+
48
+ scan_total: Any = _NoOpCounter()
49
+ scan_duration_seconds: Any = _NoOpHistogram()
50
+ scan_status_total: Any = _NoOpCounter()
51
+ active_scans: Any = _NoOpUpDownCounter()
52
+
53
+ _initialized: bool = False
54
+
55
+
56
+ def _init_instruments() -> None:
57
+ """Create real OTel instruments if the SDK is available."""
58
+ global scan_total, scan_duration_seconds, scan_status_total, active_scans, _initialized
59
+ if _initialized:
60
+ return
61
+ _initialized = True
62
+
63
+ try:
64
+ from opentelemetry import metrics as _otel_metrics # type: ignore[import]
65
+
66
+ meter = _otel_metrics.get_meter("mannf.product", version="1.0")
67
+
68
+ scan_total = meter.create_counter(
69
+ name="mannf.scan_total",
70
+ description="Total number of scans started",
71
+ unit="1",
72
+ )
73
+ scan_duration_seconds = meter.create_histogram(
74
+ name="mannf.scan_duration_seconds",
75
+ description="Wall-clock scan duration in seconds",
76
+ unit="s",
77
+ )
78
+ scan_status_total = meter.create_counter(
79
+ name="mannf.scan_status_total",
80
+ description="Total scans completed, by terminal status",
81
+ unit="1",
82
+ )
83
+ active_scans = meter.create_up_down_counter(
84
+ name="mannf.active_scans",
85
+ description="Number of scans currently running",
86
+ unit="1",
87
+ )
88
+ except ImportError:
89
+ # opentelemetry-api not installed — no-op stubs remain
90
+ pass
91
+ except Exception: # noqa: BLE001
92
+ import traceback
93
+ logger.warning(
94
+ "Failed to initialise OpenTelemetry metric instruments:\n%s",
95
+ traceback.format_exc(),
96
+ )
97
+
98
+
99
+ # Initialise eagerly so tests can inspect the state after import.
100
+ _init_instruments()
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Public helper functions
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ def record_scan_start(scan_type: str, tenant_id: Optional[str] = None) -> None:
109
+ """Increment scan_total and active_scans counters when a scan begins.
110
+
111
+ Args:
112
+ scan_type: ``"functional"`` or ``"security"``.
113
+ tenant_id: Optional tenant identifier for multi-tenant deployments.
114
+ """
115
+ try:
116
+ attrs: dict[str, str] = {"scan_type": scan_type}
117
+ if tenant_id:
118
+ attrs["tenant_id"] = str(tenant_id)
119
+ scan_total.add(1, attrs)
120
+ active_scans.add(1, attrs)
121
+ except Exception: # noqa: BLE001
122
+ pass
123
+
124
+
125
+ def record_scan_end(
126
+ scan_type: str,
127
+ tenant_id: Optional[str],
128
+ status: str,
129
+ duration_s: float,
130
+ ) -> None:
131
+ """Record scan completion metrics.
132
+
133
+ Args:
134
+ scan_type: ``"functional"`` or ``"security"``.
135
+ tenant_id: Optional tenant identifier.
136
+ status: Terminal status — one of ``"completed"``, ``"failed"``,
137
+ ``"cancelled"``, ``"timed_out"``.
138
+ duration_s: Wall-clock scan duration in seconds.
139
+ """
140
+ try:
141
+ base_attrs: dict[str, str] = {"scan_type": scan_type}
142
+ if tenant_id:
143
+ base_attrs["tenant_id"] = str(tenant_id)
144
+
145
+ scan_status_total.add(1, {**base_attrs, "status": status})
146
+ scan_duration_seconds.record(duration_s, base_attrs)
147
+ active_scans.add(-1, base_attrs)
148
+ except Exception: # noqa: BLE001
149
+ pass
@@ -0,0 +1,3 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
@@ -0,0 +1,112 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+
5
+ """API audit logging middleware.
6
+
7
+ Records every mutating HTTP call (POST, PUT, PATCH, DELETE) to the
8
+ ``api_audit_log`` table. Never blocks the request — failures are logged
9
+ and silently swallowed.
10
+
11
+ Usage::
12
+
13
+ from mannf.product.middleware.audit_middleware import attach_audit_middleware
14
+ attach_audit_middleware(app)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import uuid
21
+
22
+ from fastapi import FastAPI
23
+ from starlette.middleware.base import BaseHTTPMiddleware
24
+ from starlette.requests import Request
25
+ from starlette.responses import Response
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ _MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
30
+
31
+
32
+ class AuditMiddleware(BaseHTTPMiddleware):
33
+ """Starlette middleware that logs mutating API calls to api_audit_log."""
34
+
35
+ async def dispatch(self, request: Request, call_next) -> Response:
36
+ response = await call_next(request)
37
+
38
+ if request.method.upper() not in _MUTATING_METHODS:
39
+ return response
40
+
41
+ # Only log API paths
42
+ path = request.url.path
43
+ if not path.startswith("/api/"):
44
+ return response
45
+
46
+ try:
47
+ await _log_api_call(request, response.status_code)
48
+ except Exception as exc: # noqa: BLE001
49
+ logger.debug("AuditMiddleware: failed to log call to %s: %s", path, exc)
50
+
51
+ return response
52
+
53
+
54
+ async def _log_api_call(request: Request, status_code: int) -> None:
55
+ """Write an ApiAuditLog record for a request."""
56
+ try:
57
+ from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
58
+ from mannf.product.models import ApiAuditLog # noqa: PLC0415
59
+ except ImportError:
60
+ return
61
+
62
+ if _build_engine() is None or _async_session_factory is None:
63
+ return
64
+
65
+ tenant = getattr(request.state, "tenant", None)
66
+ user = getattr(request.state, "user", None)
67
+
68
+ tenant_id: uuid.UUID | None = None
69
+ if tenant:
70
+ try:
71
+ raw_tid = tenant.get("tenant_id") or tenant.get("id")
72
+ if raw_tid:
73
+ tenant_id = uuid.UUID(str(raw_tid))
74
+ except (ValueError, AttributeError):
75
+ pass
76
+
77
+ user_id: uuid.UUID | None = None
78
+ if user and user.get("id"):
79
+ try:
80
+ user_id = uuid.UUID(str(user["id"]))
81
+ except ValueError:
82
+ pass
83
+
84
+ # Best-effort request summary (method + path + query)
85
+ qs = str(request.query_params) if request.query_params else ""
86
+ request_summary = f"{request.method} {request.url.path}"
87
+ if qs:
88
+ request_summary += f"?{qs}"
89
+
90
+ source_ip = request.headers.get("x-forwarded-for") or (request.client.host if request.client else None)
91
+
92
+ try:
93
+ async with _async_session_factory() as session:
94
+ entry = ApiAuditLog(
95
+ id=uuid.uuid4(),
96
+ tenant_id=tenant_id,
97
+ user_id=user_id,
98
+ method=request.method[:10],
99
+ path=request.url.path[:2048],
100
+ status_code=status_code,
101
+ request_summary=request_summary[:4096] if request_summary else None,
102
+ source_ip=source_ip[:64] if source_ip else None,
103
+ )
104
+ session.add(entry)
105
+ await session.commit()
106
+ except Exception as exc: # noqa: BLE001
107
+ logger.debug("AuditMiddleware: DB write failed: %s", exc)
108
+
109
+
110
+ def attach_audit_middleware(app: FastAPI) -> None:
111
+ """Attach the AuditMiddleware to a FastAPI application."""
112
+ app.add_middleware(AuditMiddleware)