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,858 @@
1
+ """
2
+ Agent Coordinator for Patchi.
3
+
4
+ PRIMARY ENGINE: ThreadPoolExecutor (stdlib, zero extra dependencies).
5
+ - Scanner agents run in parallel (they are all read-only, never conflict).
6
+ - Fix agents always run sequentially (prevents file conflicts).
7
+ - Test agents run on their own independent track.
8
+
9
+ OPTIONAL LLM COORDINATION:
10
+ When an AI key is configured, the coordinator can ask the LLM which agents
11
+ to prioritise for a given project context. This uses direct httpx calls to
12
+ the configured provider — no third-party orchestration library required.
13
+ Falls back to full parallel execution if no LLM is configured.
14
+
15
+ Design:
16
+ - Agents are stateless. All state lives in AgentInput / AgentResult.
17
+ - Coordinator never writes to disk.
18
+ - Results always returned as list[AgentResult] regardless of path taken.
19
+ - Progress callback fires after each agent completes.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ # ── Progress event ─────────────────────────────────────────────────────────────
25
+ import logging
26
+ import queue
27
+ import threading
28
+ from collections import defaultdict
29
+ from collections.abc import Callable
30
+ from concurrent.futures import Future, ThreadPoolExecutor, as_completed
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ from loguru import logger
36
+
37
+ from patchi.core import config as cfg
38
+ from patchi.core import memory as mem
39
+ from patchi.core.agents.base import (
40
+ AgentGroup,
41
+ AgentInput,
42
+ AgentResult,
43
+ AgentStatus,
44
+ BaseAgent,
45
+ Finding,
46
+ list_agents,
47
+ )
48
+ from patchi.core.agents.cache import AgentCache
49
+
50
+ _log = logging.getLogger("patchi.agents.coordinator")
51
+
52
+
53
+ def _run_ai_bounded(func, timeout: float):
54
+ """Call `func on a daemon thread, waiting at most `timeout` seconds for it.
55
+
56
+ A worker abandoned mid-call (e.g. an LLM call we no longer wait for) must
57
+ never keep the interpreter alive at exit: `concurrent.futures` joins its
58
+ threads at `_python_exit`, so a hung non-daemon worker blocks process
59
+ shutdown indefinitely. A daemon thread + bounded queue get avoids that
60
+ entirely and needs no private executor internals.
61
+ """
62
+ q = queue.Queue(maxsize=1)
63
+ t = threading.Thread(target=lambda f=func: q.put(f()), daemon=True)
64
+ t.start()
65
+ try:
66
+ return q.get(timeout=timeout)
67
+ except Exception:
68
+ return None
69
+
70
+
71
+ @dataclass
72
+ class CoordinatorProgress:
73
+ agent_name: str
74
+ status: AgentStatus
75
+ current: int
76
+ total: int
77
+ finding_count: int = 0
78
+ message: str = ""
79
+
80
+
81
+ # ── Coordinator ────────────────────────────────────────────────────────────────
82
+
83
+
84
+ class RunMode:
85
+ PARALLEL = "parallel"
86
+ SEQUENTIAL = "sequential"
87
+ PROCESS = "process"
88
+ AUTO = "auto"
89
+
90
+
91
+ class Coordinator:
92
+ """
93
+ Spawns and collects agent results.
94
+
95
+ Usage:
96
+ coord = Coordinator(project_root)
97
+ results = coord.run_group(AgentGroup.SCANNER)
98
+ results = coord.run_agents(["CoreScanner", "EnvScanner"])
99
+ """
100
+
101
+ # Circuit breaker — tracks consecutive failures per agent (LIMIT-04)
102
+ # Key: agent_name, Value: consecutive_failures
103
+ # NOTE: Moved to instance-level in __init__ to avoid cross-instance corruption.
104
+ _CB_THRESHOLD = 3
105
+
106
+ def __init__(
107
+ self,
108
+ root: Path,
109
+ on_progress: Callable[[CoordinatorProgress], None] | None = None,
110
+ run_mode: str = RunMode.AUTO,
111
+ ):
112
+ self.run_mode = run_mode
113
+ self.root = root
114
+ self.on_progress = on_progress or (lambda _: None)
115
+ self.last_security_report: Any = None
116
+ self._circuit_breaker: dict[str, int] = {}
117
+ self._active_domains: list[str] = [] # git-diff on-demand domains
118
+
119
+ try:
120
+ self._config = cfg.load(root)
121
+ except Exception as e:
122
+ _log.warning("Coordinator.__init__ failed: %s", e)
123
+ self._config = {}
124
+
125
+ try:
126
+ self._brain = mem.get_brain(root)
127
+ except Exception as e:
128
+ _log.warning("Coordinator.__init__ failed: %s", e)
129
+ self._brain = {}
130
+
131
+ def _build_llm(self) -> dict | None:
132
+ """Build LLM config for deep scan analysis.
133
+
134
+ Returns ai config dict if an AI provider is available (Ollama, API key,
135
+ or horde fallback), or None if no provider is reachable.
136
+
137
+ Used by scan_cmd.py --deep to get a ready-to-use LLM context.
138
+ """
139
+ ai_cfg = self._config.get("ai", {})
140
+
141
+ # Local Ollama
142
+ if ai_cfg.get("local_model_name"):
143
+ return {"provider": "ollama", "config": self._config}
144
+
145
+ # API keys
146
+ keys = ai_cfg.get("keys", [])
147
+ if keys:
148
+ for k in keys:
149
+ env_var = k.get("env_var", "")
150
+ import os
151
+
152
+ if os.environ.get(env_var) or os.environ.get("_PATCHI_ENV_LOADED"):
153
+ return {"provider": "api", "config": self._config}
154
+ return {"provider": "api", "config": self._config}
155
+
156
+ return None
157
+
158
+ def reset_circuit_breaker(self, agent_name: str | None = None) -> list[str]:
159
+ """Reset circuit breaker for one or all agents. Returns list of reset names."""
160
+ if agent_name:
161
+ self._circuit_breaker.pop(agent_name, None)
162
+ return [agent_name]
163
+ reset = list(self._circuit_breaker.keys())
164
+ self._circuit_breaker.clear()
165
+ return reset
166
+
167
+ @property
168
+ def circuit_broken_agents(self) -> list[str]:
169
+ """Return list of agents currently blocked by circuit breaker."""
170
+ return [
171
+ name
172
+ for name, failures in self._circuit_breaker.items()
173
+ if failures >= self._CB_THRESHOLD
174
+ ]
175
+
176
+ def _is_circuit_broken(self, agent_name: str) -> bool:
177
+ return self._circuit_breaker.get(agent_name, 0) >= self._CB_THRESHOLD
178
+
179
+ def run_group(
180
+ self,
181
+ group: AgentGroup,
182
+ scope: list[str] | None = None,
183
+ extra: dict | None = None,
184
+ ) -> list[AgentResult]:
185
+ try:
186
+ from patchi.core.security.governance import patchi_action_log
187
+
188
+ patchi_action_log(
189
+ self.root,
190
+ "agent_group_start",
191
+ group.value if hasattr(group, "value") else str(group),
192
+ detail=f"scope={len(scope or [])} files",
193
+ )
194
+ except Exception as e:
195
+ _log.warning("Coordinator.run_group failed: %s", e)
196
+ # ── On-demand domain filtering ────────────────────────────────────────
197
+ agent_classes = list_agents(group)
198
+ if self._active_domains:
199
+ try:
200
+ from patchi.core.security.domain_activator_v2 import (
201
+ DomainActivatorV2,
202
+ )
203
+
204
+ activator = DomainActivatorV2(self.root)
205
+ relevant = activator.get_relevant_agents(self._active_domains)
206
+ # Always run core agents (PreCheckAgent, etc.)
207
+ core = ["PreCheckAgent", "PlanAuditorAgent"]
208
+ relevant.extend(core)
209
+ agent_classes = [a for a in agent_classes if getattr(a, "name", "") in relevant]
210
+ _log.info(
211
+ "Domain filter: %d → %d agents (domains=%s)",
212
+ len(list_agents(group)),
213
+ len(agent_classes),
214
+ self._active_domains[:5],
215
+ )
216
+ except Exception as e:
217
+ _log.debug("Domain filter failed: %s", e)
218
+
219
+ results = self._run_classes(agent_classes, scope=scope, extra=extra)
220
+
221
+ # Annotate findings with git blame info (all agent groups)
222
+ if self.root and results:
223
+ try:
224
+ from patchi.core.brain.git_aware import annotate_findings_with_blame
225
+
226
+ for r in results:
227
+ if r.findings:
228
+ annotate_findings_with_blame(r.findings, self.root, max_workers=2)
229
+ except Exception as e:
230
+ _log.warning("Coordinator.run_group failed: %s", e)
231
+
232
+ if group == AgentGroup.SECURITY:
233
+ try:
234
+ from patchi.core.security.orchestrator import SecurityOrchestrator
235
+
236
+ self.last_security_report = SecurityOrchestrator().correlate(results)
237
+ except Exception as e:
238
+ _log.warning("Coordinator.run_group failed: %s", e)
239
+
240
+ # NEW: DetectionPipeline + ConfidenceGate + Defense Layer
241
+ if self._config.get("pipeline", {}).get("enabled", False) and self.last_security_report:
242
+ try:
243
+ from patchi.core.security.defense_layer import DefenseLayer
244
+ from patchi.core.security.detection_pipeline import DetectionPipeline
245
+
246
+ # Wire brain context for context-aware classification
247
+ try:
248
+ from patchi.core.brain.brain_context import get_brain_context
249
+ _brain_ctx = get_brain_context(self.root, self._config)
250
+ except Exception:
251
+ _brain_ctx = None
252
+ pipeline = DetectionPipeline(self.root, self._config, brain_context=_brain_ctx)
253
+ gated = pipeline.process(self.last_security_report)
254
+ self.last_gated_report = gated
255
+ if gated.findings:
256
+ defense = DefenseLayer(self.root, self._config)
257
+ defend_results = defense.defend_all(gated.defend)
258
+ self.last_defend_results = defend_results
259
+ except Exception as e:
260
+ _log.warning("Coordinator.run_group failed: %s", e)
261
+ import traceback
262
+
263
+ logger.warning(f"Pipeline error: {traceback.format_exc()}")
264
+ return results
265
+
266
+ def run_agents(
267
+ self,
268
+ agent_names: list[str],
269
+ scope: list[str] | None = None,
270
+ extra: dict | None = None,
271
+ ) -> list[AgentResult]:
272
+ from patchi.core.agents.base import get_agent
273
+
274
+ try:
275
+ from patchi.core.security.governance import patchi_action_log
276
+
277
+ patchi_action_log(
278
+ self.root,
279
+ "agent_run_start",
280
+ ",".join(agent_names),
281
+ detail=f"scope={len(scope or [])} files",
282
+ )
283
+ except Exception as e:
284
+ _log.warning("Coordinator.run_agents failed: %s", e)
285
+ classes = [get_agent(n) for n in agent_names if get_agent(n)]
286
+ return self._run_classes(classes, scope=scope, extra=extra)
287
+
288
+ # Side file scanner names — excluded when side=False
289
+ _SIDE_SCANNERS = frozenset(
290
+ {
291
+ "SideFileScanner",
292
+ "DependencyScanner",
293
+ "EnvScanner",
294
+ "RouteGraphScanner",
295
+ "CommentScanner",
296
+ }
297
+ )
298
+
299
+ def set_active_domains(self, domains: list[str]) -> None:
300
+ """Set domains for on-demand activation (from git diff)."""
301
+ self._active_domains = domains
302
+
303
+ def run_all_scanners(
304
+ self, scope: list[str] | None = None, side: bool = True
305
+ ) -> list[AgentResult]:
306
+ if side:
307
+ return self.run_group(AgentGroup.SCANNER, scope=scope)
308
+ # Filter out side scanners — source-only scan
309
+ agents = list_agents(AgentGroup.SCANNER)
310
+ filtered = [a for a in agents if getattr(a, "name", "") not in self._SIDE_SCANNERS]
311
+ return self._run_classes(filtered, scope=scope)
312
+
313
+ def _build_input(self, scope: list[str] | None, extra: dict | None) -> AgentInput:
314
+ brain = self._brain or {}
315
+ project_ctx = brain.get("project_context") or {}
316
+ return AgentInput(
317
+ root=self.root,
318
+ scope=scope or [],
319
+ brain=brain,
320
+ config=self._config,
321
+ extra=extra or {},
322
+ purpose=brain.get("project_purpose", "") or project_ctx.get("purpose", ""),
323
+ domain=brain.get("project_domain", "") or project_ctx.get("domain", ""),
324
+ context=project_ctx,
325
+ active_domains=brain.get("active_security_domains", []),
326
+ on_message=None,
327
+ )
328
+
329
+ def _maybe_reorder(self, agent_classes: list[type[BaseAgent]]) -> list[type[BaseAgent]]:
330
+ if len(agent_classes) <= 1:
331
+ return agent_classes
332
+ brain = self._brain or {}
333
+ has_security = any(
334
+ getattr(a, "name", "")
335
+ in ("EnvScanner", "SecretScanner", "ConfigAuditAgent", "TaintAnalyzer", "CORSAuditor")
336
+ for a in agent_classes
337
+ )
338
+ if not has_security:
339
+ return agent_classes
340
+ languages = brain.get("languages", {})
341
+ fw_raw = brain.get("frameworks", brain.get("framework", ""))
342
+ if isinstance(fw_raw, list):
343
+ fw_raw = fw_raw[0] if fw_raw else ""
344
+ if isinstance(fw_raw, dict):
345
+ framework = fw_raw.get("name", "") or ""
346
+ elif isinstance(fw_raw, str):
347
+ framework = fw_raw
348
+ else:
349
+ framework = str(fw_raw) if fw_raw else ""
350
+
351
+ # Offline scans must never attempt an LLM call — avoid even spawning
352
+ # the bounded worker thread.
353
+ import os
354
+
355
+ if os.environ.get("PATCHI_OFFLINE"):
356
+ return agent_classes
357
+
358
+ # Cache key: agent set + framework so we only call LLM when something changes
359
+ agent_names_frozen = frozenset(getattr(a, "name", str(a)) for a in agent_classes)
360
+ cache_key = (agent_names_frozen, framework)
361
+ cached = getattr(self, "_reorder_cache", {})
362
+ if cache_key in cached:
363
+ ordered_names = cached[cache_key]
364
+ name_map = {getattr(a, "name", ""): a for a in agent_classes}
365
+ reordered = [name_map[n] for n in ordered_names if n in name_map]
366
+ remaining = [
367
+ a for a in agent_classes if getattr(a, "name", "") not in set(ordered_names)
368
+ ]
369
+ return reordered + remaining
370
+
371
+ prompt = (
372
+ f"Project: {languages} files, framework: {framework}\n"
373
+ f"Agents: {', '.join(getattr(a, 'name', str(a)) for a in agent_classes)}\n"
374
+ f"Prioritise critical agents first for this project type. Return ONLY a comma-separated list of agent names in desired order."
375
+ )
376
+ try:
377
+ from patchi.core.ai.client import call_ai
378
+
379
+ # Bounded call: an unreachable fallback provider (e.g. AI Horde
380
+ # DNS hang on Windows) must never freeze the scan. Time out and
381
+ # fall back to the original order. The daemon worker thread plus
382
+ # call_ai's own overall timeout mean even a hung provider can
383
+ # never keep the process alive past the 15s bound — not even at
384
+ # interpreter exit, when concurrent.futures joins every non-daemon
385
+ # worker.
386
+ result = _run_ai_bounded(
387
+ lambda: call_ai(
388
+ self._config,
389
+ "You are a project analysis coordinator.",
390
+ prompt,
391
+ max_tokens=200,
392
+ timeout=15,
393
+ ),
394
+ timeout=15,
395
+ )
396
+
397
+ if result:
398
+ ordered = [n.strip() for n in result.split(",")]
399
+ name_map = {getattr(a, "name", ""): a for a in agent_classes}
400
+ reordered = [name_map[n] for n in ordered if n in name_map]
401
+ remaining = [a for a in agent_classes if getattr(a, "name", "") not in ordered]
402
+ if reordered:
403
+ # Store in cache
404
+ if not hasattr(self, "_reorder_cache"):
405
+ self._reorder_cache: dict = {}
406
+ self._reorder_cache[cache_key] = ordered
407
+ return reordered + remaining
408
+ except Exception as e:
409
+ _log.debug("Coordinator._maybe_reorder failed: %s", e)
410
+ return agent_classes
411
+
412
+ def _run_classes(
413
+ self,
414
+ agent_classes: list[type[BaseAgent]],
415
+ scope: list[str] | None = None,
416
+ extra: dict | None = None,
417
+ ) -> list[AgentResult]:
418
+ if not agent_classes:
419
+ return []
420
+
421
+ agent_classes = self._maybe_reorder(agent_classes)
422
+ # Filter out circuit-broken agents (LIMIT-04)
423
+ filtered = []
424
+ for cls in agent_classes:
425
+ name = getattr(cls, "name", str(cls))
426
+ if self._is_circuit_broken(name):
427
+ continue
428
+ filtered.append(cls)
429
+ agent_classes = filtered
430
+
431
+ # Init agent result cache
432
+ cache = AgentCache(self.root)
433
+ use_cache = not self._config.get("pipeline", {}).get("no_agent_cache", False)
434
+
435
+ inp = self._build_input(scope, extra)
436
+ total = len(agent_classes)
437
+ completed = [0]
438
+
439
+ def on_done(result: AgentResult) -> None:
440
+ completed[0] += 1
441
+ self.on_progress(
442
+ CoordinatorProgress(
443
+ agent_name=result.agent_name,
444
+ status=result.status,
445
+ current=completed[0],
446
+ total=total,
447
+ finding_count=result.finding_count,
448
+ )
449
+ )
450
+ # Update circuit breaker (LIMIT-04)
451
+ if result.status == AgentStatus.FAILED:
452
+ self._circuit_breaker[result.agent_name] = (
453
+ self._circuit_breaker.get(result.agent_name, 0) + 1
454
+ )
455
+ else:
456
+ self._circuit_breaker[result.agent_name] = 0 # Reset on success
457
+ try:
458
+ mem.save_scan_result(
459
+ result.agent_name,
460
+ {
461
+ "status": result.status.value,
462
+ "duration_ms": result.duration_ms,
463
+ "finding_count": result.finding_count,
464
+ "files_scanned": result.files_scanned,
465
+ "errors": result.errors[:3],
466
+ "findings": [f.to_dict() for f in result.findings],
467
+ "circuit_broken": self._is_circuit_broken(result.agent_name),
468
+ },
469
+ self.root,
470
+ )
471
+ from patchi.core.security.governance import patchi_action_log
472
+
473
+ patchi_action_log(
474
+ self.root,
475
+ "agent_complete",
476
+ result.agent_name,
477
+ agent=result.agent_name,
478
+ detail=f"{result.finding_count} findings, {result.duration_ms}ms",
479
+ status="ok" if result.status == AgentStatus.DONE else "error",
480
+ )
481
+ except Exception as e:
482
+ _log.warning("Coordinator.on_done failed: %s", e)
483
+
484
+ # Check cache for each agent — skip if valid cached result exists
485
+ agent_classes_run: list[type[BaseAgent]] = []
486
+ cached_results: list[AgentResult] = []
487
+
488
+ if use_cache:
489
+ for cls in agent_classes:
490
+ cached = cache.get(cls.name)
491
+ if cached is not None:
492
+ cached_results.append(cached)
493
+ on_done(cached)
494
+ else:
495
+ agent_classes_run.append(cls)
496
+ else:
497
+ agent_classes_run = list(agent_classes)
498
+
499
+ if not agent_classes_run:
500
+ return cached_results
501
+
502
+ has_fix_agents = any(a.group == AgentGroup.FIX for a in agent_classes_run)
503
+ if has_fix_agents:
504
+ # Fix agents always run sequentially (spec: non-negotiable)
505
+ run_results = _run_sequential(agent_classes_run, inp, on_done)
506
+ elif getattr(self, "run_mode", RunMode.AUTO) == RunMode.SEQUENTIAL:
507
+ run_results = _run_sequential(agent_classes_run, inp, on_done)
508
+ elif getattr(self, "run_mode", RunMode.AUTO) == RunMode.PROCESS:
509
+ run_results = _run_process_parallel(
510
+ agent_classes_run, inp, on_done, self._config, self.root
511
+ )
512
+ elif self._config.get("scan_bus", {}).get("enabled", True):
513
+ run_results = _run_scan_bus(
514
+ agent_classes_run, inp, on_done, self._config, self.root
515
+ )
516
+ else:
517
+ run_results = _run_parallel(agent_classes_run, inp, on_done, self._config)
518
+
519
+ # Store results in cache
520
+ if use_cache:
521
+ for r in run_results:
522
+ cache.put(r.agent_name, r)
523
+
524
+ # Invalidate health score cache so the next status call picks up fresh data
525
+ try:
526
+ from patchi.core.health import invalidate_cache
527
+ invalidate_cache()
528
+ except Exception:
529
+ pass
530
+
531
+ return cached_results + run_results
532
+
533
+
534
+ # ── Execution engines ──────────────────────────────────────────────────────────
535
+
536
+
537
+ def _run_scan_bus(
538
+ agent_classes: list[type[BaseAgent]],
539
+ inp: AgentInput,
540
+ on_done: Callable[[AgentResult], None],
541
+ config: dict,
542
+ root: Path,
543
+ ) -> list[AgentResult]:
544
+ """ScanBus path: one shared corpus + FindingBus merge across shard workers.
545
+
546
+ Behavior-preserving vs _run_parallel: same agents, same full input, same
547
+ on_done (circuit breaker + save_scan_result). Fix agents never arrive here
548
+ (sequenced earlier); SEQUENTIAL/PROCESS modes bypass this branch.
549
+ """
550
+ from patchi.core.scan_bus import QueueRunner, ScanBus
551
+
552
+ sb = config.get("scan_bus", {})
553
+ runner = QueueRunner(ScanBus(root, shard_count=int(sb.get("shards", 4) or 4)))
554
+ inp.extra["scan_bus"] = runner.scan_bus
555
+ inp.extra["finding_bus"] = runner.finding_bus
556
+ return runner.run(agent_classes, lambda cls: _run_agent_safe(cls(), inp), on_done)
557
+
558
+
559
+ def _run_parallel(
560
+ agent_classes: list[type[BaseAgent]],
561
+ inp: AgentInput,
562
+ on_done: Callable[[AgentResult], None],
563
+ config: dict,
564
+ ) -> list[AgentResult]:
565
+ """ThreadPoolExecutor — I/O-bound agents benefit from threads."""
566
+ device_tier = config.get("device_tier", "mid")
567
+ from patchi.core.constants import DeviceTier
568
+
569
+ try:
570
+ max_workers = DeviceTier(device_tier).max_parallel_agents()
571
+ except ValueError:
572
+ max_workers = 8
573
+
574
+ results: list[AgentResult] = []
575
+ futures: dict[Future, str] = {}
576
+ # Per-agent timeouts from agent class attribute (LIMIT-04)
577
+ timeout_map: dict[Future, int] = {}
578
+
579
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
580
+ for cls in agent_classes:
581
+ agent_timeout = getattr(cls, "timeout", 120)
582
+ future = executor.submit(_run_agent_safe, cls(), inp)
583
+ futures[future] = cls.name
584
+ timeout_map[future] = agent_timeout
585
+
586
+ for future in as_completed(futures):
587
+ agent_timeout = timeout_map.get(future, 120)
588
+ try:
589
+ result = future.result(timeout=agent_timeout)
590
+ except Exception as e:
591
+ result = AgentResult(
592
+ agent_name=futures[future],
593
+ agent_group=AgentGroup.SCANNER,
594
+ status=AgentStatus.FAILED,
595
+ errors=[f"Coordinator caught: {e}"],
596
+ )
597
+ on_done(result)
598
+ results.append(result)
599
+
600
+ return results
601
+
602
+
603
+ def _process_worker_init(cache: Any) -> None:
604
+ """ProcessPoolExecutor initializer — runs in EACH worker before any task.
605
+
606
+ Installs the parent-built ScanCache as the shared read-only scan cache for
607
+ this worker (one walk in the parent replaces N parallel re-walks), and
608
+ resets the parse-once tree cache so every worker starts cold with a
609
+ bounded, per-process tree cache.
610
+ """
611
+ try:
612
+ from patchi.core.agents.scan_cache import activate_worker_cache
613
+
614
+ activate_worker_cache(cache)
615
+ except Exception as e:
616
+ _log.warning("Coordinator._process_worker_init failed: %s", e)
617
+
618
+
619
+ def _process_worker_run(cls: Any, inp: AgentInput) -> AgentResult:
620
+ """Module-level worker task: instantiate the agent and run it safely.
621
+
622
+ Module-level so Windows spawn can pickle it by reference. Agent classes
623
+ pickle by qualified name; AgentInput/AgentResult are plain dataclasses.
624
+ """
625
+ try:
626
+ return _run_agent_safe(cls(), inp)
627
+ except Exception as e:
628
+ return AgentResult(
629
+ agent_name=getattr(cls, "name", str(cls)),
630
+ agent_group=getattr(cls, "group", AgentGroup.SCANNER),
631
+ status=AgentStatus.FAILED,
632
+ errors=[f"Process worker failed: {e}"],
633
+ )
634
+
635
+
636
+ def _run_process_parallel(
637
+ agent_classes: list[type[BaseAgent]],
638
+ inp: AgentInput,
639
+ on_done: Callable[[AgentResult], None],
640
+ config: dict,
641
+ root: Path,
642
+ ) -> list[AgentResult]:
643
+ """ProcessPoolExecutor fanout — CPU-bound agents scale past the GIL.
644
+
645
+ The parent builds the shared read-only ScanCache exactly once (one
646
+ filesystem walk + content hashes) and hands the same immutable listing to
647
+ every worker's initializer; per-worker parse results are deduplicated by
648
+ languages.parse_source's content-hash cache. This is the "single-pass
649
+ scan" extended across processes.
650
+
651
+ Falls back to the thread pool on any pool-setup failure (spawn-pickle
652
+ edge cases, interactive interpreters without a __main__ guard, exotic
653
+ environments) — the process pool is a performance mode, never a
654
+ correctness fork. Fix agents never take this path (sequencing is
655
+ non-negotiable).
656
+ """
657
+ device_tier = config.get("device_tier", "mid")
658
+ from patchi.core.constants import DeviceTier
659
+
660
+ try:
661
+ max_workers = DeviceTier(device_tier).max_parallel_agents()
662
+ except ValueError:
663
+ max_workers = 8
664
+ max_workers = max(1, min(max_workers, 8))
665
+
666
+ try:
667
+ from concurrent.futures import ProcessPoolExecutor
668
+
669
+ from patchi.core.agents.scan_cache import build_scan_cache
670
+
671
+ cache = build_scan_cache(root)
672
+ except Exception as e:
673
+ _log.warning("Coordinator process pool unavailable (%s) - using threads", e)
674
+ return _run_parallel(agent_classes, inp, on_done, config)
675
+
676
+ results: list[AgentResult] = []
677
+ try:
678
+ with ProcessPoolExecutor(
679
+ max_workers=max_workers,
680
+ initializer=_process_worker_init,
681
+ initargs=(cache,),
682
+ ) as executor:
683
+ futures: dict[Future, str] = {}
684
+ timeout_map: dict[Future, int] = {}
685
+ for cls in agent_classes:
686
+ agent_timeout = getattr(cls, "timeout", 120)
687
+ future = executor.submit(_process_worker_run, cls, inp)
688
+ futures[future] = cls.name
689
+ timeout_map[future] = agent_timeout
690
+
691
+ for future in as_completed(futures):
692
+ agent_timeout = timeout_map.get(future, 120)
693
+ try:
694
+ result = future.result(timeout=agent_timeout)
695
+ except Exception as e:
696
+ result = AgentResult(
697
+ agent_name=futures[future],
698
+ agent_group=AgentGroup.SCANNER,
699
+ status=AgentStatus.FAILED,
700
+ errors=[f"Coordinator caught: {e}"],
701
+ )
702
+ on_done(result)
703
+ results.append(result)
704
+ except Exception as e:
705
+ _log.warning("Coordinator process pool crashed (%s) - falling back to threads", e)
706
+ return _run_parallel(agent_classes, inp, on_done, config)
707
+
708
+ return results
709
+
710
+
711
+ def _run_fix_agents_batched(
712
+ agent_classes: list[type[BaseAgent]],
713
+ inp: AgentInput,
714
+ on_done: Callable[[AgentResult], None],
715
+ config: dict,
716
+ ) -> list[AgentResult]:
717
+ """Run fix agents with file-based batching.
718
+
719
+ Agents targeting different files run in parallel.
720
+ Agents targeting the same file run sequentially.
721
+ """
722
+ device_tier = config.get("device_tier", "mid")
723
+ from patchi.core.constants import DeviceTier
724
+
725
+ try:
726
+ max_workers = DeviceTier(device_tier).max_parallel_agents()
727
+ except ValueError:
728
+ max_workers = 8
729
+
730
+ batches = _group_fix_agents_by_file(agent_classes)
731
+
732
+ if len(batches) <= 1:
733
+ return _run_sequential(agent_classes, inp, on_done)
734
+
735
+ results: list[AgentResult] = []
736
+
737
+ with ThreadPoolExecutor(max_workers=min(max_workers, len(batches))) as executor:
738
+ batch_futures: dict[Future, list[str]] = {}
739
+ for batch in batches:
740
+ future = executor.submit(_run_sequential, batch, inp, on_done)
741
+ batch_futures[future] = [c.name for c in batch]
742
+
743
+ for future in as_completed(batch_futures):
744
+ try:
745
+ batch_results = future.result(timeout=300)
746
+ results.extend(batch_results)
747
+ except Exception as e:
748
+ for name in batch_futures[future]:
749
+ results.append(
750
+ AgentResult(
751
+ agent_name=name,
752
+ agent_group=AgentGroup.FIX,
753
+ status=AgentStatus.FAILED,
754
+ errors=[f"Batch failed: {e}"],
755
+ )
756
+ )
757
+
758
+ return results
759
+
760
+
761
+ def _group_fix_agents_by_file(
762
+ agent_classes: list[type[BaseAgent]],
763
+ ) -> list[list[type[BaseAgent]]]:
764
+ """Group fix agents by their declared target files.
765
+
766
+ Agents that declare overlapping target files are placed in the same batch
767
+ (must run sequentially). Agents targeting different files go into separate
768
+ batches (can run in parallel).
769
+
770
+ Each agent class may define a ``target_files()`` classmethod returning
771
+ ``list[str]`` of relative file paths it operates on. If no agent declares
772
+ targets, every agent gets its own batch (optimistic parallelism).
773
+ """
774
+ file_map: dict[str, list[type[BaseAgent]]] = defaultdict(list)
775
+ ungrouped: list[type[BaseAgent]] = []
776
+
777
+ for cls in agent_classes:
778
+ target_files_fn = getattr(cls, "target_files", None)
779
+ if callable(target_files_fn):
780
+ try:
781
+ files = list(target_files_fn())
782
+ except Exception as e:
783
+ _log.warning("_group_fix_agents_by_file failed: %s", e)
784
+ ungrouped.append(cls)
785
+ continue
786
+ else:
787
+ ungrouped.append(cls)
788
+ continue
789
+
790
+ if not files:
791
+ ungrouped.append(cls)
792
+ continue
793
+
794
+ key = tuple(sorted(files))
795
+ file_map[key].append(cls)
796
+
797
+ batches: list[list[type[BaseAgent]]] = list(file_map.values())
798
+
799
+ # Ungrouped agents each get their own batch (can run in parallel with others)
800
+ for cls in ungrouped:
801
+ batches.append([cls])
802
+
803
+ return batches if batches else [list(agent_classes)]
804
+
805
+
806
+ def _run_sequential(
807
+ agent_classes: list[type[BaseAgent]],
808
+ inp: AgentInput,
809
+ on_done: Callable[[AgentResult], None],
810
+ ) -> list[AgentResult]:
811
+ results: list[AgentResult] = []
812
+ for cls in agent_classes:
813
+ result = _run_agent_safe(cls(), inp)
814
+ on_done(result)
815
+ results.append(result)
816
+ return results
817
+
818
+
819
+ def _run_agent_safe(agent: BaseAgent, inp: AgentInput) -> AgentResult:
820
+ try:
821
+ return agent.run(inp)
822
+ except Exception as e:
823
+ return AgentResult(
824
+ agent_name=agent.name,
825
+ agent_group=agent.group,
826
+ status=AgentStatus.FAILED,
827
+ errors=[f"Unhandled: {e}"],
828
+ )
829
+
830
+
831
+ # ── Merge helper ───────────────────────────────────────────────────────────────
832
+
833
+
834
+ def merge_results(results: list[AgentResult]) -> dict:
835
+ all_findings: list[Finding] = []
836
+ all_data: dict[str, Any] = {}
837
+ errors: list[str] = []
838
+ total_files = 0
839
+ total_ms = 0
840
+
841
+ for r in results:
842
+ all_findings.extend(r.findings)
843
+ all_data[r.agent_name] = r.data
844
+ errors.extend(r.errors)
845
+ total_files += r.files_scanned
846
+ total_ms += r.duration_ms
847
+
848
+ all_findings.sort(key=lambda f: (f.severity.sort_key(), f.file, f.line))
849
+
850
+ return {
851
+ "findings": [f.to_dict() for f in all_findings],
852
+ "by_agent": all_data,
853
+ "total_findings": len(all_findings),
854
+ "total_files": total_files,
855
+ "total_ms": total_ms,
856
+ "agent_count": len(results),
857
+ "errors": errors,
858
+ }