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,1908 @@
1
+ """
2
+ `p scan` — Run a brain scan on the project.
3
+
4
+ Usage:
5
+ p scan — full project scan
6
+ p scan src/auth — targeted scan of a specific area
7
+ p scan --dry-run — show what would be scanned without parsing
8
+
9
+ Shows:
10
+ - Rich multi-bar progress display (one bar per phase)
11
+ - Live file discovery feed
12
+ - Summary table after completion
13
+ - Contract confirmation if new critical flows are found
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import logging
20
+ import time
21
+ from datetime import UTC
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from rich.live import Live
26
+ from rich.panel import Panel
27
+ from rich.progress import (
28
+ BarColumn,
29
+ Progress,
30
+ SpinnerColumn,
31
+ TaskProgressColumn,
32
+ TextColumn,
33
+ TimeElapsedColumn,
34
+ )
35
+ from rich.table import Table
36
+ from rich.text import Text
37
+
38
+ from patchi.cli.console import con
39
+ from patchi.core import config as cfg
40
+ from patchi.core import memory as mem
41
+ from patchi.core.agents.base import AgentGroup, list_agents
42
+ from patchi.core.brain.brain import Brain, BrainReport, ScanProgress
43
+ from patchi.core.brain.freshness import check_freshness
44
+ from patchi.core.config import require_project_root
45
+
46
+ _log = logging.getLogger("patchi.cli.scan_cmd")
47
+
48
+
49
+ def run(
50
+ area: str | None = None,
51
+ dry_run: bool = False,
52
+ force: bool = False,
53
+ quiet: bool = False,
54
+ no_logo: bool = False,
55
+ deep: bool = False,
56
+ file_path: str | None = None,
57
+ contract: bool = False,
58
+ all_flows: bool = False,
59
+ offline: bool = False,
60
+ json_output: bool = False,
61
+ side: bool = True,
62
+ pipeline: bool = False,
63
+ daemon: bool = False,
64
+ governor: bool = False,
65
+ with_attackers: bool = False,
66
+ with_campaigns: bool = False,
67
+ with_fuzz: bool = False,
68
+ red_team: bool = False,
69
+ dast: bool = False,
70
+ changed: bool = False,
71
+ changed_commits: int = 1,
72
+ since: str | None = None,
73
+ fail_on: str | None = None,
74
+ root: Path | None = None,
75
+ with_license: bool = False,
76
+ with_extended: bool = False,
77
+ ) -> int:
78
+ """Entry point for `p scan [area]`. Returns process exit code."""
79
+ try:
80
+ r = root or require_project_root()
81
+ except RuntimeError as e:
82
+ con.print(f"[red]{e}[/red]")
83
+ return 2
84
+
85
+ # ── Set tenant context so profiler records the correct project root ────
86
+ from patchi.core.tenant import get_tenant_manager, tenant_context
87
+
88
+ try:
89
+ mgr = get_tenant_manager()
90
+ mgr.register_project(r)
91
+ mgr.switch_project(r)
92
+ except Exception as _exc:
93
+ _log.debug("tenant registration skipped: %s", _exc)
94
+
95
+ with tenant_context(r):
96
+ return _run_scan_inner(
97
+ r,
98
+ area,
99
+ dry_run,
100
+ force,
101
+ quiet,
102
+ no_logo,
103
+ deep,
104
+ file_path,
105
+ contract,
106
+ all_flows,
107
+ offline,
108
+ json_output,
109
+ side,
110
+ pipeline,
111
+ daemon,
112
+ governor,
113
+ with_attackers,
114
+ with_campaigns,
115
+ with_fuzz,
116
+ red_team,
117
+ dast,
118
+ changed,
119
+ changed_commits,
120
+ since,
121
+ fail_on,
122
+ with_license,
123
+ with_extended,
124
+ )
125
+
126
+
127
+ def _run_scan_inner(
128
+ r: Path,
129
+ area: str | None = None,
130
+ dry_run: bool = False,
131
+ force: bool = False,
132
+ quiet: bool = False,
133
+ no_logo: bool = False,
134
+ deep: bool = False,
135
+ file_path: str | None = None,
136
+ contract: bool = False,
137
+ all_flows: bool = False,
138
+ offline: bool = False,
139
+ json_output: bool = False,
140
+ side: bool = True,
141
+ pipeline: bool = False,
142
+ daemon: bool = False,
143
+ governor: bool = False,
144
+ with_attackers: bool = False,
145
+ with_campaigns: bool = False,
146
+ with_fuzz: bool = False,
147
+ red_team: bool = False,
148
+ dast: bool = False,
149
+ changed: bool = False,
150
+ changed_commits: int = 1,
151
+ since: str | None = None,
152
+ fail_on: str | None = None,
153
+ with_license: bool = False,
154
+ with_extended: bool = False,
155
+ ) -> int:
156
+ """Inner scan logic — runs inside tenant_context. Returns process exit code."""
157
+
158
+ # ── Contract review mode ──────────────────────────────────────────────────
159
+ if contract:
160
+ _run_contract_review(r, all_flows=all_flows)
161
+ return 0
162
+
163
+ # ── Offline mode ──────────────────────────────────────────────────────────
164
+ if offline:
165
+ import os
166
+
167
+ os.environ["PATCHI_OFFLINE"] = "1"
168
+ if not quiet:
169
+ con.print(
170
+ "[bold #C8621A]OFFLINE MODE[/bold #C8621A] — Static analysis only. Zero API calls."
171
+ )
172
+ con.print(
173
+ "[dim]Findings will have no AI explanations. Run without --offline to add them.[/dim]"
174
+ )
175
+ con.print()
176
+
177
+ # ── Dry run ───────────────────────────────────────────────────────────────
178
+ if dry_run and not changed:
179
+ _show_dry_run(r, area)
180
+ return 0
181
+
182
+ # ── Changed dry-run mode ──────────────────────────────────────────────
183
+ if changed and dry_run:
184
+ _show_changed_dry_run(r, changed_commits)
185
+ return 0
186
+
187
+ # ── Handle file-specific deep scan ────────────────────────────────────────
188
+ if file_path:
189
+ _run_file_scan(r, file_path, deep)
190
+ return 0
191
+
192
+ # ── Check freshness first ─────────────────────────────────────────────────
193
+ freshness = check_freshness(r)
194
+ if (
195
+ not freshness["is_stale"]
196
+ and freshness["last_recorded"]
197
+ and not area
198
+ and not force
199
+ and not deep
200
+ and not contract
201
+ ):
202
+ con.print()
203
+ con.print(
204
+ "[dim]Brain is already fresh.[/dim] "
205
+ f"[dim]Last scan: {_fmt_time(freshness['last_recorded'])}[/dim]"
206
+ )
207
+ con.print("[dim]Run [bold]p scan --force[/bold] to re-scan anyway.[/dim]")
208
+ con.print()
209
+ # Still show current status
210
+ _show_summary_from_memory(r)
211
+ return 0
212
+
213
+ # ── Scan ──────────────────────────────────────────────────────────────────
214
+ con.print()
215
+ area_label = f" [dim]→ {area}[/dim]" if area else ""
216
+ scan_type = "Deep" if deep else "Scanning"
217
+ con.print(f"[bold #C8621A]{scan_type}{area_label}[/bold #C8621A]")
218
+ con.print()
219
+
220
+ # Chain progress display
221
+ from patchi.cli.ux import format_step, status_icon, status_style
222
+
223
+ scan_phases = [
224
+ "File discovery",
225
+ "Source parsing",
226
+ "Framework detection",
227
+ "Route mapping",
228
+ "Import graph",
229
+ "Contract inference",
230
+ "Scanner agents",
231
+ ]
232
+ total_steps = len(scan_phases)
233
+ current_step = 0
234
+
235
+ def show_scan_step(step_name: str, status: str = "running"):
236
+ nonlocal current_step
237
+ current_step += 1
238
+ icon = status_icon(status)
239
+ style = status_style(status)
240
+ con.print(format_step(current_step, total_steps, f"[{style}]{icon} {step_name}[/{style}]"))
241
+
242
+ # Show initial chain steps
243
+ for phase_name in scan_phases[:6]: # First 6 are brain phases
244
+ show_scan_step(phase_name, "done")
245
+
246
+ _scan_start = time.monotonic() # wall clock for entire scan
247
+
248
+ progress = _build_progress()
249
+ tasks: dict[str, Any] = {}
250
+
251
+ phases = {
252
+ "discovery": "Discovering files",
253
+ "parsing": "Parsing source files",
254
+ "framework": "Detecting framework",
255
+ "routes": "Mapping routes",
256
+ "graph": "Building import graph",
257
+ "contract": "Inferring app contract",
258
+ }
259
+
260
+ for phase, label in phases.items():
261
+ tasks[phase] = progress.add_task(
262
+ f"[dim]{label}[/dim]",
263
+ total=None, # indeterminate until we know file count
264
+ )
265
+
266
+ last_phase = [None]
267
+
268
+ def on_progress(sp: ScanProgress) -> None:
269
+ task_id = tasks.get(sp.phase)
270
+ if task_id is None:
271
+ return
272
+
273
+ # Advance completed previous phase
274
+ if last_phase[0] and last_phase[0] != sp.phase:
275
+ prev_id = tasks.get(last_phase[0])
276
+ if prev_id is not None:
277
+ progress.update(prev_id, completed=100, total=100)
278
+ last_phase[0] = sp.phase
279
+
280
+ if sp.total:
281
+ progress.update(
282
+ task_id,
283
+ total=sp.total,
284
+ completed=sp.current,
285
+ description=f"[dim]{sp.message[:60]}[/dim]",
286
+ )
287
+ else:
288
+ progress.update(task_id, description=f"[dim]{sp.message[:60]}[/dim]", total=None)
289
+
290
+ report: BrainReport | None = None
291
+ agent_results: list | None = None
292
+ error: str | None = None
293
+
294
+ _is_tty = con.is_terminal
295
+
296
+ # ── Pre-load DomainLoader in background (saves ~5-10s) ──────────────
297
+ # Auto-detect component types first (cheap), then load scoped taxonomy
298
+ # in a background thread while the main scan runs.
299
+ _ctypes = []
300
+ try:
301
+ _root = Path(str(r))
302
+ if any((_root / d).exists() for d in ("templates", "static", "public")):
303
+ _ctypes.append("frontend-web")
304
+ if any((_root / f).exists() for f in ("requirements.txt", "pyproject.toml", "setup.py")):
305
+ _ctypes.append("backend-api")
306
+ if any((_root / d).exists() for d in ("docker", "k8s", "kubernetes", ".github")) or (_root / "Dockerfile").exists():
307
+ _ctypes.append("infra")
308
+ except Exception as _exc:
309
+ _log.debug("component type detect skipped: %s", _exc)
310
+ _domain_loader_future = None
311
+ try:
312
+ import concurrent.futures as _cf
313
+
314
+ from patchi.core.security.domain_loader import DomainLoader as _DL
315
+ _loader_pool = _cf.ThreadPoolExecutor(max_workers=1, thread_name_prefix="dl-preload")
316
+ def _preload_loader() -> _DL:
317
+ # Construct + force the (lazy) load so YAML parsing happens in
318
+ # this background thread, not on first use in the main thread.
319
+ _ldr = _DL(r, component_types=_ctypes if _ctypes else None)
320
+ _ldr.list_domains() # public force-load; parses + caches taxonomy
321
+ return _ldr
322
+
323
+ _domain_loader_future = _loader_pool.submit(_preload_loader)
324
+ _loader_pool.shutdown(wait=False)
325
+ except Exception as _exc:
326
+ _log.debug('suppressed: %s', _exc)
327
+
328
+ def _run_scan() -> None:
329
+ nonlocal report, agent_results, error
330
+ try:
331
+ brain = Brain(r, on_progress=on_progress)
332
+ report = brain.scan(area)
333
+
334
+ # ── Run scanner agents via coordinator ─────────────────────────────
335
+ # Import scanners to trigger @register decorators
336
+ import patchi.core.agents.scanners # noqa: F401
337
+ from patchi.core.agents.coordinator import Coordinator, CoordinatorProgress
338
+
339
+ if not governor:
340
+ agents_task = progress.add_task(
341
+ "[dim]Running scanner agents…[/dim]", total=len(list_agents(AgentGroup.SCANNER))
342
+ )
343
+
344
+ def on_agent_progress(cp: CoordinatorProgress) -> None:
345
+ progress.update(
346
+ agents_task,
347
+ completed=cp.current,
348
+ total=cp.total,
349
+ description=f"[dim]{cp.agent_name} — {cp.finding_count} findings[/dim]",
350
+ )
351
+
352
+ coord = Coordinator(r, on_progress=on_agent_progress)
353
+ scope = list(report.import_graph.nodes) if report.import_graph else []
354
+
355
+ # ── On-demand domain activation from git diff ────────────
356
+ if changed:
357
+ try:
358
+ from patchi.core.security.git_diff_activator import (
359
+ activate_from_diff,
360
+ )
361
+
362
+ diff_result = activate_from_diff(r, commits=changed_commits)
363
+ if diff_result.activated_domains:
364
+ con.print(
365
+ f" [dim]Changed files: {len(diff_result.changed_files)}[/dim]"
366
+ )
367
+ dom_str = ", ".join(
368
+ f"{d} ({s:.1f})"
369
+ for d, s in list(diff_result.activated_domains.items())[:8]
370
+ )
371
+ con.print(f" [dim]Activated domains: {dom_str}[/dim]")
372
+ coord.set_active_domains(list(diff_result.activated_domains.keys()))
373
+ else:
374
+ con.print(
375
+ " [dim]No domain-relevant changes detected — running full scan[/dim]"
376
+ )
377
+ except Exception as e:
378
+ _log.debug("Git-diff activation failed: %s", e)
379
+
380
+ agent_results = coord.run_all_scanners(scope=scope if area else None, side=side)
381
+
382
+ # ── Noise trim: license & extended are opt-in ─────────────
383
+ # Main scan stays focused; heavy/noisy audits are separate
384
+ # runs: `p scan --with-license` and `p scan --with-extended`
385
+ _license_types = {"copyleft_license", "unknown_license", "missing_license"}
386
+ _license_suppressed = 0
387
+ _extended_suppressed = 0
388
+ if not with_license:
389
+ for ar in agent_results:
390
+ before = len(ar.findings)
391
+ ar.findings = [
392
+ f for f in ar.findings
393
+ if f.type not in _license_types and "license" not in f.type.lower()
394
+ ]
395
+ _license_suppressed += before - len(ar.findings)
396
+ if not with_extended:
397
+ # Extended = duplicate/hygiene heavy hitters that drown signal
398
+ # For now we keep them but count; future: skip those agents
399
+ pass
400
+ if _license_suppressed and not quiet:
401
+ con.print(
402
+ f"[dim] license findings suppressed: {_license_suppressed} "
403
+ "(run [cyan]p scan --with-license[/cyan] for full audit)[/dim]"
404
+ )
405
+
406
+ # ── Noise filter: discard low-value findings from tests/fixtures/locks/generated/docs
407
+ # This is the user-reported "1000s when 74 are real" fix — NoiseFilter was defined
408
+ # but never wired into the scan pipeline. We apply it here before any reporting.
409
+ try:
410
+ from patchi.core.security.noise_filter import NoiseFilter
411
+
412
+ nf = NoiseFilter(root=r, config={"noise_filter": {"enabled": True, "mode": "discard", "skip_tests": True, "skip_locks": True, "skip_generated": True, "skip_docs": True}})
413
+ _noise_before = sum(len(getattr(ar, "findings", [])) for ar in agent_results)
414
+ _noise_by_cat: dict[str, int] = {}
415
+ for ar in agent_results:
416
+ kept, rep = nf.apply(getattr(ar, "findings", []))
417
+ ar.findings = kept # type: ignore
418
+ for cat, cnt in rep.by_category.items():
419
+ _noise_by_cat[cat] = _noise_by_cat.get(cat, 0) + cnt
420
+ _noise_after = sum(len(getattr(ar, "findings", [])) for ar in agent_results)
421
+ _noise_discarded = _noise_before - _noise_after
422
+ if _noise_discarded and not quiet:
423
+ cats = ", ".join(f"{k}={v}" for k, v in sorted(_noise_by_cat.items()))
424
+ con.print(f"[dim] noise filtered: {_noise_discarded} discarded ({cats}) — real findings kept[/dim]")
425
+ except Exception as _exc:
426
+ _log.debug("noise filter failed: %s", _exc)
427
+
428
+ # ── Confidence gate: demote low-confidence medium/low (trust fix)
429
+ try:
430
+ from patchi.core.agents.base import Severity as _Sev
431
+ from patchi.core.security.confidence_gate import ConfidenceGate
432
+ from patchi.core.security.orchestrator import CorrelatedFinding
433
+
434
+ # Build a pseudo report for gating — we reuse the gate's scoring without re-running AI
435
+ _gate = ConfidenceGate(root=r, config={"confidence_gate": {"ai_weight": 0.0, "min_agents_for_defend": 1, "fp_auto_discard": False}})
436
+ _gate_before = sum(len(getattr(ar, "findings", [])) for ar in agent_results)
437
+ for ar in agent_results:
438
+ new_findings = []
439
+ for f in getattr(ar, "findings", []):
440
+ # Build minimal CorrelatedFinding for scoring
441
+ cf = CorrelatedFinding(finding=f, confirmed_by=[f.agent], composite_score=0.0)
442
+ score = _gate._compute_score(cf) # type: ignore
443
+ tier = _gate._assign_tier(score)
444
+ routing = _gate._assign_routing(tier, cf)
445
+ # Trust fix: keep critical/high always; medium/low only if high confidence
446
+ if routing == "discard" or (tier == "low" and f.severity in (_Sev.MEDIUM, _Sev.LOW, _Sev.INFO)):
447
+ continue
448
+ new_findings.append(f)
449
+ ar.findings = new_findings # type: ignore
450
+ _gate_after = sum(len(getattr(ar, "findings", [])) for ar in agent_results)
451
+ _gate_discarded = _gate_before - _gate_after
452
+ if _gate_discarded and not quiet:
453
+ con.print(f"[dim] confidence gate: {_gate_discarded} low-trust medium/low discarded[/dim]")
454
+ except Exception as _exc:
455
+ _log.debug("confidence gate skipped: %s", _exc)
456
+
457
+ # ── --since: keep only findings in files changed since git ref
458
+ if since:
459
+ try:
460
+ from patchi.core import ci_bundle
461
+
462
+ _since_before = sum(len(getattr(ar, "findings", [])) for ar in agent_results)
463
+ for ar in agent_results:
464
+ _dicts = [f.to_dict() for f in getattr(ar, "findings", [])]
465
+ _kept = ci_bundle.filter_since(_dicts, since, r)
466
+ _keep = {(d.get("file"), d.get("line"), d.get("type")) for d in _kept}
467
+ ar.findings = [ # type: ignore
468
+ f for f in getattr(ar, "findings", [])
469
+ if (f.file, f.line, f.type) in _keep
470
+ ]
471
+ _since_cut = _since_before - sum(len(getattr(ar, "findings", [])) for ar in agent_results)
472
+ if not quiet:
473
+ con.print(f"[dim] --since {since}: {_since_cut} finding(s) outside changed files hidden[/dim]")
474
+ except Exception as _exc:
475
+ _log.debug("--since filter skipped: %s", _exc)
476
+
477
+ # ── Blame annotation §10.3.2 — who introduced each error and when
478
+ try:
479
+ from patchi.core.brain.git_aware import annotate_findings_with_blame
480
+
481
+ _blame_n = 0
482
+ for ar in agent_results:
483
+ _blame_n += annotate_findings_with_blame(getattr(ar, "findings", []), r, max_workers=4)
484
+ if _blame_n and not quiet:
485
+ con.print(f"[dim] blame: { _blame_n} findings annotated with git author/date[/dim]")
486
+ except Exception as _exc:
487
+ _log.debug("blame annotate skipped: %s", _exc)
488
+
489
+ # ── Ignore expiry §10.2.3 — patchi-ignore with date, warn on expired
490
+ try:
491
+ from patchi.core.security.ignore_expiry import filter_ignores
492
+
493
+ _all_findings = [f for ar in agent_results for f in getattr(ar, "findings", [])]
494
+ _kept, _supp, _exp = filter_ignores(_all_findings, r)
495
+ # Apply kept back to agents (preserve per-agent buckets for reporting)
496
+ _kept_ids = {id(f) for f in _kept}
497
+ for ar in agent_results:
498
+ ar.findings = [f for f in getattr(ar, "findings", []) if id(f) in _kept_ids] # type: ignore
499
+ if _exp and not quiet:
500
+ for w in _exp[:3]:
501
+ con.print(f"[yellow]expired ignore[/yellow] {w['file']}:{w['line']} {w['rule']} was {w['message']}")
502
+ if len(_exp) > 3:
503
+ con.print(f"[dim] +{len(_exp)-3} more expired ignores[/dim]")
504
+ if _supp and not quiet:
505
+ con.print(f"[dim] ignore: {len(_supp)} findings suppressed by patchi-ignore[/dim]")
506
+ except Exception as _exc:
507
+ _log.debug("ignore expiry skipped: %s", _exc)
508
+
509
+ # ── Self-profiling: record per-agent latency/cost ──────────
510
+ try:
511
+ from patchi.core.agents.coordinator import merge_results as _pmr
512
+ from patchi.core.ai.agent_profiler import record_run
513
+
514
+ _pm = _pmr(agent_results)
515
+ for ar in agent_results or []:
516
+ aname = getattr(ar, "agent_name", type(ar).__name__)
517
+ acount = getattr(ar, "finding_count", 0)
518
+ with record_run(r, aname, files_scanned=acount) as run:
519
+ run.findings_produced = acount
520
+ except Exception as _exc:
521
+ _log.debug("profiling skipped: %s", _exc)
522
+
523
+ # ── Attack feedback loop: feed findings into learning ──────
524
+ try:
525
+ from patchi.core.agents.coordinator import merge_results as _fbr
526
+ from patchi.core.security.attack_feedback import (
527
+ record_confirmed_attack,
528
+ )
529
+
530
+ _fb = _fbr(agent_results)
531
+ for f in _fb.get("findings", []):
532
+ if f.get("severity") in ("critical", "high"):
533
+ record_confirmed_attack(
534
+ r,
535
+ {
536
+ "tool": f.get("agent", "unknown"),
537
+ "payload": f.get("message", ""),
538
+ "endpoint": f.get("file", ""),
539
+ "severity": f.get("severity", "medium"),
540
+ "evidence": f.get("message", ""),
541
+ },
542
+ )
543
+ except Exception as _exc:
544
+ _log.debug("attack feedback skipped: %s", _exc)
545
+
546
+ # Mark all tasks complete
547
+ for tid in tasks.values():
548
+ progress.update(tid, completed=100, total=100)
549
+ progress.update(agents_task, completed=len(agent_results), total=len(agent_results))
550
+
551
+ except Exception as e:
552
+ import traceback
553
+
554
+ error = f"{e}\n{traceback.format_exc()}"
555
+
556
+ if _is_tty:
557
+ with Live(progress, console=con, refresh_per_second=10):
558
+ _run_scan()
559
+ else:
560
+ _run_scan()
561
+
562
+ if error:
563
+ con.print(f"\n[red]Scan failed:[/red] {error}")
564
+ return 2
565
+
566
+ # ── Deep scan processing ──────────────────────────────────────────────────
567
+ if deep:
568
+ _run_deep_scan_analysis(r, report, agent_results)
569
+
570
+ if report is None:
571
+ con.print("\n[red]Scan returned no results.[/red]")
572
+ return 2
573
+
574
+ # ── Results summary ───────────────────────────────────────────────────────
575
+ con.print()
576
+ _scan_elapsed = time.monotonic() - _scan_start
577
+ _show_report_summary(report, agent_results, wall_time=_scan_elapsed, root=r)
578
+
579
+ # ── Threat Model Generation (auto-updated from findings) ────────────────
580
+ try:
581
+ from patchi.core.agents.coordinator import merge_results as _mr
582
+ from patchi.core.security.threat_model_updater import update_threat_model
583
+
584
+ _findings_for_tm = _mr(agent_results).get("findings", []) if agent_results else []
585
+ threat_model = update_threat_model(r, _findings_for_tm)
586
+ if threat_model.applicable_scenarios > 0:
587
+ con.print()
588
+ con.print("[bold #C8621A]─ Threat Model ─[/bold #C8621A]")
589
+ con.print(
590
+ f" Scenarios: [bold]{threat_model.applicable_scenarios}[/bold] applicable "
591
+ f"out of {threat_model.total_scenarios} total"
592
+ )
593
+ if threat_model.by_severity:
594
+ sev_str = ", ".join(f"{k}={v}" for k, v in sorted(threat_model.by_severity.items()))
595
+ con.print(f" By severity: {sev_str}")
596
+ if threat_model.recommendations:
597
+ for rec in threat_model.recommendations[:3]:
598
+ con.print(f" [dim]• {rec}[/dim]")
599
+ # Persist for web UI and assurance
600
+ tm_path = r / ".patchi" / "threat_model.json"
601
+ tm_path.parent.mkdir(parents=True, exist_ok=True)
602
+ import json as _json
603
+
604
+ tm_path.write_text(_json.dumps(threat_model.to_dict(), indent=2), encoding="utf-8")
605
+ except Exception as e:
606
+ _log.debug("Threat model generation failed: %s", e)
607
+
608
+ # ── Chain & Intent Analysis ─────────────────────────────────────────────
609
+ try:
610
+ from patchi.core.agents.base import list_agents as _la
611
+
612
+ _sec_names = {a.name for a in _la(AgentGroup.SECURITY)}
613
+ _sec_agents = [
614
+ a for a in (agent_results or []) if getattr(a, "agent_name", "") in _sec_names
615
+ ]
616
+ if _sec_agents:
617
+ from patchi.core.security.orchestrator import SecurityOrchestrator
618
+
619
+ _sec_report = SecurityOrchestrator().correlate(_sec_agents)
620
+
621
+ # ── Exploit Chains ──────────────────────────────────────
622
+ if _sec_report.chains:
623
+ con.print()
624
+ con.print("[bold #C8621A]─ Exploit Chains ─[/bold #C8621A]")
625
+ con.print(
626
+ f" [bold]{len(_sec_report.chains)}[/bold] cross-file attack paths discovered"
627
+ )
628
+ for chain in _sec_report.chains[:5]:
629
+ sev_color = {"critical": "red", "high": "red", "medium": "yellow"}.get(
630
+ chain.severity, "dim"
631
+ )
632
+ con.print(
633
+ f" [{sev_color}]●[{chain.severity}] score={chain.score:.0f} "
634
+ f"length={chain.length}[/{sev_color}]"
635
+ )
636
+ con.print(f" [dim]{chain.narrative[:100]}[/dim]")
637
+ if len(_sec_report.chains) > 5:
638
+ con.print(f" [dim]… and {len(_sec_report.chains) - 5} more[/dim]")
639
+
640
+ # ── Intent Gaps ─────────────────────────────────────────
641
+ intent = _sec_report.intent_report
642
+ if intent and intent.gap_count > 0:
643
+ con.print()
644
+ con.print("[bold #C8621A]─ Intent Gaps ─[/bold #C8621A]")
645
+ con.print(
646
+ f" [bold]{intent.gap_count}[/bold] logic gaps across "
647
+ f"[bold]{len(intent.routes)}[/bold] routes"
648
+ )
649
+ if intent.unauthenticated_state_changing:
650
+ con.print(
651
+ f" [red]● {len(intent.unauthenticated_state_changing)}[/red] "
652
+ f"state-changing routes without auth"
653
+ )
654
+ for r in intent.unauthenticated_state_changing[:3]:
655
+ con.print(f" [dim]{r.method} {r.path} @ {r.file}:{r.line}[/dim]")
656
+ if intent.admin_without_strict_guard:
657
+ con.print(
658
+ f" [yellow]● {len(intent.admin_without_strict_guard)}[/yellow] "
659
+ f"admin routes without strict guard"
660
+ )
661
+ for r in intent.admin_without_strict_guard[:3]:
662
+ con.print(f" [dim]{r.method} {r.path} @ {r.file}:{r.line}[/dim]")
663
+ if intent.unprotected_among_protected:
664
+ con.print(
665
+ f" [yellow]● {len(intent.unprotected_among_protected)}[/yellow] "
666
+ f"unprotected routes among protected peers"
667
+ )
668
+ for r in intent.unprotected_among_protected[:3]:
669
+ con.print(f" [dim]{r.method} {r.path} @ {r.file}:{r.line}[/dim]")
670
+
671
+ if _sec_report.charter_violations:
672
+ con.print(
673
+ f" [bold]{len(_sec_report.charter_violations)}[/bold] "
674
+ f"[yellow]charter violation(s)[/yellow]"
675
+ )
676
+ for cv in _sec_report.charter_violations[:5]:
677
+ sev = cv.get("severity", "medium")
678
+ con.print(
679
+ f" [yellow]● [{sev}] {cv.get('rule_id', '?')}[/yellow]: "
680
+ f"{cv.get('message', '')}"
681
+ )
682
+ if cv.get("suggestion"):
683
+ con.print(f" [dim]→ {cv['suggestion']}[/dim]")
684
+ if len(_sec_report.charter_violations) > 5:
685
+ con.print(
686
+ f" [dim]… and {len(_sec_report.charter_violations) - 5} more[/dim]"
687
+ )
688
+
689
+ # Persist for web UI
690
+ import json as _cjson
691
+
692
+ _ci_path = r / ".patchi" / "chain_intent.json"
693
+ _ci_path.parent.mkdir(parents=True, exist_ok=True)
694
+ _ci_path.write_text(_cjson.dumps(_sec_report.to_dict(), indent=2), encoding="utf-8")
695
+
696
+ # ── Feed chains + intent into assurance graph ──────────────
697
+ try:
698
+ from patchi.core.security.chain_to_assurance import feed_chains_to_graph
699
+
700
+ _fed = feed_chains_to_graph(r)
701
+ if _fed:
702
+ con.print(f" [dim]Fed {_fed} evidence items into assurance graph[/dim]")
703
+ except Exception as e:
704
+ _log.debug("Chain-to-assurance bridge failed: %s", e)
705
+
706
+ except Exception as e:
707
+ _log.debug("Chain/intent analysis failed: %s", e)
708
+
709
+ # ── Assurance analysis (attackers, campaigns, fuzz) ─────────────────────
710
+ if with_attackers or with_campaigns or with_fuzz:
711
+ con.print()
712
+ con.print("[bold #C8621A]─ Assurance Analysis ─[/bold #C8621A]")
713
+ try:
714
+ from patchi.core.assurance.graph import AssuranceGraph
715
+
716
+ agraph = AssuranceGraph.load(r)
717
+ if not agraph.claims:
718
+ con.print(
719
+ " [dim]No assurance graph found — run 'p scan' first to build one.[/dim]"
720
+ )
721
+ else:
722
+ con.print(f" [dim]Loaded assurance graph: {len(agraph.claims)} claims[/dim]")
723
+
724
+ # ── Campaigns ────────────────────────────────────────────
725
+ if with_campaigns:
726
+ from patchi.core.campaigns import CampaignOrchestrator
727
+
728
+ orch = CampaignOrchestrator(agraph)
729
+ campaign_result = orch.run_all()
730
+ con.print(f" Campaigns: [bold]{len(campaign_result.campaigns)}[/bold] run")
731
+ for cr in campaign_result.campaigns:
732
+ status = (
733
+ "[green]PASS[/green]"
734
+ if cr.total_findings == 0
735
+ else f"[yellow]{cr.total_findings} findings[/yellow]"
736
+ )
737
+ con.print(f" {cr.name}: {status}")
738
+ for step in cr.steps:
739
+ if step.findings:
740
+ for f in step.findings:
741
+ sev = f.get("severity", "info")
742
+ con.print(f" [{sev}] {f.get('detail', '')[:80]}")
743
+
744
+ # ── Attackers ────────────────────────────────────────────
745
+ if with_attackers:
746
+ from patchi.core.attackers import AttackPlanner
747
+
748
+ planner = AttackPlanner(agraph)
749
+ attack_results = planner.run_all()
750
+ confirmed = [r for r in attack_results if r.confirmed]
751
+ con.print(
752
+ f" Attackers: [bold]{len(attack_results)}[/bold] hypotheses tested, "
753
+ f"[bold]{len(confirmed)}[/bold] confirmed"
754
+ )
755
+ for r in confirmed[:10]:
756
+ con.print(f" [red]●[/red] {r.hypothesis.attacker}: {r.evidence[:70]}")
757
+
758
+ # ── Fuzz ─────────────────────────────────────────────────
759
+ if with_fuzz:
760
+ from patchi.core.fuzz import InputFuzzer
761
+
762
+ fuzzer = InputFuzzer(seed=42)
763
+ # Fuzz route parameters
764
+ route_finds = 0
765
+ for claim in agraph.claims.values():
766
+ if "endpoint" in claim.domain:
767
+ route_finds += 1
768
+ con.print(f" Fuzz: [bold]{route_finds}[/bold] endpoints available for fuzzing")
769
+ if route_finds > 0:
770
+ sample = fuzzer.fuzz_string("test", count=5)
771
+ con.print(f" Generated {len(sample)} sample mutations")
772
+
773
+ except Exception as e:
774
+ import traceback
775
+
776
+ con.print(f" [red]Assurance analysis error: {e}[/red]")
777
+ con.print(traceback.format_exc())
778
+
779
+ # ── Red Team Engine (live attack simulation) ─────────────────────────────
780
+ if red_team:
781
+ con.print()
782
+ con.print("[bold #C8621A]─ Red Team Engine ─[/bold #C8621A]")
783
+ try:
784
+ from patchi.core.security.red_team_engine import RedTeamEngine
785
+
786
+ # Launcher-provided base_url first (config port, then dev ports)
787
+ target_url = None
788
+ try:
789
+ from patchi.core.testing._browser import find_server
790
+
791
+ target_url = find_server(r, {}, None)
792
+ except Exception as exc: # noqa: BLE001
793
+ _log.debug("red-team target resolve failed: %s", exc)
794
+ if target_url:
795
+ con.print(f" [dim]Target: {target_url} (detected running server)[/dim]")
796
+ else:
797
+ con.print(" [dim]No running web server detected — running code-only attacks[/dim]")
798
+
799
+ engine = RedTeamEngine(
800
+ root=r,
801
+ target_url=target_url,
802
+ safe_mode=True,
803
+ on_progress=lambda msg: con.print(f" [dim]{msg}[/dim]"),
804
+ )
805
+ import asyncio
806
+
807
+ report = asyncio.run(
808
+ engine.run_assessment(
809
+ scope="full",
810
+ intensity="standard",
811
+ )
812
+ )
813
+ con.print(f" Scenarios run: [bold]{len(report.scenarios_run)}[/bold]")
814
+ con.print(f" Findings: [bold]{report.total_findings}[/bold]")
815
+ if report.by_severity:
816
+ sev_str = ", ".join(f"{k}={v}" for k, v in sorted(report.by_severity.items()))
817
+ con.print(f" By severity: {sev_str}")
818
+ if report.remediation_playbooks:
819
+ con.print(f" Playbooks: [bold]{len(report.remediation_playbooks)}[/bold]")
820
+
821
+ # Auto-fix confirmed findings
822
+ if report.total_findings > 0:
823
+ con.print("\n [dim]Generating fixes for confirmed findings...[/dim]")
824
+ from patchi.core.security.auto_fixer import AutoFixer
825
+
826
+ fixer = AutoFixer(
827
+ r, cfg.load(r), on_progress=lambda msg: con.print(f" [dim]{msg}[/dim]")
828
+ )
829
+ for scenario in report.scenarios_run:
830
+ for finding in scenario.findings:
831
+ import asyncio
832
+
833
+ result = asyncio.run(
834
+ fixer.fix_finding(finding, strategy="auto", apply=False, verify=False)
835
+ )
836
+ if result.get("success"):
837
+ con.print(
838
+ f" [green]Fixed[/green] {finding.type} → patch {result['patch_id']}"
839
+ )
840
+
841
+ except Exception as e:
842
+ import traceback
843
+
844
+ con.print(f" [red]Red team error: {e}[/red]")
845
+ con.print(traceback.format_exc())
846
+
847
+ # ── DAST (Dynamic Application Security Testing) ──────────────────────────
848
+ if dast:
849
+ con.print()
850
+ con.print("[bold #C8621A]─ DAST Scanner ─[/bold #C8621A]")
851
+ try:
852
+ import asyncio
853
+
854
+ from patchi.core.security.dast_scanner import DastScanner
855
+
856
+ # Auto-detect target URL
857
+ target_url = None
858
+ try:
859
+ import urllib.request
860
+
861
+ urllib.request.urlopen("http://127.0.0.1:1612/api/health", timeout=2)
862
+ target_url = "http://127.0.0.1:1612"
863
+ con.print(f" [dim]Target: {target_url} (detected running server)[/dim]")
864
+ except Exception:
865
+ con.print(" [yellow]No running web server detected on :1612[/yellow]")
866
+ con.print(" [dim]Start the web server first: p web[/dim]")
867
+
868
+ if target_url:
869
+ scanner = DastScanner(
870
+ root=r,
871
+ target_url=target_url,
872
+ on_progress=lambda msg: con.print(f" [dim]{msg}[/dim]"),
873
+ )
874
+ report = asyncio.run(scanner.run())
875
+
876
+ con.print(f" Pages tested: [bold]{report.pages_tested}[/bold]")
877
+ con.print(f" Findings: [bold]{len(report.findings)}[/bold]")
878
+ con.print(f" Screenshots: [bold]{len(report.screenshots)}[/bold]")
879
+
880
+ if report.findings:
881
+ sev_counts = {}
882
+ for f in report.findings:
883
+ sev_counts[f.severity] = sev_counts.get(f.severity, 0) + 1
884
+ sev_str = ", ".join(f"{k}={v}" for k, v in sorted(sev_counts.items()))
885
+ con.print(f" By severity: {sev_str}")
886
+ con.print()
887
+ for f in report.findings[:10]:
888
+ sev_color = {
889
+ "critical": "red",
890
+ "high": "red",
891
+ "medium": "yellow",
892
+ "low": "dim",
893
+ }.get(f.severity, "dim")
894
+ con.print(
895
+ f" [{sev_color}] [{f.severity}] {f.test}: {f.evidence[:60]}[/{sev_color}]"
896
+ )
897
+ else:
898
+ con.print(" [green]No security issues found.[/green]")
899
+
900
+ if report.errors:
901
+ con.print(f" [dim]Errors: {len(report.errors)}[/dim]")
902
+
903
+ except Exception as e:
904
+ import traceback
905
+
906
+ con.print(f" [red]DAST error: {e}[/red]")
907
+ con.print(traceback.format_exc())
908
+
909
+ # ── Domain enrichment (always runs) ────────────────────────────────────
910
+ try:
911
+ from patchi.core.security.domain_loader import DomainLoader
912
+ # Use pre-loaded DomainLoader if available, else create new
913
+ _dl = None
914
+ if _domain_loader_future is not None:
915
+ try:
916
+ _dl = _domain_loader_future.result(timeout=0)
917
+ except Exception as _exc:
918
+ _log.debug('suppressed: %s', _exc)
919
+ if _dl is None:
920
+ _dl = DomainLoader(r, component_types=_ctypes if _ctypes else None)
921
+ _SEC_AGENTS = {
922
+ "EnvScanner", "SideFileScanner", "CoreScanner",
923
+ "DependencyScanner", "RouteGraphScanner", "SBOMGeneratorAgent",
924
+ "CommentScanner", "DeadCodeScanner", "DeadCodeHygieneAgent",
925
+ }
926
+ for _ar in (agent_results or []):
927
+ _aname = getattr(_ar, "agent_name", "") or ""
928
+ for _f in getattr(_ar, "findings", []):
929
+ # Skip enrichment for agents that never produce security findings
930
+ _fagent = getattr(_f, "agent", "") or _aname
931
+ if _fagent not in _SEC_AGENTS:
932
+ continue
933
+ _msg = getattr(_f, "message", "") or ""
934
+ _file = getattr(_f, "file", "") or ""
935
+ _type = getattr(_f, "type", "") or _fagent
936
+ _ctrls = _dl.match_finding_to_controls(_type, _file, _msg)
937
+ if _ctrls:
938
+ _f.extra["domain_controls"] = [
939
+ {"control_id": c.control_id, "name": c.name, "severity": c.severity}
940
+ for c in _ctrls[:5]
941
+ ]
942
+ _pb = _dl.get_playbook(_ctrls[0].control_id)
943
+ if _pb:
944
+ _f.extra["playbook_ref"] = _pb.control_id
945
+ _f.extra["fix_strategy"] = _pb.fix_strategy
946
+ except Exception as _e:
947
+ import logging
948
+ logging.getLogger("patchi.scan").debug("Domain enrichment skipped: %s", _e)
949
+
950
+ # ── Pipeline / defense mode ───────────────────────────────────────────────
951
+ if pipeline:
952
+ con.print()
953
+ con.print("[bold #C8621A]─ Defense Pipeline ─[/bold #C8621A]")
954
+ try:
955
+ from patchi.core.security.defenders import ADAPTER_REGISTRY, DefenseAction, get_adapter
956
+ from patchi.core.security.defense_layer import DefenseLayer
957
+ from patchi.core.security.detection_pipeline import DetectionPipeline
958
+ from patchi.core.security.orchestrator import SecurityOrchestrator
959
+
960
+ con.print(f" [dim]Adapter registry: {len(ADAPTER_REGISTRY)} adapters loaded[/dim]")
961
+
962
+ sec_group_names = {a.name for a in list_agents(AgentGroup.SECURITY)}
963
+ sec_group_names.update(
964
+ {
965
+ "DependencyScanner",
966
+ "EnvScanner",
967
+ "SideFileScanner",
968
+ }
969
+ )
970
+ sec_agents = [
971
+ a for a in agent_results if getattr(a, "agent_name", "") in sec_group_names
972
+ ]
973
+ report_sec = SecurityOrchestrator().correlate(sec_agents)
974
+ pipeline_inst = DetectionPipeline(r, cfg.load(r))
975
+ gated = pipeline_inst.process(report_sec)
976
+ con.print(
977
+ f" Findings gated: [bold]{len(gated.findings)}[/bold] "
978
+ f"(defend={len(gated.defend)}, "
979
+ f"ai_analyze={len(gated.ai_analyze)}, "
980
+ f"human_review={len(gated.human_review)}, "
981
+ f"discarded={len(gated.discarded)})"
982
+ )
983
+ noise_stats = gated.stats.get("noise")
984
+ if noise_stats:
985
+ cats = ", ".join(
986
+ f"{k}={v}" for k, v in sorted(noise_stats.get("by_category", {}).items())
987
+ )
988
+ con.print(
989
+ f" Noise muted: [bold]{noise_stats.get('capped', 0)}[/bold] capped, "
990
+ f"[bold]{noise_stats.get('discarded', 0)}[/bold] discarded"
991
+ + (f" ({cats})" if cats else "")
992
+ )
993
+ if gated.defend:
994
+ # Use adapter registry directly for each finding
995
+ from patchi.core.fix.risk_gate import RiskGate
996
+
997
+ risk_gate = RiskGate(r)
998
+ results = []
999
+ action_counts = {"applied": 0, "queued": 0, "blocked": 0, "skipped": 0}
1000
+ for gf in gated.defend:
1001
+ f = gf.finding
1002
+ ftype = f.type.lower()
1003
+ # Map finding type to action type
1004
+ action_type = None
1005
+ for key, val in DefenseLayer._finding_to_action_map().items():
1006
+ if key in ftype or ftype in key:
1007
+ action_type = val
1008
+ break
1009
+ if action_type is None:
1010
+ action_type = "escalate"
1011
+
1012
+ target = f.file
1013
+ if action_type == "block_ip":
1014
+ target = (
1015
+ f.extra.get("ip", "")
1016
+ if hasattr(f, "extra") and isinstance(f.extra, dict)
1017
+ else ""
1018
+ )
1019
+
1020
+ action = DefenseAction(
1021
+ type=action_type,
1022
+ target=target,
1023
+ finding=f,
1024
+ severity=f.severity.value
1025
+ if hasattr(f.severity, "value")
1026
+ else str(f.severity),
1027
+ fix_code=f.suggestion or "",
1028
+ )
1029
+ adapter = get_adapter(action_type, root=r, risk_gate=risk_gate)
1030
+ result = adapter.execute(action)
1031
+ results.append(result)
1032
+ action_counts[result.action] = action_counts.get(result.action, 0) + 1
1033
+
1034
+ con.print(
1035
+ f" Defense actions: [bold]{action_counts['applied']}[/bold] applied, "
1036
+ f"[bold]{action_counts['queued']}[/bold] queued, "
1037
+ f"[bold]{action_counts['blocked']}[/bold] blocked, "
1038
+ f"[bold]{action_counts['skipped']}[/bold] skipped"
1039
+ )
1040
+ for d in results:
1041
+ if d.action == "applied":
1042
+ adapter_name = type(d.defense_action).__name__ if d.defense_action else "?"
1043
+ con.print(
1044
+ f" [green]✓[/green] {d.defense_action.type} → {d.defense_action.target} (via {adapter_name})"
1045
+ )
1046
+ elif d.action == "queued":
1047
+ con.print(
1048
+ f" [yellow]⏳[/yellow] {d.defense_action.type} → queued for review"
1049
+ )
1050
+ else:
1051
+ con.print(" [dim]No actionable defense findings.[/dim]")
1052
+ except Exception as e:
1053
+ import traceback
1054
+
1055
+ con.print(f" [red]Pipeline error: {e}[/red]")
1056
+ con.print(traceback.format_exc())
1057
+
1058
+ # ── Daemon mode ───────────────────────────────────────────────────────────
1059
+ if daemon:
1060
+ con.print()
1061
+ con.print("[bold #C8621A]─ Scan Scheduler Daemon ─[/bold #C8621A]")
1062
+ try:
1063
+ from patchi.core.security.scheduler import ScanScheduler
1064
+
1065
+ scheduler = ScanScheduler(r, cfg.load(r), on_result=lambda res: None)
1066
+ scheduler.start()
1067
+ if scheduler.is_running:
1068
+ con.print(
1069
+ f" [green]✓[/green] Scheduler started with {len(scheduler._agents)} security agents"
1070
+ )
1071
+ con.print(
1072
+ f" [dim]Default interval: {cfg.load(r).get('pipeline', {}).get('scheduler', {}).get('intervals', {}).get('default', '1h')}[/dim]"
1073
+ )
1074
+ con.print(
1075
+ " [dim]Use [bold]p hosted daemon[/bold] for production daemon mode[/dim]"
1076
+ )
1077
+ else:
1078
+ con.print(
1079
+ " [yellow]Scheduler not enabled (pipeline.scheduler.enabled=false)[/yellow]"
1080
+ )
1081
+ except Exception as e:
1082
+ import traceback
1083
+
1084
+ con.print(f" [red]Daemon error: {e}[/red]")
1085
+ con.print(traceback.format_exc())
1086
+
1087
+ # ── Governor v2 pipeline ─────────────────────────────────────────────────
1088
+ if governor:
1089
+ from patchi.core.agents.governor import Governor
1090
+
1091
+ con.print()
1092
+ con.print("[bold #C8621A]─ Governor v2 Pipeline ─[/bold #C8621A]")
1093
+ gov = Governor(r)
1094
+ try:
1095
+ results = gov.run_full_pipeline_v2()
1096
+ for pr in results:
1097
+ status_style = "#4ADE80" if pr.passed else "#FF4D6D"
1098
+ con.print(
1099
+ f" {pr.phase.value}: [bold {status_style}]{pr.status.value}[/bold {status_style}]"
1100
+ f" [dim]{pr.duration_ms}ms {pr.findings_count} findings {pr.agents_run} agents[/dim]"
1101
+ )
1102
+ if pr.errors:
1103
+ for err in pr.errors[:3]:
1104
+ con.print(f" [dim] {err}[/dim]")
1105
+ con.print(
1106
+ f" [bold]Pipeline {'[#4ADE80]PASSED[/#4ADE80]' if any(r.passed for r in results) else '[#FF4D6D]FAILED[/#FF4D6D]'}[/bold]"
1107
+ )
1108
+ except Exception as e:
1109
+ import traceback
1110
+
1111
+ con.print(f" [red]Governor pipeline error: {e}[/red]")
1112
+ con.print(traceback.format_exc())
1113
+ finally:
1114
+ gov.close()
1115
+
1116
+ # ── Contract confirmation ─────────────────────────────────────────────────
1117
+ import sys
1118
+
1119
+ is_interactive = sys.stdin.isatty() if hasattr(sys.stdin, "isatty") else False
1120
+
1121
+ if contract and is_interactive and report.inferred_flows:
1122
+ _run_contract_confirmation(r, report, all_flows=all_flows)
1123
+
1124
+ elif report.inferred_flows:
1125
+ # New flows inferred that aren't yet confirmed
1126
+ new_flows = [
1127
+ f for f in report.inferred_flows if f.id not in {cf.id for cf in report.confirmed_flows}
1128
+ ]
1129
+ if new_flows:
1130
+ con.print()
1131
+ con.print(
1132
+ f"[yellow]![/yellow] [dim]{len(new_flows)} new potential critical flow(s) found.[/dim]"
1133
+ )
1134
+ con.print("[dim]Run [bold]p scan --contract[/bold] to review and confirm.[/dim]")
1135
+
1136
+ # ── Doc validation ────────────────────────────────────────────────────────
1137
+ dv = report.doc_validation or {}
1138
+ if dv.get("total_claims", 0) > 0:
1139
+ stale = len(dv.get("stale_claims", []))
1140
+ validated = len(dv.get("validated_claims", []))
1141
+ total = dv["total_claims"]
1142
+ doc_files = len(dv.get("doc_files_found", []))
1143
+ if stale > 0:
1144
+ con.print(
1145
+ f"[yellow]![/yellow] [dim]{stale}/{total} doc claim(s) stale — "
1146
+ f"docs say it but code doesn't have it[/dim]"
1147
+ )
1148
+ else:
1149
+ con.print(
1150
+ f"[dim]{validated}/{total} doc claim(s) verified across {doc_files} file(s)[/dim]"
1151
+ )
1152
+
1153
+ # ── Health score ──────────────────────────────────────────────────────────
1154
+ health_score = None
1155
+ try:
1156
+ from patchi.core.health import compute as compute_health
1157
+
1158
+ hs = compute_health(r)
1159
+ health_score = hs.total
1160
+ con.print()
1161
+ con.print(
1162
+ f"[bold {hs.color}]● Health: {hs.total}/100 ({hs.grade})[/bold {hs.color}] "
1163
+ f"[dim]security {hs.security:.0f} tests {hs.test_coverage:.0f} "
1164
+ f"dead code {hs.dead_code:.0f} deps {hs.dependency:.0f} "
1165
+ f"contract {hs.contract:.0f}[/dim]"
1166
+ )
1167
+ except Exception as e:
1168
+ con.print(f"[dim]Health score unavailable: {e}[/dim]")
1169
+
1170
+ # ── Auto-update BRAIN.md ──────────────────────────────────────────────────
1171
+ try:
1172
+ _brain_path = r / ".patchi" / "BRAIN.md"
1173
+ if _brain_path.exists():
1174
+ import time as _t
1175
+ if _t.time() - _brain_path.stat().st_mtime > 300:
1176
+ con.print("[dim]BRAIN.md is stale. Run p scan to regenerate.[/dim]")
1177
+ except Exception as e:
1178
+ con.print(f"[dim]BRAIN.md auto-update failed: {e}[/dim]")
1179
+
1180
+ # ── JSON output ──────────────────────────────────────────────────────────
1181
+ if json_output:
1182
+ import json
1183
+
1184
+ from patchi.core.agents.coordinator import merge_results
1185
+
1186
+ merged = (
1187
+ merge_results(agent_results) if agent_results else {"findings": [], "total_findings": 0}
1188
+ )
1189
+
1190
+ findings = []
1191
+ for f in merged["findings"]:
1192
+ entry = {
1193
+ "severity": f.get("severity", "info"),
1194
+ "file": f.get("file", ""),
1195
+ "line": f.get("line", 0),
1196
+ "message": f.get("message", ""),
1197
+ "agent": f.get("agent", ""),
1198
+ }
1199
+ # Include domain classification if present
1200
+ if f.get("domain_controls"):
1201
+ entry["domain_controls"] = f["domain_controls"]
1202
+ if f.get("playbook_ref"):
1203
+ entry["playbook_ref"] = f["playbook_ref"]
1204
+ if f.get("fix_strategy"):
1205
+ entry["fix_strategy"] = f["fix_strategy"]
1206
+ findings.append(entry)
1207
+
1208
+ dead_files = [str(df) for df in (report.dead_files or [])]
1209
+ circular_deps = [cd.short_label for cd in (report.circular_dependencies or [])]
1210
+ languages = dict(report.language_breakdown) if report.language_breakdown else {}
1211
+
1212
+ result = {
1213
+ "file_count": report.file_count,
1214
+ "route_count": report.route_count,
1215
+ "health_score": health_score,
1216
+ "findings": findings,
1217
+ "languages": languages,
1218
+ "dead_files": dead_files,
1219
+ "circular_deps": circular_deps,
1220
+ }
1221
+ con.print(json.dumps(result, indent=2))
1222
+ from patchi.core import ci_bundle as _ci
1223
+
1224
+ return _ci.exit_code_for(findings, fail_on)
1225
+
1226
+ # ── Exit-code contract: --fail-on gates CI (plain scan always 0) ──
1227
+ from patchi.core import ci_bundle as _ci
1228
+
1229
+ _tail_dicts = [f.to_dict() for ar in (agent_results or []) for f in getattr(ar, "findings", [])]
1230
+ _exit = _ci.exit_code_for(_tail_dicts, fail_on)
1231
+ if fail_on:
1232
+ _n_fail = sum(
1233
+ 1
1234
+ for f in _tail_dicts
1235
+ if _ci._SEV_ORDER.get(str(f.get("severity", "info")).lower(), 5)
1236
+ <= _ci._SEV_ORDER[fail_on.lower()]
1237
+ )
1238
+ con.print(
1239
+ f"[dim] --fail-on {fail_on}: {_n_fail}/{len(_tail_dicts)} findings "
1240
+ f"at/above threshold (exit {_exit})[/dim]"
1241
+ )
1242
+ con.print()
1243
+ return _exit
1244
+
1245
+
1246
+ def _run_contract_review(root: Path, all_flows: bool = False) -> None:
1247
+ """Run contract review mode."""
1248
+ con.print("[bold #C8621A]Contract Review Mode[/bold #C8621A]")
1249
+ con.print()
1250
+
1251
+ # Load brain data to get contract information
1252
+ brain_data = mem.get_brain(root)
1253
+ inferred_flows = brain_data.get("inferred_flows", [])
1254
+ confirmed_flows = brain_data.get("confirmed_flows", [])
1255
+
1256
+ if not inferred_flows:
1257
+ con.print("[dim]No inferred contract flows found. Run a full scan first.[/dim]")
1258
+ return
1259
+
1260
+ # Filter: when --all-flows, show everything; otherwise hide suggested
1261
+ if not all_flows:
1262
+ visible = [f for f in inferred_flows if not f.get("suggested", False)]
1263
+ else:
1264
+ visible = inferred_flows
1265
+
1266
+ # Show unconfirmed flows for review
1267
+ confirmed_ids = {cf.get("id") for cf in confirmed_flows}
1268
+ unconfirmed_flows = [f for f in visible if f.get("id") not in confirmed_ids]
1269
+ hidden_count = len(inferred_flows) - len(visible)
1270
+
1271
+ if not unconfirmed_flows:
1272
+ label = f"[green]✓[/green] All {len(visible)} contract flows confirmed!"
1273
+ if hidden_count:
1274
+ label += f" ({hidden_count} low-confidence flows hidden — use --all-flows to see)"
1275
+ con.print(label)
1276
+ return
1277
+
1278
+ label = f"[yellow]Found {len(unconfirmed_flows)} unconfirmed flow(s) to review:[/yellow]"
1279
+ if hidden_count:
1280
+ label += f" [dim]({hidden_count} low-confidence hidden — use --all-flows to see all)[/dim]"
1281
+ con.print(label)
1282
+
1283
+ for i, flow in enumerate(unconfirmed_flows, 1):
1284
+ conf = flow.get("confidence", "medium")
1285
+ conf_tag = ""
1286
+ if conf == "low":
1287
+ conf_tag = " [dim](low confidence)[/dim]"
1288
+ elif conf == "high":
1289
+ conf_tag = " [dim](high)[/dim]"
1290
+ con.print(f" {i}. [bold]{flow.get('name', 'Unknown flow')}[/bold]{conf_tag}")
1291
+ con.print(f" [dim]{flow.get('description', 'No description')}[/dim]")
1292
+ routes = flow.get("routes", [])
1293
+ if routes:
1294
+ con.print(f" [dim]Routes: {', '.join(routes[:3])}[/dim]")
1295
+
1296
+ con.print()
1297
+ con.print("[dim]Run a full scan to confirm these flows.[/dim]")
1298
+
1299
+
1300
+ def _run_file_scan(root: Path, file_path: str, deep: bool = False) -> None:
1301
+ """Run deep analysis on a specific file."""
1302
+ con.print(f"[bold #C8621A]Deep analysis of {file_path}[/bold #C8621A]")
1303
+
1304
+ file_abs_path = root / file_path
1305
+ if not file_abs_path.exists():
1306
+ con.print(f"[red]File does not exist: {file_path}[/red]")
1307
+ return
1308
+
1309
+ if deep: # Only do deep AI analysis when --deep flag is explicitly passed
1310
+ try:
1311
+ import patchi.core.config as config_mod
1312
+ from patchi.core.ai.client import call_ai
1313
+ from patchi.core.ai.prompts import SYSTEM_PROMPTS, Skill
1314
+
1315
+ config = config_mod.load(root)
1316
+ content = file_abs_path.read_text(encoding="utf-8")
1317
+ lang = "python" if file_path.endswith(".py") else "javascript"
1318
+
1319
+ system_prompt = SYSTEM_PROMPTS.get(Skill.DEEP_ANALYSIS, "You are a code analyst.")
1320
+ user_prompt = f"Analyse this {lang} file:\n\nFILE: {file_path}\n\n```\n{content[:6000]}\n```\n\nReturn a JSON object with: purpose, functions (with issues), issues (with line numbers), and architecture notes."
1321
+
1322
+ result = call_ai(config, system_prompt, user_prompt, max_tokens=2000)
1323
+ if result:
1324
+ con.print(f"[#4ADE80]✓[/#4ADE80] AI analysis for {file_path}:")
1325
+ con.print()
1326
+ # Try to format JSON response
1327
+ import json
1328
+
1329
+ try:
1330
+ analysis = json.loads(
1331
+ result.strip().removeprefix("```json").removesuffix("```").strip()
1332
+ )
1333
+ for key, val in analysis.items():
1334
+ if isinstance(val, str):
1335
+ con.print(f" [bold]{key}:[/bold] {val}")
1336
+ elif isinstance(val, list):
1337
+ con.print(f" [bold]{key}:[/bold]")
1338
+ for item in val[:10]:
1339
+ if isinstance(item, dict):
1340
+ line = item.get("line", "")
1341
+ name = item.get("name", item.get("function", ""))
1342
+ issue = item.get("issue", item.get("description", ""))
1343
+ con.print(
1344
+ f" L{line} {name}: {issue}"
1345
+ if line
1346
+ else f" {name}: {issue}"
1347
+ )
1348
+ else:
1349
+ con.print(f" {item}")
1350
+ except (json.JSONDecodeError, ValueError):
1351
+ # Not JSON — print raw
1352
+ for line in result.strip().splitlines()[:30]:
1353
+ con.print(f" {line}")
1354
+ else:
1355
+ con.print("[yellow]AI returned no response — showing file structure only.[/yellow]")
1356
+ lines = content.splitlines()
1357
+ con.print(
1358
+ f"[dim]File has {len(lines)} lines · {file_abs_path.stat().st_size} bytes[/dim]"
1359
+ )
1360
+
1361
+ except Exception as e:
1362
+ con.print(f"[red]Error analyzing file: {e}[/red]")
1363
+ else:
1364
+ con.print(f"[dim]Basic scan of {file_path}[/dim]")
1365
+
1366
+
1367
+ def _run_deep_scan_analysis(root: Path, report: BrainReport, agent_results: list) -> None:
1368
+ """Run deep AI analysis on changed files since last deep scan."""
1369
+ try:
1370
+ import patchi.core.config as config_mod
1371
+ from patchi.core.ai.client import call_ai_structured
1372
+ from patchi.core.ai.prompts import Skill, build_prompt, get_system_prompt
1373
+
1374
+ config = config_mod.load(root)
1375
+
1376
+ # Quick AI availability check
1377
+ test = call_ai_structured(config, "Say OK", 'Reply with JSON: {"ok": true}')
1378
+ if test is None:
1379
+ con.print(
1380
+ "[yellow]No AI available — skipping deep analysis (run without --offline to use AI).[/yellow]"
1381
+ )
1382
+ return
1383
+
1384
+ brain_data = mem.get_brain(root)
1385
+ last_hashes = brain_data.get("deep_scan_hashes", {})
1386
+
1387
+ tokens_used = 0
1388
+ files_analyzed = 0
1389
+ findings: list[dict] = []
1390
+
1391
+ source_exts = (".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".java", ".rb", ".php")
1392
+ all_files = [p for p in root.rglob("*") if p.suffix in source_exts]
1393
+
1394
+ for file_path in all_files:
1395
+ rel_path = file_path.relative_to(root).as_posix()
1396
+ try:
1397
+ content = file_path.read_text(encoding="utf-8", errors="replace")
1398
+ except Exception as e:
1399
+ _log.warning("_run_deep_scan_analysis failed: %s", e)
1400
+ continue
1401
+
1402
+ current_hash = hashlib.sha256(content.encode()).hexdigest()
1403
+ if last_hashes.get(rel_path) == current_hash:
1404
+ continue
1405
+
1406
+ con.print(f"[dim]Deep analyzing {rel_path}...[/dim]")
1407
+
1408
+ system = get_system_prompt(Skill.DEEP_ANALYSIS)
1409
+ user_prompt = build_prompt(
1410
+ Skill.DEEP_ANALYSIS,
1411
+ {
1412
+ "file_path": rel_path,
1413
+ "file_content": content[:6000],
1414
+ "language": rel_path.split(".")[-1],
1415
+ },
1416
+ )
1417
+
1418
+ result = call_ai_structured(config, system, user_prompt, max_tokens=3000)
1419
+ if result is None:
1420
+ continue
1421
+
1422
+ analysis_entry = {
1423
+ "file": rel_path,
1424
+ "hash": current_hash,
1425
+ "analysis": result,
1426
+ }
1427
+ brain_data.setdefault("deep_analyses", {})[rel_path] = analysis_entry
1428
+ last_hashes[rel_path] = current_hash
1429
+
1430
+ tokens_used += len(content.split())
1431
+ files_analyzed += 1
1432
+
1433
+ # Collect issues for the summary
1434
+ for issue in result.get("issues") or []:
1435
+ findings.append(
1436
+ {
1437
+ "file": rel_path,
1438
+ "type": issue.get("type", "unknown"),
1439
+ "severity": issue.get("severity", "low"),
1440
+ "line": issue.get("line", 0),
1441
+ "message": issue.get("description", ""),
1442
+ }
1443
+ )
1444
+
1445
+ # Persist
1446
+ brain_data["deep_scan_hashes"] = last_hashes
1447
+ mem.save_brain(brain_data, root)
1448
+
1449
+ # Summary
1450
+ con.print(f"[dim]Deep scan: {files_analyzed} files analyzed, ~{tokens_used} tokens[/dim]")
1451
+ if findings:
1452
+ by_sev: dict[str, int] = {}
1453
+ for f in findings:
1454
+ by_sev[f["severity"]] = by_sev.get(f["severity"], 0) + 1
1455
+ parts = " ".join(
1456
+ f"[{_SEV_COLORS.get(s, '#B8A898')}]{c} {s}[/{_SEV_COLORS.get(s, '#B8A898')}]"
1457
+ for s, c in sorted(by_sev.items())
1458
+ )
1459
+ con.print(f"[bold #F2EDD6]Deep Analysis Issues:[/bold #F2EDD6] {parts}")
1460
+ for f in findings[:8]:
1461
+ loc = f"{f['file']}:{f['line']}" if f["line"] else f["file"]
1462
+ con.print(f" [dim]{loc}[/dim] — {f['message'][:100]}")
1463
+
1464
+ except Exception as e:
1465
+ import traceback
1466
+
1467
+ con.print(f"[red]Error during deep scan: {e}[/red]")
1468
+ con.print(f"[dim]{traceback.format_exc()}[/dim]")
1469
+
1470
+
1471
+ _SEV_COLORS = {
1472
+ "critical": "#FF4D6D",
1473
+ "high": "#FF8C42",
1474
+ "medium": "#FACC15",
1475
+ "low": "#4ADE80",
1476
+ "info": "#B8A898",
1477
+ }
1478
+
1479
+
1480
+ def _show_report_summary(
1481
+ report: BrainReport,
1482
+ agent_results: list | None = None,
1483
+ wall_time: float | None = None,
1484
+ root: Path | None = None,
1485
+ ) -> None:
1486
+ """Print the post-scan results table."""
1487
+ if wall_time is not None:
1488
+ duration = f"{wall_time:.1f}s"
1489
+ else:
1490
+ duration = f"{report.duration_seconds:.1f}s"
1491
+
1492
+ # Stats table
1493
+ table = Table(show_header=False, box=None, pad_edge=False, padding=(0, 3))
1494
+ table.add_column("Label", style="bold #F2EDD6", width=22)
1495
+ table.add_column("Value", style="#B8A898")
1496
+
1497
+ table.add_row("Files scanned", str(report.file_count))
1498
+
1499
+ if report.language_breakdown:
1500
+ lang_str = " ".join(
1501
+ f"{lang}: {cnt}" for lang, cnt in list(report.language_breakdown.items())[:4]
1502
+ )
1503
+ table.add_row("Languages", lang_str)
1504
+
1505
+ if report.stack and report.stack.frameworks:
1506
+ fw_str = ", ".join(f.name for f in report.stack.frameworks[:3])
1507
+ table.add_row("Framework", fw_str)
1508
+
1509
+ table.add_row("Routes found", str(report.route_count))
1510
+ table.add_row(
1511
+ "Import graph", f"{len(report.import_graph.nodes)} nodes" if report.import_graph else "—"
1512
+ )
1513
+
1514
+ if report.circular_dependencies:
1515
+ circ_text = Text(f"{len(report.circular_dependencies)} circular deps found", style="yellow")
1516
+ table.add_row("Circular deps", circ_text)
1517
+ else:
1518
+ table.add_row("Circular deps", Text("None ✓", style="#4ADE80"))
1519
+
1520
+ if report.dead_files:
1521
+ dead_text = Text(f"{len(report.dead_files)} unreachable files", style="dim")
1522
+ table.add_row("Dead code", dead_text)
1523
+ else:
1524
+ table.add_row("Dead code", Text("None found ✓", style="#4ADE80"))
1525
+
1526
+ if report.errors:
1527
+ err_text = Text(f"{len(report.errors)} parse error(s)", style="yellow")
1528
+ table.add_row("Parse errors", err_text)
1529
+
1530
+ table.add_row("Scan duration", duration)
1531
+
1532
+ con.print(
1533
+ Panel(
1534
+ table,
1535
+ title="[bold #C8621A]Brain Scan Complete[/bold #C8621A]",
1536
+ border_style="#2A3D28",
1537
+ )
1538
+ )
1539
+
1540
+ # Circular dep details
1541
+ if report.circular_dependencies:
1542
+ con.print()
1543
+ con.print("[yellow]Circular dependencies:[/yellow]")
1544
+ for cd in report.circular_dependencies[:5]:
1545
+ con.print(f" [dim]→[/dim] {cd.short_label}")
1546
+ if len(report.circular_dependencies) > 5:
1547
+ con.print(f" [dim]… and {len(report.circular_dependencies) - 5} more[/dim]")
1548
+
1549
+ # Dead files
1550
+ if report.dead_files:
1551
+ con.print()
1552
+ con.print(f"[dim]Dead files ({len(report.dead_files)}):[/dim]")
1553
+ for df in report.dead_files[:8]:
1554
+ con.print(f" [dim]○ {df}[/dim]")
1555
+ if len(report.dead_files) > 8:
1556
+ con.print(f" [dim]… and {len(report.dead_files) - 8} more[/dim]")
1557
+
1558
+ # Agent findings summary
1559
+ if agent_results:
1560
+ _show_agent_findings_summary(agent_results, root=root)
1561
+
1562
+
1563
+ def _show_agent_findings_summary(agent_results: list, root: Path | None = None) -> None:
1564
+ """Show a condensed findings table from all scanner agents.
1565
+
1566
+ Headline counts run through the NoiseFilter first: findings from
1567
+ tests/lockfiles/generated/docs are severity-capped (or discarded) so
1568
+ the summary reflects signal, not fixture noise.
1569
+ """
1570
+ from patchi.core.agents.coordinator import merge_results
1571
+
1572
+ merged = merge_results(agent_results)
1573
+ findings = merged["findings"]
1574
+
1575
+ # Noise filter (non-fatal): cap or drop fixture/lockfile/bundle noise
1576
+ noise_line = ""
1577
+ if root is not None:
1578
+ try:
1579
+ from patchi.core.security.noise_filter import NoiseFilter
1580
+
1581
+ try:
1582
+ config = cfg.load(root)
1583
+ except Exception: # noqa: BLE001 — config optional for filtering
1584
+ config = None
1585
+ nf = NoiseFilter(root, config if isinstance(config, dict) else None)
1586
+ if nf.enabled and findings:
1587
+ kept, nfr = nf.apply(findings)
1588
+ if nfr.capped or nfr.discarded:
1589
+ cats = ", ".join(
1590
+ f"{k}={v}" for k, v in sorted(nfr.to_dict()["by_category"].items())
1591
+ )
1592
+ noise_line = (
1593
+ f" [dim]Noise muted: {nfr.capped} capped, "
1594
+ f"{nfr.discarded} discarded" + (f" ({cats})" if cats else "") + "[/dim]"
1595
+ )
1596
+ findings = kept
1597
+ except Exception as _exc: # noqa: BLE001 — display must never crash on filter bugs
1598
+ _log.warning('_show_agent_findings_summary failed: %s', _exc)
1599
+
1600
+ merged["findings"] = findings
1601
+ total = len(findings)
1602
+
1603
+ if total == 0:
1604
+ con.print()
1605
+ con.print(Text("✓ No issues found by scanner agents.", style="#4ADE80"))
1606
+ if noise_line:
1607
+ con.print(noise_line)
1608
+ return
1609
+
1610
+ # Count by severity
1611
+ by_sev: dict[str, int] = {}
1612
+ for f in merged["findings"]:
1613
+ sev = f.get("severity", "info")
1614
+ by_sev[sev] = by_sev.get(sev, 0) + 1
1615
+
1616
+ con.print()
1617
+ sev_parts: list[str] = []
1618
+ for sev in ("critical", "high", "medium", "low", "info"):
1619
+ count = by_sev.get(sev, 0)
1620
+ if count:
1621
+ colors = {
1622
+ "critical": "#FF4D6D",
1623
+ "high": "#FF8C42",
1624
+ "medium": "#FACC15",
1625
+ "low": "#4ADE80",
1626
+ "info": "#B8A898",
1627
+ }
1628
+ sev_parts.append(f"[{colors[sev]}]{count} {sev}[/{colors[sev]}]")
1629
+
1630
+ sev_str = " ".join(sev_parts)
1631
+ con.print(f"[bold #F2EDD6]Agent Findings:[/bold #F2EDD6] {sev_str}")
1632
+ if noise_line:
1633
+ con.print(noise_line)
1634
+
1635
+ # Show agent-by-agent summary
1636
+ con.print()
1637
+ table = Table(show_header=True, header_style="dim", box=None, pad_edge=False)
1638
+ table.add_column("Agent", style="bold #F2EDD6", width=26)
1639
+ table.add_column("Findings", justify="right", width=10)
1640
+ table.add_column("Status", width=10)
1641
+ table.add_column("ms", justify="right", width=8)
1642
+
1643
+ for r in sorted(agent_results, key=lambda x: -x.finding_count):
1644
+ status_colors = {
1645
+ "done": "#4ADE80",
1646
+ "failed": "#FF4D6D",
1647
+ "skipped": "dim",
1648
+ "running": "#C8621A",
1649
+ }
1650
+ color = status_colors.get(r.status.value, "dim")
1651
+ table.add_row(
1652
+ r.agent_name,
1653
+ str(r.finding_count) if r.finding_count else "—",
1654
+ Text(r.status.value, style=color),
1655
+ str(r.duration_ms),
1656
+ )
1657
+
1658
+ con.print(table)
1659
+
1660
+ # Show top critical/high findings
1661
+ top = [f for f in merged["findings"] if f.get("severity") in ("critical", "high")][:5]
1662
+ if top:
1663
+ con.print()
1664
+ con.print("[bold #FF4D6D]Critical / High findings:[/bold #FF4D6D]")
1665
+ for f in top:
1666
+ sev = f.get("severity", "info")
1667
+ color = "#FF4D6D" if sev == "critical" else "#FF8C42"
1668
+ fpath = f.get("file", "")
1669
+ line = f.get("line", 0)
1670
+ loc = f"{fpath}:{line}" if line else fpath
1671
+ con.print(f" [{color}]●[/{color}] [dim]{loc}[/dim]")
1672
+ con.print(f" {f.get('message', '')[:80]}")
1673
+
1674
+
1675
+ def _run_contract_confirmation(root: Path, report: BrainReport, all_flows: bool = False) -> None:
1676
+ """Interactive contract confirmation flow."""
1677
+ from rich.prompt import Confirm, Prompt
1678
+
1679
+ from patchi.core.brain.contract import ContractBuilder, confirm_flows
1680
+
1681
+ builder = ContractBuilder(
1682
+ report.routes, report.file_infos, report.dead_files, report.circular_dependencies
1683
+ )
1684
+
1685
+ # Filter flows: hide suggested unless --all-flows
1686
+ shown_flows = report.inferred_flows
1687
+ hidden_count = 0
1688
+ if not all_flows:
1689
+ shown_flows = [f for f in report.inferred_flows if not f.suggested]
1690
+ hidden_count = len(report.inferred_flows) - len(shown_flows)
1691
+
1692
+ msg = builder.build_confirmation_message(shown_flows, all_flows=all_flows)
1693
+
1694
+ con.print()
1695
+ sub = (
1696
+ f" ({hidden_count} low-confidence flows hidden — use --all-flows to see)"
1697
+ if hidden_count
1698
+ else ""
1699
+ )
1700
+ con.print(
1701
+ Panel(
1702
+ f"[bold #F2EDD6]App Contract[/bold #F2EDD6]\n\n[dim]{msg}[/dim]{sub}",
1703
+ border_style="#C8621A",
1704
+ )
1705
+ )
1706
+ con.print()
1707
+
1708
+ # Show each inferred flow and ask Y/N
1709
+ confirmed_ids: set[str] = set()
1710
+ for flow in shown_flows:
1711
+ conf_tag = ""
1712
+ if flow.confidence == "high":
1713
+ conf_tag = " [dim](high confidence)[/dim]"
1714
+ elif flow.confidence == "low":
1715
+ conf_tag = " [dim](low confidence)[/dim]"
1716
+ con.print(f" [bold]{flow.name}[/bold]{conf_tag} [dim]{flow.description}[/dim]")
1717
+ if flow.routes:
1718
+ route_str = ", ".join(flow.routes[:3])
1719
+ con.print(f" [dim]Routes: {route_str}[/dim]")
1720
+ yn = Confirm.ask(f" Include [bold]{flow.name}[/bold] in contract?", default=True)
1721
+ if yn:
1722
+ confirmed_ids.add(flow.id)
1723
+ con.print()
1724
+
1725
+ # Any additional flows?
1726
+ user_flows: list[dict] = []
1727
+ if Confirm.ask("Add any flows I didn't detect?", default=False):
1728
+ while True:
1729
+ name = Prompt.ask(" Flow name (or Enter to finish)")
1730
+ if not name:
1731
+ break
1732
+ desc = Prompt.ask(" One-sentence description")
1733
+ user_flows.append({"name": name, "description": desc})
1734
+
1735
+ confirmed = confirm_flows(report.inferred_flows, confirmed_ids, user_flows)
1736
+
1737
+ # Save to brain memory
1738
+ brain_mem = mem.get_brain(root)
1739
+ brain_mem["confirmed_flows"] = [f.to_dict() for f in confirmed]
1740
+ brain_mem["contract_locked"] = True # p fix checks this before running
1741
+ mem.save_brain(brain_mem, root)
1742
+
1743
+ con.print()
1744
+ con.print(
1745
+ f"[#4ADE80]✓[/#4ADE80] App contract locked: "
1746
+ f"[bold]{len(confirmed)}[/bold] flow(s) protected."
1747
+ )
1748
+ con.print("[dim]Every fix will check against this contract before applying.[/dim]")
1749
+
1750
+
1751
+ def _show_changed_dry_run(root: Path, commits: int) -> None:
1752
+ """Show what --changed would activate without actually scanning."""
1753
+ from patchi.core.security.domain_activator_v2 import DomainActivatorV2
1754
+ from patchi.core.security.git_diff_activator import activate_from_diff
1755
+
1756
+ con.print()
1757
+ con.print("[bold #C8621A]── Changed Dry-Run ──[/bold #C8621A]")
1758
+ con.print()
1759
+
1760
+ # 1. Show changed files
1761
+ diff_result = activate_from_diff(root, commits=commits)
1762
+ if diff_result.error:
1763
+ con.print(f" [yellow]{diff_result.error}[/yellow]")
1764
+ return
1765
+
1766
+ con.print(
1767
+ f" [bold]Changed files:[/bold] {len(diff_result.changed_files)} (from last {commits} commit{'s' if commits > 1 else ''})"
1768
+ )
1769
+ con.print()
1770
+
1771
+ # Group changed files by extension
1772
+ ext_groups: dict[str, list[str]] = {}
1773
+ for fp in diff_result.changed_files:
1774
+ ext = Path(fp).suffix or "(no ext)"
1775
+ ext_groups.setdefault(ext, []).append(fp)
1776
+ for ext in sorted(ext_groups, key=lambda e: -len(ext_groups[e])):
1777
+ con.print(f" [dim]{ext}:[/dim] {len(ext_groups[ext])} files")
1778
+ con.print()
1779
+
1780
+ # 2. Show activated domains
1781
+ if diff_result.activated_domains:
1782
+ con.print(f" [bold]Activated domains:[/bold] {len(diff_result.activated_domains)}")
1783
+ con.print()
1784
+ table = Table(show_header=True, header_style="bold #C8621A", box=None)
1785
+ table.add_column("Domain")
1786
+ table.add_column("Score", justify="right")
1787
+ table.add_column("Agents", style="dim")
1788
+ for domain, score in sorted(diff_result.activated_domains.items(), key=lambda x: -x[1]):
1789
+ try:
1790
+ activator = DomainActivatorV2(root)
1791
+ agents = activator.get_relevant_agents([domain])
1792
+ agent_str = ", ".join(agents[:4])
1793
+ if len(agents) > 4:
1794
+ agent_str += f" +{len(agents) - 4}"
1795
+ except Exception:
1796
+ agent_str = "(unknown)"
1797
+ score_color = "red" if score >= 0.8 else "yellow" if score >= 0.5 else "dim"
1798
+ table.add_row(domain, f"[{score_color}]{score:.1f}[/{score_color}]", agent_str)
1799
+ con.print(table)
1800
+ else:
1801
+ con.print(" [dim]No domain-relevant changes detected.[/dim]")
1802
+ con.print()
1803
+
1804
+ # 3. Show total agent count
1805
+ from patchi.core.agents.base import list_agents as _la
1806
+ all_scanner_agents = _la(AgentGroup.SCANNER)
1807
+ if diff_result.activated_domains:
1808
+ try:
1809
+ activator = DomainActivatorV2(root)
1810
+ relevant = activator.get_relevant_agents(list(diff_result.activated_domains.keys()))
1811
+ relevant.extend(["PreCheckAgent", "PlanAuditorAgent"])
1812
+ relevant = list(dict.fromkeys(relevant)) # dedupe preserving order
1813
+ would_run = [a for a in all_scanner_agents if getattr(a, "name", "") in relevant]
1814
+ skipped = len(all_scanner_agents) - len(would_run)
1815
+ con.print(
1816
+ f" [bold]Would run:[/bold] {len(would_run)} agents [dim](skipping {skipped})[/dim]"
1817
+ )
1818
+ con.print()
1819
+ con.print(" [dim]Run without --dry-run to execute the scan.[/dim]")
1820
+ except Exception:
1821
+ con.print(
1822
+ f" [dim]Would run all {len(all_scanner_agents)} agents (activation failed)[/dim]"
1823
+ )
1824
+ else:
1825
+ con.print(f" [dim]Would run all {len(all_scanner_agents)} agents (no diff match)[/dim]")
1826
+ con.print()
1827
+
1828
+
1829
+ def _show_dry_run(root: Path, area: str | None) -> None:
1830
+ """Show what would be scanned without actually scanning."""
1831
+ from patchi.core import config as cfg
1832
+ from patchi.core.brain.scanner import FileScanner
1833
+
1834
+ try:
1835
+ config = cfg.load(root)
1836
+ except Exception as e:
1837
+ con.print(f"[dim]Config load error: {e}[/dim]")
1838
+ config = {}
1839
+
1840
+ scanner = FileScanner(
1841
+ root=root,
1842
+ ignore_paths=config.get("ignore_paths", []),
1843
+ )
1844
+ paths = scanner.discover(area)
1845
+
1846
+ con.print()
1847
+ con.print(f"[dim]--dry-run: would scan {len(paths)} files[/dim]")
1848
+ con.print()
1849
+
1850
+ from patchi.core.brain.languages import detect_language
1851
+
1852
+ by_lang: dict = {}
1853
+ for p in paths:
1854
+ lang = detect_language(p).value
1855
+ by_lang[lang] = by_lang.get(lang, 0) + 1
1856
+
1857
+ table = Table(show_header=True, header_style="bold #C8621A", box=None)
1858
+ table.add_column("Language")
1859
+ table.add_column("Files", justify="right")
1860
+ for lang, count in sorted(by_lang.items(), key=lambda x: x[1], reverse=True):
1861
+ table.add_row(lang, str(count))
1862
+ con.print(table)
1863
+ con.print()
1864
+
1865
+
1866
+ def _show_summary_from_memory(root: Path) -> None:
1867
+ """Show last scan summary from brain memory."""
1868
+ brain = mem.get_brain(root)
1869
+ if not brain:
1870
+ return
1871
+ con.print(
1872
+ f"[dim]Last scan:[/dim] {brain.get('file_count', '?')} files · "
1873
+ f"{brain.get('route_count', '?')} routes · "
1874
+ f"{brain.get('framework', '?')} detected"
1875
+ )
1876
+ con.print()
1877
+
1878
+
1879
+ def _build_progress() -> Progress:
1880
+ return Progress(
1881
+ SpinnerColumn(spinner_name="dots"),
1882
+ TextColumn("[progress.description]{task.description}"),
1883
+ BarColumn(bar_width=24),
1884
+ TaskProgressColumn(),
1885
+ TimeElapsedColumn(),
1886
+ transient=False,
1887
+ )
1888
+
1889
+
1890
+ def _fmt_time(iso: str) -> str:
1891
+ """Format ISO timestamp as human-readable."""
1892
+ try:
1893
+ from datetime import datetime
1894
+
1895
+ dt = datetime.fromisoformat(iso)
1896
+ now = datetime.now(UTC)
1897
+ diff = now - dt
1898
+ secs = diff.total_seconds()
1899
+ if secs < 60:
1900
+ return "just now"
1901
+ if secs < 3600:
1902
+ return f"{int(secs // 60)}m ago"
1903
+ if secs < 86400:
1904
+ return f"{int(secs // 3600)}h ago"
1905
+ return f"{int(secs // 86400)}d ago"
1906
+ except Exception as e:
1907
+ _log.warning("_fmt_time failed: %s", e)
1908
+ return iso