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,1012 @@
1
+ """
2
+ Agent base class and shared result types for Patchi.
3
+
4
+ Every agent in the system — scanner, fix, test, security, guard — inherits
5
+ from BaseAgent and returns an AgentResult. No agent ever:
6
+ - Writes files directly (only fix agents propose patches; the queue applies them)
7
+ - Calls AI when static analysis is sufficient
8
+ - Runs outside its declared scope
9
+
10
+ Agent contract:
11
+ 1. Receive AgentInput (project root, target scope, brain data, config)
12
+ 2. Do work (static analysis / AST parse / subprocess / http request)
13
+ 3. Return AgentResult (findings, metadata, duration, errors)
14
+ 4. Done. Never mutate state. Never touch the queue directly.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import ast
19
+ import fnmatch
20
+ import logging
21
+ import os
22
+ import time
23
+ import traceback
24
+ from abc import ABC, abstractmethod
25
+ from dataclasses import dataclass, field
26
+ from datetime import UTC, datetime
27
+ from enum import StrEnum
28
+ from pathlib import Path
29
+
30
+ from patchi.core.brain.file_corpus import FileCorpus
31
+ from patchi.core.brain.languages import DEFAULT_IGNORE_DIRS, is_minified_asset
32
+
33
+ # ponytail: rglob traverses node_modules (30k+ files). Shared skip sets.
34
+ _SKIP_DIRS = frozenset(DEFAULT_IGNORE_DIRS)
35
+ _SKIP_FILES = frozenset(
36
+ {"package-lock.json", "yarn.lock", "pnpm-lock.yaml", "composer.lock", "Gemfile.lock"}
37
+ )
38
+
39
+
40
+ def _skip_asset(fname: str, skip_files: frozenset[str]) -> bool:
41
+ """True when a filename is user-skipped or a minified/bundled asset."""
42
+ return fname in skip_files or is_minified_asset(fname)
43
+
44
+
45
+ def scope_allows(inp: AgentInput, rel_path: str) -> bool:
46
+ """True when `rel_path` is inside the agent's target scope.
47
+
48
+ Empty scope means whole-tree (unchanged behavior). Entries match as
49
+ exact files or directory prefixes, so per-file sharding and --since
50
+ scoping work without touching every agent's walk logic.
51
+ """
52
+ scope = inp.scope or []
53
+ if not scope:
54
+ return True
55
+ rel = rel_path.replace("\\", "/").lstrip("./")
56
+ for entry in scope:
57
+ e = str(entry).replace("\\", "/").lstrip("./")
58
+ if rel == e or rel.startswith(e.rstrip("/") + "/"):
59
+ return True
60
+ return False
61
+
62
+
63
+ _log = logging.getLogger("patchi.agents.base")
64
+
65
+
66
+ def safe_rglob(
67
+ root: Path,
68
+ pattern: str,
69
+ skip_files: frozenset[str] = frozenset(),
70
+ corpus: FileCorpus | None = None,
71
+ ):
72
+ """Yield Path objects matching `pattern` without entering node_modules etc.
73
+
74
+ When `corpus` is provided, uses the pre-built FileCorpus index instead of
75
+ walking the filesystem — avoids redundant directory traversal across
76
+ the 60+ callers that each independently walk the tree.
77
+ """
78
+ if corpus is not None:
79
+ for entry in corpus.by_glob(pattern):
80
+ if _skip_asset(entry.path, skip_files) or _skip_asset(
81
+ Path(entry.path).name, skip_files
82
+ ):
83
+ continue
84
+ yield root / entry.path
85
+ return
86
+
87
+ if "/" not in pattern and "**" not in pattern:
88
+ # Simple pattern like "*.py" — walk with pruning
89
+ for dirpath, dirnames, filenames in os.walk(root):
90
+ dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
91
+ for fname in filenames:
92
+ if _skip_asset(fname, skip_files):
93
+ continue
94
+ if fnmatch.fnmatch(fname, pattern):
95
+ yield Path(dirpath) / fname
96
+ elif "**" in pattern:
97
+ # Compound pattern like "**/routes/**" — split and match
98
+ parts = pattern.split("/")
99
+ mid = parts[1] if len(parts) > 2 else None
100
+ for dirpath, dirnames, filenames in os.walk(root):
101
+ dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
102
+ rel = Path(dirpath).relative_to(root).as_posix()
103
+ if mid and mid not in rel.split("/"):
104
+ continue
105
+ for fname in filenames:
106
+ if _skip_asset(fname, skip_files):
107
+ continue
108
+ if fnmatch.fnmatch(fname, parts[-1]):
109
+ yield Path(dirpath) / fname
110
+ else:
111
+ # Compound pattern like ".github/workflows/*" — walk, match dir+filename
112
+ parts = pattern.rsplit("/", 1)
113
+ dir_part = parts[0] if len(parts) > 1 else ""
114
+ file_part = parts[-1]
115
+ for dirpath, dirnames, filenames in os.walk(root):
116
+ dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
117
+ rel = Path(dirpath).relative_to(root).as_posix()
118
+ if dir_part and dir_part not in rel:
119
+ continue
120
+ for fname in filenames:
121
+ if _skip_asset(fname, skip_files):
122
+ continue
123
+ if fnmatch.fnmatch(fname, file_part):
124
+ yield Path(dirpath) / fname
125
+
126
+
127
+ # ── Agent groups ───────────────────────────────────────────────────────────────
128
+
129
+
130
+ class AgentGroup(StrEnum):
131
+ SCANNER = "scanner"
132
+ FIX = "fix"
133
+ TEST = "test"
134
+ SECURITY = "security"
135
+ GUARD = "guard"
136
+
137
+ def label(self) -> str:
138
+ labels = {
139
+ "scanner": "Scanner Agents",
140
+ "fix": "Fix Agents",
141
+ "test": "Test Agents",
142
+ "security": "Security Agents",
143
+ "guard": "Guard Agents",
144
+ }
145
+ return labels[self.value]
146
+
147
+ def ant_type(self) -> str:
148
+ """Which ant type represents this group in the web UI colony."""
149
+ return {
150
+ "scanner": "minor_worker",
151
+ "fix": "minor_worker",
152
+ "test": "minor_worker",
153
+ "security": "soldier",
154
+ "guard": "soldier",
155
+ }[self.value]
156
+
157
+
158
+ class AgentDomain(StrEnum):
159
+ """User-facing domain categories for organizing agents by purpose."""
160
+
161
+ TESTING = "testing" # Does my code work?
162
+ CODE_QUALITY = "code_quality" # Is my code well-structured?
163
+ INTEGRATION = "integration" # Is everything wired?
164
+ PERFORMANCE = "performance" # Does it perform?
165
+ SECURITY = "security" # Are there security holes?
166
+ VISUAL = "visual" # Does it look right?
167
+ INFRASTRUCTURE = "infrastructure" # Is the project set up?
168
+
169
+ def label(self) -> str:
170
+ return {
171
+ "testing": "Testing",
172
+ "code_quality": "Code Quality",
173
+ "integration": "Integration",
174
+ "performance": "Performance",
175
+ "security": "Security",
176
+ "visual": "Visual",
177
+ "infrastructure": "Infrastructure",
178
+ }[self.value]
179
+
180
+ def description(self) -> str:
181
+ return {
182
+ "testing": "Does my code work?",
183
+ "code_quality": "Is my code well-structured?",
184
+ "integration": "Is everything wired?",
185
+ "performance": "Does it perform?",
186
+ "security": "Are there security holes?",
187
+ "visual": "Does it look right?",
188
+ "infrastructure": "Is the project set up?",
189
+ }[self.value]
190
+
191
+
192
+ # ── Agent status ───────────────────────────────────────────────────────────────
193
+
194
+
195
+ class AgentStatus(StrEnum):
196
+ IDLE = "idle"
197
+ RUNNING = "running"
198
+ DONE = "done"
199
+ SUCCEEDED = "done"
200
+ FAILED = "failed"
201
+ SKIPPED = "skipped"
202
+
203
+
204
+ # ── Severity levels (used across all finding types) ────────────────────────────
205
+
206
+
207
+ class Severity(StrEnum):
208
+ CRITICAL = "critical" # level 1 alert
209
+ HIGH = "high" # level 2 alert
210
+ MEDIUM = "medium" # level 3 alert
211
+ LOW = "low" # level 4, digest only
212
+ INFO = "info" # never alerts, informational only
213
+
214
+ def color(self) -> str:
215
+ return {
216
+ "critical": "#FF4D6D",
217
+ "high": "#FF8C42",
218
+ "medium": "#FACC15",
219
+ "low": "#4ADE80",
220
+ "info": "#B8A898",
221
+ }[self.value]
222
+
223
+ def sort_key(self) -> int:
224
+ return {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}[self.value]
225
+
226
+
227
+ # ── Finding — universal result item ───────────────────────────────────────────
228
+
229
+
230
+ @dataclass
231
+ class Finding:
232
+ """
233
+ A single finding from any agent.
234
+ Used by scanner, security, and guard agents.
235
+ Fix agents produce Patch objects , not findings.
236
+ """
237
+
238
+ agent: str # agent name that produced this
239
+ type: str # e.g. "dead_code", "missing_type", "todo_comment"
240
+ severity: Severity
241
+ file: str # relative path to project root
242
+ line: int = 0 # line number (0 = file-level finding)
243
+ column: int = 0
244
+ message: str = "" # one sentence, human readable
245
+ detail: str = "" # full context, multi-line
246
+ code_snippet: str = "" # the relevant code, never secret values
247
+ suggestion: str = "" # what to do about it (no code — that's fix agents)
248
+ fix_agent: str | None = None # which fix agent handles this
249
+ cwe: str = "" # CWE reference if applicable
250
+ extra: dict = field(default_factory=dict)
251
+
252
+ def to_dict(self) -> dict:
253
+ return {
254
+ "agent": self.agent,
255
+ "type": self.type,
256
+ "severity": self.severity.value,
257
+ "file": self.file,
258
+ "line": self.line,
259
+ "column": self.column,
260
+ "message": self.message,
261
+ "detail": self.detail,
262
+ "code_snippet": self.code_snippet,
263
+ "suggestion": self.suggestion,
264
+ "fix_agent": self.fix_agent,
265
+ "cwe": self.cwe,
266
+ **self.extra,
267
+ }
268
+
269
+ @classmethod
270
+ def from_dict(cls, d: dict) -> Finding:
271
+ extra_keys = {
272
+ "agent",
273
+ "type",
274
+ "severity",
275
+ "file",
276
+ "line",
277
+ "column",
278
+ "message",
279
+ "detail",
280
+ "code_snippet",
281
+ "suggestion",
282
+ "fix_agent",
283
+ "cwe",
284
+ }
285
+ extra = {k: v for k, v in d.items() if k not in extra_keys}
286
+ return cls(
287
+ agent=d.get("agent", ""),
288
+ type=d.get("type", ""),
289
+ severity=Severity(d.get("severity", "medium")),
290
+ file=d.get("file", ""),
291
+ line=d.get("line", 0),
292
+ column=d.get("column", 0),
293
+ message=d.get("message", ""),
294
+ detail=d.get("detail", ""),
295
+ code_snippet=d.get("code_snippet", ""),
296
+ suggestion=d.get("suggestion", ""),
297
+ fix_agent=d.get("fix_agent"),
298
+ cwe=d.get("cwe", ""),
299
+ extra=extra,
300
+ )
301
+
302
+ @property
303
+ def title(self) -> str:
304
+ """Legacy alias for finding message/title compatibility."""
305
+ return self.message
306
+
307
+ @title.setter
308
+ def title(self, value: str) -> None:
309
+ self.message = value
310
+
311
+
312
+ # ── Agent input ────────────────────────────────────────────────────────────────
313
+
314
+
315
+ @dataclass
316
+ class AgentInput:
317
+ """
318
+ Standard input passed to every agent's run() method.
319
+
320
+ Agents must not read config or brain directly — everything
321
+ they need is injected here. This keeps agents testable in isolation.
322
+ """
323
+
324
+ root: Path # project root
325
+ scope: list[str] # relative file paths to analyse (empty = all)
326
+ brain: dict # current brain state (from memory.get_brain())
327
+ config: dict # current config (from config.load())
328
+ extra: dict = field(default_factory=dict) # agent-specific params
329
+ purpose: str = "" # project purpose (from brain scan, e.g. "A AI CLI for code scanning")
330
+ domain: str = "" # project domain (e.g. "dev-tool", "web-app", "library")
331
+ context: dict = field(default_factory=dict) # rich project context from brain context phase
332
+ active_domains: list[str] = field(default_factory=list) # activated security domain IDs
333
+ on_message: callable | None = (
334
+ None # callback for live progress streaming: fn(agent_name, message, style)
335
+ )
336
+ on_ai_progress: callable | None = (
337
+ None # callback for AI call progress: fn(message)
338
+ )
339
+
340
+
341
+ # ── Agent result ───────────────────────────────────────────────────────────────
342
+
343
+
344
+ @dataclass
345
+ class AgentResult:
346
+ """
347
+ Standard output from every agent's run() method.
348
+ """
349
+
350
+ agent_name: str = ""
351
+ agent_group: AgentGroup = AgentGroup.SCANNER
352
+ status: AgentStatus = AgentStatus.IDLE
353
+ started_at: str = ""
354
+ completed_at: str = ""
355
+ duration_ms: int = 0
356
+ metadata: dict | None = None
357
+
358
+ findings: list[Finding] = field(default_factory=list)
359
+ data: dict = field(default_factory=dict) # structured output beyond findings
360
+ errors: list[str] = field(default_factory=list)
361
+ files_scanned: int = 0
362
+ ai_calls_made: int = 0 # track AI usage for cost awareness
363
+
364
+ def __post_init__(self) -> None:
365
+ if self.metadata is not None:
366
+ self.data.update(self.metadata)
367
+
368
+ def add_finding(self, finding: Finding) -> None:
369
+ self.findings.append(finding)
370
+
371
+ def add_error(self, error: str) -> None:
372
+ self.errors.append(error)
373
+ self.status = AgentStatus.FAILED
374
+
375
+ @property
376
+ def finding_count(self) -> int:
377
+ return len(self.findings)
378
+
379
+ @property
380
+ def has_critical(self) -> bool:
381
+ return any(f.severity == Severity.CRITICAL for f in self.findings)
382
+
383
+ @property
384
+ def by_severity(self) -> dict[str, list[Finding]]:
385
+ result: dict[str, list[Finding]] = {s.value: [] for s in Severity}
386
+ for f in self.findings:
387
+ result[f.severity.value].append(f)
388
+ return result
389
+
390
+ def to_dict(self) -> dict:
391
+ return {
392
+ "agent": self.agent_name,
393
+ "group": self.agent_group.value,
394
+ "status": self.status.value,
395
+ "started_at": self.started_at,
396
+ "completed_at": self.completed_at,
397
+ "duration_ms": self.duration_ms,
398
+ "files_scanned": self.files_scanned,
399
+ "ai_calls_made": self.ai_calls_made,
400
+ "finding_count": self.finding_count,
401
+ "findings": [f.to_dict() for f in self.findings],
402
+ "data": self.data,
403
+ "errors": self.errors,
404
+ }
405
+
406
+ @classmethod
407
+ def from_dict(cls, d: dict) -> AgentResult:
408
+ return cls(
409
+ agent_name=d.get("agent", ""),
410
+ agent_group=AgentGroup(d.get("group", "scanner")),
411
+ status=AgentStatus(d.get("status", "idle")),
412
+ started_at=d.get("started_at", ""),
413
+ completed_at=d.get("completed_at", ""),
414
+ duration_ms=d.get("duration_ms", 0),
415
+ files_scanned=d.get("files_scanned", 0),
416
+ ai_calls_made=d.get("ai_calls_made", 0),
417
+ findings=[Finding.from_dict(f) for f in d.get("findings", [])],
418
+ data=d.get("data", {}),
419
+ errors=d.get("errors", []),
420
+ )
421
+
422
+ def summary_line(self) -> str:
423
+ status_icon = {
424
+ AgentStatus.DONE: "✓",
425
+ AgentStatus.FAILED: "✗",
426
+ AgentStatus.SKIPPED: "○",
427
+ AgentStatus.RUNNING: "↻",
428
+ AgentStatus.IDLE: "·",
429
+ }[self.status]
430
+ findings_str = f"{self.finding_count} findings" if self.finding_count else "no findings"
431
+ return f"{status_icon} {self.agent_name:<24} {findings_str:<18} {self.duration_ms}ms"
432
+
433
+
434
+ # ── Skills directory (companion .skill.md files for security/review agents) ─────
435
+
436
+ _SKILLS_DIR: Path | None = None
437
+
438
+
439
+ def _get_skills_dir() -> Path | None:
440
+ global _SKILLS_DIR
441
+ if _SKILLS_DIR is not None:
442
+ return _SKILLS_DIR
443
+ candidate = Path(__file__).resolve().parent.parent / "security" / "skills"
444
+ _SKILLS_DIR = candidate if candidate.is_dir() else None
445
+ return _SKILLS_DIR
446
+
447
+
448
+ _SKILL_CACHE: dict[str, dict[str, str]] | None = None
449
+ """Global cache: agent_name -> {skill_stem -> skill_content}. Populated once."""
450
+
451
+
452
+ def _build_skill_map() -> dict[str, dict[str, str]]:
453
+ """Scan skills_dir, parse target_agents frontmatter, return reverse map."""
454
+ global _SKILL_CACHE
455
+ if _SKILL_CACHE is not None:
456
+ return _SKILL_CACHE
457
+ skills_dir = _get_skills_dir()
458
+ if not skills_dir:
459
+ _SKILL_CACHE = {}
460
+ return _SKILL_CACHE
461
+
462
+ import re
463
+
464
+ agent_skills: dict[str, dict[str, str]] = {}
465
+ for f in sorted(skills_dir.glob("*.skill.md")):
466
+ content = f.read_text(encoding="utf-8")
467
+ stem = f.stem
468
+ # Extract target_agents from YAML frontmatter (between --- markers)
469
+ m = re.search(r"(?m)^target_agents:\s*\[([^\]]+)\]", content)
470
+ if not m:
471
+ m = re.search(r"(?m)^target_agents:\s*(.+?)$", content)
472
+ if m:
473
+ raw = m.group(1)
474
+ names = [n.strip().strip("'\"") for n in raw.split(",") if n.strip()]
475
+ for name in names:
476
+ agent_skills.setdefault(name, {})[stem] = content
477
+ _SKILL_CACHE = agent_skills
478
+ return agent_skills
479
+
480
+
481
+ # ── Base agent ─────────────────────────────────────────────────────────────────
482
+
483
+
484
+ class BaseAgent(ABC):
485
+ """
486
+ Abstract base for all Patchi agents.
487
+
488
+ Subclasses implement _run() with their analysis logic.
489
+ run() wraps _run() with timing, error handling, and result finalization.
490
+
491
+ Agents must:
492
+ - Be stateless between calls (all state lives in AgentInput/AgentResult)
493
+ - Return within their declared timeout
494
+ - Never write to disk
495
+ - Never modify the queue or brain directly
496
+ """
497
+
498
+ # Override these in subclasses
499
+ name: str = "BaseAgent"
500
+ group: AgentGroup = AgentGroup.SCANNER
501
+ domain: AgentDomain = AgentDomain.INFRASTRUCTURE
502
+ timeout: int = 60 # seconds — coordinator enforces this
503
+
504
+ def run(self, inp: AgentInput) -> AgentResult:
505
+ """
506
+ Public entry point. Wraps _run() with timing, error handling,
507
+ and companion skill loading.
508
+
509
+ Companion skill files (.skill.md) from patchi/core/security/skills/
510
+ are auto-discovered based on self.name appearing in the file's
511
+ target_agents frontmatter. Loaded content is available as:
512
+
513
+ inp.extra.get("skill_context", {}) — dict of {skill_stem: content}
514
+ getattr(self, "_skill_context", {}) — same, on the agent instance
515
+
516
+ Never override this — override _run() instead.
517
+ """
518
+ # Gate Rule — Testing/Live/Attack must confirm P-Check READY_TO_SERVE
519
+ if self.group in (AgentGroup.TEST, AgentGroup.SECURITY) and self.name not in ("PreCheckAgent",):
520
+ # Only gate live/test/attack agents, not pure static scanners
521
+ needs_gate = self.group == AgentGroup.TEST or self.name in (
522
+ "RedTeamAgent",
523
+ "RedTeamEngineAgent",
524
+ "DastAgent",
525
+ "BrowserTestAgent",
526
+ "UIButtonAgent",
527
+ "UILayoutAgent",
528
+ "UIAccessibilityAgent",
529
+ "VisualRegressionAgent",
530
+ "E2EFlowAgent",
531
+ "ApiFuzzerAgent",
532
+ "StressTestAgent",
533
+ )
534
+ if needs_gate:
535
+ try:
536
+ from patchi.core.testing.gate import require_ready
537
+
538
+ ready, url, st = require_ready(inp.root)
539
+ if not ready:
540
+ from patchi.core.testing.gate import gate_message
541
+
542
+ msg = gate_message(st)
543
+ # Stay idle — do not run, request P-Check
544
+ result = AgentResult(
545
+ agent_name=self.name,
546
+ agent_group=self.group,
547
+ status=AgentStatus.SKIPPED,
548
+ )
549
+ result.data["gate_blocked"] = True
550
+ result.data["gate_reason"] = msg
551
+ result.data["gate_status"] = st
552
+ result.errors.append(msg)
553
+ return result
554
+ # Inject url for agents that need it
555
+ if url and not inp.extra.get("base_url"):
556
+ safe_extra = dict(inp.extra)
557
+ safe_extra["base_url"] = url
558
+ safe_extra["live_probe"] = True
559
+ except Exception as e:
560
+ _log.debug("gate check failed: %s", e)
561
+
562
+ # Load companion skills for this agent name
563
+ skill_map = _build_skill_map()
564
+ self._skill_context = skill_map.get(self.name, {})
565
+ # Inject into inp.extra with a unique key to avoid cross-agent interference
566
+ # when multiple agents share the same AgentInput object in parallel runs.
567
+ safe_extra = dict(inp.extra)
568
+ safe_extra["skill_context"] = self._skill_context
569
+ inp = AgentInput(
570
+ root=inp.root,
571
+ scope=inp.scope,
572
+ brain=inp.brain,
573
+ config=inp.config,
574
+ extra=safe_extra,
575
+ on_message=getattr(inp, "on_message", None),
576
+ domain=getattr(inp, "domain", ""),
577
+ purpose=getattr(inp, "purpose", ""),
578
+ context=getattr(inp, "context", {}),
579
+ active_domains=getattr(inp, "active_domains", None),
580
+ )
581
+
582
+ result = AgentResult(
583
+ agent_name=self.name,
584
+ agent_group=self.group,
585
+ status=AgentStatus.RUNNING,
586
+ started_at=_now(),
587
+ )
588
+
589
+ t0 = time.monotonic()
590
+ try:
591
+ # Every registered agent implements _run(inp, result) -> None
592
+ # (enforced by register()/validate_agent_registry()); the old
593
+ # inspect.signature shim for _run(inp) -> AgentResult is gone.
594
+ self._run(inp, result)
595
+ if result.status == AgentStatus.RUNNING:
596
+ result.status = AgentStatus.DONE
597
+ except Exception as e:
598
+ result.errors.append(f"{type(e).__name__}: {e}")
599
+ result.errors.append(traceback.format_exc())
600
+ result.status = AgentStatus.FAILED
601
+
602
+ result.completed_at = _now()
603
+ result.duration_ms = int((time.monotonic() - t0) * 1000)
604
+ return result
605
+
606
+ @abstractmethod
607
+ def _run(self, inp: AgentInput, result: AgentResult) -> None:
608
+ """
609
+ Implement analysis logic here.
610
+ Append findings to result.findings.
611
+ Write structured data to result.data.
612
+ Call result.add_error() on non-fatal errors.
613
+ Raise exceptions for fatal errors (they are caught by run()).
614
+ """
615
+ ...
616
+
617
+ def skip(self, result: AgentResult, reason: str) -> None:
618
+ """Mark agent as skipped with a reason."""
619
+ result.status = AgentStatus.SKIPPED
620
+ result.data["skip_reason"] = reason
621
+
622
+ @classmethod
623
+ def describe(cls) -> dict:
624
+ """Return metadata for `p agents list`."""
625
+ return {
626
+ "name": cls.name,
627
+ "group": cls.group.value,
628
+ "timeout": cls.timeout,
629
+ } # ── Registry ───────────────────────────────────────────────────────────────────
630
+
631
+
632
+ _REGISTRY: dict[str, type[BaseAgent]] = {}
633
+
634
+ # Packages walked by discover_agent_modules() so every @register side-effect
635
+ # fires. Mirrors where agents live across the codebase; a module added here
636
+ # without being imported by an aggregator (scanners.py, security_agents.py,
637
+ # test_agents.py, fix/__init__.py) still gets discovered.
638
+ AGENT_PACKAGES: tuple[str, ...] = (
639
+ "patchi.core.agents",
640
+ "patchi.core.security",
641
+ "patchi.core.testing",
642
+ "patchi.core.fix",
643
+ "patchi.core.detector",
644
+ )
645
+
646
+
647
+ def discover_agent_modules(packages: tuple[str, ...] = AGENT_PACKAGES) -> list[str]:
648
+ """Import every module under the agent packages so @register fires.
649
+
650
+ Replaces hand-maintained aggregator import lists (the sweep previously
651
+ enumerated 10 modules by hand; a new agent module nobody imported was
652
+ silently invisible). Walks each package with pkgutil and imports every
653
+ submodule, tolerating import failures: returns formatted failure strings
654
+ ("module -> ErrorType: message") for every module that FAILED to import
655
+ (empty = everything imported cleanly).
656
+
657
+ NOTE: modules with heavy optional deps (playwright, torch, pyre) are
658
+ imported lazily by their agent bodies, so discovery stays cheap.
659
+ """
660
+ import importlib
661
+ import pkgutil
662
+
663
+ failures: list[str] = []
664
+ for pkg_name in packages:
665
+ try:
666
+ pkg = importlib.import_module(pkg_name)
667
+ except Exception as e: # noqa: BLE001 — report and continue
668
+ failures.append(f"{pkg_name} -> {type(e).__name__}: {e}")
669
+ continue
670
+ pkg_path = getattr(pkg, "__path__", [])
671
+ for mod_info in pkgutil.iter_modules(pkg_path, prefix=pkg_name + "."):
672
+ if mod_info.ispkg:
673
+ # Import sub-packages (e.g. security sub-folders) too.
674
+ for sub_fail in discover_agent_modules((mod_info.name,)):
675
+ failures.append(sub_fail)
676
+ continue
677
+ try:
678
+ importlib.import_module(mod_info.name)
679
+ except Exception as e: # noqa: BLE001 — report and continue
680
+ failures.append(f"{mod_info.name} -> {type(e).__name__}: {e}")
681
+ return failures
682
+
683
+
684
+ def validate_agent_registry() -> list[str]:
685
+ """Startup contract check over every registered agent.
686
+
687
+ Returns a list of violation strings (empty = all agents satisfy the
688
+ contract). Catches the audit's registration bug classes AT IMPORT TIME
689
+ instead of in the sweep or mid-pipeline:
690
+
691
+ * a @register-ed plain class that is not a BaseAgent subclass
692
+ (PysaAgent/CodeqlAgent had no run() and no AgentResult)
693
+ * a class whose body raised while reading AgentGroup (e.g. referencing
694
+ a nonexistent AgentGroup member, so it silently never registered)
695
+ * an agent without a name, or with a group outside AgentGroup
696
+
697
+ Call after discover_agent_modules() (or after any aggregator import).
698
+ """
699
+ # Note: this is defense-in-depth — strict register() already guarantees the
700
+ # contract for anything added through the decorator, so this only fires for
701
+ # entries injected directly into _REGISTRY. Kept as the startup audit the
702
+ # sweep runs, per the deep-audit requirement.
703
+ violations: list[str] = []
704
+ for name, cls in _REGISTRY.items():
705
+ if not (isinstance(cls, type) and issubclass(cls, BaseAgent)):
706
+ violations.append(
707
+ f"{name}: registered class is not a BaseAgent subclass "
708
+ f"(no run()/AgentResult contract)"
709
+ )
710
+ continue
711
+ if not callable(getattr(cls, "run", None)):
712
+ violations.append(f"{name}: registered agent has no callable run()")
713
+ agent_name = getattr(cls, "name", "")
714
+ if not agent_name or not isinstance(agent_name, str) or agent_name == "BaseAgent":
715
+ violations.append(f"{name}: agent has no usable .name (inherited BaseAgent default)")
716
+ group = getattr(cls, "group", None)
717
+ if not isinstance(group, AgentGroup):
718
+ violations.append(f"{name}: group {group!r} is not an AgentGroup member")
719
+ try:
720
+ _check_run_signature(cls)
721
+ except TypeError as e:
722
+ violations.append(str(e))
723
+ # _run(inp, result) mutates the passed-in result; any rebinding of the
724
+ # `result` name (for-loop target, comprehension target, plain assign)
725
+ # that is followed by a LATER use of `result` silently discards the
726
+ # AgentResult and crashes (AttributeError: 'dict' object has no
727
+ # attribute 'status' — the accessibility/api-contract shadow bugs).
728
+ for lineno, kind in _find_result_shadowing(cls):
729
+ violations.append(
730
+ f"{name}: _run rebinds `result` at line {lineno} "
731
+ f"({kind}) and uses it afterwards — rename the loop variable"
732
+ )
733
+ return violations
734
+
735
+
736
+ def _iter_target_names(node) -> list[str]:
737
+ """All assigned names under an assign/for/comprehension target, recursing
738
+ through tuple/list/starred unpacking."""
739
+ if isinstance(node, ast.Name):
740
+ return [node.id]
741
+ if isinstance(node, (ast.Tuple, ast.List)):
742
+ out: list[str] = []
743
+ for elt in node.elts:
744
+ out.extend(_iter_target_names(elt))
745
+ return out
746
+ if isinstance(node, ast.Starred):
747
+ return _iter_target_names(node.value)
748
+ return []
749
+
750
+
751
+ def _find_result_shadowing(cls: type) -> list[tuple[int, str]]:
752
+ """Return [(lineno, kind)] where `_run` rebinds `result` and then uses it
753
+ again later — the passed-in AgentResult would be lost.
754
+
755
+ A rebind with NO later use is intentionally not flagged (harmless, e.g. a
756
+ trailing loop that iterates dicts and returns). Rebinds are detected at
757
+ the statement level; a load on the SAME line (e.g. `result.get(...)`
758
+ inside the comprehension that rebinds it) refers to the new binding and is
759
+ correct, so only loads on strictly later lines trigger a violation.
760
+ """
761
+ # Read source directly from the file to avoid inspect.getsource()
762
+ # deadlocks on Windows when pytest-timeout threads conflict.
763
+ try:
764
+ import sys as _sys
765
+ mod_name = cls.__module__
766
+ mod = _sys.modules.get(mod_name)
767
+ if mod and hasattr(mod, '__file__') and mod.__file__:
768
+ src_path = Path(mod.__file__)
769
+ if src_path.suffix == '.pyc' and src_path.with_suffix('.py').exists():
770
+ src_path = src_path.with_suffix('.py')
771
+ if src_path.exists():
772
+ src = src_path.read_text(encoding='utf-8', errors='replace')
773
+ else:
774
+ return []
775
+ else:
776
+ return []
777
+ except Exception:
778
+ return []
779
+ try:
780
+ tree = ast.parse(src)
781
+ except (SyntaxError, IndentationError):
782
+ return []
783
+ # Find the class node matching cls.__name__, then its _run method
784
+ run_fn = None
785
+ for node in ast.walk(tree):
786
+ if isinstance(node, (ast.ClassDef, ast.FunctionDef)):
787
+ if isinstance(node, ast.ClassDef) and node.name == cls.__name__:
788
+ for item in node.body:
789
+ if isinstance(item, ast.FunctionDef) and item.name == "_run":
790
+ if "result" in [a.arg for a in item.args.args]:
791
+ run_fn = item
792
+ break
793
+ if run_fn:
794
+ break
795
+ if run_fn is None:
796
+ return []
797
+
798
+ rebinds: dict[int, str] = {} # lineno -> kind
799
+ load_lines: set[int] = set()
800
+
801
+ for node in ast.walk(run_fn):
802
+ if isinstance(node, ast.For):
803
+ for n in _iter_target_names(node.target):
804
+ if n == "result":
805
+ rebinds[node.lineno] = "for-loop target"
806
+ elif isinstance(node, ast.comprehension):
807
+ for n in _iter_target_names(node.target):
808
+ if n == "result":
809
+ rebinds[node.target.lineno] = "comprehension target"
810
+ elif isinstance(node, (ast.Assign, ast.AugAssign, ast.NamedExpr)):
811
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
812
+ for t in targets:
813
+ for n in _iter_target_names(t):
814
+ if n == "result":
815
+ rebinds[node.lineno] = "plain assignment"
816
+ elif isinstance(node, ast.Name) and node.id == "result" and isinstance(node.ctx, ast.Load):
817
+ load_lines.add(node.lineno)
818
+
819
+ return sorted(
820
+ (line, kind) for line, kind in rebinds.items() if any(load > line for load in load_lines)
821
+ )
822
+
823
+
824
+ def register(agent_cls: type[BaseAgent]) -> type[BaseAgent]:
825
+ """Decorator to register an agent class by name.
826
+
827
+ Enforces the agent contract at registration time: the decorated object
828
+ must be a BaseAgent subclass with a name and a real AgentGroup. A plain
829
+ class decorated with @register (the PysaAgent/CodeqlAgent bug) raises
830
+ immediately instead of registering a broken entry.
831
+ """
832
+ if not (isinstance(agent_cls, type) and issubclass(agent_cls, BaseAgent)):
833
+ raise TypeError(
834
+ f"register() requires a BaseAgent subclass, got {agent_cls!r} — "
835
+ "agents must inherit BaseAgent to expose run()/AgentResult"
836
+ )
837
+ if not callable(getattr(agent_cls, "run", None)):
838
+ raise TypeError(f"register() requires {agent_cls.__name__} to expose a callable run()")
839
+ # An agent that never sets .name inherits "BaseAgent" from the base class
840
+ # and would silently OVERWRITE the base entry in the registry — reject the
841
+ # inherited default, not just the empty string.
842
+ agent_name = getattr(agent_cls, "name", "") or ""
843
+ if not agent_name or not isinstance(agent_name, str) or agent_name == "BaseAgent":
844
+ raise TypeError(
845
+ f"register() requires agent {agent_cls.__name__} to define its own .name "
846
+ f"(inherited the BaseAgent default)"
847
+ )
848
+ if not isinstance(getattr(agent_cls, "group", None), AgentGroup):
849
+ raise TypeError(
850
+ f"register() requires {agent_cls.__name__}.group to be an AgentGroup member"
851
+ )
852
+ # The _run contract: every agent must implement _run(self, inp, result)
853
+ # -> None. The old per-call inspect.signature shim in run() was removed, so
854
+ # a wrong-shaped _run would only explode mid-pipeline — catch it here at
855
+ # registration time instead (enforced, not probed).
856
+ _check_run_signature(agent_cls)
857
+ _REGISTRY[agent_cls.name] = agent_cls
858
+ return agent_cls
859
+
860
+
861
+ def _check_run_signature(agent_cls: type[BaseAgent]) -> None:
862
+ """Raise TypeError unless _run takes exactly (self, inp, result)."""
863
+ import inspect
864
+
865
+ run_impl = getattr(agent_cls, "_run", None)
866
+ if not callable(run_impl):
867
+ raise TypeError(f"register() requires {agent_cls.__name__} to implement _run()")
868
+ try:
869
+ sig = inspect.signature(run_impl)
870
+ except (TypeError, ValueError) as e: # noqa: BLE001 — signature probe failure
871
+ raise TypeError(f"register() could not inspect {agent_cls.__name__}._run: {e}") from e
872
+ params = list(sig.parameters.values())
873
+ positional = [p for p in params if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)]
874
+ if len(positional) < 3 or positional[1].name != "inp" or positional[2].name != "result":
875
+ raise TypeError(
876
+ f"register() requires {agent_cls.__name__}._run(self, inp, result) — "
877
+ f"got {sig}. The old _run(inp) -> AgentResult shape is removed."
878
+ )
879
+
880
+
881
+ def deregister(agent_cls: type[BaseAgent]) -> None:
882
+ """Remove an agent class from the registry (for test cleanup)."""
883
+ _REGISTRY.pop(agent_cls.name, None)
884
+
885
+
886
+ def get_agent(name: str) -> type[BaseAgent] | None:
887
+ return _REGISTRY.get(name)
888
+
889
+
890
+ def list_agents(group: AgentGroup | None = None) -> list[type[BaseAgent]]:
891
+ agents = list(_REGISTRY.values())
892
+ if group:
893
+ agents = [a for a in agents if a.group == group]
894
+ return sorted(agents, key=lambda a: (a.group.value, a.name))
895
+
896
+
897
+ # ── Utilities ──────────────────────────────────────────────────────────────────
898
+
899
+
900
+ def _now() -> str:
901
+ return datetime.now(UTC).isoformat()
902
+
903
+
904
+ def _infer_agent_name() -> str:
905
+ """Try to infer the calling agent name for legacy make_finding calls."""
906
+ import inspect
907
+
908
+ frame = inspect.currentframe()
909
+ if not frame:
910
+ return "Unknown"
911
+
912
+ caller = frame.f_back
913
+ while caller:
914
+ self_obj = caller.f_locals.get("self")
915
+ if self_obj is not None:
916
+ agent_name = getattr(self_obj, "name", None)
917
+ if isinstance(agent_name, str) and agent_name:
918
+ return agent_name
919
+ caller = caller.f_back
920
+
921
+ return "Unknown"
922
+
923
+
924
+ def make_finding(
925
+ *args,
926
+ **kwargs,
927
+ ) -> Finding:
928
+ """Create a Finding with backwards compatibility for old call signatures."""
929
+ # Legacy positional form: (Severity, file, line_start, title, description, evidence)
930
+ if args and isinstance(args[0], Severity):
931
+ severity = args[0]
932
+ file = args[1] if len(args) > 1 else ""
933
+ line = args[2] if len(args) > 2 else 0
934
+ title = args[3] if len(args) > 3 else ""
935
+ description = args[4] if len(args) > 4 else ""
936
+ evidence = args[5] if len(args) > 5 else ""
937
+ finding_type = kwargs.pop("finding_type", None) or title.lower().replace(" ", "_").replace(
938
+ ":", ""
939
+ ).replace("'", "").replace("-", "_")
940
+ agent = kwargs.pop("agent", _infer_agent_name())
941
+ message = kwargs.pop("message", "") or title or description
942
+ detail = kwargs.pop("detail", description or evidence)
943
+ return Finding(
944
+ agent=agent,
945
+ type=finding_type,
946
+ severity=severity,
947
+ file=file,
948
+ line=line,
949
+ message=message,
950
+ detail=detail,
951
+ code_snippet=kwargs.pop("code_snippet", ""),
952
+ suggestion=kwargs.pop("suggestion", ""),
953
+ fix_agent=kwargs.pop("fix_agent", None),
954
+ cwe=kwargs.pop("cwe", ""),
955
+ extra={**kwargs},
956
+ )
957
+
958
+ # Legacy keyword form: severity=..., file=..., title=..., description=..., evidence=...
959
+ if "severity" in kwargs and ("file" in kwargs or "file_path" in kwargs):
960
+ severity = kwargs.pop("severity")
961
+ file = kwargs.pop("file", kwargs.pop("file_path", ""))
962
+ line = kwargs.pop("line_start", kwargs.pop("line", 0))
963
+ title = kwargs.pop("title", "")
964
+ description = kwargs.pop("description", "")
965
+ message = kwargs.pop("message", "") or title or description
966
+ evidence = kwargs.pop("evidence", "")
967
+ finding_type = kwargs.pop("finding_type", None) or title.lower().replace(" ", "_").replace(
968
+ ":", ""
969
+ ).replace("'", "").replace("-", "_")
970
+ agent = kwargs.pop("agent", _infer_agent_name())
971
+ detail = kwargs.pop("detail", description or evidence)
972
+ return Finding(
973
+ agent=agent,
974
+ type=finding_type,
975
+ severity=severity,
976
+ file=file,
977
+ line=line,
978
+ message=message,
979
+ detail=detail,
980
+ code_snippet=kwargs.pop("code_snippet", ""),
981
+ suggestion=kwargs.pop("suggestion", ""),
982
+ fix_agent=kwargs.pop("fix_agent", None),
983
+ cwe=kwargs.pop("cwe", ""),
984
+ extra={**kwargs},
985
+ )
986
+
987
+ # Standard constructor: (agent, finding_type, severity, file, message, ...)
988
+ if len(args) >= 5 and isinstance(args[2], Severity):
989
+ agent = args[0]
990
+ finding_type = args[1]
991
+ severity = args[2]
992
+ file = args[3]
993
+ message = args[4]
994
+ line = kwargs.pop("line", kwargs.pop("line_start", 0))
995
+ evidence = kwargs.pop("evidence", "") # Extract evidence if passed
996
+ detail = kwargs.pop("detail", evidence) # Map evidence to detail
997
+ return Finding(
998
+ agent=agent,
999
+ type=finding_type,
1000
+ severity=severity,
1001
+ file=file,
1002
+ line=line,
1003
+ message=message,
1004
+ detail=detail,
1005
+ code_snippet=kwargs.pop("code_snippet", ""),
1006
+ suggestion=kwargs.pop("suggestion", ""),
1007
+ fix_agent=kwargs.pop("fix_agent", None),
1008
+ cwe=kwargs.pop("cwe", ""),
1009
+ extra={**kwargs},
1010
+ )
1011
+
1012
+ raise TypeError("Invalid make_finding signature")