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,192 @@
1
+ """
2
+ Channel configuration models and Apprise URL builders for Patchi notifications.
3
+
4
+ Each channel type stores its config in the project's config.json under the
5
+ `notifications` list as a plain dict. This module converts those dicts to
6
+ validated models and builds the Apprise URL strings required to send alerts.
7
+
8
+ Supported types: email, slack, discord, webhook, telegram
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from enum import StrEnum
15
+ from typing import Any
16
+ from urllib.parse import quote
17
+
18
+
19
+ class ChannelType(StrEnum):
20
+ EMAIL = "email"
21
+ SLACK = "slack"
22
+ DISCORD = "discord"
23
+ WEBHOOK = "webhook"
24
+ TELEGRAM = "telegram"
25
+
26
+
27
+ # Severity levels that each channel is willing to receive.
28
+ # Channels with min_severity="high" skip MEDIUM/LOW alerts.
29
+ _SEVERITY_RANK: dict[str, int] = {
30
+ "critical": 0,
31
+ "high": 1,
32
+ "medium": 2,
33
+ "low": 3,
34
+ "info": 4,
35
+ }
36
+
37
+
38
+ @dataclass
39
+ class ChannelConfig:
40
+ """Parsed, validated representation of one entry in config `notifications`."""
41
+
42
+ name: str
43
+ channel_type: ChannelType
44
+ apprise_url: str # ready-to-use Apprise URL
45
+ min_severity: str = "medium" # alerts below this rank are skipped
46
+ in_digest: bool = False # if True, always batch regardless of severity
47
+
48
+ def accepts_severity(self, severity: str) -> bool:
49
+ """Return True if this channel should receive an alert at the given severity."""
50
+ channel_rank = _SEVERITY_RANK.get(self.min_severity, 2)
51
+ alert_rank = _SEVERITY_RANK.get(severity, 4)
52
+ return alert_rank <= channel_rank
53
+
54
+
55
+ # ── Apprise URL builders ───────────────────────────────────────────────────────
56
+
57
+
58
+ def _build_email_url(cfg: dict) -> str:
59
+ """
60
+ Build an Apprise mailto:// URL from email config dict.
61
+ Required keys: user, password, host
62
+ Optional: port, to, from_addr, secure (bool)
63
+ """
64
+ user = cfg["user"]
65
+ password = quote(cfg["password"], safe="")
66
+ host = cfg["host"]
67
+ port = cfg.get("port", "")
68
+ to = cfg.get("to", user)
69
+ secure = cfg.get("secure", True)
70
+ schema = "mailtos" if secure else "mailto"
71
+ port_str = f":{port}" if port else ""
72
+ return f"{schema}://{user}:{password}@{host}{port_str}?to={quote(to, safe='@.')}"
73
+
74
+
75
+ def _build_slack_url(cfg: dict) -> str:
76
+ """
77
+ Build an Apprise slack:// URL.
78
+ Accepts either a raw webhook URL (webhook_url key) or
79
+ tokenA/tokenB/tokenC format (token_a, token_b, token_c keys).
80
+ """
81
+ if "webhook_url" in cfg:
82
+ # Slack incoming webhook — extract path tokens from URL
83
+ # Format: https://hooks.slack.com/services/T.../B.../xxx
84
+ path = cfg["webhook_url"].split("hooks.slack.com/services/")[-1]
85
+ parts = path.strip("/").split("/")
86
+ if len(parts) == 3:
87
+ return f"slack://{parts[0]}/{parts[1]}/{parts[2]}"
88
+ # Non-standard — pass raw URL through Apprise's https:// handler
89
+ return cfg["webhook_url"]
90
+ return "slack://{token_a}/{token_b}/{token_c}".format(**cfg)
91
+
92
+
93
+ def _build_discord_url(cfg: dict) -> str:
94
+ """
95
+ Build an Apprise discord:// URL.
96
+ Required: webhook_id, webhook_token
97
+ (Discord webhook URL format: .../webhooks/{id}/{token})
98
+ """
99
+ if "webhook_url" in cfg:
100
+ parts = cfg["webhook_url"].rstrip("/").split("/")
101
+ # Last two segments are id and token
102
+ return f"discord://{parts[-2]}/{parts[-1]}"
103
+ return "discord://{webhook_id}/{webhook_token}".format(**cfg)
104
+
105
+
106
+ def _build_webhook_url(cfg: dict) -> str:
107
+ """
108
+ Custom HTTP webhook — just pass the URL through directly.
109
+ Apprise supports json:// and xml:// schemas; plain https:// also works.
110
+ """
111
+ url = cfg["url"]
112
+ # If already an apprise-style schema, use as-is
113
+ if any(url.startswith(s) for s in ("json://", "xml://", "form://")):
114
+ return url
115
+ # Convert plain https:// to json:// so Apprise sends JSON body
116
+ return url.replace("https://", "json://", 1).replace("http://", "json://", 1)
117
+
118
+
119
+ def _build_telegram_url(cfg: dict) -> str:
120
+ """
121
+ Build an Apprise tgram:// URL.
122
+ Required: bot_token, chat_id
123
+ """
124
+ return "tgram://{bot_token}/{chat_id}".format(**cfg)
125
+
126
+
127
+ _URL_BUILDERS = {
128
+ ChannelType.EMAIL: _build_email_url,
129
+ ChannelType.SLACK: _build_slack_url,
130
+ ChannelType.DISCORD: _build_discord_url,
131
+ ChannelType.WEBHOOK: _build_webhook_url,
132
+ ChannelType.TELEGRAM: _build_telegram_url,
133
+ }
134
+
135
+
136
+ # ── Public API ─────────────────────────────────────────────────────────────────
137
+
138
+
139
+ def from_config(raw: dict) -> ChannelConfig:
140
+ """
141
+ Parse one raw dict from config `notifications` list into a ChannelConfig.
142
+ Raises ValueError for missing required fields or unknown channel type.
143
+ """
144
+ try:
145
+ channel_type = ChannelType(raw["type"])
146
+ except (KeyError, ValueError) as exc:
147
+ valid = ", ".join(t.value for t in ChannelType)
148
+ raise ValueError(f"Channel type must be one of: {valid}") from exc
149
+
150
+ if "name" not in raw:
151
+ raise ValueError("Channel config missing required key: name")
152
+
153
+ builder = _URL_BUILDERS[channel_type]
154
+ try:
155
+ apprise_url = builder(raw.get("credentials", raw))
156
+ except KeyError as exc:
157
+ raise ValueError(
158
+ f"Channel '{raw['name']}' ({channel_type.value}) missing required field: {exc}"
159
+ ) from exc
160
+
161
+ return ChannelConfig(
162
+ name=raw["name"],
163
+ channel_type=channel_type,
164
+ apprise_url=apprise_url,
165
+ min_severity=raw.get("min_severity", "medium"),
166
+ in_digest=raw.get("in_digest", False),
167
+ )
168
+
169
+
170
+ def load_channels(config: dict) -> list[ChannelConfig]:
171
+ """
172
+ Load and validate all channels from a config dict.
173
+ Skips entries that fail validation (logs name for debugging).
174
+ """
175
+ channels: list[ChannelConfig] = []
176
+ for raw in config.get("notifications", []):
177
+ try:
178
+ channels.append(from_config(raw))
179
+ except ValueError:
180
+ # Corrupt/incomplete entry — skip silently in runtime, surface in `p notify list`
181
+ pass
182
+ return channels
183
+
184
+
185
+ def channel_to_dict(ch: ChannelConfig) -> dict[str, Any]:
186
+ """Serialise a ChannelConfig back for display (never exposes the full apprise_url)."""
187
+ return {
188
+ "name": ch.name,
189
+ "type": ch.channel_type.value,
190
+ "min_severity": ch.min_severity,
191
+ "in_digest": ch.in_digest,
192
+ }
@@ -0,0 +1,141 @@
1
+ """
2
+ Digest queue for Patchi notifications.
3
+
4
+ MEDIUM, LOW, and INFO alerts are batched here instead of firing immediately.
5
+ The queue persists to .patchi/digest.json so alerts survive restarts.
6
+ Flushing sends all queued alerts as a single digest notification per channel.
7
+
8
+ Flush is triggered either:
9
+ - Manually via `p notify flush`
10
+ - Automatically when Notifier.send() is called and the digest window has elapsed
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import time as _time
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ # Severities that feed the digest queue (not immediate)
21
+ DIGEST_SEVERITIES: frozenset[str] = frozenset({"medium", "low", "info"})
22
+
23
+ # Frequency → seconds mapping
24
+ _FREQUENCY_SECONDS: dict[str, int] = {
25
+ "hourly": 3600,
26
+ "6h": 21600,
27
+ "daily": 86400,
28
+ "weekly": 604800,
29
+ }
30
+
31
+ _DIGEST_FILE = ".patchi/digest.json"
32
+
33
+
34
+ def _load_queue(root: Path) -> dict:
35
+ path = root / _DIGEST_FILE
36
+ if not path.exists():
37
+ return {"items": [], "last_flushed": 0.0}
38
+ try:
39
+ with path.open("r", encoding="utf-8") as f:
40
+ return json.load(f)
41
+ except (json.JSONDecodeError, OSError):
42
+ return {"items": [], "last_flushed": 0.0}
43
+
44
+
45
+ def _save_queue(root: Path, data: dict) -> None:
46
+ path = root / _DIGEST_FILE
47
+ path.parent.mkdir(parents=True, exist_ok=True)
48
+ with path.open("w", encoding="utf-8") as f:
49
+ json.dump(data, f, indent=2)
50
+
51
+
52
+ def _frequency_to_seconds(frequency: str) -> int:
53
+ return _FREQUENCY_SECONDS.get(frequency, _FREQUENCY_SECONDS["daily"])
54
+
55
+
56
+ class DigestQueue:
57
+ """
58
+ Persistent queue for batched (MEDIUM/LOW/INFO) notifications.
59
+
60
+ Usage:
61
+ dq = DigestQueue(project_root)
62
+ dq.enqueue("medium", "Title", "Body text")
63
+ if dq.should_flush(digest_frequency):
64
+ items = dq.flush(channels) # returns list of (channel, message) tuples
65
+ """
66
+
67
+ def __init__(self, root: Path) -> None:
68
+ self._root = root
69
+
70
+ def enqueue(self, severity: str, title: str, body: str, data: dict | None = None) -> None:
71
+ """Add one alert to the persistent queue."""
72
+ queue = _load_queue(self._root)
73
+ queue["items"].append(
74
+ {
75
+ "severity": severity,
76
+ "title": title,
77
+ "body": body,
78
+ "data": data or {},
79
+ "queued_at": _time.time(),
80
+ }
81
+ )
82
+ _save_queue(self._root, queue)
83
+
84
+ def should_flush(self, frequency: str) -> bool:
85
+ """Return True if enough time has elapsed since the last flush."""
86
+ queue = _load_queue(self._root)
87
+ elapsed = _time.time() - queue.get("last_flushed", 0.0)
88
+ interval = _frequency_to_seconds(frequency)
89
+ return elapsed >= interval
90
+
91
+ def flush(self) -> list[dict[str, Any]]:
92
+ """
93
+ Drain the queue and return all pending items.
94
+ Resets the queue and updates last_flushed timestamp.
95
+ Returns an empty list if nothing was queued.
96
+ """
97
+ queue = _load_queue(self._root)
98
+ items = queue.get("items", [])
99
+ if not items:
100
+ return []
101
+ _save_queue(self._root, {"items": [], "last_flushed": _time.time()})
102
+ return items
103
+
104
+ def peek(self) -> list[dict[str, Any]]:
105
+ """Return queued items without draining."""
106
+ return _load_queue(self._root).get("items", [])
107
+
108
+ def clear(self) -> int:
109
+ """Remove all queued items without sending. Returns count cleared."""
110
+ queue = _load_queue(self._root)
111
+ count = len(queue.get("items", []))
112
+ _save_queue(self._root, {"items": [], "last_flushed": _time.time()})
113
+ return count
114
+
115
+ @staticmethod
116
+ def build_digest_body(items: list[dict]) -> str:
117
+ """
118
+ Render a human-readable digest body from a list of queued items.
119
+ Groups by severity, most critical first.
120
+ """
121
+ if not items:
122
+ return "No alerts."
123
+
124
+ order = ["medium", "low", "info"]
125
+ grouped: dict[str, list[dict]] = {s: [] for s in order}
126
+ for item in items:
127
+ sev = item.get("severity", "info")
128
+ if sev in grouped:
129
+ grouped[sev].append(item)
130
+
131
+ lines: list[str] = [f"Patchi digest — {len(items)} alert(s)\n"]
132
+ labels = {"medium": "⚠ Medium", "low": "✓ Low", "info": "ℹ Info"}
133
+ for sev in order:
134
+ group = grouped[sev]
135
+ if not group:
136
+ continue
137
+ lines.append(f"\n{labels[sev]} ({len(group)})")
138
+ for item in group:
139
+ lines.append(f" • {item['title']}: {item['body']}")
140
+
141
+ return "\n".join(lines)
@@ -0,0 +1,158 @@
1
+ """
2
+ Escalation tracking for CRITICAL and HIGH Patchi alerts.
3
+
4
+ When an alert fires, it is registered here with a unique ID.
5
+ If the user does not acknowledge it within the configured window,
6
+ escalation fires to the next channel in the notification list.
7
+
8
+ Ack store persists to .patchi/escalation.json.
9
+ `p notify ack [id]` calls EscalationTracker.acknowledge(id).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import threading
16
+ import time as _time
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ # Severities that require acknowledgement tracking
21
+ ESCALATION_SEVERITIES: frozenset[str] = frozenset({"critical", "high"})
22
+
23
+ _ESCALATION_FILE = ".patchi/escalation.json"
24
+ _DEFAULT_ESCALATION_MINS = 30 # escalate after 30 min if unacknowledged
25
+ _POLL_INTERVAL_SECS = 60 # check every 60 seconds
26
+
27
+
28
+ _log = logging.getLogger("patchi.core.escalation")
29
+
30
+
31
+ def _load_store(root: Path) -> dict:
32
+ path = root / _ESCALATION_FILE
33
+ if not path.exists():
34
+ return {"alerts": {}}
35
+ try:
36
+ with path.open("r", encoding="utf-8") as f:
37
+ return json.load(f)
38
+ except (json.JSONDecodeError, OSError):
39
+ return {"alerts": {}}
40
+
41
+
42
+ def _save_store(root: Path, data: dict) -> None:
43
+ path = root / _ESCALATION_FILE
44
+ path.parent.mkdir(parents=True, exist_ok=True)
45
+ with path.open("w", encoding="utf-8") as f:
46
+ json.dump(data, f, indent=2)
47
+
48
+
49
+ class EscalationTracker:
50
+ """
51
+ Tracks unacknowledged CRITICAL/HIGH alerts and escalates them.
52
+
53
+ The background thread polls every 60 seconds and fires escalation
54
+ callbacks for overdue unacknowledged alerts.
55
+
56
+ escalation_fn signature: (alert_id: str, alert: dict, channels: list) -> None
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ root: Path,
62
+ escalation_fn: Any,
63
+ escalation_minutes: int = _DEFAULT_ESCALATION_MINS,
64
+ ) -> None:
65
+ self._root = root
66
+ self._escalation_fn = escalation_fn
67
+ self._escalation_secs = escalation_minutes * 60
68
+ self._stop_event = threading.Event()
69
+ self._thread: threading.Thread | None = None
70
+
71
+ def register(self, alert_id: str, severity: str, title: str, body: str) -> None:
72
+ """Register a new alert as requiring acknowledgement."""
73
+ if severity not in ESCALATION_SEVERITIES:
74
+ return
75
+ store = _load_store(self._root)
76
+ store["alerts"][alert_id] = {
77
+ "severity": severity,
78
+ "title": title,
79
+ "body": body,
80
+ "fired_at": _time.time(),
81
+ "acked": False,
82
+ "escalated": False,
83
+ }
84
+ _save_store(self._root, store)
85
+
86
+ def acknowledge(self, alert_id: str) -> bool:
87
+ """Mark an alert as acknowledged. Returns True if the ID was found."""
88
+ store = _load_store(self._root)
89
+ if alert_id not in store["alerts"]:
90
+ return False
91
+ store["alerts"][alert_id]["acked"] = True
92
+ _save_store(self._root, store)
93
+ return True
94
+
95
+ def pending(self) -> list[dict]:
96
+ """Return all alerts that are unacknowledged and not yet escalated."""
97
+ store = _load_store(self._root)
98
+ return [
99
+ {"id": aid, **alert}
100
+ for aid, alert in store["alerts"].items()
101
+ if not alert.get("acked") and not alert.get("escalated")
102
+ ]
103
+
104
+ def list_all(self) -> list[dict]:
105
+ """Return all tracked alerts (acked and unacked)."""
106
+ store = _load_store(self._root)
107
+ return [{"id": aid, **alert} for aid, alert in store["alerts"].items()]
108
+
109
+ def start(self) -> None:
110
+ """Start the background escalation polling thread."""
111
+ if self._thread and self._thread.is_alive():
112
+ return
113
+ self._stop_event.clear()
114
+ self._thread = threading.Thread(target=self._poll_loop, daemon=True)
115
+ self._thread.start()
116
+
117
+ def stop(self) -> None:
118
+ """Stop the background polling thread."""
119
+ self._stop_event.set()
120
+
121
+ def _poll_loop(self) -> None:
122
+ while not self._stop_event.is_set():
123
+ self._check_overdue()
124
+ self._stop_event.wait(timeout=_POLL_INTERVAL_SECS)
125
+
126
+ def _check_overdue(self) -> None:
127
+ """Fire escalation for any alert past the window."""
128
+ store = _load_store(self._root)
129
+ now = _time.time()
130
+ changed = False
131
+
132
+ for alert_id, alert in store["alerts"].items():
133
+ if alert.get("acked") or alert.get("escalated"):
134
+ continue
135
+ age = now - alert.get("fired_at", now)
136
+ if age >= self._escalation_secs:
137
+ alert["escalated"] = True
138
+ changed = True
139
+ try:
140
+ self._escalation_fn(alert_id, alert)
141
+ except Exception as e:
142
+ _log.warning("EscalationTracker._check_overdue failed: %s", e)
143
+
144
+ if changed:
145
+ _save_store(self._root, store)
146
+
147
+ def purge_old(self, max_age_hours: int = 72) -> int:
148
+ """Remove acked/escalated alerts older than max_age_hours. Returns count removed."""
149
+ store = _load_store(self._root)
150
+ cutoff = _time.time() - (max_age_hours * 3600)
151
+ before = len(store["alerts"])
152
+ store["alerts"] = {
153
+ aid: a
154
+ for aid, a in store["alerts"].items()
155
+ if not (a.get("acked") or a.get("escalated")) or a.get("fired_at", 0) >= cutoff
156
+ }
157
+ _save_store(self._root, store)
158
+ return before - len(store["alerts"])
@@ -0,0 +1,182 @@
1
+ """
2
+ Central notification dispatcher for Patchi.
3
+
4
+ Single entry point: Notifier.send(severity, title, body, data)
5
+
6
+ Routing rules:
7
+ CRITICAL → immediate, all channels, bypass quiet hours, start escalation
8
+ HIGH → immediate, bypass quiet hours, start escalation
9
+ MEDIUM → digest queue unless quiet hours bypassed, respects quiet hours
10
+ LOW/INFO → digest queue only, respects quiet hours
11
+
12
+ Usage:
13
+ notifier = Notifier(project_root)
14
+ notifier.send("critical", "SQL injection found", "in routes/user.py:44")
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import uuid
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import apprise
24
+ from apprise import NotifyType
25
+
26
+ from patchi.core.notifications.channels import ChannelConfig, load_channels
27
+ from patchi.core.notifications.digest import DIGEST_SEVERITIES, DigestQueue
28
+ from patchi.core.notifications.escalation import ESCALATION_SEVERITIES, EscalationTracker
29
+ from patchi.core.notifications.quiet_hours import should_suppress
30
+
31
+ # Severity → Apprise NotifyType mapping
32
+ _APPRISE_TYPE: dict[str, str] = {
33
+ "critical": NotifyType.FAILURE,
34
+ "high": NotifyType.WARNING,
35
+ "medium": NotifyType.INFO,
36
+ "low": NotifyType.SUCCESS,
37
+ "info": NotifyType.SUCCESS,
38
+ }
39
+
40
+
41
+ class Notifier:
42
+ """
43
+ Dispatch Patchi alerts to configured channels.
44
+
45
+ Instantiate once per command invocation or keep alive in watch mode.
46
+ Thread-safe for the escalation background thread.
47
+ """
48
+
49
+ def __init__(self, root: Path, config: dict) -> None:
50
+ self._root = root
51
+ self._config = config
52
+ self._channels = load_channels(config)
53
+ self._digest = DigestQueue(root)
54
+ self._tracker = EscalationTracker(
55
+ root,
56
+ escalation_fn=self._escalate,
57
+ escalation_minutes=config.get("escalation_minutes", 30),
58
+ )
59
+
60
+ def send(
61
+ self,
62
+ severity: str,
63
+ title: str,
64
+ body: str,
65
+ data: dict[str, Any] | None = None,
66
+ ) -> int:
67
+ """
68
+ Route one alert by severity.
69
+ Returns the number of channels that received the alert immediately.
70
+ """
71
+ data = data or {}
72
+ channels = self._eligible_channels(severity)
73
+
74
+ if not channels:
75
+ return 0
76
+
77
+ in_digest_mode = severity in DIGEST_SEVERITIES
78
+ suppressed = should_suppress(severity, self._config.get("quiet_hours", {}))
79
+
80
+ if suppressed or in_digest_mode:
81
+ self._digest.enqueue(severity, title, body, data)
82
+ return 0
83
+
84
+ # Immediate fire
85
+ sent = self._fire(channels, severity, title, body)
86
+
87
+ # Register for escalation if CRITICAL or HIGH
88
+ if severity in ESCALATION_SEVERITIES and sent > 0:
89
+ alert_id = str(uuid.uuid4())[:8]
90
+ self._tracker.register(alert_id, severity, title, body)
91
+
92
+ # Auto-flush digest if window has elapsed
93
+ freq = self._config.get("digest_frequency", "daily")
94
+ if self._digest.should_flush(freq):
95
+ self.flush_digest()
96
+
97
+ return sent
98
+
99
+ def flush_digest(self) -> int:
100
+ """
101
+ Flush the digest queue to all channels configured with in_digest=True
102
+ or that accept MEDIUM severity. Returns number of channels notified.
103
+ """
104
+ items = self._digest.flush()
105
+ if not items:
106
+ return 0
107
+
108
+ body = DigestQueue.build_digest_body(items)
109
+ title = f"Patchi digest — {len(items)} alert(s)"
110
+ channels = [ch for ch in self._channels if ch.accepts_severity("medium")]
111
+ return self._fire(channels, "medium", title, body)
112
+
113
+ def test(self, channel_name: str | None = None) -> list[tuple[str, bool]]:
114
+ """
115
+ Send a test alert to one or all channels.
116
+ Returns list of (channel_name, success) tuples.
117
+ """
118
+ targets = (
119
+ [ch for ch in self._channels if ch.name == channel_name]
120
+ if channel_name
121
+ else self._channels
122
+ )
123
+ results: list[tuple[str, bool]] = []
124
+ for ch in targets:
125
+ ok = _apprise_send(
126
+ ch.apprise_url,
127
+ "info",
128
+ "Patchi test alert",
129
+ "If you see this, the channel is working.",
130
+ )
131
+ results.append((ch.name, ok))
132
+ return results
133
+
134
+ def acknowledge(self, alert_id: str) -> bool:
135
+ """Acknowledge a CRITICAL/HIGH alert by its short ID."""
136
+ return self._tracker.acknowledge(alert_id)
137
+
138
+ def pending_escalations(self) -> list[dict]:
139
+ """Return all unacknowledged, unescalated alerts."""
140
+ return self._tracker.pending()
141
+
142
+ # ── Internal helpers ───────────────────────────────────────────────────────
143
+
144
+ def _eligible_channels(self, severity: str) -> list[ChannelConfig]:
145
+ return [ch for ch in self._channels if ch.accepts_severity(severity)]
146
+
147
+ def _fire(
148
+ self,
149
+ channels: list[ChannelConfig],
150
+ severity: str,
151
+ title: str,
152
+ body: str,
153
+ ) -> int:
154
+ notify_type = _APPRISE_TYPE.get(severity, NotifyType.INFO)
155
+ sent = 0
156
+ for ch in channels:
157
+ if _apprise_send(ch.apprise_url, notify_type, title, body):
158
+ sent += 1
159
+ return sent
160
+
161
+ def _escalate(self, alert_id: str, alert: dict) -> None:
162
+ """
163
+ Escalation callback — fires remaining channels that haven't received the alert.
164
+ Called by EscalationTracker on the background thread.
165
+ """
166
+ channels = self._eligible_channels(alert.get("severity", "high"))
167
+ # Skip first channel (already fired) — escalate to the rest
168
+ escalation_targets = channels[1:] if len(channels) > 1 else channels
169
+ if escalation_targets:
170
+ self._fire(
171
+ escalation_targets,
172
+ alert.get("severity", "high"),
173
+ f"[ESCALATED] {alert.get('title', 'Alert')}",
174
+ alert.get("body", ""),
175
+ )
176
+
177
+
178
+ def _apprise_send(url: str, notify_type: str, title: str, body: str) -> bool:
179
+ """Send one notification via Apprise. Returns True on success."""
180
+ ap = apprise.Apprise()
181
+ ap.add(url)
182
+ return bool(ap.notify(title=title, body=body, notify_type=notify_type))