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,1972 @@
1
+ """
2
+ Governor — Pipeline state machine wrapping Coordinator.
3
+
4
+ Phases (v2, per files-5 Testing Strategy §3):
5
+ SCAN — Structural pass: rules, dangling edges, CVEs, secrets
6
+ GRAPH_UPDATE — Incremental SymbolGraph patch from scan diff
7
+ TEST_GENERATION — Graph-scoped test generation using neighborhood context
8
+ TEST_EXECUTION — Run generated tests, results become oracle for fix verify
9
+ FIX_GENERATION — Multiple candidates with deterministic autofix first, LLM fallback
10
+ SANDBOX_REVERIFY — Re-run scoped tests + loop-back scan on each candidate
11
+ SCORE_SELECT — Composite scoring → select winner or escalate to human
12
+
13
+ Design:
14
+ - Additive — wraps Coordinator. Coordinator still works standalone.
15
+ - State is stored in SQLite (.patchi/pipeline_state.db) for crash recovery.
16
+ - Phase transitions gated by acceptance criteria (max errors, findings, etc).
17
+ - Structured finding format enforced across all ants.
18
+ - Ambiguous findings → explicit escalation rule, never model guessing.
19
+
20
+ Usage:
21
+ gov = Governor(project_root)
22
+ gov.run_full_pipeline_v2() # Full 7-step pipeline
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import sqlite3
28
+ import time
29
+ from collections import deque
30
+ from collections.abc import Callable
31
+ from dataclasses import dataclass, field
32
+ from datetime import UTC, datetime
33
+ from enum import StrEnum
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ import yaml
38
+ from loguru import logger
39
+
40
+ import patchi.core.agents.scanners # noqa: F401 — trigger scanner registration
41
+
42
+ # Trigger security agent registration. Security agents register lazily inside
43
+ # security_agents.py, so the module MUST be imported or Coordinator.
44
+ # run_group(AgentGroup.SECURITY) would resolve zero agents.
45
+ try:
46
+ import patchi.core.security.security_agents # noqa: F401
47
+ except Exception as e:
48
+ logger.debug(f"security_agents not available: {e}")
49
+
50
+ # Trigger test agent registration (pulled in via test_agents.py)
51
+ try:
52
+ import patchi.core.testing.test_agents # noqa: F401
53
+ except Exception as e:
54
+ logger.debug(f"test_agents not available: {e}")
55
+
56
+ # Trigger fix agent registration. fix/__init__ imports code_fixer,
57
+ # dead_code_remover, and security_fixer; code_fixer imports fix_agents, so all
58
+ # 8 FIX agents register. Without this, run_group(AgentGroup.FIX) resolves ZERO
59
+ # agents and the pipeline's FIX phase silently runs nothing (caught by the
60
+ # smoke-sweep --pipeline orchestration gate).
61
+ try:
62
+ import patchi.core.fix # noqa: F401
63
+ except Exception as e:
64
+ logger.debug(f"fix agents not available: {e}")
65
+
66
+ # ── Incident state machine types ────────────────────────────────────────────────
67
+ import logging
68
+
69
+ from patchi.core import memory as mem
70
+ from patchi.core.agents.base import (
71
+ AgentGroup,
72
+ AgentResult,
73
+ AgentStatus,
74
+ )
75
+ from patchi.core.agents.coordinator import Coordinator, CoordinatorProgress
76
+
77
+ _log = logging.getLogger("patchi.agents.governor")
78
+
79
+
80
+ class IncidentState(StrEnum):
81
+ DETECTED = "detected"
82
+ CLASSIFIED = "classified"
83
+ TEST_SCOPED = "test_scoped"
84
+ TEST_RUNNING = "test_running"
85
+ TEST_COMPLETE = "test_complete"
86
+ FIX_CANDIDATE_GENERATION = "fix_candidate_generation"
87
+ FIX_CANDIDATE_SCORING = "fix_candidate_scoring"
88
+ AUTO_APPLIED = "auto_applied"
89
+ ESCALATED_TO_HUMAN = "escalated_to_human"
90
+ REJECTED_NO_VIABLE_FIX = "rejected_no_viable_fix"
91
+ VERIFIED_RESOLVED = "verified_resolved"
92
+ AWAITING_HUMAN_DECISION = "awaiting_human_decision"
93
+ FLAGGED_OPEN = "flagged_open"
94
+
95
+ @property
96
+ def is_terminal(self) -> bool:
97
+ return self in (
98
+ IncidentState.VERIFIED_RESOLVED,
99
+ IncidentState.AWAITING_HUMAN_DECISION,
100
+ IncidentState.FLAGGED_OPEN,
101
+ )
102
+
103
+
104
+ @dataclass
105
+ class AuditEntry:
106
+ prior_state: IncidentState | None
107
+ new_state: IncidentState
108
+ rule_id: str | None
109
+ timestamp: str
110
+ metadata: dict = field(default_factory=dict)
111
+
112
+
113
+ @dataclass
114
+ class Condition:
115
+ field: str
116
+ operator: str
117
+ value: Any
118
+
119
+
120
+ @dataclass
121
+ class Action:
122
+ type: str
123
+ target: str | None = None
124
+ next_state: str | None = None
125
+ reason: str | None = None
126
+
127
+
128
+ @dataclass
129
+ class DispatchRule:
130
+ rule_id: str
131
+ applies_at_state: str
132
+ priority: int
133
+ conditions: list[Condition]
134
+ action: Action
135
+ fallback_if_no_match: bool = False
136
+
137
+
138
+ @dataclass
139
+ class Incident:
140
+ id: str
141
+ state: IncidentState
142
+ control_id: str | None
143
+ symbol_id: str | None
144
+ technique_id: str | None
145
+ confidence: float
146
+ criticality: str | None = None
147
+ check_method: str | None = None
148
+ bug_class: str | None = None
149
+ domain_activation_state: str | None = None
150
+ fix_retries: int = 0
151
+ created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
152
+ updated_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
153
+ audit_trail: list[AuditEntry] = field(default_factory=list)
154
+
155
+
156
+ # ── Pipeline phase enum ─────────────────────────────────────────────────────────
157
+
158
+
159
+ class PipelinePhase(StrEnum):
160
+ IDLE = "idle"
161
+ SCAN = "scan"
162
+ GRAPH_UPDATE = "graph_update"
163
+ TEST_GENERATION = "test_generation"
164
+ TEST_EXECUTION = "test_execution"
165
+ FIX_GENERATION = "fix_generation"
166
+ SANDBOX_REVERIFY = "sandbox_reverify"
167
+ SCORE_SELECT = "score_select"
168
+ COMPLETE = "complete"
169
+ FAILED = "failed"
170
+
171
+ @property
172
+ def order(self) -> int:
173
+ return _PHASE_ORDER[self]
174
+
175
+ @property
176
+ def next_phase(self) -> PipelinePhase | None:
177
+ if self == PipelinePhase.FAILED:
178
+ return None
179
+ if self == PipelinePhase.COMPLETE:
180
+ return None
181
+ phases = list(PipelinePhase)
182
+ idx = phases.index(self)
183
+ if idx + 1 < len(phases):
184
+ n = phases[idx + 1]
185
+ return n if n != PipelinePhase.FAILED else None
186
+ return None
187
+
188
+ @classmethod
189
+ def is_valid_transition(cls, current: PipelinePhase, target: PipelinePhase) -> bool:
190
+ if current == PipelinePhase.IDLE:
191
+ return target == PipelinePhase.SCAN
192
+ if current == PipelinePhase.COMPLETE:
193
+ return target == PipelinePhase.SCAN
194
+ return target.order == current.order + 1
195
+
196
+
197
+ _PHASE_ORDER = {
198
+ PipelinePhase.IDLE: 0,
199
+ PipelinePhase.SCAN: 1,
200
+ PipelinePhase.GRAPH_UPDATE: 2,
201
+ PipelinePhase.TEST_GENERATION: 3,
202
+ PipelinePhase.TEST_EXECUTION: 4,
203
+ PipelinePhase.FIX_GENERATION: 5,
204
+ PipelinePhase.SANDBOX_REVERIFY: 6,
205
+ PipelinePhase.SCORE_SELECT: 7,
206
+ PipelinePhase.COMPLETE: 8,
207
+ PipelinePhase.FAILED: -1,
208
+ }
209
+
210
+
211
+ # ── Acceptance criteria ─────────────────────────────────────────────────────────
212
+
213
+
214
+ @dataclass
215
+ class PhaseCriteria:
216
+ max_errors: int = 0
217
+ max_critical_findings: int = 0
218
+ max_high_findings: int = 10
219
+ min_agents_run: int = 1
220
+ require_zero_errors: bool = True
221
+
222
+
223
+ DEFAULT_CRITERIA: dict[PipelinePhase, PhaseCriteria] = {
224
+ PipelinePhase.SCAN: PhaseCriteria(
225
+ max_errors=5,
226
+ max_critical_findings=200,
227
+ max_high_findings=1000,
228
+ min_agents_run=1,
229
+ ),
230
+ PipelinePhase.GRAPH_UPDATE: PhaseCriteria(
231
+ max_errors=1,
232
+ min_agents_run=0,
233
+ ),
234
+ PipelinePhase.TEST_GENERATION: PhaseCriteria(
235
+ max_errors=3,
236
+ min_agents_run=0,
237
+ ),
238
+ PipelinePhase.TEST_EXECUTION: PhaseCriteria(
239
+ max_errors=5,
240
+ max_critical_findings=200,
241
+ max_high_findings=1000,
242
+ min_agents_run=0,
243
+ ),
244
+ PipelinePhase.FIX_GENERATION: PhaseCriteria(
245
+ max_errors=3,
246
+ max_critical_findings=50,
247
+ max_high_findings=200,
248
+ min_agents_run=1,
249
+ ),
250
+ PipelinePhase.SANDBOX_REVERIFY: PhaseCriteria(
251
+ max_errors=3,
252
+ max_critical_findings=50,
253
+ max_high_findings=200,
254
+ min_agents_run=0,
255
+ ),
256
+ PipelinePhase.SCORE_SELECT: PhaseCriteria(
257
+ max_errors=1,
258
+ min_agents_run=0,
259
+ ),
260
+ }
261
+
262
+
263
+ @dataclass
264
+ class PhaseResult:
265
+ phase: PipelinePhase
266
+ status: AgentStatus
267
+ results: list[AgentResult] = field(default_factory=list)
268
+ errors: list[str] = field(default_factory=list)
269
+ duration_ms: int = 0
270
+ findings_count: int = 0
271
+ agents_run: int = 0
272
+ # Structured per-phase detail (e.g. verify-loop outcomes under data["verify"])
273
+ data: dict = field(default_factory=dict)
274
+
275
+ @property
276
+ def passed(self) -> bool:
277
+ return self.status == AgentStatus.DONE
278
+
279
+
280
+ # ── Governor ────────────────────────────────────────────────────────────────────
281
+
282
+
283
+ class Governor:
284
+ """Pipeline state machine wrapping Coordinator."""
285
+
286
+ def __init__(
287
+ self,
288
+ root: Path,
289
+ on_progress: Callable[[CoordinatorProgress], None] | None = None,
290
+ criteria: dict[PipelinePhase, PhaseCriteria] | None = None,
291
+ ):
292
+ self.root = root
293
+ self.coordinator = Coordinator(root, on_progress=on_progress)
294
+ self.criteria = criteria or DEFAULT_CRITERIA.copy()
295
+ self._on_progress = on_progress or (lambda _: None)
296
+ self._db_path = root / ".patchi" / "pipeline_state.db"
297
+ self._conn: sqlite3.Connection | None = None
298
+ self._init_db()
299
+
300
+ def _init_db(self) -> None:
301
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
302
+ conn = self._get_conn()
303
+ conn.executescript("""
304
+ CREATE TABLE IF NOT EXISTS pipeline_state (
305
+ key TEXT PRIMARY KEY,
306
+ value TEXT
307
+ );
308
+ CREATE TABLE IF NOT EXISTS phase_history (
309
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
310
+ phase TEXT NOT NULL,
311
+ status TEXT NOT NULL,
312
+ timestamp TEXT NOT NULL,
313
+ duration_ms INTEGER DEFAULT 0,
314
+ findings_count INTEGER DEFAULT 0,
315
+ agents_run INTEGER DEFAULT 0,
316
+ errors TEXT DEFAULT ''
317
+ );
318
+ INSERT OR IGNORE INTO pipeline_state (key, value)
319
+ VALUES ('current_phase', 'idle');
320
+ """)
321
+
322
+ def _get_conn(self) -> sqlite3.Connection:
323
+ if self._conn is None:
324
+ self._conn = sqlite3.connect(str(self._db_path))
325
+ self._conn.execute("PRAGMA journal_mode=WAL")
326
+ return self._conn
327
+
328
+ def close(self) -> None:
329
+ """Release all SQLite resources so the db file can be deleted (Windows)."""
330
+ db_path = self._db_path
331
+ if self._conn is not None:
332
+ try:
333
+ self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
334
+ self._conn.execute("PRAGMA journal_mode=DELETE")
335
+ except Exception as e:
336
+ _log.warning("Governor.close checkpoint failed: %s", e)
337
+ try:
338
+ self._conn.close()
339
+ except Exception as _exc:
340
+ _log.warning('close failed: %s', _exc)
341
+ self._conn = None
342
+ for suffix in (".db-wal", ".db-shm"):
343
+ try:
344
+ Path(f"{db_path}{suffix}").unlink(missing_ok=True)
345
+ except OSError:
346
+ pass
347
+
348
+ # ── Phase management ───────────────────────────────────────────────────
349
+
350
+ @property
351
+ def current_phase(self) -> PipelinePhase:
352
+ conn = self._get_conn()
353
+ cur = conn.execute("SELECT value FROM pipeline_state WHERE key = 'current_phase'")
354
+ row = cur.fetchone()
355
+ return PipelinePhase(row[0]) if row else PipelinePhase.IDLE
356
+
357
+ @current_phase.setter
358
+ def current_phase(self, phase: PipelinePhase) -> None:
359
+ conn = self._get_conn()
360
+ conn.execute(
361
+ "INSERT OR REPLACE INTO pipeline_state (key, value) VALUES ('current_phase', ?)",
362
+ (phase.value,),
363
+ )
364
+
365
+ def get_history(self, limit: int = 20) -> list[dict]:
366
+ conn = self._get_conn()
367
+ cur = conn.execute("SELECT * FROM phase_history ORDER BY id DESC LIMIT ?", (limit,))
368
+ rows = [
369
+ {
370
+ "id": r[0],
371
+ "phase": r[1],
372
+ "status": r[2],
373
+ "timestamp": r[3],
374
+ "duration_ms": r[4],
375
+ "findings_count": r[5],
376
+ "agents_run": r[6],
377
+ "errors": r[7].split(";") if r[7] else [],
378
+ }
379
+ for r in cur.fetchall()
380
+ ]
381
+ return rows
382
+
383
+ def _record_phase(self, result: PhaseResult) -> None:
384
+ conn = self._get_conn()
385
+ conn.execute(
386
+ """INSERT INTO phase_history (phase, status, timestamp, duration_ms, findings_count, agents_run, errors)
387
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
388
+ (
389
+ result.phase.value,
390
+ result.status.value,
391
+ datetime.now(UTC).isoformat(),
392
+ result.duration_ms,
393
+ result.findings_count,
394
+ result.agents_run,
395
+ ";".join(result.errors[:5]),
396
+ ),
397
+ )
398
+
399
+ def _check_criteria(self, phase: PipelinePhase, results: list[AgentResult]) -> list[str]:
400
+ """Check acceptance criteria for a phase. Returns list of violations."""
401
+ violations: list[str] = []
402
+ crit = self.criteria.get(phase, DEFAULT_CRITERIA[PipelinePhase.SCAN])
403
+
404
+ errors = [r for r in results if r.status == AgentStatus.FAILED]
405
+ if len(errors) > crit.max_errors:
406
+ violations.append(f"Too many agent errors: {len(errors)} > {crit.max_errors}")
407
+
408
+ if crit.require_zero_errors and errors:
409
+ violations.append(f"Agents failed: {', '.join(r.agent_name for r in errors)}")
410
+
411
+ critical = sum(1 for r in results for f in r.findings if f.severity.name == "CRITICAL")
412
+ if critical > crit.max_critical_findings:
413
+ violations.append(
414
+ f"Too many CRITICAL findings: {critical} > {crit.max_critical_findings}"
415
+ )
416
+
417
+ high = sum(1 for r in results for f in r.findings if f.severity.name == "HIGH")
418
+ if high > crit.max_high_findings:
419
+ violations.append(f"Too many HIGH findings: {high} > {crit.max_high_findings}")
420
+
421
+ if len(results) < crit.min_agents_run:
422
+ violations.append(f"Not enough agents ran: {len(results)} < {crit.min_agents_run}")
423
+
424
+ return violations
425
+
426
+ def _transition_to(self, target: PipelinePhase, results: list[AgentResult]) -> PhaseResult:
427
+ """Attempt to transition to the target phase. Checks criteria."""
428
+ current = self.current_phase
429
+ if current == PipelinePhase.FAILED:
430
+ logger.warning("Pipeline is in FAILED state. Reset with reset_pipeline() to continue.")
431
+
432
+ if not PipelinePhase.is_valid_transition(current, target):
433
+ logger.warning(
434
+ f"Invalid phase transition: {current.value} → {target.value}. "
435
+ f"Skipping ordering check."
436
+ )
437
+
438
+ violations = self._check_criteria(target, results)
439
+ duration_ms = sum(r.duration_ms for r in results)
440
+ findings_count = sum(r.finding_count for r in results)
441
+
442
+ if violations:
443
+ phase_result = PhaseResult(
444
+ phase=target,
445
+ status=AgentStatus.FAILED,
446
+ results=results,
447
+ errors=violations,
448
+ duration_ms=duration_ms,
449
+ findings_count=findings_count,
450
+ agents_run=len(results),
451
+ )
452
+ self.current_phase = PipelinePhase.FAILED
453
+ self._record_phase(phase_result)
454
+ return phase_result
455
+
456
+ phase_result = PhaseResult(
457
+ phase=target,
458
+ status=AgentStatus.DONE,
459
+ results=results,
460
+ duration_ms=duration_ms,
461
+ findings_count=findings_count,
462
+ agents_run=len(results),
463
+ )
464
+ self.current_phase = target
465
+ self._record_phase(phase_result)
466
+ return phase_result
467
+
468
+ # ── Graph neighborhood context (files-5 Testing Strategy §4) ──────────
469
+
470
+ def _build_graph_neighborhood(self, symbol_names: list[str], radius: int = 1) -> list[dict]:
471
+ """Build scoped context for a set of symbols.
472
+
473
+ Returns list of symbol summaries with immediate callers/callees.
474
+ This is the graph-scoped context that replaces whole-file context
475
+ for AI test generation and fix candidate generation.
476
+ """
477
+ neighborhood: list[dict] = []
478
+ try:
479
+ from patchi.core.brain.symbol_graph import SymbolGraph
480
+
481
+ with SymbolGraph(self.root) as sym_graph:
482
+ sym_graph.ensure_built()
483
+ for sym_name in symbol_names:
484
+ sym = sym_graph.get_symbol(sym_name)
485
+ if not sym:
486
+ continue
487
+ entry = {
488
+ "name": sym.name,
489
+ "kind": sym.kind,
490
+ "file": sym.file,
491
+ "line": sym.line,
492
+ "is_exported": sym.is_exported,
493
+ "params": sym.params,
494
+ "docstring": sym.docstring[:120] if sym.docstring else "",
495
+ "callers": [],
496
+ "callees": [],
497
+ }
498
+ if radius >= 1:
499
+ for dep in sym_graph.get_dependents(sym.name, sym.file):
500
+ entry["callers"].append(
501
+ {"name": dep.name, "file": dep.file, "kind": dep.kind}
502
+ )
503
+ for dep in sym_graph.get_dependencies(sym.id):
504
+ entry["callees"].append(
505
+ {"name": dep.name, "file": dep.file, "kind": dep.kind}
506
+ )
507
+ neighborhood.append(entry)
508
+ except Exception as e:
509
+ logger.warning(f"build_graph_neighborhood error: {e}")
510
+ return neighborhood
511
+
512
+ # ── Pipeline execution ────────────────────────────────────────────────
513
+
514
+ def run_scan(self, scope: list[str] | None = None, side: bool = True) -> PhaseResult:
515
+ """Phase 1: Run scanner agents."""
516
+ logger.info(f"Pipeline phase: SCAN (scope={len(scope or [])} files, side={side})")
517
+ results = self.coordinator.run_all_scanners(scope=scope, side=side)
518
+ return self._transition_to(PipelinePhase.SCAN, results)
519
+
520
+ def run_scan_deep(self, scope: list[str] | None = None) -> PhaseResult:
521
+ """Phase 1b: Deep scan with AI analysis."""
522
+ logger.info(f"Pipeline phase: SCAN --deep (scope={len(scope or [])} files)")
523
+ results = self.coordinator.run_group(AgentGroup.SCANNER, scope=scope)
524
+ # Deep analysis via coordinator's _build_llm
525
+ llm = self.coordinator._build_llm()
526
+ if llm:
527
+ try:
528
+ from patchi.core.ai.client import call_ai
529
+
530
+ for r in results:
531
+ if r.findings:
532
+ prompt = (
533
+ f"Analyze these findings for file context. "
534
+ f"Findings: {[f.to_dict() for f in r.findings[:5]]}"
535
+ )
536
+ analysis = call_ai(
537
+ self.coordinator._config,
538
+ "You are a security analysis assistant.",
539
+ prompt,
540
+ max_tokens=300,
541
+ )
542
+ if analysis:
543
+ r.data["deep_analysis"] = analysis
544
+ except Exception as e:
545
+ _log.warning("Governor.run_scan_deep failed: %s", e)
546
+ return self._transition_to(PipelinePhase.SCAN, results)
547
+
548
+ def run_test(self, scope: list[str] | None = None) -> PhaseResult:
549
+ """Phase 2: Run test agents."""
550
+ logger.info(f"Pipeline phase: TEST (scope={len(scope or [])} files)")
551
+ results = self.coordinator.run_group(AgentGroup.TEST, scope=scope)
552
+ return self._transition_to(PipelinePhase.TEST_EXECUTION, results)
553
+
554
+ def run_test_security(self) -> PhaseResult:
555
+ """Phase 2b: Security-specific tests."""
556
+ logger.info("Pipeline phase: TEST --security")
557
+ from patchi.core.agents.base import get_agent
558
+
559
+ agent = get_agent("SecurityTestAgent")
560
+ if agent:
561
+ results = self.coordinator.run_agents(["SecurityTestAgent"])
562
+ else:
563
+ results = []
564
+ return self._transition_to(PipelinePhase.TEST_EXECUTION, results)
565
+
566
+ def run_security(self, scope: list[str] | None = None) -> PhaseResult:
567
+ """Run security agents (part of scan phase or standalone)."""
568
+ logger.info(f"Pipeline phase: SCAN --security (scope={len(scope or [])} files)")
569
+ results = self.coordinator.run_group(AgentGroup.SECURITY, scope=scope)
570
+ return PhaseResult(
571
+ phase=PipelinePhase.SCAN,
572
+ status=AgentStatus.DONE,
573
+ results=results,
574
+ duration_ms=sum(r.duration_ms for r in results),
575
+ findings_count=sum(r.finding_count for r in results),
576
+ agents_run=len(results),
577
+ )
578
+
579
+ def run_fix(self, dry_run: bool = False) -> PhaseResult:
580
+ """Phase: Run fix agents, then route every produced patch through the
581
+ fix → verify → retry loop (verify_loop).
582
+
583
+ Reuses the exact wiring ``p fix`` has: the applier is injected, each
584
+ patch is applied and its SPECIFIC failing test re-run (retries up to
585
+ 2 with fresh failure feedback), and test-weakening patches (only test
586
+ files changed) are flagged requires_review — never auto-applied.
587
+
588
+ NOTE: this phase now MUTATES the working tree (applies patches) —
589
+ previously FIX was candidate-generation only and nothing applied.
590
+ ``dry_run=True`` is the only non-mutating path.
591
+
592
+ Outcomes land in ``result.data["verify"]`` as lists of patch ids:
593
+ verified / applied_unverified / review_required / rolled_back
594
+ (``skipped`` holds error strings, not patch ids).
595
+ """
596
+ logger.info(f"Pipeline phase: FIX-GENERATION (dry_run={dry_run})")
597
+ results = self.coordinator.run_group(AgentGroup.FIX, extra={"dry_run": dry_run})
598
+ verify = self._verify_fix_patches(results, dry_run=dry_run)
599
+ result = self._transition_to(PipelinePhase.FIX_GENERATION, results)
600
+ result.data["verify"] = verify
601
+ return result
602
+
603
+ def _verify_fix_patches(self, results: list[AgentResult], dry_run: bool = False) -> dict:
604
+ """Route produced fix patches through verify_loop (fix → verify → retry).
605
+
606
+ Mirrors ``fix_cmd``: test-weakening guard runs first (a fix that only
607
+ edits test files goes to human review, never auto-apply), then each
608
+ patch is applied via ``run_verify_loop`` with a real ``PatchApplier``
609
+ injected, which re-runs the exact failing test and retries up to 2
610
+ times with fresh failure output. Returns a per-patch summary:
611
+
612
+ {"verified": [ids], "applied_unverified": [ids],
613
+ "review_required": [ids], "rolled_back": [ids], "skipped": [ids]}
614
+
615
+ Also records ``self._last_applied_patches`` — (Patch, failing test
616
+ file) pairs — so the REVERIFY phase can re-run those exact tests.
617
+ """
618
+ from patchi.core.fix.applier import PatchApplier
619
+ from patchi.core.fix.patch import Patch
620
+ from patchi.core.fix.verify_loop import run_verify_loop, should_flag_for_review
621
+
622
+ summary: dict = {
623
+ "verified": [],
624
+ "applied_unverified": [],
625
+ "review_required": [],
626
+ "rolled_back": [],
627
+ "skipped": [],
628
+ }
629
+ self._last_applied_patches: list[tuple[Patch, str]] = []
630
+ if dry_run:
631
+ return summary
632
+
633
+ try:
634
+ applier = PatchApplier(self.root)
635
+ except Exception as e:
636
+ logger.warning(f"FIX verify: PatchApplier unavailable, skipping apply: {e}")
637
+ summary["skipped"].append(f"PatchApplier: {e}")
638
+ return summary
639
+ config = self.config or {}
640
+ brain = self.brain or {}
641
+
642
+ for r in results:
643
+ for patch_dict in r.data.get("patches", []):
644
+ try:
645
+ patch = Patch.from_dict(patch_dict)
646
+ except Exception as e:
647
+ summary["skipped"].append(str(e))
648
+ continue
649
+
650
+ # Test-weakening guard — flag BEFORE the loop so AUTOPILOT can't
651
+ # slip a test-only fix past the apply path.
652
+ if should_flag_for_review(patch):
653
+ patch.requires_review = True
654
+ summary["review_required"].append(patch.id)
655
+ self._last_applied_patches.append((patch, ""))
656
+ continue
657
+
658
+ try:
659
+ outcome = run_verify_loop(
660
+ patch,
661
+ root=self.root,
662
+ config=config,
663
+ brain=brain,
664
+ applier=applier,
665
+ )
666
+ except Exception as e:
667
+ summary["rolled_back"].append(patch.id)
668
+ self._last_applied_patches.append((patch, ""))
669
+ logger.warning(f"verify loop failed for {patch.id}: {e}")
670
+ continue
671
+
672
+ done = outcome.patch or patch
673
+ test_file = (done.source_finding or {}).get("file", "")
674
+ if outcome.review_required:
675
+ summary["review_required"].append(done.id)
676
+ elif outcome.applied and outcome.verified:
677
+ summary["verified"].append(done.id)
678
+ elif outcome.applied:
679
+ summary["applied_unverified"].append(done.id)
680
+ else:
681
+ summary["rolled_back"].append(done.id)
682
+ self._last_applied_patches.append((done, test_file))
683
+
684
+ logger.info(
685
+ f"FIX verify: {len(summary['verified'])} verified, "
686
+ f"{len(summary['applied_unverified'])} applied-unverified, "
687
+ f"{len(summary['review_required'])} review-required, "
688
+ f"{len(summary['rolled_back'])} rolled back"
689
+ )
690
+ return summary
691
+
692
+ def _recheck_applied_patches(self) -> dict:
693
+ """REVERIFY: re-run the SPECIFIC failing test of each patch that the
694
+ FIX phase applied/verified — not the whole test group.
695
+
696
+ Returns {"rechecked": n, "passed": [ids], "regressed": [ids],
697
+ "unrunnable": [ids]} where regressed means a previously-verified test
698
+ now fails after later phases touched the tree.
699
+ """
700
+ from patchi.core.fix.verify_loop import recheck_test_file
701
+
702
+ report: dict = {"rechecked": 0, "passed": [], "regressed": [], "unrunnable": []}
703
+ for patch, test_file in getattr(self, "_last_applied_patches", []):
704
+ if not test_file:
705
+ continue
706
+ check = recheck_test_file(self.root, test_file)
707
+ report["rechecked"] += 1
708
+ if check.get("passed") is True:
709
+ report["passed"].append(patch.id)
710
+ elif check.get("passed") is False:
711
+ report["regressed"].append(patch.id)
712
+ else:
713
+ report["unrunnable"].append(patch.id)
714
+ return report
715
+
716
+ def run_fix_security(self) -> PhaseResult:
717
+ """Phase: Security-specific fixes."""
718
+ logger.info("Pipeline phase: FIX-GENERATION --security")
719
+ from patchi.core.agents.base import get_agent
720
+
721
+ agent = get_agent("SecurityFixer")
722
+ if agent:
723
+ results = self.coordinator.run_agents(["SecurityFixer"])
724
+ else:
725
+ results = []
726
+ return self._transition_to(PipelinePhase.FIX_GENERATION, results)
727
+
728
+ def run_reverify(self) -> PhaseResult:
729
+ """Phase 4: Re-verify by re-running scan agents and checking fixes.
730
+
731
+ On top of the loop-back scan, re-runs the SPECIFIC failing tests of
732
+ patches applied in the FIX phase (via verify_loop.recheck_test_file) so
733
+ a regression in an applied fix fails the phase, not just "some findings
734
+ remain". Recheck results land in ``result.data["reverify"]``.
735
+ """
736
+ logger.info("Pipeline phase: REVERIFY")
737
+ scan_results = self.coordinator.run_all_scanners()
738
+ security_results = self.coordinator.run_group(AgentGroup.SECURITY)
739
+
740
+ all_results = scan_results + security_results
741
+ total_findings = sum(r.finding_count for r in all_results)
742
+ reverify = self._recheck_applied_patches()
743
+
744
+ crit = self.criteria.get(
745
+ PipelinePhase.SANDBOX_REVERIFY, DEFAULT_CRITERIA[PipelinePhase.SANDBOX_REVERIFY]
746
+ )
747
+ violations: list[str] = []
748
+ if total_findings > crit.max_critical_findings:
749
+ violations.append(f"Re-verify failed: {total_findings} findings remain")
750
+ if reverify["regressed"]:
751
+ violations.append(
752
+ f"Re-verify failed: {len(reverify['regressed'])} previously-verified "
753
+ f"test(s) regressed: {', '.join(reverify['regressed'])}"
754
+ )
755
+
756
+ if violations:
757
+ phase_result = PhaseResult(
758
+ phase=PipelinePhase.SANDBOX_REVERIFY,
759
+ status=AgentStatus.FAILED,
760
+ results=all_results,
761
+ errors=violations,
762
+ duration_ms=sum(r.duration_ms for r in all_results),
763
+ findings_count=total_findings,
764
+ agents_run=len(all_results),
765
+ data={"reverify": reverify},
766
+ )
767
+ self._record_phase(phase_result)
768
+ return phase_result
769
+
770
+ phase_result = PhaseResult(
771
+ phase=PipelinePhase.SANDBOX_REVERIFY,
772
+ status=AgentStatus.DONE,
773
+ results=all_results,
774
+ duration_ms=sum(r.duration_ms for r in all_results),
775
+ findings_count=total_findings,
776
+ agents_run=len(all_results),
777
+ data={"reverify": reverify},
778
+ )
779
+ self._record_phase(phase_result)
780
+ self.current_phase = PipelinePhase.COMPLETE
781
+ return phase_result
782
+
783
+ # ── New v2 phases ──────────────────────────────────────────────────────
784
+
785
+ def run_graph_update(self) -> PhaseResult:
786
+ """Phase 2: Build or incrementally update the SymbolGraph.
787
+
788
+ The full graph is built HERE — during the SCAN/GRAPH_UPDATE phase — so
789
+ later FIX/TEST phases never pay a full tree-sitter parse latency spike
790
+ through the lazy ensure_built() query path.
791
+
792
+ Uses the graph_diff from BrainReport to identify changed files and
793
+ apply SymbolGraph.patch() incrementally; when no diff exists (fresh
794
+ project, no prior scan), it falls back to a full build_from_root().
795
+ """
796
+ start = time.monotonic()
797
+ logger.info("Pipeline phase: GRAPH_UPDATE")
798
+ errors: list[str] = []
799
+ changed_paths: list[Path] = []
800
+ built = False
801
+
802
+ try:
803
+ brain_mem = mem.get_brain(self.root)
804
+ graph_diff = brain_mem.get("graph_diff", {})
805
+
806
+ for f in graph_diff.get("new_files", []):
807
+ p = self.root / f
808
+ if p.exists():
809
+ changed_paths.append(p)
810
+ for f in graph_diff.get("removed_files", []):
811
+ p = self.root / f
812
+ if p.exists():
813
+ changed_paths.append(p)
814
+
815
+ try:
816
+ from patchi.core.brain.symbol_graph import SymbolGraph
817
+
818
+ with SymbolGraph(self.root) as sym_graph:
819
+ if changed_paths:
820
+ # Build a base graph if the DB is empty, then apply
821
+ # the incremental patch from the scan diff.
822
+ sym_graph.ensure_built()
823
+ diff = sym_graph.patch(changed_paths)
824
+ built = True
825
+ logger.info(f"Graph update: patched {diff.summary()}")
826
+ else:
827
+ # No diff — ensure the full graph exists now so
828
+ # FIX/TEST phases never trigger the parse spike later.
829
+ # ensure_built() does a full build_from_root when the
830
+ # DB is empty (fresh project) and is a cheap no-op on
831
+ # re-runs where the graph is already populated.
832
+ count = sym_graph.ensure_built()
833
+ built = True
834
+ logger.info(f"Graph update: graph ensured ({count} symbols)")
835
+ except Exception as e:
836
+ errors.append(f"SymbolGraph build/patch failed: {e}")
837
+ except Exception as e:
838
+ errors.append(f"Graph update failed: {e}")
839
+
840
+ duration_ms = int((time.monotonic() - start) * 1000)
841
+ result = PhaseResult(
842
+ phase=PipelinePhase.GRAPH_UPDATE,
843
+ status=AgentStatus.DONE if not errors else AgentStatus.FAILED,
844
+ errors=errors,
845
+ duration_ms=duration_ms,
846
+ agents_run=1 if built else 0,
847
+ )
848
+ self._record_phase(result)
849
+ self.current_phase = PipelinePhase.GRAPH_UPDATE
850
+ return result
851
+
852
+ def _affected_symbols_from_scan(self) -> list[str]:
853
+ """Extract affected symbol names from the last scan's graph diff."""
854
+ symbols: list[str] = []
855
+ try:
856
+ brain_mem = mem.get_brain(self.root)
857
+ graph_diff = brain_mem.get("graph_diff", {})
858
+
859
+ for f in graph_diff.get("new_files", []):
860
+ try:
861
+ from patchi.core.brain.symbol_graph import SymbolGraph
862
+
863
+ with SymbolGraph(self.root) as sym_graph:
864
+ sym_graph.ensure_built()
865
+ for sym in sym_graph.get_symbols_in_file(f):
866
+ symbols.append(sym.name)
867
+ except Exception as e:
868
+ logger.debug(f"SymbolGraph error for {f}: {e}")
869
+ except Exception as e:
870
+ logger.debug(f"get_symbols_in_file error: {e}")
871
+ return symbols
872
+
873
+ def run_test_generation(self) -> PhaseResult:
874
+ """Phase 3: Graph-scoped test generation.
875
+
876
+ Uses symbol neighborhood context instead of whole-file context.
877
+ This directly addresses the 11-vs-1 hallucination reduction finding
878
+ from Testing Strategy §4.
879
+ """
880
+ start = time.monotonic()
881
+ logger.info("Pipeline phase: TEST_GENERATION")
882
+ errors: list[str] = []
883
+
884
+ affected_symbols = self._affected_symbols_from_scan()
885
+ if not affected_symbols:
886
+ logger.info("Test generation: no affected symbols, skipping")
887
+ result = PhaseResult(
888
+ phase=PipelinePhase.TEST_GENERATION,
889
+ status=AgentStatus.DONE,
890
+ duration_ms=int((time.monotonic() - start) * 1000),
891
+ )
892
+ self._record_phase(result)
893
+ return result
894
+
895
+ # Build graph-scoped context
896
+ neighborhood = self._build_graph_neighborhood(affected_symbols, radius=1)
897
+ if not neighborhood:
898
+ logger.info("Test generation: no graph context, skipping")
899
+ result = PhaseResult(
900
+ phase=PipelinePhase.TEST_GENERATION,
901
+ status=AgentStatus.DONE,
902
+ duration_ms=int((time.monotonic() - start) * 1000),
903
+ )
904
+ self._record_phase(result)
905
+ return result
906
+
907
+ # Generate tests scoped to affected symbols
908
+ try:
909
+ from patchi.core.agents.base import get_agent
910
+
911
+ agent = get_agent("SecurityTestAgent")
912
+ if agent:
913
+ extra = {
914
+ "graph_neighborhood": neighborhood,
915
+ "affected_symbols": affected_symbols,
916
+ "generation_mode": "scoped",
917
+ }
918
+ agent_results = self.coordinator.run_agents(["SecurityTestAgent"], extra=extra)
919
+ else:
920
+ # Fallback: use test agent if available
921
+ agent_results = self.coordinator.run_group(
922
+ AgentGroup.TEST,
923
+ extra={"graph_neighborhood": neighborhood, "generation_mode": "scoped"},
924
+ )
925
+ except Exception as e:
926
+ errors.append(f"Test generation failed: {e}")
927
+ agent_results = []
928
+
929
+ duration_ms = int((time.monotonic() - start) * 1000)
930
+ findings_count = sum(r.finding_count for r in agent_results)
931
+
932
+ result = PhaseResult(
933
+ phase=PipelinePhase.TEST_GENERATION,
934
+ status=AgentStatus.DONE if not errors else AgentStatus.FAILED,
935
+ results=agent_results,
936
+ errors=errors,
937
+ duration_ms=duration_ms,
938
+ findings_count=findings_count,
939
+ agents_run=len(agent_results),
940
+ )
941
+ self._record_phase(result)
942
+ self.current_phase = PipelinePhase.TEST_GENERATION
943
+ return result
944
+
945
+ def run_fix_generation(self, dry_run: bool = False) -> PhaseResult:
946
+ """Phase 5: Fix candidate generation with multiple candidates.
947
+
948
+ Deterministic autofix first (ESLint --fix, Semgrep autofix, etc.).
949
+ LLM candidates for residual cases. Multiple candidates generated
950
+ and scored against the composite (tests pass, blast-radius delta,
951
+ no new scan findings).
952
+ """
953
+ start = time.monotonic()
954
+ logger.info(f"Pipeline phase: FIX_GENERATION (dry_run={dry_run})")
955
+
956
+ # Build graph-scoped context for fix agents
957
+ affected_symbols = self._affected_symbols_from_scan()
958
+ extra: dict = {"dry_run": dry_run}
959
+ if affected_symbols:
960
+ neighborhood = self._build_graph_neighborhood(affected_symbols, radius=1)
961
+ extra["graph_neighborhood"] = neighborhood
962
+ extra["affected_symbols"] = affected_symbols
963
+
964
+ # Run fix agents with graph-scoped context
965
+ results = self.coordinator.run_group(AgentGroup.FIX, extra=extra)
966
+
967
+ # fix → verify → retry loop over every produced patch (never weaken
968
+ # tests): apply with the applier injected, re-run the specific failing
969
+ # test, retry up to 2 with fresh failure feedback, and flag test-only
970
+ # patches for human review. Outcomes land in result.data["verify"].
971
+ verify = self._verify_fix_patches(results, dry_run=dry_run)
972
+
973
+ # Score candidates (store for SCORE_SELECT phase)
974
+ scored_candidates = []
975
+ for r in results:
976
+ candidate = {
977
+ "agent_name": r.agent_name,
978
+ "status": r.status.value,
979
+ "findings": r.finding_count,
980
+ "duration_ms": r.duration_ms,
981
+ "score": self._score_fix_candidate(r),
982
+ }
983
+ scored_candidates.append(candidate)
984
+ self._last_candidates = scored_candidates
985
+ self._last_fix_results = results
986
+
987
+ duration_ms = int((time.monotonic() - start) * 1000)
988
+ result = self._transition_to(PipelinePhase.FIX_GENERATION, results)
989
+ result.data["verify"] = verify
990
+ result.duration_ms = duration_ms
991
+ return result
992
+
993
+ def _score_fix_candidate(self, result: AgentResult) -> float:
994
+ """Score a fix candidate on 0-1 scale.
995
+
996
+ Factors:
997
+ - Agent completed without errors (0.4)
998
+ - Finding count (lower = better, 0.3)
999
+ - Has fix data (0.2)
1000
+ - Fast runtime (0.1)
1001
+ """
1002
+ score = 0.0
1003
+ if result.status == AgentStatus.DONE:
1004
+ score += 0.4
1005
+ score += max(0, 0.3 - (result.finding_count * 0.02))
1006
+ if result.data:
1007
+ score += 0.2
1008
+ score += max(0, 0.1 - (result.duration_ms * 0.00001))
1009
+ return min(1.0, max(0.0, score))
1010
+
1011
+ def run_sandbox_reverify(self) -> PhaseResult:
1012
+ """Phase 6: Sandbox reverification with loop-back scan + test re-run.
1013
+
1014
+ For each fix candidate:
1015
+ 1. Re-run the scoped test set from TEST_EXECUTION
1016
+ 2. Re-run the scanner on the candidate diff
1017
+ 3. Check for new findings introduced by the fix
1018
+
1019
+ Uses git worktree or temp directory as sandbox.
1020
+ """
1021
+ start = time.monotonic()
1022
+ logger.info("Pipeline phase: SANDBOX_REVERIFY")
1023
+ errors: list[str] = []
1024
+ all_results: list[AgentResult] = []
1025
+
1026
+ candidates = getattr(self, "_last_candidates", [])
1027
+ fix_results = getattr(self, "_last_fix_results", [])
1028
+
1029
+ if not fix_results:
1030
+ logger.info("Sandbox reverify: no fix candidates to verify")
1031
+ result = PhaseResult(
1032
+ phase=PipelinePhase.SANDBOX_REVERIFY,
1033
+ status=AgentStatus.DONE,
1034
+ duration_ms=int((time.monotonic() - start) * 1000),
1035
+ )
1036
+ self._record_phase(result)
1037
+ return result
1038
+
1039
+ for i, (candidate, _fix_res) in enumerate(zip(candidates, fix_results, strict=False)):
1040
+ logger.info(
1041
+ f"Reverifying candidate {i + 1}/{len(candidates)}: {candidate['agent_name']}"
1042
+ )
1043
+
1044
+ # Re-run scanner agents on the working tree
1045
+ try:
1046
+ scan_results = self.coordinator.run_all_scanners()
1047
+ all_results.extend(scan_results)
1048
+ except Exception as e:
1049
+ errors.append(f"Re-scan for {candidate['agent_name']} failed: {e}")
1050
+
1051
+ # Re-run test agents
1052
+ try:
1053
+ test_results = self.coordinator.run_group(AgentGroup.TEST)
1054
+ all_results.extend(test_results)
1055
+ except Exception as e:
1056
+ errors.append(f"Re-test for {candidate['agent_name']} failed: {e}")
1057
+
1058
+ # Check for newly introduced findings
1059
+ total_findings = sum(r.finding_count for r in all_results)
1060
+ if total_findings > 0:
1061
+ logger.warning(f"Sandbox reverify: {total_findings} findings remain after fix")
1062
+
1063
+ # Re-run the SPECIFIC failing tests of patches applied in FIX_GENERATION
1064
+ # (verify_loop.recheck_test_file) — a regression there fails the phase.
1065
+ reverify = self._recheck_applied_patches()
1066
+ if reverify["regressed"]:
1067
+ errors.append(
1068
+ f"Sandbox reverify: {len(reverify['regressed'])} previously-verified "
1069
+ f"test(s) regressed: {', '.join(reverify['regressed'])}"
1070
+ )
1071
+
1072
+ duration_ms = int((time.monotonic() - start) * 1000)
1073
+ result = PhaseResult(
1074
+ phase=PipelinePhase.SANDBOX_REVERIFY,
1075
+ status=AgentStatus.DONE,
1076
+ results=all_results,
1077
+ errors=errors,
1078
+ duration_ms=duration_ms,
1079
+ findings_count=total_findings,
1080
+ agents_run=len(all_results),
1081
+ data={"reverify": reverify},
1082
+ )
1083
+
1084
+ violations = self._check_criteria(PipelinePhase.SANDBOX_REVERIFY, all_results)
1085
+ if violations:
1086
+ result.status = AgentStatus.FAILED
1087
+ result.errors.extend(violations)
1088
+
1089
+ self._record_phase(result)
1090
+ if result.status == AgentStatus.DONE:
1091
+ self.current_phase = PipelinePhase.SANDBOX_REVERIFY
1092
+ return result
1093
+
1094
+ def run_score_select(self) -> PhaseResult:
1095
+ """Phase 7: Composite scoring → select winner or escalate to human.
1096
+
1097
+ Compares all fix candidates, selects the best-scoring one,
1098
+ logs the decision with all candidate scores for audit trail.
1099
+ If no candidate passes all gates, escalates to human review.
1100
+ """
1101
+ start = time.monotonic()
1102
+ logger.info("Pipeline phase: SCORE_SELECT")
1103
+ errors: list[str] = []
1104
+
1105
+ candidates = getattr(self, "_last_candidates", [])
1106
+ fix_results = getattr(self, "_last_fix_results", [])
1107
+
1108
+ if not candidates:
1109
+ errors.append("No fix candidates to score")
1110
+ result = PhaseResult(
1111
+ phase=PipelinePhase.SCORE_SELECT,
1112
+ status=AgentStatus.FAILED,
1113
+ errors=errors,
1114
+ duration_ms=int((time.monotonic() - start) * 1000),
1115
+ )
1116
+ self._record_phase(result)
1117
+ return result
1118
+
1119
+ # Sort by score descending
1120
+ ranked = sorted(
1121
+ zip(candidates, fix_results, strict=False),
1122
+ key=lambda x: x[0]["score"],
1123
+ reverse=True,
1124
+ )
1125
+
1126
+ winners: list[dict] = []
1127
+ for cand, res in ranked:
1128
+ decision = self._select_candidate(cand, res)
1129
+ entry = {
1130
+ "candidate": cand,
1131
+ "decision": decision["action"],
1132
+ "reason": decision["reason"],
1133
+ }
1134
+ winners.append(entry)
1135
+
1136
+ # Log all candidates and decision
1137
+ logger.info(f"Score-select: {len(candidates)} candidates evaluated")
1138
+ for w in winners:
1139
+ logger.info(
1140
+ f" {w['candidate']['agent_name']}: score={w['candidate']['score']:.2f} → {w['decision']}"
1141
+ )
1142
+
1143
+ self._last_selection = winners
1144
+
1145
+ duration_ms = int((time.monotonic() - start) * 1000)
1146
+
1147
+ # If any candidate passes, phase passes
1148
+ any_pass = any(w["decision"] == "auto_apply" for w in winners)
1149
+ result = PhaseResult(
1150
+ phase=PipelinePhase.SCORE_SELECT,
1151
+ status=AgentStatus.DONE if any_pass or not errors else AgentStatus.FAILED,
1152
+ errors=errors,
1153
+ duration_ms=duration_ms,
1154
+ agents_run=len(candidates),
1155
+ )
1156
+ if any_pass:
1157
+ self.current_phase = PipelinePhase.COMPLETE
1158
+ self._record_phase(result)
1159
+ return result
1160
+
1161
+ def _select_candidate(self, candidate: dict, result: AgentResult) -> dict:
1162
+ """Decide whether a candidate is auto-applied or escalated.
1163
+
1164
+ Returns {"action": "auto_apply"|"escalate"|"discard", "reason": str}.
1165
+ """
1166
+ score = candidate["score"]
1167
+
1168
+ # Gates
1169
+ if result.status != AgentStatus.DONE:
1170
+ return {"action": "discard", "reason": f"Agent failed: {result.status.value}"}
1171
+
1172
+ if candidate["findings"] > 0:
1173
+ return {"action": "escalate", "reason": f"{candidate['findings']} findings remain"}
1174
+
1175
+ # Score thresholds
1176
+ if score >= 0.8:
1177
+ return {"action": "auto_apply", "reason": f"Score {score:.2f} ≥ 0.8, all gates passed"}
1178
+ if score >= 0.5:
1179
+ return {
1180
+ "action": "escalate",
1181
+ "reason": f"Score {score:.2f} moderate, needs human review",
1182
+ }
1183
+
1184
+ return {"action": "discard", "reason": f"Score {score:.2f} too low"}
1185
+
1186
+ # ── Full pipeline runners ─────────────────────────────────────────────
1187
+
1188
+ def run_full_pipeline(
1189
+ self,
1190
+ scope: list[str] | None = None,
1191
+ dry_run: bool = False,
1192
+ ) -> list[PhaseResult]:
1193
+ """Run the full SCAN → TEST → FIX → REVERIFY pipeline sequentially."""
1194
+ phases: list[PhaseResult] = []
1195
+
1196
+ scan_result = self.run_scan(scope=scope)
1197
+ phases.append(scan_result)
1198
+ if not scan_result.passed:
1199
+ logger.error("SCAN phase failed, aborting pipeline")
1200
+ return phases
1201
+
1202
+ test_result = self.run_test()
1203
+ phases.append(test_result)
1204
+ if not test_result.passed:
1205
+ logger.warning("TEST phase has issues, proceeding with caution")
1206
+
1207
+ fix_result = self.run_fix(dry_run=dry_run)
1208
+ phases.append(fix_result)
1209
+ if not fix_result.passed:
1210
+ logger.warning("FIX phase has issues")
1211
+
1212
+ reverify_result = self.run_reverify()
1213
+ phases.append(reverify_result)
1214
+
1215
+ return phases
1216
+
1217
+ def run_full_pipeline_v2(
1218
+ self,
1219
+ scope: list[str] | None = None,
1220
+ dry_run: bool = False,
1221
+ ) -> list[PhaseResult]:
1222
+ """Run the full 7-step pipeline.
1223
+
1224
+ SCAN → GRAPH_UPDATE → TEST_GENERATION → TEST_EXECUTION
1225
+ → FIX_GENERATION → SANDBOX_REVERIFY → SCORE_SELECT
1226
+ """
1227
+ phases: list[PhaseResult] = []
1228
+
1229
+ # 1. SCAN
1230
+ scan_result = self.run_scan(scope=scope)
1231
+ phases.append(scan_result)
1232
+ if not scan_result.passed:
1233
+ logger.error("SCAN failed, aborting v2 pipeline")
1234
+ self.current_phase = PipelinePhase.FAILED
1235
+ return phases
1236
+
1237
+ # 2. GRAPH_UPDATE — incremental SymbolGraph patch
1238
+ graph_result = self.run_graph_update()
1239
+ phases.append(graph_result)
1240
+
1241
+ # 3. TEST_GENERATION — graph-scoped
1242
+ test_gen_result = self.run_test_generation()
1243
+ phases.append(test_gen_result)
1244
+
1245
+ # 4. TEST_EXECUTION — run test agents (existing)
1246
+ test_exec_result = self.run_test()
1247
+ phases.append(test_exec_result)
1248
+ if not test_exec_result.passed:
1249
+ logger.warning("TEST_EXECUTION has issues, proceeding with caution")
1250
+
1251
+ # 5. FIX_GENERATION — multiple candidates, deterministic first
1252
+ fix_gen_result = self.run_fix_generation(dry_run=dry_run)
1253
+ phases.append(fix_gen_result)
1254
+ if not fix_gen_result.passed:
1255
+ logger.warning("FIX_GENERATION has issues")
1256
+
1257
+ # 6. SANDBOX_REVERIFY — loop-back scan + test re-run
1258
+ reverify_result = self.run_sandbox_reverify()
1259
+ phases.append(reverify_result)
1260
+
1261
+ # 7. SCORE_SELECT — composite score → select or escalate
1262
+ select_result = self.run_score_select()
1263
+ phases.append(select_result)
1264
+
1265
+ return phases
1266
+
1267
+ # ── State management ──────────────────────────────────────────────────
1268
+
1269
+ def reset_pipeline(self) -> None:
1270
+ """Reset pipeline state back to IDLE."""
1271
+ self.current_phase = PipelinePhase.IDLE
1272
+ logger.info("Pipeline reset to IDLE")
1273
+
1274
+ def status(self) -> dict:
1275
+ """Return current pipeline status as a dict."""
1276
+ phase = self.current_phase
1277
+ history = self.get_history(5)
1278
+ return {
1279
+ "current_phase": phase.value,
1280
+ "phase_order": phase.order,
1281
+ "next_phase": phase.next_phase.value if phase.next_phase else None,
1282
+ "is_running": phase
1283
+ not in (PipelinePhase.IDLE, PipelinePhase.COMPLETE, PipelinePhase.FAILED),
1284
+ "recent_history": history,
1285
+ }
1286
+
1287
+ # ── Coordinator passthrough ───────────────────────────────────────────
1288
+
1289
+ @property
1290
+ def config(self) -> dict:
1291
+ return getattr(self.coordinator, "_config", {})
1292
+
1293
+ @property
1294
+ def brain(self) -> dict:
1295
+ return getattr(self.coordinator, "_brain", {})
1296
+
1297
+ def run_agents(
1298
+ self, agent_names: list[str], scope: list[str] | None = None
1299
+ ) -> list[AgentResult]:
1300
+ """Passthrough to Coordinator.run_agents."""
1301
+ return self.coordinator.run_agents(agent_names, scope=scope)
1302
+
1303
+ def run_group(self, group: AgentGroup, scope: list[str] | None = None) -> list[AgentResult]:
1304
+ """Passthrough to Coordinator.run_group."""
1305
+ return self.coordinator.run_group(group, scope=scope)
1306
+
1307
+
1308
+ # ── GovernorEngine ──────────────────────────────────────────────────────────────
1309
+
1310
+
1311
+ class GovernorEngine:
1312
+ """Incident-based state machine wrapping Governor.
1313
+
1314
+ Manages incidents (create, transition, query), loads dispatch rules,
1315
+ evaluates escalation rules (spec §3), tracks fix retries and agent dispatch rate.
1316
+
1317
+ Additive — wraps Governor, does not replace it. The existing Governor
1318
+ and run_full_pipeline_v2() continue working unchanged.
1319
+ """
1320
+
1321
+ def __init__(
1322
+ self,
1323
+ root: Path,
1324
+ on_progress: Callable[[CoordinatorProgress], None] | None = None,
1325
+ criteria: dict[PipelinePhase, PhaseCriteria] | None = None,
1326
+ rules_dir: Path | str | None = None,
1327
+ ):
1328
+ self.root = Path(root)
1329
+ self.governor = Governor(root, on_progress=on_progress, criteria=criteria)
1330
+ self._on_progress = on_progress or (lambda _: None)
1331
+ self._rules_dir = Path(rules_dir) if rules_dir else self.root / ".patchi" / "rules"
1332
+ self._incidents: dict[str, Incident] = {}
1333
+ self._rules: list[DispatchRule] = []
1334
+ self._dispatch_timestamps: deque[float] = deque()
1335
+
1336
+ # Configurable thresholds (spec §4 reference table)
1337
+ self._conf_threshold: float = 0.4
1338
+ self._auto_apply_threshold: float = 0.75
1339
+ self._max_fix_retries: int = 3
1340
+ self._max_dispatches_per_window: int = 50
1341
+ self._dispatch_window_seconds: int = 60
1342
+
1343
+ self._load_rules()
1344
+ self._init_db()
1345
+
1346
+ # ── Database schema extension ─────────────────────────────────────────
1347
+
1348
+ def _init_db(self) -> None:
1349
+ """Extend the existing pipeline_state.db with incident tables."""
1350
+ try:
1351
+ conn = self.governor._get_conn()
1352
+ conn.executescript("""
1353
+ CREATE TABLE IF NOT EXISTS incidents (
1354
+ id TEXT PRIMARY KEY,
1355
+ state TEXT NOT NULL,
1356
+ control_id TEXT,
1357
+ symbol_id TEXT,
1358
+ technique_id TEXT,
1359
+ confidence REAL DEFAULT 0.0,
1360
+ criticality TEXT,
1361
+ check_method TEXT,
1362
+ bug_class TEXT,
1363
+ domain_activation_state TEXT,
1364
+ fix_retries INTEGER DEFAULT 0,
1365
+ created_at TEXT NOT NULL,
1366
+ updated_at TEXT NOT NULL
1367
+ );
1368
+ CREATE TABLE IF NOT EXISTS incident_audit (
1369
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1370
+ incident_id TEXT NOT NULL,
1371
+ prior_state TEXT,
1372
+ new_state TEXT NOT NULL,
1373
+ rule_id TEXT,
1374
+ timestamp TEXT NOT NULL,
1375
+ metadata TEXT DEFAULT '{}',
1376
+ FOREIGN KEY (incident_id) REFERENCES incidents(id)
1377
+ );
1378
+ CREATE TABLE IF NOT EXISTS dispatch_log (
1379
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1380
+ incident_id TEXT,
1381
+ rule_id TEXT,
1382
+ target_agent TEXT,
1383
+ timestamp TEXT NOT NULL
1384
+ );
1385
+ """)
1386
+ except Exception as e:
1387
+ logger.warning(f"Could not extend schema for incidents: {e}")
1388
+
1389
+ # ── Rule loading (file-based YAML, spec §2) ───────────────────────────
1390
+
1391
+ def _load_rules(self) -> None:
1392
+ """Load dispatch rules from YAML files in .patchi/rules/."""
1393
+ if not self._rules_dir.exists():
1394
+ logger.info(f"No rules directory at {self._rules_dir}, skipping")
1395
+ return
1396
+
1397
+ loaded = 0
1398
+ for yaml_path in sorted(self._rules_dir.glob("**/*.yaml")):
1399
+ try:
1400
+ with open(yaml_path) as f:
1401
+ data = yaml.safe_load(f)
1402
+ if not data:
1403
+ continue
1404
+
1405
+ conditions = [
1406
+ Condition(field=c["field"], operator=c["operator"], value=c["value"])
1407
+ for c in data.get("conditions", [])
1408
+ ]
1409
+
1410
+ action_raw = data.get("action", {})
1411
+ action = Action(
1412
+ type=action_raw.get("type", "transition"),
1413
+ target=action_raw.get("target"),
1414
+ next_state=action_raw.get("next_state"),
1415
+ reason=action_raw.get("reason"),
1416
+ )
1417
+
1418
+ rule = DispatchRule(
1419
+ rule_id=data.get("rule_id", yaml_path.stem),
1420
+ applies_at_state=data.get("applies_at_state", "DETECTED"),
1421
+ priority=data.get("priority", 100),
1422
+ conditions=conditions,
1423
+ action=action,
1424
+ fallback_if_no_match=data.get("fallback_if_no_match", False),
1425
+ )
1426
+ self._rules.append(rule)
1427
+ loaded += 1
1428
+ except Exception as e:
1429
+ logger.error(f"Failed to load rule {yaml_path}: {e}")
1430
+
1431
+ self._rules.sort(key=lambda r: r.priority)
1432
+ logger.info(f"Loaded {loaded} dispatch rules from {self._rules_dir}")
1433
+
1434
+ # ── Incident CRUD ─────────────────────────────────────────────────────
1435
+
1436
+ def create_incident(
1437
+ self,
1438
+ control_id: str | None = None,
1439
+ symbol_id: str | None = None,
1440
+ technique_id: str | None = None,
1441
+ confidence: float = 0.0,
1442
+ criticality: str | None = None,
1443
+ check_method: str | None = None,
1444
+ bug_class: str | None = None,
1445
+ domain_activation_state: str | None = None,
1446
+ finding_dict: dict | None = None,
1447
+ ) -> Incident:
1448
+ """Create a new incident, optionally from a finding dict."""
1449
+ if finding_dict:
1450
+ control_id = control_id or finding_dict.get("control_id")
1451
+ symbol_id = (
1452
+ symbol_id
1453
+ or finding_dict.get("symbol_id")
1454
+ or finding_dict.get("affected_node", {}).get("symbol")
1455
+ )
1456
+ technique_id = technique_id or finding_dict.get("technique_id")
1457
+ confidence = confidence if confidence else finding_dict.get("confidence", 0.0)
1458
+ criticality = (
1459
+ criticality
1460
+ or finding_dict.get("affected_node", {}).get("criticality")
1461
+ or finding_dict.get("criticality")
1462
+ )
1463
+ check_method = check_method or finding_dict.get("check_method")
1464
+ bug_class = bug_class or finding_dict.get("bug_class")
1465
+ domain_activation_state = domain_activation_state or finding_dict.get(
1466
+ "domain_activation_state"
1467
+ )
1468
+
1469
+ incident_id = f"INC-{int(time.time() * 1000)}-{len(self._incidents) + 1}"
1470
+ now = datetime.now(UTC).isoformat()
1471
+
1472
+ incident = Incident(
1473
+ id=incident_id,
1474
+ state=IncidentState.DETECTED,
1475
+ control_id=control_id,
1476
+ symbol_id=symbol_id,
1477
+ technique_id=technique_id,
1478
+ confidence=confidence,
1479
+ criticality=criticality,
1480
+ check_method=check_method,
1481
+ bug_class=bug_class,
1482
+ domain_activation_state=domain_activation_state,
1483
+ created_at=now,
1484
+ updated_at=now,
1485
+ audit_trail=[
1486
+ AuditEntry(
1487
+ prior_state=None,
1488
+ new_state=IncidentState.DETECTED,
1489
+ rule_id="system",
1490
+ timestamp=now,
1491
+ metadata={"source": "finding_ingested"},
1492
+ )
1493
+ ],
1494
+ )
1495
+ self._incidents[incident_id] = incident
1496
+ self._persist_incident(incident)
1497
+ logger.info(
1498
+ f"Created incident {incident_id} (technique={technique_id}, confidence={confidence})"
1499
+ )
1500
+ return incident
1501
+
1502
+ def get_incident(self, incident_id: str) -> Incident | None:
1503
+ return self._incidents.get(incident_id)
1504
+
1505
+ def get_incidents(self, state: IncidentState | None = None) -> list[Incident]:
1506
+ if state is None:
1507
+ return list(self._incidents.values())
1508
+ return [i for i in self._incidents.values() if i.state == state]
1509
+
1510
+ def _persist_incident(self, incident: Incident) -> None:
1511
+ try:
1512
+ conn = self.governor._get_conn()
1513
+ conn.execute(
1514
+ """INSERT OR REPLACE INTO incidents
1515
+ (id, state, control_id, symbol_id, technique_id, confidence,
1516
+ criticality, check_method, bug_class, domain_activation_state,
1517
+ fix_retries, created_at, updated_at)
1518
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
1519
+ (
1520
+ incident.id,
1521
+ incident.state.value,
1522
+ incident.control_id,
1523
+ incident.symbol_id,
1524
+ incident.technique_id,
1525
+ incident.confidence,
1526
+ incident.criticality,
1527
+ incident.check_method,
1528
+ incident.bug_class,
1529
+ incident.domain_activation_state,
1530
+ incident.fix_retries,
1531
+ incident.created_at,
1532
+ incident.updated_at,
1533
+ ),
1534
+ )
1535
+ except Exception as e:
1536
+ logger.warning(f"Failed to persist incident {incident.id}: {e}")
1537
+
1538
+ def _persist_audit_entry(self, incident_id: str, entry: AuditEntry) -> None:
1539
+ try:
1540
+ conn = self.governor._get_conn()
1541
+ conn.execute(
1542
+ """INSERT INTO incident_audit (incident_id, prior_state, new_state, rule_id, timestamp, metadata)
1543
+ VALUES (?, ?, ?, ?, ?, ?)""",
1544
+ (
1545
+ incident_id,
1546
+ entry.prior_state.value if entry.prior_state else None,
1547
+ entry.new_state.value,
1548
+ entry.rule_id,
1549
+ entry.timestamp,
1550
+ str(entry.metadata),
1551
+ ),
1552
+ )
1553
+ except Exception as e:
1554
+ logger.warning(f"Failed to persist audit entry: {e}")
1555
+
1556
+ # ── State transitions ─────────────────────────────────────────────────
1557
+
1558
+ def transition_incident(
1559
+ self,
1560
+ incident_id: str,
1561
+ new_state: IncidentState,
1562
+ rule_id: str | None = None,
1563
+ metadata: dict | None = None,
1564
+ ) -> Incident | None:
1565
+ """Transition an incident to a new state. Logs the transition to the audit trail."""
1566
+ incident = self._incidents.get(incident_id)
1567
+ if not incident:
1568
+ logger.warning(f"Incident {incident_id} not found")
1569
+ return None
1570
+
1571
+ prior_state = incident.state
1572
+ now = datetime.now(UTC).isoformat()
1573
+
1574
+ entry = AuditEntry(
1575
+ prior_state=prior_state,
1576
+ new_state=new_state,
1577
+ rule_id=rule_id,
1578
+ timestamp=now,
1579
+ metadata=metadata or {},
1580
+ )
1581
+
1582
+ incident.state = new_state
1583
+ incident.updated_at = now
1584
+ incident.audit_trail.append(entry)
1585
+
1586
+ self._persist_incident(incident)
1587
+ self._persist_audit_entry(incident_id, entry)
1588
+
1589
+ logger.debug(
1590
+ f"Incident {incident_id}: {prior_state.value} → {new_state.value} (rule={rule_id})"
1591
+ )
1592
+ return incident
1593
+
1594
+ # ── Dispatch rule evaluation (spec §2, first-match-wins) ──────────────
1595
+
1596
+ def _resolve_field(self, incident: Incident, field_path: str) -> Any:
1597
+ """Resolve a dotted field path against an incident (e.g. finding.technique_id)."""
1598
+ if field_path.startswith("finding."):
1599
+ key = field_path[len("finding.") :]
1600
+ mapping = {
1601
+ "technique_id": incident.technique_id,
1602
+ "confidence": incident.confidence,
1603
+ "criticality": incident.criticality,
1604
+ "check_method": incident.check_method,
1605
+ "bug_class": incident.bug_class,
1606
+ "domain_activation_state": incident.domain_activation_state,
1607
+ "control_id": incident.control_id,
1608
+ "symbol_id": incident.symbol_id,
1609
+ }
1610
+ return mapping.get(key)
1611
+
1612
+ if field_path.startswith("best_candidate."):
1613
+ key = field_path[len("best_candidate.") :]
1614
+ return getattr(self, f"_candidate_{key}", None)
1615
+
1616
+ return getattr(incident, field_path, None)
1617
+
1618
+ def _evaluate_conditions(self, incident: Incident, conditions: list[Condition]) -> bool:
1619
+ for cond in conditions:
1620
+ value = self._resolve_field(incident, cond.field)
1621
+
1622
+ if cond.operator == "in":
1623
+ if isinstance(value, list):
1624
+ if not any(v in cond.value for v in value):
1625
+ return False
1626
+ elif value not in cond.value:
1627
+ return False
1628
+ elif cond.operator == ">=":
1629
+ if not (value is not None and value >= cond.value):
1630
+ return False
1631
+ elif cond.operator == "<=":
1632
+ if not (value is not None and value <= cond.value):
1633
+ return False
1634
+ elif cond.operator == ">":
1635
+ if not (value is not None and value > cond.value):
1636
+ return False
1637
+ elif cond.operator == "<":
1638
+ if not (value is not None and value < cond.value):
1639
+ return False
1640
+ elif cond.operator == "==":
1641
+ if value != cond.value:
1642
+ return False
1643
+ elif cond.operator == "!=":
1644
+ if value == cond.value:
1645
+ return False
1646
+ else:
1647
+ logger.warning(f"Unknown condition operator: {cond.operator}")
1648
+ return False
1649
+ return True
1650
+
1651
+ def evaluate_dispatch_rules(
1652
+ self, incident: Incident, at_state: IncidentState | None = None
1653
+ ) -> DispatchRule | None:
1654
+ """First-match-wins rule evaluation. Returns the first matching rule or None."""
1655
+ state = at_state or incident.state
1656
+ for rule in self._rules:
1657
+ if rule.applies_at_state != state.value:
1658
+ continue
1659
+ if self._evaluate_conditions(incident, rule.conditions):
1660
+ logger.info(f"Rule {rule.rule_id} matched incident {incident.id}")
1661
+ return rule
1662
+ return None
1663
+
1664
+ # ── Escalation rules: §3.1 Hard triggers ──────────────────────────────
1665
+
1666
+ def check_hard_escalation_triggers(self, incident: Incident) -> str | None:
1667
+ """§3.1 — unconditional, score-independent triggers."""
1668
+ # criticality in [auth, secrets, payment, data-write] at FIX_CANDIDATE_SCORING
1669
+ if incident.state == IncidentState.FIX_CANDIDATE_SCORING:
1670
+ if incident.criticality in ("auth", "secrets", "payment", "data-write"):
1671
+ return "hard-trigger-criticality"
1672
+
1673
+ # check_method == "manual-review" at CLASSIFIED
1674
+ if incident.state == IncidentState.CLASSIFIED:
1675
+ if incident.check_method == "manual-review":
1676
+ return "hard-trigger-manual-review"
1677
+
1678
+ # bug_class == "semantic-mismatch" at TEST_COMPLETE
1679
+ if incident.state == IncidentState.TEST_COMPLETE:
1680
+ if incident.bug_class == "semantic-mismatch":
1681
+ return "hard-trigger-semantic-mismatch"
1682
+
1683
+ # domain_activation_state == "unclear" at CLASSIFIED
1684
+ if incident.state == IncidentState.CLASSIFIED:
1685
+ if incident.domain_activation_state == "unclear":
1686
+ return "hard-trigger-unclear-domain"
1687
+
1688
+ return None
1689
+
1690
+ # ── Escalation rules: §3.2 Score-based ────────────────────────────────
1691
+
1692
+ def check_score_escalation(
1693
+ self, incident: Incident, best_score: float | None = None
1694
+ ) -> str | None:
1695
+ """§3.2 — composite score below configurable threshold."""
1696
+ if incident.state != IncidentState.FIX_CANDIDATE_SCORING:
1697
+ return None
1698
+ if best_score is not None and best_score < self._auto_apply_threshold:
1699
+ return f"score-below-threshold-{best_score:.2f}"
1700
+ return None
1701
+
1702
+ # ── Escalation rules: §3.3 Rate-limiting / loop prevention ────────────
1703
+
1704
+ def check_rate_limiting(self, incident: Incident) -> str | None:
1705
+ """§3.3 — fix-retry loops and dispatch-rate ceilings."""
1706
+ if incident.state in (
1707
+ IncidentState.FIX_CANDIDATE_GENERATION,
1708
+ IncidentState.FIX_CANDIDATE_SCORING,
1709
+ ):
1710
+ if incident.fix_retries >= self._max_fix_retries:
1711
+ return "max-fix-retries-exceeded"
1712
+
1713
+ now = time.time()
1714
+ while (
1715
+ self._dispatch_timestamps
1716
+ and self._dispatch_timestamps[0] < now - self._dispatch_window_seconds
1717
+ ):
1718
+ self._dispatch_timestamps.popleft()
1719
+
1720
+ if len(self._dispatch_timestamps) >= self._max_dispatches_per_window:
1721
+ return "dispatch-rate-exceeded"
1722
+
1723
+ return None
1724
+
1725
+ # ── Combined escalation check ─────────────────────────────────────────
1726
+
1727
+ def check_escalation(self, incident: Incident, best_score: float | None = None) -> str | None:
1728
+ """Evaluate all escalation rules in order. Returns the first reason or None."""
1729
+ hard = self.check_hard_escalation_triggers(incident)
1730
+ if hard:
1731
+ return hard
1732
+
1733
+ score = self.check_score_escalation(incident, best_score)
1734
+ if score:
1735
+ return score
1736
+
1737
+ rate = self.check_rate_limiting(incident)
1738
+ if rate:
1739
+ return rate
1740
+
1741
+ return None
1742
+
1743
+ # ── Dispatch rate tracking ────────────────────────────────────────────
1744
+
1745
+ def record_dispatch(self, incident_id: str | None = None, rule_id: str | None = None) -> None:
1746
+ now = time.time()
1747
+ self._dispatch_timestamps.append(now)
1748
+ try:
1749
+ conn = self.governor._get_conn()
1750
+ conn.execute(
1751
+ """INSERT INTO dispatch_log (incident_id, rule_id, target_agent, timestamp)
1752
+ VALUES (?, ?, ?, ?)""",
1753
+ (incident_id, rule_id, None, datetime.now(UTC).isoformat()),
1754
+ )
1755
+ except Exception as e:
1756
+ logger.warning(f"Failed to log dispatch for {incident_id}: {e}")
1757
+
1758
+ def can_dispatch(self) -> bool:
1759
+ """Check dispatch rate — True if under the ceiling."""
1760
+ now = time.time()
1761
+ while (
1762
+ self._dispatch_timestamps
1763
+ and self._dispatch_timestamps[0] < now - self._dispatch_window_seconds
1764
+ ):
1765
+ self._dispatch_timestamps.popleft()
1766
+ return len(self._dispatch_timestamps) < self._max_dispatches_per_window
1767
+
1768
+ # ── Pipeline integration ──────────────────────────────────────────────
1769
+
1770
+ @staticmethod
1771
+ def _map_phase_to_incident_state(phase: PipelinePhase) -> IncidentState | None:
1772
+ mapping = {
1773
+ PipelinePhase.SCAN: IncidentState.CLASSIFIED,
1774
+ PipelinePhase.GRAPH_UPDATE: IncidentState.CLASSIFIED,
1775
+ PipelinePhase.TEST_GENERATION: IncidentState.TEST_SCOPED,
1776
+ PipelinePhase.TEST_EXECUTION: IncidentState.TEST_COMPLETE,
1777
+ PipelinePhase.FIX_GENERATION: IncidentState.FIX_CANDIDATE_GENERATION,
1778
+ PipelinePhase.SANDBOX_REVERIFY: None,
1779
+ PipelinePhase.SCORE_SELECT: IncidentState.FIX_CANDIDATE_SCORING,
1780
+ PipelinePhase.COMPLETE: None,
1781
+ PipelinePhase.FAILED: None,
1782
+ }
1783
+ return mapping.get(phase)
1784
+
1785
+ def run_pipeline_with_incident(
1786
+ self,
1787
+ scope: list[str] | None = None,
1788
+ dry_run: bool = False,
1789
+ ) -> dict:
1790
+ """Run the full v2 pipeline wrapped with incident tracking and escalation.
1791
+
1792
+ Returns a dict with keys:
1793
+ - phases: list[PhaseResult] from the underlying pipeline
1794
+ - incidents: list[Incident] tracked during this run
1795
+ - escalated: list[str] — incident IDs that were escalated to human
1796
+ - queued: int — 1 if pipeline was queued due to rate limit, else 0
1797
+ """
1798
+ if not self.can_dispatch():
1799
+ logger.warning("Dispatch rate exceeded — pipeline queued")
1800
+ return {
1801
+ "phases": [],
1802
+ "incidents": [],
1803
+ "escalated": [],
1804
+ "queued": 1,
1805
+ "reason": "dispatch_rate_exceeded",
1806
+ }
1807
+
1808
+ phases = self.governor.run_full_pipeline_v2(scope=scope, dry_run=dry_run)
1809
+
1810
+ incidents: list[Incident] = []
1811
+ escalated: list[str] = []
1812
+
1813
+ for phase_result in phases:
1814
+ incident_state = self._map_phase_to_incident_state(phase_result.phase)
1815
+ if incident_state is None:
1816
+ continue
1817
+
1818
+ for agent_result in phase_result.results:
1819
+ for finding in getattr(agent_result, "findings", []):
1820
+ try:
1821
+ finding_dict = finding.to_dict()
1822
+ except Exception as e:
1823
+ logger.warning(f"Failed to convert finding to dict: {e}")
1824
+ continue
1825
+
1826
+ confidence = finding_dict.get("confidence", 0.0)
1827
+ if confidence < self._conf_threshold:
1828
+ continue
1829
+
1830
+ control_id = finding_dict.get("control_id")
1831
+ symbol_id = finding_dict.get("symbol_id") or finding_dict.get(
1832
+ "affected_node", {}
1833
+ ).get("symbol")
1834
+ technique_id = finding_dict.get("technique_id")
1835
+
1836
+ existing: Incident | None = None
1837
+ for inc in self._incidents.values():
1838
+ if (
1839
+ inc.control_id == control_id
1840
+ and inc.symbol_id == symbol_id
1841
+ and not inc.state.is_terminal
1842
+ ):
1843
+ existing = inc
1844
+ break
1845
+
1846
+ if existing:
1847
+ incident = existing
1848
+ self.transition_incident(
1849
+ incident.id,
1850
+ incident_state,
1851
+ rule_id="pipeline-phase-auto",
1852
+ metadata={"phase": phase_result.phase.value},
1853
+ )
1854
+ else:
1855
+ incident = self.create_incident(
1856
+ finding_dict=finding_dict,
1857
+ control_id=control_id,
1858
+ symbol_id=symbol_id,
1859
+ technique_id=technique_id,
1860
+ confidence=confidence,
1861
+ )
1862
+ if incident_state != IncidentState.DETECTED:
1863
+ self.transition_incident(
1864
+ incident.id,
1865
+ incident_state,
1866
+ rule_id="dispatch-rule-auto",
1867
+ metadata={"phase": phase_result.phase.value},
1868
+ )
1869
+
1870
+ incidents.append(incident)
1871
+ self.record_dispatch(incident.id)
1872
+
1873
+ escalation_reason = self.check_escalation(incident)
1874
+ if escalation_reason:
1875
+ self.transition_incident(
1876
+ incident.id,
1877
+ IncidentState.ESCALATED_TO_HUMAN,
1878
+ rule_id=escalation_reason,
1879
+ metadata={"escalation_reason": escalation_reason},
1880
+ )
1881
+ escalated.append(incident.id)
1882
+
1883
+ for incident in self._incidents.values():
1884
+ if incident.state.is_terminal:
1885
+ continue
1886
+ escalation_reason = self.check_escalation(incident)
1887
+ if escalation_reason and incident.id not in escalated:
1888
+ self.transition_incident(
1889
+ incident.id,
1890
+ IncidentState.ESCALATED_TO_HUMAN,
1891
+ rule_id=escalation_reason,
1892
+ metadata={"escalation_reason": escalation_reason, "phase": "final"},
1893
+ )
1894
+ escalated.append(incident.id)
1895
+
1896
+ if (
1897
+ phases
1898
+ and phases[-1].status == AgentStatus.DONE
1899
+ and phases[-1].phase in (PipelinePhase.COMPLETE, PipelinePhase.SCORE_SELECT)
1900
+ ):
1901
+ for incident in self._incidents.values():
1902
+ if not incident.state.is_terminal and incident.id not in escalated:
1903
+ self.transition_incident(
1904
+ incident.id,
1905
+ IncidentState.VERIFIED_RESOLVED,
1906
+ rule_id="pipeline-complete",
1907
+ metadata={"phase": "complete"},
1908
+ )
1909
+
1910
+ return {
1911
+ "phases": phases,
1912
+ "incidents": list(self._incidents.values()),
1913
+ "escalated": escalated,
1914
+ "queued": 0,
1915
+ }
1916
+
1917
+ # ── Config accessors (spec §4 — all thresholds are configurable) ──────
1918
+
1919
+ @property
1920
+ def auto_apply_threshold(self) -> float:
1921
+ return self._auto_apply_threshold
1922
+
1923
+ @auto_apply_threshold.setter
1924
+ def auto_apply_threshold(self, value: float) -> None:
1925
+ self._auto_apply_threshold = value
1926
+
1927
+ @property
1928
+ def max_fix_retries(self) -> int:
1929
+ return self._max_fix_retries
1930
+
1931
+ @max_fix_retries.setter
1932
+ def max_fix_retries(self, value: int) -> None:
1933
+ self._max_fix_retries = value
1934
+
1935
+ @property
1936
+ def max_dispatches_per_window(self) -> int:
1937
+ return self._max_dispatches_per_window
1938
+
1939
+ @max_dispatches_per_window.setter
1940
+ def max_dispatches_per_window(self, value: int) -> None:
1941
+ self._max_dispatches_per_window = value
1942
+
1943
+ @property
1944
+ def confidence_threshold(self) -> float:
1945
+ return self._conf_threshold
1946
+
1947
+ @confidence_threshold.setter
1948
+ def confidence_threshold(self, value: float) -> None:
1949
+ self._conf_threshold = value
1950
+
1951
+ # ── Status / passthrough ──────────────────────────────────────────────
1952
+
1953
+ @property
1954
+ def config(self) -> dict:
1955
+ return self.governor.config
1956
+
1957
+ def status(self) -> dict:
1958
+ base = self.governor.status()
1959
+ base["incident_count"] = len(self._incidents)
1960
+ base["incidents_by_state"] = {
1961
+ s.value: sum(1 for i in self._incidents.values() if i.state == s) for s in IncidentState
1962
+ }
1963
+ base["rules_loaded"] = len(self._rules)
1964
+ base["dispatches_in_window"] = len(self._dispatch_timestamps)
1965
+ return base
1966
+
1967
+ def reset(self) -> None:
1968
+ """Reset pipeline and incident state."""
1969
+ self.governor.reset_pipeline()
1970
+ self._incidents.clear()
1971
+ self._dispatch_timestamps.clear()
1972
+ logger.info("GovernorEngine reset")