patchi 0.7.5__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 (496) hide show
  1. patchi/__init__.py +1 -0
  2. patchi/__main__.py +13 -0
  3. patchi/cli/__init__.py +0 -0
  4. patchi/cli/command_families.py +235 -0
  5. patchi/cli/commands/__init__.py +0 -0
  6. patchi/cli/commands/access_cmd.py +65 -0
  7. patchi/cli/commands/agent_stats_cmd.py +273 -0
  8. patchi/cli/commands/agents_cmd.py +187 -0
  9. patchi/cli/commands/ai_cmd.py +308 -0
  10. patchi/cli/commands/assure_cmd.py +538 -0
  11. patchi/cli/commands/audit_cmd.py +286 -0
  12. patchi/cli/commands/auto_cmd.py +110 -0
  13. patchi/cli/commands/blame_cmd.py +53 -0
  14. patchi/cli/commands/chains_cmd.py +327 -0
  15. patchi/cli/commands/charter_cmd.py +173 -0
  16. patchi/cli/commands/chat_cmd.py +597 -0
  17. patchi/cli/commands/check_cmd.py +232 -0
  18. patchi/cli/commands/cleanup_cmd.py +328 -0
  19. patchi/cli/commands/cockpit_cmd.py +208 -0
  20. patchi/cli/commands/commands_cmd.py +160 -0
  21. patchi/cli/commands/cross_repo_cmd.py +113 -0
  22. patchi/cli/commands/deps_cmd.py +110 -0
  23. patchi/cli/commands/dev_check_cmd.py +342 -0
  24. patchi/cli/commands/dev_cmd.py +445 -0
  25. patchi/cli/commands/doctor_cmd.py +457 -0
  26. patchi/cli/commands/findings_cmd.py +187 -0
  27. patchi/cli/commands/fix_cmd.py +648 -0
  28. patchi/cli/commands/fix_review_cmd.py +273 -0
  29. patchi/cli/commands/heatmap_cmd.py +62 -0
  30. patchi/cli/commands/help_cmd.py +159 -0
  31. patchi/cli/commands/hosted_cmd.py +980 -0
  32. patchi/cli/commands/init.py +456 -0
  33. patchi/cli/commands/key_cmd.py +571 -0
  34. patchi/cli/commands/learn_cmd.py +140 -0
  35. patchi/cli/commands/link_cmd.py +104 -0
  36. patchi/cli/commands/log_cmd.py +57 -0
  37. patchi/cli/commands/memory_cmd.py +215 -0
  38. patchi/cli/commands/model_cmd.py +207 -0
  39. patchi/cli/commands/notify_cmd.py +213 -0
  40. patchi/cli/commands/notify_prompts.py +29 -0
  41. patchi/cli/commands/patch_cmd.py +231 -0
  42. patchi/cli/commands/plan_cmd.py +128 -0
  43. patchi/cli/commands/plugins_cmd.py +307 -0
  44. patchi/cli/commands/queue_cmd.py +152 -0
  45. patchi/cli/commands/quick_cmd.py +159 -0
  46. patchi/cli/commands/ready_cmd.py +292 -0
  47. patchi/cli/commands/report_cmd.py +416 -0
  48. patchi/cli/commands/restrict_cmd.py +127 -0
  49. patchi/cli/commands/review_cmd.py +225 -0
  50. patchi/cli/commands/rules_cmd.py +233 -0
  51. patchi/cli/commands/scan_cmd.py +1908 -0
  52. patchi/cli/commands/settings_cmd.py +45 -0
  53. patchi/cli/commands/status_cmd.py +679 -0
  54. patchi/cli/commands/test_cmd.py +1052 -0
  55. patchi/cli/commands/trend_cmd.py +296 -0
  56. patchi/cli/commands/undo_cmd.py +235 -0
  57. patchi/cli/commands/update_cmd.py +345 -0
  58. patchi/cli/commands/verify_cmd.py +56 -0
  59. patchi/cli/commands/vr_cmd.py +440 -0
  60. patchi/cli/commands/watch_cmd.py +304 -0
  61. patchi/cli/commands/web_cmd.py +114 -0
  62. patchi/cli/console.py +101 -0
  63. patchi/cli/display/__init__.py +1 -0
  64. patchi/cli/display/live_audit.py +269 -0
  65. patchi/cli/display/live_progress.py +169 -0
  66. patchi/cli/framework.py +241 -0
  67. patchi/cli/logo.py +128 -0
  68. patchi/cli/main.py +222 -0
  69. patchi/cli/registry.py +1171 -0
  70. patchi/cli/style.py +68 -0
  71. patchi/cli/themes.py +187 -0
  72. patchi/cli/ux.py +444 -0
  73. patchi/core/__init__.py +0 -0
  74. patchi/core/agents/__init__.py +0 -0
  75. patchi/core/agents/api_fuzzer_agent.py +141 -0
  76. patchi/core/agents/attack_agent.py +242 -0
  77. patchi/core/agents/base.py +1012 -0
  78. patchi/core/agents/bug_predictor_agent.py +58 -0
  79. patchi/core/agents/build_tool_validator.py +150 -0
  80. patchi/core/agents/cache.py +192 -0
  81. patchi/core/agents/chaos_agent.py +133 -0
  82. patchi/core/agents/cicd_generator.py +240 -0
  83. patchi/core/agents/comment_scanner.py +280 -0
  84. patchi/core/agents/console_logging_agent.py +156 -0
  85. patchi/core/agents/coordinator.py +858 -0
  86. patchi/core/agents/core_scanner.py +269 -0
  87. patchi/core/agents/coverage_prioritizer.py +181 -0
  88. patchi/core/agents/dead_code_hygiene.py +296 -0
  89. patchi/core/agents/dead_code_scanner.py +692 -0
  90. patchi/core/agents/dependency_scanner.py +725 -0
  91. patchi/core/agents/doc_claim_agent.py +573 -0
  92. patchi/core/agents/duplicate_scanner.py +568 -0
  93. patchi/core/agents/env_scanner.py +410 -0
  94. patchi/core/agents/feature_flag_agent.py +89 -0
  95. patchi/core/agents/frontend_framework_agent.py +133 -0
  96. patchi/core/agents/goroutine_agent.py +82 -0
  97. patchi/core/agents/governor.py +1972 -0
  98. patchi/core/agents/i18n_agent.py +95 -0
  99. patchi/core/agents/license_compliance.py +247 -0
  100. patchi/core/agents/linking_agent.py +251 -0
  101. patchi/core/agents/memory_profiler_agent.py +102 -0
  102. patchi/core/agents/modernization_agent.py +84 -0
  103. patchi/core/agents/mutation_agent.py +174 -0
  104. patchi/core/agents/nl_query_agent.py +74 -0
  105. patchi/core/agents/promise_rejection_agent.py +100 -0
  106. patchi/core/agents/refactoring_agent.py +286 -0
  107. patchi/core/agents/resource_leak_agent.py +91 -0
  108. patchi/core/agents/route_graph_scanner.py +951 -0
  109. patchi/core/agents/run_agent.py +106 -0
  110. patchi/core/agents/rust_unwrap_agent.py +100 -0
  111. patchi/core/agents/sbom_generator.py +284 -0
  112. patchi/core/agents/scanners.py +67 -0
  113. patchi/core/agents/side/__init__.py +1 -0
  114. patchi/core/agents/side/build_agent.py +65 -0
  115. patchi/core/agents/side/format_agent.py +50 -0
  116. patchi/core/agents/side/install_agent.py +63 -0
  117. patchi/core/agents/side_file_scanner.py +549 -0
  118. patchi/core/agents/snapshot_drift_detector.py +146 -0
  119. patchi/core/agents/spa_route_inventory.py +203 -0
  120. patchi/core/agents/test_scanner.py +442 -0
  121. patchi/core/agents/tool_health.py +109 -0
  122. patchi/core/agents/tool_runner.py +162 -0
  123. patchi/core/agents/type_scanner.py +168 -0
  124. patchi/core/agents/ui_scanner.py +558 -0
  125. patchi/core/ai/__init__.py +20 -0
  126. patchi/core/ai/agent_audit.py +153 -0
  127. patchi/core/ai/agent_profiler.py +277 -0
  128. patchi/core/ai/client.py +499 -0
  129. patchi/core/ai/cost_tracker.py +107 -0
  130. patchi/core/ai/model_router.py +318 -0
  131. patchi/core/ai/orchestrator.py +625 -0
  132. patchi/core/ai/prompts.py +681 -0
  133. patchi/core/ai/smart.py +302 -0
  134. patchi/core/ai/tool_executor.py +24 -0
  135. patchi/core/ai/tools/__init__.py +10 -0
  136. patchi/core/ai/tools/executor.py +621 -0
  137. patchi/core/ai/tools/realize.py +1160 -0
  138. patchi/core/ai/tools/registry.py +1577 -0
  139. patchi/core/assurance/__init__.py +33 -0
  140. patchi/core/assurance/builder.py +211 -0
  141. patchi/core/assurance/graph.py +342 -0
  142. patchi/core/assurance/invariants.py +168 -0
  143. patchi/core/atomic.py +61 -0
  144. patchi/core/attackers/__init__.py +17 -0
  145. patchi/core/attackers/api_abuse.py +89 -0
  146. patchi/core/attackers/auth_bypass.py +73 -0
  147. patchi/core/attackers/base.py +91 -0
  148. patchi/core/attackers/business_logic.py +88 -0
  149. patchi/core/attackers/planner.py +108 -0
  150. patchi/core/attackers/priv_escalation.py +88 -0
  151. patchi/core/attackers/recon.py +87 -0
  152. patchi/core/brain/__init__.py +0 -0
  153. patchi/core/brain/ast_utils/__init__.py +81 -0
  154. patchi/core/brain/ast_utils/assignments.py +219 -0
  155. patchi/core/brain/ast_utils/calls.py +139 -0
  156. patchi/core/brain/ast_utils/config.py +132 -0
  157. patchi/core/brain/ast_utils/control_flow.py +74 -0
  158. patchi/core/brain/ast_utils/dead_symbols.py +205 -0
  159. patchi/core/brain/ast_utils/decorators.py +108 -0
  160. patchi/core/brain/ast_utils/helpers.py +144 -0
  161. patchi/core/brain/ast_utils/imports.py +169 -0
  162. patchi/core/brain/ast_utils/scan.py +143 -0
  163. patchi/core/brain/ast_utils/taint.py +310 -0
  164. patchi/core/brain/audit.py +379 -0
  165. patchi/core/brain/baseline.py +195 -0
  166. patchi/core/brain/blast_radius.py +312 -0
  167. patchi/core/brain/body_tags.py +144 -0
  168. patchi/core/brain/brain.py +1161 -0
  169. patchi/core/brain/brain_context.py +393 -0
  170. patchi/core/brain/brain_watcher.py +340 -0
  171. patchi/core/brain/charter.py +473 -0
  172. patchi/core/brain/classifier.py +214 -0
  173. patchi/core/brain/cockpit.py +350 -0
  174. patchi/core/brain/code_query.py +710 -0
  175. patchi/core/brain/contract.py +889 -0
  176. patchi/core/brain/contract_diff.py +155 -0
  177. patchi/core/brain/council.py +800 -0
  178. patchi/core/brain/cross_repo.py +104 -0
  179. patchi/core/brain/dead_code_tools.py +157 -0
  180. patchi/core/brain/doc_validator.py +197 -0
  181. patchi/core/brain/domain_activator.py +2183 -0
  182. patchi/core/brain/enriched_context.py +220 -0
  183. patchi/core/brain/file_corpus.py +220 -0
  184. patchi/core/brain/framework.py +953 -0
  185. patchi/core/brain/freshness.py +273 -0
  186. patchi/core/brain/git_aware.py +206 -0
  187. patchi/core/brain/heatmap.py +104 -0
  188. patchi/core/brain/ignore_parser.py +153 -0
  189. patchi/core/brain/import_graph.py +486 -0
  190. patchi/core/brain/languages.py +397 -0
  191. patchi/core/brain/layered_brain.py +419 -0
  192. patchi/core/brain/learning.py +229 -0
  193. patchi/core/brain/orphaned_endpoints.py +313 -0
  194. patchi/core/brain/personas/__init__.py +648 -0
  195. patchi/core/brain/personas/bad_user.py +165 -0
  196. patchi/core/brain/personas/base.py +349 -0
  197. patchi/core/brain/proactive.py +807 -0
  198. patchi/core/brain/project_context.py +121 -0
  199. patchi/core/brain/project_reader.py +521 -0
  200. patchi/core/brain/reasoning.py +269 -0
  201. patchi/core/brain/route_detector/__init__.py +15 -0
  202. patchi/core/brain/route_detector/base.py +31 -0
  203. patchi/core/brain/route_detector/csharp.py +209 -0
  204. patchi/core/brain/route_detector/file_based.py +81 -0
  205. patchi/core/brain/route_detector/go.py +152 -0
  206. patchi/core/brain/route_detector/java.py +195 -0
  207. patchi/core/brain/route_detector/javascript.py +197 -0
  208. patchi/core/brain/route_detector/php.py +158 -0
  209. patchi/core/brain/route_detector/python.py +193 -0
  210. patchi/core/brain/route_detector/registry.py +25 -0
  211. patchi/core/brain/route_detector/ruby.py +134 -0
  212. patchi/core/brain/route_detector/rust.py +216 -0
  213. patchi/core/brain/route_detector/swift.py +118 -0
  214. patchi/core/brain/route_mapper.py +265 -0
  215. patchi/core/brain/scanner.py +2136 -0
  216. patchi/core/brain/secrets.py +280 -0
  217. patchi/core/brain/symbol_graph.py +1955 -0
  218. patchi/core/brain/team.py +28 -0
  219. patchi/core/brain/trace_log.py +132 -0
  220. patchi/core/brain/type_checker/__init__.py +65 -0
  221. patchi/core/brain/type_checker/base.py +60 -0
  222. patchi/core/brain/type_checker/csharp.py +59 -0
  223. patchi/core/brain/type_checker/dart.py +59 -0
  224. patchi/core/brain/type_checker/go.py +81 -0
  225. patchi/core/brain/type_checker/java.py +75 -0
  226. patchi/core/brain/type_checker/kotlin.py +75 -0
  227. patchi/core/brain/type_checker/php.py +88 -0
  228. patchi/core/brain/type_checker/python.py +87 -0
  229. patchi/core/brain/type_checker/rust.py +81 -0
  230. patchi/core/brain/type_checker/swift.py +59 -0
  231. patchi/core/brain/type_checker/typescript.py +298 -0
  232. patchi/core/brain/understander.py +130 -0
  233. patchi/core/brain/verify.py +379 -0
  234. patchi/core/campaigns/__init__.py +14 -0
  235. patchi/core/campaigns/base.py +62 -0
  236. patchi/core/campaigns/data_flow_abuse.py +153 -0
  237. patchi/core/campaigns/orchestrator.py +140 -0
  238. patchi/core/campaigns/state_transitions.py +178 -0
  239. patchi/core/ci_bundle.py +104 -0
  240. patchi/core/config.py +311 -0
  241. patchi/core/constants.py +245 -0
  242. patchi/core/debug/__init__.py +25 -0
  243. patchi/core/debug/adapters/__init__.py +11 -0
  244. patchi/core/debug/adapters/codelldb.py +379 -0
  245. patchi/core/debug/adapters/node.py +222 -0
  246. patchi/core/debug/adapters/powershell.py +210 -0
  247. patchi/core/debug/adapters/python.py +244 -0
  248. patchi/core/debug/capture.py +210 -0
  249. patchi/core/debug/dap_client.py +268 -0
  250. patchi/core/detector/__init__.py +0 -0
  251. patchi/core/detector/audit.py +194 -0
  252. patchi/core/detector/bus.py +226 -0
  253. patchi/core/detector/dispatcher.py +275 -0
  254. patchi/core/detector/event.py +254 -0
  255. patchi/core/detector/sigma_engine.py +378 -0
  256. patchi/core/detector/triage.py +289 -0
  257. patchi/core/export/sarif.py +113 -0
  258. patchi/core/fix/__init__.py +17 -0
  259. patchi/core/fix/applier.py +574 -0
  260. patchi/core/fix/base.py +510 -0
  261. patchi/core/fix/code_fixer.py +169 -0
  262. patchi/core/fix/dead_code_remover.py +131 -0
  263. patchi/core/fix/fix_agents.py +775 -0
  264. patchi/core/fix/patch.py +444 -0
  265. patchi/core/fix/risk_gate.py +383 -0
  266. patchi/core/fix/security_fixer.py +93 -0
  267. patchi/core/fix/verify_loop.py +204 -0
  268. patchi/core/fuzz/__init__.py +8 -0
  269. patchi/core/fuzz/corpus.py +137 -0
  270. patchi/core/fuzz/input_fuzzer.py +256 -0
  271. patchi/core/fuzz/sequence_fuzzer.py +162 -0
  272. patchi/core/fuzz/state_fuzzer.py +236 -0
  273. patchi/core/health.py +407 -0
  274. patchi/core/hosted/__init__.py +1 -0
  275. patchi/core/hosted/anomaly.py +298 -0
  276. patchi/core/hosted/audit_log.py +102 -0
  277. patchi/core/hosted/compliance_report.py +148 -0
  278. patchi/core/hosted/ip_reputation.py +169 -0
  279. patchi/core/hosted/log_parsers.py +251 -0
  280. patchi/core/hosted/tokens.py +127 -0
  281. patchi/core/hosted/watchlist.py +164 -0
  282. patchi/core/hosted/webhooks.py +156 -0
  283. patchi/core/memory.py +437 -0
  284. patchi/core/notifications/__init__.py +18 -0
  285. patchi/core/notifications/channels.py +192 -0
  286. patchi/core/notifications/digest.py +141 -0
  287. patchi/core/notifications/escalation.py +158 -0
  288. patchi/core/notifications/notifier.py +182 -0
  289. patchi/core/notifications/quiet_hours.py +69 -0
  290. patchi/core/plugins/__init__.py +48 -0
  291. patchi/core/plugins/analyzer.py +326 -0
  292. patchi/core/plugins/analyzers/__init__.py +6 -0
  293. patchi/core/plugins/analyzers/dead_code_analyzer.py +120 -0
  294. patchi/core/plugins/analyzers/secret_scanner.py +158 -0
  295. patchi/core/plugins/analyzers/todo_scanner.py +115 -0
  296. patchi/core/plugins/registry.py +335 -0
  297. patchi/core/queue.py +375 -0
  298. patchi/core/reliability/__init__.py +13 -0
  299. patchi/core/reliability/chaos.py +163 -0
  300. patchi/core/reliability/fault_injector.py +172 -0
  301. patchi/core/reliability/idempotency.py +128 -0
  302. patchi/core/reliability/recovery.py +147 -0
  303. patchi/core/runtime/__init__.py +5 -0
  304. patchi/core/runtime/tracer.py +230 -0
  305. patchi/core/scan_bus.py +156 -0
  306. patchi/core/security/__init__.py +1 -0
  307. patchi/core/security/ai_validator.py +462 -0
  308. patchi/core/security/app_mapper.py +216 -0
  309. patchi/core/security/app_profile.py +464 -0
  310. patchi/core/security/attack_feedback.py +141 -0
  311. patchi/core/security/attack_scenarios/__init__.py +1 -0
  312. patchi/core/security/attack_tree.py +176 -0
  313. patchi/core/security/auth_audit_agent.py +344 -0
  314. patchi/core/security/authz_agent.py +602 -0
  315. patchi/core/security/auto_fix_proactive.py +620 -0
  316. patchi/core/security/auto_fixer.py +660 -0
  317. patchi/core/security/auto_ticket.py +53 -0
  318. patchi/core/security/bandit_agent.py +152 -0
  319. patchi/core/security/blast_radius.py +226 -0
  320. patchi/core/security/browser_tester.py +333 -0
  321. patchi/core/security/business_logic_agent.py +263 -0
  322. patchi/core/security/catch_block_auditor.py +395 -0
  323. patchi/core/security/cdn_cache_agent.py +357 -0
  324. patchi/core/security/chain_analyzer.py +321 -0
  325. patchi/core/security/chain_to_assurance.py +168 -0
  326. patchi/core/security/charter.py +510 -0
  327. patchi/core/security/cloud_waf_detector.py +295 -0
  328. patchi/core/security/codeql_agent.py +233 -0
  329. patchi/core/security/compliance_agent.py +348 -0
  330. patchi/core/security/confidence_gate.py +423 -0
  331. patchi/core/security/container_scanner.py +282 -0
  332. patchi/core/security/crypto_agent.py +378 -0
  333. patchi/core/security/cve_monitor.py +262 -0
  334. patchi/core/security/dast_agent.py +865 -0
  335. patchi/core/security/defectdojo.py +238 -0
  336. patchi/core/security/defenders/__init__.py +75 -0
  337. patchi/core/security/defenders/auth_middleware.py +115 -0
  338. patchi/core/security/defenders/base.py +97 -0
  339. patchi/core/security/defenders/block_ip.py +71 -0
  340. patchi/core/security/defenders/block_ws_origin.py +36 -0
  341. patchi/core/security/defenders/crypto_fix.py +118 -0
  342. patchi/core/security/defenders/enforce_rate_limit.py +108 -0
  343. patchi/core/security/defenders/fix_code.py +93 -0
  344. patchi/core/security/defenders/invalidate_session.py +40 -0
  345. patchi/core/security/defenders/patch_config.py +70 -0
  346. patchi/core/security/defenders/rotate_secret.py +54 -0
  347. patchi/core/security/defenders/suspend_account.py +41 -0
  348. patchi/core/security/defenders/update_dependency.py +160 -0
  349. patchi/core/security/defense_layer.py +217 -0
  350. patchi/core/security/dependency_vulnerability_agent.py +616 -0
  351. patchi/core/security/detection_pipeline.py +289 -0
  352. patchi/core/security/dns_security_agent.py +497 -0
  353. patchi/core/security/domain_activator_v2.py +913 -0
  354. patchi/core/security/domain_loader.py +1004 -0
  355. patchi/core/security/domains/__init__.py +0 -0
  356. patchi/core/security/email_authentication_agent.py +417 -0
  357. patchi/core/security/env_var_validator.py +304 -0
  358. patchi/core/security/evidence.py +100 -0
  359. patchi/core/security/falco_runtime_agent.py +387 -0
  360. patchi/core/security/fix-playbooks/__init__.py +0 -0
  361. patchi/core/security/gated_finding.py +85 -0
  362. patchi/core/security/git_diff_activator.py +367 -0
  363. patchi/core/security/governance.py +208 -0
  364. patchi/core/security/gradual_gate.py +34 -0
  365. patchi/core/security/history.py +315 -0
  366. patchi/core/security/iac_scanner.py +250 -0
  367. patchi/core/security/ignore_expiry.py +111 -0
  368. patchi/core/security/ignore_learner.py +416 -0
  369. patchi/core/security/injection_agent.py +319 -0
  370. patchi/core/security/insecure_randomness_agent.py +288 -0
  371. patchi/core/security/intent_analyzer.py +326 -0
  372. patchi/core/security/jwt_agent.py +453 -0
  373. patchi/core/security/kubernetes_agent.py +638 -0
  374. patchi/core/security/layer2_orchestrator.py +341 -0
  375. patchi/core/security/llm_security_agent.py +252 -0
  376. patchi/core/security/misconfig_agent.py +600 -0
  377. patchi/core/security/mobile_security_agent.py +474 -0
  378. patchi/core/security/network_agent.py +376 -0
  379. patchi/core/security/noise_filter.py +274 -0
  380. patchi/core/security/orchestrator.py +313 -0
  381. patchi/core/security/pattern_context.py +125 -0
  382. patchi/core/security/pattern_loader.py +101 -0
  383. patchi/core/security/pentest/__init__.py +4 -0
  384. patchi/core/security/pentest/base.py +58 -0
  385. patchi/core/security/pentest/dalfox_adapter.py +40 -0
  386. patchi/core/security/pentest/ffuf_adapter.py +48 -0
  387. patchi/core/security/pentest/nuclei_adapter.py +50 -0
  388. patchi/core/security/pentest/registry.py +80 -0
  389. patchi/core/security/pentest/shannon_adapter.py +107 -0
  390. patchi/core/security/pentest/sqlmap_adapter.py +56 -0
  391. patchi/core/security/pentest/zap_adapter.py +52 -0
  392. patchi/core/security/plan_auditor.py +276 -0
  393. patchi/core/security/policy_engine.py +272 -0
  394. patchi/core/security/prechecks.py +172 -0
  395. patchi/core/security/precommit_hook.py +247 -0
  396. patchi/core/security/privacy_agent.py +361 -0
  397. patchi/core/security/push_notification_agent.py +417 -0
  398. patchi/core/security/pysa_agent.py +143 -0
  399. patchi/core/security/reasoning.py +442 -0
  400. patchi/core/security/red_team_agent.py +184 -0
  401. patchi/core/security/red_team_engine.py +1142 -0
  402. patchi/core/security/remediation.py +360 -0
  403. patchi/core/security/request_interceptor.py +294 -0
  404. patchi/core/security/runtime_validator.py +245 -0
  405. patchi/core/security/saml_sso_agent.py +401 -0
  406. patchi/core/security/sast_agent.py +213 -0
  407. patchi/core/security/sast_gate.py +95 -0
  408. patchi/core/security/scheduler.py +287 -0
  409. patchi/core/security/secrets_runtime_agent.py +476 -0
  410. patchi/core/security/security_agents.py +175 -0
  411. patchi/core/security/security_config.py +551 -0
  412. patchi/core/security/security_probe.py +762 -0
  413. patchi/core/security/security_taint.py +1005 -0
  414. patchi/core/security/sensitive_data_agent.py +271 -0
  415. patchi/core/security/service_mesh_agent.py +412 -0
  416. patchi/core/security/session_management_agent.py +245 -0
  417. patchi/core/security/ssrf_agent.py +422 -0
  418. patchi/core/security/supply_chain.py +449 -0
  419. patchi/core/security/threat_model_generator.py +378 -0
  420. patchi/core/security/threat_model_updater.py +196 -0
  421. patchi/core/security/tool_adapters.py +135 -0
  422. patchi/core/security/tool_verify.py +173 -0
  423. patchi/core/security/websocket_security_agent.py +188 -0
  424. patchi/core/snapshot.py +242 -0
  425. patchi/core/tenant.py +386 -0
  426. patchi/core/testing/__init__.py +17 -0
  427. patchi/core/testing/_browser.py +147 -0
  428. patchi/core/testing/ads_agent.py +141 -0
  429. patchi/core/testing/api_contract_agent.py +450 -0
  430. patchi/core/testing/app_discovery_agent.py +641 -0
  431. patchi/core/testing/app_launcher.py +184 -0
  432. patchi/core/testing/browser_test_agent.py +516 -0
  433. patchi/core/testing/e2e_flow_agent.py +354 -0
  434. patchi/core/testing/flake_detector_agent.py +292 -0
  435. patchi/core/testing/gate.py +31 -0
  436. patchi/core/testing/live_test_runner.py +304 -0
  437. patchi/core/testing/live_v2/__init__.py +1 -0
  438. patchi/core/testing/live_v2/browser_pool.py +349 -0
  439. patchi/core/testing/live_v2/browser_test_runner.py +313 -0
  440. patchi/core/testing/live_v2/runner.py +626 -0
  441. patchi/core/testing/live_v2/screenshot_manager.py +474 -0
  442. patchi/core/testing/live_v2/stress_orchestrator.py +523 -0
  443. patchi/core/testing/live_v2/video_recorder.py +303 -0
  444. patchi/core/testing/regression_agent.py +302 -0
  445. patchi/core/testing/security_test_agent.py +253 -0
  446. patchi/core/testing/stress_test_agent.py +203 -0
  447. patchi/core/testing/test_agents.py +50 -0
  448. patchi/core/testing/test_config.py +194 -0
  449. patchi/core/testing/ui_accessibility_agent.py +324 -0
  450. patchi/core/testing/ui_button_agent.py +294 -0
  451. patchi/core/testing/ui_layout_agent.py +330 -0
  452. patchi/core/testing/unit_test_agent.py +1149 -0
  453. patchi/core/testing/visual_regression_agent.py +260 -0
  454. patchi/web/__init__.py +1 -0
  455. patchi/web/api/__init__.py +1 -0
  456. patchi/web/api/brain_map.py +114 -0
  457. patchi/web/api/charts.py +47 -0
  458. patchi/web/api/chat.py +224 -0
  459. patchi/web/api/cicd.py +576 -0
  460. patchi/web/api/dast_evidence.py +49 -0
  461. patchi/web/api/dev_check.py +320 -0
  462. patchi/web/api/fix.py +134 -0
  463. patchi/web/api/guard.py +65 -0
  464. patchi/web/api/hosted.py +128 -0
  465. patchi/web/api/hosted_v2.py +234 -0
  466. patchi/web/api/live_testing.py +699 -0
  467. patchi/web/api/scan.py +582 -0
  468. patchi/web/api/smart.py +111 -0
  469. patchi/web/api/tenant.py +117 -0
  470. patchi/web/api/tools.py +85 -0
  471. patchi/web/api_legacy.py +2401 -0
  472. patchi/web/app.py +310 -0
  473. patchi/web/events.py +483 -0
  474. patchi/web/routes/__init__.py +1 -0
  475. patchi/web/routes/assurance.py +541 -0
  476. patchi/web/routes/brain.py +36 -0
  477. patchi/web/routes/charter.py +227 -0
  478. patchi/web/routes/chat.py +23 -0
  479. patchi/web/routes/dashboard.py +558 -0
  480. patchi/web/routes/dashboard_v2.py +402 -0
  481. patchi/web/routes/findings.py +330 -0
  482. patchi/web/routes/guard.py +35 -0
  483. patchi/web/routes/history.py +126 -0
  484. patchi/web/routes/landing.py +52 -0
  485. patchi/web/routes/live_testing.py +24 -0
  486. patchi/web/routes/review.py +57 -0
  487. patchi/web/routes/self_improvement.py +110 -0
  488. patchi/web/routes/settings.py +49 -0
  489. patchi/web/routes/tokens.py +29 -0
  490. patchi/web/spawn.py +139 -0
  491. patchi/web/ws.py +141 -0
  492. patchi-0.7.5.dist-info/METADATA +315 -0
  493. patchi-0.7.5.dist-info/RECORD +496 -0
  494. patchi-0.7.5.dist-info/WHEEL +5 -0
  495. patchi-0.7.5.dist-info/licenses/LICENSE +60 -0
  496. patchi-0.7.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,699 @@
1
+ """Live Testing API — browser tests, screenshots, stress tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+
11
+ from fastapi import APIRouter, Request
12
+ from fastapi.responses import JSONResponse
13
+ from pydantic import BaseModel
14
+
15
+ _log = logging.getLogger("patchi.web.api.live_testing")
16
+
17
+
18
+ router = APIRouter(prefix="/api/live-testing")
19
+
20
+
21
+ class StressTestRequest(BaseModel):
22
+ url: str = "http://127.0.0.1:1612"
23
+ users: int = 10
24
+ duration_seconds: int = 30
25
+ ramp_up_seconds: int = 5
26
+
27
+
28
+ class SmokeTestRequest(BaseModel):
29
+ url: str = "http://127.0.0.1:1612"
30
+
31
+
32
+ # ── State ────────────────────────────────────────────────────────────────────
33
+
34
+ _stress_state = {"running": False, "last_result": None, "task": None, "cancelled": False}
35
+ _audit_state = {
36
+ "running": False,
37
+ "progress": {"current": 0, "total": 0, "route": ""},
38
+ "last_result": None,
39
+ }
40
+ _dast_state = {"running": False, "task": None, "cancelled": False}
41
+ _smoke_state = {"running": False, "task": None, "cancelled": False}
42
+
43
+
44
+ def _run_audit_sync(base_url: str, root: Path, routes: list[str] | None = None) -> dict:
45
+ """Run full-page browser audit synchronously (called via to_thread)."""
46
+ from playwright.sync_api import sync_playwright
47
+
48
+ from patchi.core.testing._browser import (
49
+ discover_routes,
50
+ open_page,
51
+ save_screenshot,
52
+ )
53
+
54
+ if routes is None:
55
+ routes = discover_routes({}, {})
56
+
57
+ evidence_dir = root / ".patchi" / "evidence" / "browser_audit"
58
+ evidence_dir.mkdir(parents=True, exist_ok=True)
59
+
60
+ results = []
61
+ total = len(routes)
62
+ _audit_state["progress"] = {"current": 0, "total": total, "route": ""}
63
+
64
+ with sync_playwright() as pw:
65
+ browser = pw.chromium.launch(headless=True)
66
+ try:
67
+ for i, route in enumerate(routes):
68
+ url = f"{base_url}{route}"
69
+ _audit_state["progress"] = {
70
+ "current": i + 1,
71
+ "total": total,
72
+ "route": route,
73
+ }
74
+
75
+ page_session = open_page(browser, url)
76
+ page = page_session.page
77
+ load_time_ms = 0
78
+
79
+ try:
80
+ slug = route.strip("/").replace("/", "_") or "root"
81
+
82
+ # Re-measure load time with a fresh goto
83
+ perf_start = page.evaluate("() => performance.now()")
84
+ try:
85
+ page.reload(wait_until="load", timeout=15000)
86
+ page.wait_for_timeout(500)
87
+ except Exception as _exc:
88
+ _log.warning('_run_audit_sync failed: %s', _exc)
89
+ perf_end = page.evaluate("() => performance.now()")
90
+ load_time_ms = round(perf_end - perf_start, 1)
91
+
92
+ # Screenshot
93
+ shot_name = f"audit_{slug}"
94
+ shot_path = save_screenshot(page, evidence_dir, shot_name)
95
+ screenshot_file = shot_path.name if shot_path else None
96
+
97
+ # Collect page metrics
98
+ metrics = page.evaluate(
99
+ "() => ({dom_nodes: document.querySelector('*').length, "
100
+ "images: document.querySelector('img').length, "
101
+ "links: document.querySelector('a').length, "
102
+ "scripts: document.querySelector('script').length, "
103
+ "stylesheets: document.querySelector('link[rel=stylesheet]').length, "
104
+ "title: document.title || '', "
105
+ "has_viewport_meta: !!document.querySelector('meta[name=viewport]'), "
106
+ "has_h1: !!document.querySelector('h1')})"
107
+ )
108
+
109
+ # Check for broken images
110
+ broken_images = page.evaluate(
111
+ "() => Array.from(document.querySelectorAll('img'))"
112
+ ".filter(img => !img.complete || img.naturalWidth === 0)"
113
+ ".map(img => img.src)"
114
+ )
115
+
116
+ # Check for empty links
117
+ empty_links = page.evaluate(
118
+ "() => Array.from(document.querySelectorAll('a[href]'))"
119
+ ".filter(a => !a.textContent.trim() && !a.querySelector('img'))"
120
+ ".length"
121
+ )
122
+
123
+ results.append({
124
+ "route": route,
125
+ "status": page_session.status,
126
+ "status_label": _status_label(page_session.status),
127
+ "load_time_ms": load_time_ms,
128
+ "console_errors": page_session.console_errors,
129
+ "console_error_count": len(page_session.console_errors),
130
+ "page_errors": page_session.page_errors,
131
+ "page_error_count": len(page_session.page_errors),
132
+ "screenshot": screenshot_file,
133
+ "metrics": metrics,
134
+ "broken_images": broken_images,
135
+ "broken_image_count": len(broken_images),
136
+ "empty_links": empty_links,
137
+ "severity": _severity_for_page(page_session, broken_images, empty_links),
138
+ })
139
+ except Exception as e:
140
+ results.append({
141
+ "route": route,
142
+ "status": -1,
143
+ "status_label": "ERROR",
144
+ "load_time_ms": 0,
145
+ "console_errors": [str(e)],
146
+ "console_error_count": 1,
147
+ "page_errors": [],
148
+ "page_error_count": 0,
149
+ "screenshot": None,
150
+ "metrics": {},
151
+ "broken_images": [],
152
+ "broken_image_count": 0,
153
+ "empty_links": 0,
154
+ "severity": "critical",
155
+ })
156
+ finally:
157
+ try:
158
+ page.close()
159
+ except Exception as _exc:
160
+ _log.warning('_run_audit_sync failed: %s', _exc)
161
+ finally:
162
+ browser.close()
163
+
164
+ # Summary
165
+ statuses = [r["status"] for r in results]
166
+ errors_4xx = sum(1 for s in statuses if 400 <= s < 500)
167
+ errors_5xx = sum(1 for s in statuses if s >= 500)
168
+ ok_count = sum(1 for s in statuses if 200 <= s < 400)
169
+ total_console = sum(r["console_error_count"] for r in results)
170
+ total_page_err = sum(r["page_error_count"] for r in results)
171
+ total_broken = sum(r["broken_image_count"] for r in results)
172
+ avg_load = round(sum(r["load_time_ms"] for r in results) / max(len(results), 1), 1)
173
+ critical = sum(1 for r in results if r["severity"] == "critical")
174
+ warnings = sum(1 for r in results if r["severity"] == "warning")
175
+ passed = sum(1 for r in results if r["severity"] == "pass")
176
+
177
+ # Save results JSON
178
+ result_data = {
179
+ "ok": True,
180
+ "base_url": base_url,
181
+ "total_routes": len(results),
182
+ "summary": {
183
+ "passed": passed,
184
+ "warnings": warnings,
185
+ "critical": critical,
186
+ "ok_2xx": ok_count,
187
+ "errors_4xx": errors_4xx,
188
+ "errors_5xx": errors_5xx,
189
+ "total_console_errors": total_console,
190
+ "total_page_errors": total_page_err,
191
+ "total_broken_images": total_broken,
192
+ "avg_load_time_ms": avg_load,
193
+ },
194
+ "pages": results,
195
+ }
196
+
197
+ # Persist to disk
198
+ audit_file = evidence_dir / "audit_results.json"
199
+ audit_file.write_text(json.dumps(result_data, indent=2, default=str), encoding="utf-8")
200
+
201
+ _audit_state["last_result"] = result_data
202
+ return result_data
203
+
204
+
205
+ def _status_label(status: int) -> str:
206
+ if status == 200:
207
+ return "OK"
208
+ if status == 301:
209
+ return "Redirect"
210
+ if status == 304:
211
+ return "Cached"
212
+ if status == 404:
213
+ return "Not Found"
214
+ if status == 500:
215
+ return "Server Error"
216
+ if status < 0:
217
+ return "Failed"
218
+ return str(status)
219
+
220
+
221
+ def _severity_for_page(session, broken_images: list, empty_links: int) -> str:
222
+ if session.status >= 500 or session.status < 0:
223
+ return "critical"
224
+ if session.status >= 400:
225
+ return "warning"
226
+ if session.page_errors:
227
+ return "critical"
228
+ if len(session.console_errors) > 3 or broken_images or empty_links > 2:
229
+ return "warning"
230
+ if session.console_errors:
231
+ return "info"
232
+ return "pass"
233
+
234
+
235
+ # ── Endpoints ────────────────────────────────────────────────────────────────
236
+
237
+
238
+ @router.get("/status")
239
+ async def status(request: Request):
240
+ """Get live testing status."""
241
+ root: Path = request.app.state.root
242
+ return {
243
+ "stress_running": _stress_state["running"],
244
+ "evidence_dir": str(root / ".patchi" / "evidence"),
245
+ "has_browser_pool": True,
246
+ }
247
+
248
+
249
+ @router.post("/smoke-test")
250
+ async def smoke_test(req: SmokeTestRequest, request: Request):
251
+ """Run a quick smoke test against the app."""
252
+ root: Path = request.app.state.root
253
+ try:
254
+ from patchi.core.testing.live_v2.browser_test_runner import BrowserTestRunner
255
+
256
+ runner = BrowserTestRunner(root, base_url=req.url)
257
+ result = await runner.run_smoke_test(req.url)
258
+ return result.to_dict()
259
+ except Exception as e:
260
+ return JSONResponse(
261
+ status_code=500,
262
+ content={"error": str(e)},
263
+ )
264
+
265
+
266
+ @router.post("/stress-test")
267
+ async def stress_test(req: StressTestRequest, request: Request):
268
+ """Start a stress test against the app."""
269
+ if _stress_state["running"]:
270
+ return JSONResponse(
271
+ status_code=409,
272
+ content={"error": "Stress test already running"},
273
+ )
274
+
275
+ _stress_state["running"] = True
276
+ _stress_state["cancelled"] = False
277
+
278
+ try:
279
+ from patchi.core.testing.live_v2.stress_orchestrator import (
280
+ StressConfig,
281
+ StressOrchestrator,
282
+ )
283
+
284
+ config = StressConfig(
285
+ base_url=req.url,
286
+ users=req.users,
287
+ duration_seconds=req.duration_seconds,
288
+ ramp_up_seconds=req.ramp_up_seconds,
289
+ )
290
+ orchestrator = StressOrchestrator(config)
291
+ report = await orchestrator.run()
292
+ if _stress_state["cancelled"]:
293
+ return JSONResponse({"ok": False, "message": "Stress test was cancelled"})
294
+ _stress_state["last_result"] = report.to_dict()
295
+ return report.to_dict()
296
+ except asyncio.CancelledError:
297
+ return JSONResponse({"ok": False, "message": "Stress test was cancelled"})
298
+ except Exception as e:
299
+ return JSONResponse(status_code=500, content={"error": str(e)})
300
+ finally:
301
+ _stress_state["running"] = False
302
+ _stress_state["task"] = None
303
+
304
+
305
+ @router.get("/stress-result")
306
+ async def stress_result():
307
+ """Get the last stress test result."""
308
+ return _stress_state["last_result"] or {"message": "No stress test results yet"}
309
+
310
+
311
+ @router.post("/stress-cancel")
312
+ async def cancel_stress() -> JSONResponse:
313
+ """Cancel a running stress test."""
314
+ if not _stress_state["running"]:
315
+ return JSONResponse({"ok": True, "message": "No stress test running"})
316
+ _stress_state["cancelled"] = True
317
+ task = _stress_state.get("task")
318
+ if task and not task.done():
319
+ task.cancel()
320
+ return JSONResponse({"ok": True, "message": "Stress test cancellation requested"})
321
+
322
+
323
+ @router.post("/dast-cancel")
324
+ async def cancel_dast() -> JSONResponse:
325
+ """Cancel a running DAST scan."""
326
+ if not _dast_state["running"]:
327
+ return JSONResponse({"ok": True, "message": "No DAST scan running"})
328
+ _dast_state["cancelled"] = True
329
+ task = _dast_state.get("task")
330
+ if task and not task.done():
331
+ task.cancel()
332
+ return JSONResponse({"ok": True, "message": "DAST scan cancellation requested"})
333
+
334
+
335
+ @router.post("/smoke-cancel")
336
+ async def cancel_smoke() -> JSONResponse:
337
+ """Cancel a running smoke test."""
338
+ if not _smoke_state["running"]:
339
+ return JSONResponse({"ok": True, "message": "No smoke test running"})
340
+ _smoke_state["cancelled"] = True
341
+ task = _smoke_state.get("task")
342
+ if task and not task.done():
343
+ task.cancel()
344
+ return JSONResponse({"ok": True, "message": "Smoke test cancellation requested"})
345
+
346
+
347
+ @router.get("/operations")
348
+ async def list_operations():
349
+ """List all currently running operations across the system."""
350
+ ops = []
351
+ # Check scan state
352
+ try:
353
+ from patchi.web.api.scan import _scan_state
354
+ if _scan_state.get("running"):
355
+ ops.append({"type": "scan", "label": "Security Scan", "cancel_url": "/api/scan/cancel"})
356
+ except Exception as _exc:
357
+ _log.warning('list_operations failed: %s', _exc)
358
+ if _stress_state["running"]:
359
+ ops.append({"type": "stress", "label": "Stress Test", "cancel_url": "/api/live-testing/stress-cancel"})
360
+ if _dast_state["running"]:
361
+ ops.append({"type": "dast", "label": "DAST Scan", "cancel_url": "/api/live-testing/dast-cancel"})
362
+ if _smoke_state["running"]:
363
+ ops.append({"type": "smoke", "label": "Smoke Test", "cancel_url": "/api/live-testing/smoke-cancel"})
364
+ if _audit_state["running"]:
365
+ ops.append({"type": "audit", "label": "Browser Audit", "cancel_url": ""})
366
+ try:
367
+ from patchi.web.api.smart import _current_task
368
+ if _current_task and not _current_task.done():
369
+ ops.append({"type": "smart", "label": "Smart Agent", "cancel_url": "/api/smart/cancel"})
370
+ except Exception as _exc:
371
+ _log.warning('list_operations failed: %s', _exc)
372
+ return JSONResponse({"ok": True, "operations": ops, "count": len(ops)})
373
+
374
+
375
+ @router.post("/screenshot")
376
+ async def capture_screenshot(req: SmokeTestRequest, request: Request):
377
+ """Capture a screenshot of the app."""
378
+ root: Path = request.app.state.root
379
+ try:
380
+ from patchi.core.testing.live_v2.browser_pool import get_browser_pool
381
+ from patchi.core.testing.live_v2.screenshot_manager import ScreenshotManager
382
+
383
+ evidence_dir = root / ".patchi" / "evidence" / "screenshots"
384
+ evidence_dir.mkdir(parents=True, exist_ok=True)
385
+ screenshot_mgr = ScreenshotManager(evidence_dir)
386
+ pool = await get_browser_pool()
387
+
388
+ page = await pool.get_page()
389
+ try:
390
+ await page.goto(req.url, wait_until="domcontentloaded")
391
+ ss_result = await screenshot_mgr.capture(page, req.url, name="manual_screenshot")
392
+ return {"success": True, "path": ss_result.image_path, "size": ss_result.file_size}
393
+ finally:
394
+ await pool.release_page(page)
395
+ except Exception as e:
396
+ return JSONResponse(status_code=500, content={"error": str(e)})
397
+
398
+
399
+ @router.get("/browser-pool-stats")
400
+ async def browser_pool_stats():
401
+ """Get browser pool statistics."""
402
+ try:
403
+ from patchi.core.testing.live_v2.browser_pool import get_browser_pool
404
+
405
+ pool = await get_browser_pool()
406
+ return pool.get_stats()
407
+ except Exception as e:
408
+ return {"error": str(e)}
409
+
410
+
411
+ def _ads_dirs(root: Path) -> list[Path]:
412
+ """Ads gallery viewport subdirs (evidence/ads/<WxH>/), if present."""
413
+ base = root / ".patchi" / "evidence" / "ads"
414
+ if not base.is_dir():
415
+ return []
416
+ try:
417
+ return [d for d in sorted(base.iterdir()) if d.is_dir()]
418
+ except OSError:
419
+ return []
420
+
421
+
422
+ @router.get("/videos")
423
+ async def list_videos(request: Request):
424
+ """List all recorded video evidence files."""
425
+ root: Path = request.app.state.root
426
+ video_dirs = [
427
+ root / ".patchi" / "evidence" / "video",
428
+ root / ".patchi" / "evidence" / "browser_tests" / "video",
429
+ *_ads_dirs(root),
430
+ ]
431
+
432
+ videos = []
433
+ seen_names: set[str] = set()
434
+ for video_dir in video_dirs:
435
+ if not video_dir.is_dir():
436
+ continue
437
+ for f in sorted(video_dir.glob("*.webm"), key=lambda x: x.stat().st_mtime, reverse=True):
438
+ try:
439
+ stat = f.stat()
440
+ # Deduplicate by filename across directories
441
+ if f.name in seen_names:
442
+ continue
443
+ seen_names.add(f.name)
444
+ videos.append({
445
+ "name": f.stem,
446
+ "filename": f.name,
447
+ "path": str(f.relative_to(root)),
448
+ "size_bytes": stat.st_size,
449
+ "size_kb": round(stat.st_size / 1024, 1),
450
+ "timestamp": datetime.fromtimestamp(stat.st_mtime, UTC).isoformat(),
451
+ })
452
+ except Exception as _exc:
453
+ _log.warning('list_videos failed: %s', _exc)
454
+ # Sort all videos by mtime descending
455
+ videos.sort(key=lambda v: v["timestamp"], reverse=True)
456
+
457
+ return JSONResponse({
458
+ "ok": True,
459
+ "videos": videos[:50],
460
+ "total": len(videos),
461
+ "total_size_kb": sum(v["size_kb"] for v in videos),
462
+ })
463
+
464
+
465
+ @router.get("/video/{filename}")
466
+ async def serve_video(filename: str, request: Request):
467
+ """Serve a video recording file for playback."""
468
+ root: Path = request.app.state.root
469
+ # Check multiple video directories
470
+ video_dirs = [
471
+ root / ".patchi" / "evidence" / "video",
472
+ root / ".patchi" / "evidence" / "browser_tests" / "video",
473
+ *_ads_dirs(root),
474
+ ]
475
+
476
+ # Security: only allow .webm files
477
+ import re as _re
478
+ if not _re.match(r'^[a-zA-Z0-9_@.\-]+\.webm$', filename):
479
+ return JSONResponse({"error": "Invalid filename"}, status_code=403)
480
+
481
+ for video_dir in video_dirs:
482
+ file_path = video_dir / filename
483
+ if file_path.exists() and file_path.is_file():
484
+ try:
485
+ file_path.resolve().relative_to(root.resolve())
486
+ except ValueError:
487
+ return JSONResponse({"error": "Access denied"}, status_code=403)
488
+ from starlette.responses import FileResponse
489
+ return FileResponse(file_path, media_type="video/webm")
490
+
491
+ return JSONResponse({"error": "Video not found"}, status_code=404)
492
+
493
+
494
+ @router.post("/visual-regression")
495
+ async def run_visual_regression(request: Request):
496
+ """Run visual regression agent — captures screenshots of all pages,
497
+ compares against baselines, and generates pixel-diff overlays."""
498
+ root: Path = request.app.state.root
499
+ try:
500
+ import asyncio
501
+
502
+ from patchi.core import config as cfg
503
+ from patchi.core import memory as mem
504
+ from patchi.core.agents.base import AgentInput, AgentResult
505
+ from patchi.core.testing.visual_regression_agent import VisualRegressionAgent
506
+
507
+ config = cfg.load(root) if root.exists() else {}
508
+ brain = mem.get_brain(root)
509
+ inp = AgentInput(root=root, scope=[], brain=brain, config=config)
510
+ result = AgentResult(agent_name="VisualRegressionAgent")
511
+
512
+ agent = VisualRegressionAgent()
513
+ # Run sync agent in thread pool so we don't block the event loop
514
+ await asyncio.to_thread(agent._run, inp, result)
515
+
516
+ return {
517
+ "ok": True,
518
+ "screenshots": result.data.get("suite", {}).get("screenshots", 0),
519
+ "regressions": result.data.get("suite", {}).get("regressions", 0),
520
+ "new_baselines": result.data.get("suite", {}).get("new_baselines", 0),
521
+ "error_pages": result.data.get("suite", {}).get("error_pages", 0),
522
+ "findings": [f.to_dict() for f in result.findings],
523
+ }
524
+ except Exception as e:
525
+ return JSONResponse(status_code=500, content={"ok": False, "error": str(e)})
526
+
527
+
528
+ @router.get("/screenshots")
529
+ async def list_screenshots(request: Request):
530
+ """List visual regression screenshots and diff images."""
531
+ root: Path = request.app.state.root
532
+ evidence_dir = root / ".patchi" / "evidence" / "screenshots" / "visual_regression"
533
+ baseline_dir = root / ".patchi" / "visual_baselines"
534
+
535
+ screenshots = []
536
+ if evidence_dir.is_dir():
537
+ for f in sorted(evidence_dir.glob("*.png"), key=lambda x: x.stat().st_mtime, reverse=True):
538
+ try:
539
+ stat = f.stat()
540
+ is_diff = "_diff" in f.stem
541
+ screenshots.append({
542
+ "name": f.stem,
543
+ "filename": f.name,
544
+ "path": str(f.relative_to(root)),
545
+ "size_kb": round(stat.st_size / 1024, 1),
546
+ "timestamp": datetime.fromtimestamp(stat.st_mtime, UTC).isoformat(),
547
+ "is_diff": is_diff,
548
+ })
549
+ except Exception as _exc:
550
+ _log.warning('list_screenshots failed: %s', _exc)
551
+
552
+ baseline_count = 0
553
+ if baseline_dir.is_dir():
554
+ baseline_count = len(list(baseline_dir.rglob("*.png")))
555
+
556
+ return JSONResponse({
557
+ "ok": True,
558
+ "screenshots": screenshots[:100],
559
+ "total": len(screenshots),
560
+ "baselines": baseline_count,
561
+ "total_size_kb": round(sum(s["size_kb"] for s in screenshots), 1),
562
+ })
563
+
564
+
565
+ @router.get("/screenshot/{filename}")
566
+ async def serve_screenshot(filename: str, request: Request):
567
+ """Serve a screenshot file."""
568
+ root: Path = request.app.state.root
569
+ evidence_dir = root / ".patchi" / "evidence" / "screenshots"
570
+ file_path = evidence_dir / filename
571
+
572
+ if not file_path.suffix == ".png":
573
+ return JSONResponse({"error": "Only .png files allowed"}, status_code=403)
574
+ try:
575
+ file_path.resolve().relative_to(evidence_dir.resolve())
576
+ except ValueError:
577
+ return JSONResponse({"error": "Access denied"}, status_code=403)
578
+
579
+ if not file_path.exists():
580
+ # Fall back to ads gallery viewport subdirs
581
+ for ads_dir in _ads_dirs(root):
582
+ cand = ads_dir / filename
583
+ try:
584
+ cand.resolve().relative_to(ads_dir.resolve())
585
+ except ValueError:
586
+ return JSONResponse({"error": "Access denied"}, status_code=403)
587
+ if cand.is_file():
588
+ file_path = cand
589
+ break
590
+ else:
591
+ return JSONResponse({"error": "Screenshot not found"}, status_code=404)
592
+
593
+ from starlette.responses import FileResponse
594
+ return FileResponse(file_path, media_type="image/png")
595
+
596
+
597
+ @router.get("/ads-gallery")
598
+ async def ads_gallery(request: Request):
599
+ """Marketing gallery groups (viewport → screenshots + videos)."""
600
+ root: Path = request.app.state.root
601
+ groups: list[dict] = []
602
+ for vdir in _ads_dirs(root):
603
+ try:
604
+ shots = sorted(
605
+ (p.name for p in vdir.glob("*.png") if p.is_file()),
606
+ key=lambda n: (vdir / n).stat().st_mtime,
607
+ reverse=True,
608
+ )
609
+ vids = sorted(
610
+ (p.name for p in vdir.glob("*.webm") if p.is_file()),
611
+ key=lambda n: (vdir / n).stat().st_mtime,
612
+ reverse=True,
613
+ )
614
+ except OSError:
615
+ shots, vids = [], []
616
+ if shots or vids:
617
+ groups.append({"viewport": vdir.name, "screenshots": shots, "videos": vids})
618
+ return JSONResponse({"ok": True, "groups": groups})
619
+
620
+
621
+ # ── Full-Page Browser Audit ───────────────────────────────────────────────
622
+
623
+
624
+ class FullAuditRequest(BaseModel):
625
+ url: str = "http://127.0.0.1:1612"
626
+ routes: list[str] | None = None
627
+
628
+
629
+ @router.post("/full-audit")
630
+ async def run_full_audit(req: FullAuditRequest, request: Request):
631
+ """Run full-page browser audit — navigates every page, captures
632
+ HTTP status, console errors, page errors, load time, screenshots,
633
+ broken images, empty links, and page metrics."""
634
+ root: Path = request.app.state.root
635
+
636
+ if _audit_state["running"]:
637
+ return JSONResponse(status_code=409, content={"error": "Audit already running"})
638
+
639
+ _audit_state["running"] = True
640
+ try:
641
+ import asyncio
642
+ result = await asyncio.to_thread(
643
+ _run_audit_sync, req.url, root, req.routes,
644
+ )
645
+ return result
646
+ except Exception as e:
647
+ return JSONResponse(status_code=500, content={"ok": False, "error": str(e)})
648
+ finally:
649
+ _audit_state["running"] = False
650
+
651
+
652
+ @router.get("/full-audit")
653
+ async def get_full_audit(request: Request):
654
+ """Get the last full-page audit results, or current progress if running."""
655
+ if _audit_state["running"]:
656
+ return {"running": True, "progress": _audit_state["progress"]}
657
+
658
+ # Try to load persisted results
659
+ if _audit_state["last_result"]:
660
+ return _audit_state["last_result"]
661
+
662
+ root: Path = request.app.state.root
663
+ audit_file = root / ".patchi" / "evidence" / "browser_audit" / "audit_results.json"
664
+ if audit_file.exists():
665
+ try:
666
+ return json.loads(audit_file.read_text(encoding="utf-8"))
667
+ except Exception as _exc:
668
+ _log.warning('get_full_audit failed: %s', _exc)
669
+
670
+ return {"ok": False, "message": "No audit results yet. Run a full-page audit first."}
671
+
672
+
673
+ @router.get("/full-audit/progress")
674
+ async def full_audit_progress():
675
+ """Poll audit progress while running."""
676
+ if not _audit_state["running"]:
677
+ return {"running": False}
678
+ return {"running": True, "progress": _audit_state["progress"]}
679
+
680
+
681
+ @router.get("/full-audit/screenshot/{filename}")
682
+ async def serve_audit_screenshot(filename: str, request: Request):
683
+ """Serve a full-page audit screenshot."""
684
+ root: Path = request.app.state.root
685
+ evidence_dir = root / ".patchi" / "evidence" / "browser_audit"
686
+ file_path = evidence_dir / filename
687
+
688
+ if not file_path.suffix == ".png":
689
+ return JSONResponse({"error": "Only .png files allowed"}, status_code=403)
690
+ try:
691
+ file_path.resolve().relative_to(evidence_dir.resolve())
692
+ except ValueError:
693
+ return JSONResponse({"error": "Access denied"}, status_code=403)
694
+
695
+ if not file_path.exists():
696
+ return JSONResponse({"error": "Screenshot not found"}, status_code=404)
697
+
698
+ from starlette.responses import FileResponse
699
+ return FileResponse(file_path, media_type="image/png")