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,242 @@
1
+ """
2
+ Snapshot / rollback system for Patchi.
3
+
4
+ Before any fix is applied, Patchi creates a snapshot of the target file(s).
5
+ If tests fail after a fix, the snapshot is used to restore the original state.
6
+
7
+ Snapshots are stored as JSON in .patchi/snapshots/<snapshot_id>.json.
8
+ They contain the full original file content, so rollback is always possible
9
+ regardless of how many changes were made.
10
+
11
+ Design decisions:
12
+ - Full-file snapshots rather than diffs: simpler to implement, always reliable.
13
+ - Diffs are computed separately for display (review panel) but rollback always
14
+ uses the full-file snapshot.
15
+ - Snapshots are never deleted automatically. The user cleans them via `p memory`.
16
+ """
17
+
18
+ import json
19
+ import uuid
20
+ from datetime import UTC, datetime
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from patchi.core.atomic import atomic_replace, atomic_write_json
25
+ from patchi.core.config import require_project_root
26
+ from patchi.core.constants import SNAPSHOT_DIR
27
+
28
+ # ── Data model ─────────────────────────────────────────────────────────────────
29
+ #
30
+ # A snapshot record looks like:
31
+ # {
32
+ # "id": "a1b2c3d4",
33
+ # "patch_id": "e5f6g7h8", # the fix this snapshot guards
34
+ # "created": "2025-01-01T00:00:00+00:00",
35
+ # "files": {
36
+ # "src/auth/middleware.py": "<full original content>",
37
+ # ...
38
+ # }
39
+ # }
40
+
41
+
42
+ def _snapshots_dir(root: Path) -> Path:
43
+ return root / SNAPSHOT_DIR
44
+
45
+
46
+ def _snapshot_path(snapshot_id: str, root: Path) -> Path:
47
+ return _snapshots_dir(root) / f"{snapshot_id}.json"
48
+
49
+
50
+ def _root(root: Path | None) -> Path:
51
+ return root or require_project_root()
52
+
53
+
54
+ # ── Create ─────────────────────────────────────────────────────────────────────
55
+
56
+
57
+ def create(file_paths: list[str], patch_id: str, root: Path | None = None) -> str:
58
+ """
59
+ Read the current content of every file in file_paths and store a snapshot.
60
+ Returns the snapshot_id.
61
+
62
+ file_paths should be relative to the project root.
63
+ Files that don't exist are recorded with content = None (new-file case).
64
+ """
65
+ r = _root(root)
66
+ _snapshots_dir(r).mkdir(parents=True, exist_ok=True)
67
+
68
+ files: dict[str, str | None] = {}
69
+ for rel_path in file_paths:
70
+ abs_path = r / rel_path
71
+ if abs_path.exists():
72
+ files[rel_path] = abs_path.read_text(encoding="utf-8")
73
+ else:
74
+ files[rel_path] = None # file doesn't exist yet (creation fix)
75
+
76
+ snapshot_id = str(uuid.uuid4())[:8]
77
+ record = {
78
+ "id": snapshot_id,
79
+ "patch_id": patch_id,
80
+ "created": _now(),
81
+ "files": files,
82
+ }
83
+
84
+ _write_json(_snapshot_path(snapshot_id, r), record)
85
+ return snapshot_id
86
+
87
+
88
+ # ── Restore (rollback) ─────────────────────────────────────────────────────────
89
+
90
+
91
+ def restore(snapshot_id: str, root: Path | None = None) -> list[str]:
92
+ """
93
+ Roll back all files covered by this snapshot to their original content.
94
+ Returns a list of file paths that were restored.
95
+
96
+ If the original content was None (file didn't exist), the file is deleted.
97
+ """
98
+ r = _root(root)
99
+ record = load(snapshot_id, r)
100
+ if record is None:
101
+ raise FileNotFoundError(f"Snapshot {snapshot_id!r} not found.")
102
+
103
+ restored: list[str] = []
104
+ for rel_path, original_content in record["files"].items():
105
+ abs_path = r / rel_path
106
+ if original_content is None:
107
+ # File was created by the fix — delete it on rollback
108
+ if abs_path.exists():
109
+ abs_path.unlink()
110
+ else:
111
+ abs_path.parent.mkdir(parents=True, exist_ok=True)
112
+ # Atomic write: tmp + rename to prevent half-rolled-back state
113
+ tmp = abs_path.with_suffix(abs_path.suffix + ".rollback_tmp")
114
+ tmp.write_text(original_content, encoding="utf-8")
115
+ tmp.replace(abs_path)
116
+ restored.append(rel_path)
117
+
118
+ return restored
119
+
120
+
121
+ # ── Read ───────────────────────────────────────────────────────────────────────
122
+
123
+
124
+ def load(snapshot_id: str, root: Path | None = None) -> dict | None:
125
+ """Return the snapshot record, or None if it doesn't exist."""
126
+ r = _root(root)
127
+ path = _snapshot_path(snapshot_id, r)
128
+ if not path.exists():
129
+ return None
130
+ return _read_json(path)
131
+
132
+
133
+ def list_all(root: Path | None = None) -> list[dict]:
134
+ """Return all snapshots, sorted newest first, with file content omitted."""
135
+ r = _root(root)
136
+ snap_dir = _snapshots_dir(r)
137
+ if not snap_dir.exists():
138
+ return []
139
+
140
+ records: list[dict] = []
141
+ for snap_file in sorted(snap_dir.glob("*.json"), reverse=True):
142
+ record = _read_json(snap_file)
143
+ # Strip file contents for the list view — only show paths
144
+ records.append(
145
+ {
146
+ "id": record["id"],
147
+ "patch_id": record.get("patch_id"),
148
+ "created": record.get("created"),
149
+ "file_count": len(record.get("files", {})),
150
+ "file_paths": list(record.get("files", {}).keys()),
151
+ }
152
+ )
153
+ return records
154
+
155
+
156
+ def get_by_patch_id(patch_id: str, root: Path | None = None) -> dict | None:
157
+ """Find the snapshot that guards a specific patch."""
158
+ r = _root(root)
159
+ snap_dir = _snapshots_dir(r)
160
+ if not snap_dir.exists():
161
+ return None
162
+ for snap_file in snap_dir.glob("*.json"):
163
+ record = _read_json(snap_file)
164
+ if record.get("patch_id") == patch_id:
165
+ return record
166
+ return None
167
+
168
+
169
+ # ── Delete ─────────────────────────────────────────────────────────────────────
170
+
171
+
172
+ def delete(snapshot_id: str, root: Path | None = None) -> bool:
173
+ """Delete a single snapshot. Returns True if it existed."""
174
+ r = _root(root)
175
+ path = _snapshot_path(snapshot_id, r)
176
+ if path.exists():
177
+ path.unlink()
178
+ return True
179
+ return False
180
+
181
+
182
+ def delete_all(root: Path | None = None) -> int:
183
+ """Delete all snapshots. Returns count deleted."""
184
+ r = _root(root)
185
+ snap_dir = _snapshots_dir(r)
186
+ if not snap_dir.exists():
187
+ return 0
188
+ count = 0
189
+ for snap_file in snap_dir.glob("*.json"):
190
+ snap_file.unlink()
191
+ count += 1
192
+ return count
193
+
194
+
195
+ # ── Diff helper ────────────────────────────────────────────────────────────────
196
+
197
+
198
+ def compute_diff(snapshot_id: str, root: Path | None = None) -> dict[str, str]:
199
+ """
200
+ Compute unified diffs for all files in this snapshot vs their current state.
201
+ Returns a dict of {relative_path: unified_diff_string}.
202
+ """
203
+ import difflib
204
+
205
+ r = _root(root)
206
+ record = load(snapshot_id, r)
207
+ if record is None:
208
+ return {}
209
+
210
+ diffs: dict[str, str] = {}
211
+ for rel_path, original in record["files"].items():
212
+ abs_path = r / rel_path
213
+ current = abs_path.read_text(encoding="utf-8") if abs_path.exists() else ""
214
+ original = original or ""
215
+
216
+ diff_lines = list(
217
+ difflib.unified_diff(
218
+ original.splitlines(keepends=True),
219
+ current.splitlines(keepends=True),
220
+ fromfile=f"a/{rel_path}",
221
+ tofile=f"b/{rel_path}",
222
+ )
223
+ )
224
+ diffs[rel_path] = "".join(diff_lines)
225
+
226
+ return diffs
227
+
228
+
229
+ # ── Utilities ──────────────────────────────────────────────────────────────────
230
+
231
+
232
+ def _write_json(path: Path, data: Any) -> None:
233
+ atomic_write_json(path, data)
234
+
235
+
236
+ def _read_json(path: Path) -> Any:
237
+ with path.open("r", encoding="utf-8") as f:
238
+ return json.load(f)
239
+
240
+
241
+ def _now() -> str:
242
+ return datetime.now(UTC).isoformat()
patchi/core/tenant.py ADDED
@@ -0,0 +1,386 @@
1
+ """
2
+ Tenant Manager — Project-level state isolation for multi-tenant operation.
3
+
4
+ Each project gets isolated:
5
+ - Brain state (.patchi/)
6
+ - Scan results
7
+ - Cost tracking
8
+ - Findings history
9
+ - Configuration
10
+
11
+ The active project is tracked in the server state and can be switched at runtime.
12
+ Supports request-scoped tenant context for concurrent web requests.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import threading
20
+ from contextlib import contextmanager
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ _log = logging.getLogger("patchi.tenant")
25
+
26
+ # Thread-local storage for request-scoped tenant context
27
+ _thread_local = threading.local()
28
+
29
+
30
+ @dataclass
31
+ class TenantState:
32
+ """State for a single project/tenant."""
33
+
34
+ root: Path
35
+ name: str
36
+ active: bool = True
37
+ last_accessed: str = ""
38
+ findings_count: int = 0
39
+ scan_count: int = 0
40
+ cost_estimate: float = 0.0
41
+
42
+ def to_dict(self) -> dict:
43
+ return {
44
+ "root": str(self.root),
45
+ "name": self.name,
46
+ "active": self.active,
47
+ "last_accessed": self.last_accessed,
48
+ "findings_count": self.findings_count,
49
+ "scan_count": self.scan_count,
50
+ "cost_estimate": self.cost_estimate,
51
+ }
52
+
53
+
54
+ class TenantManager:
55
+ """Manages multiple project tenants with state isolation."""
56
+
57
+ def __init__(self, state_dir: Path | None = None):
58
+ self._state_dir = state_dir or Path.home() / ".patchi" / "tenants"
59
+ self._state_dir.mkdir(parents=True, exist_ok=True)
60
+ self._tenants: dict[str, TenantState] = {}
61
+ self._active_tenant: str | None = None
62
+ self._load_state()
63
+
64
+ def register_project(self, root: Path, name: str | None = None) -> TenantState:
65
+ """Register a project as a tenant."""
66
+ key = str(root.resolve())
67
+ if key in self._tenants:
68
+ self._tenants[key].active = True
69
+ return self._tenants[key]
70
+
71
+ tenant = TenantState(
72
+ root=root.resolve(),
73
+ name=name or root.name,
74
+ active=True,
75
+ )
76
+ self._tenants[key] = tenant
77
+ self._save_state()
78
+ _log.info("Registered tenant: %s (%s)", tenant.name, key)
79
+ return tenant
80
+
81
+ def switch_project(self, root: Path) -> TenantState:
82
+ """Switch to a different project."""
83
+ key = str(root.resolve())
84
+ if key not in self._tenants:
85
+ self.register_project(root)
86
+
87
+ # Deactivate previous
88
+ if self._active_tenant and self._active_tenant in self._tenants:
89
+ self._tenants[self._active_tenant].active = False
90
+
91
+ # Activate new
92
+ self._tenants[key].active = True
93
+ self._active_tenant = key
94
+
95
+ # Initialize the project state
96
+ self._init_project_state(root)
97
+
98
+ self._save_state()
99
+ _log.info("Switched to tenant: %s", self._tenants[key].name)
100
+ return self._tenants[key]
101
+
102
+ def get_active(self) -> TenantState | None:
103
+ """Get the currently active tenant."""
104
+ if self._active_tenant:
105
+ return self._tenants.get(self._active_tenant)
106
+ return None
107
+
108
+ def get_active_root(self) -> Path:
109
+ """Get the active project root path."""
110
+ tenant = self.get_active()
111
+ if tenant:
112
+ return tenant.root
113
+ # Fallback to current working directory
114
+ return Path.cwd()
115
+
116
+ def list_projects(self) -> list[TenantState]:
117
+ """List all registered projects."""
118
+ return list(self._tenants.values())
119
+
120
+ def remove_project(self, root: Path) -> bool:
121
+ """Remove a project from the tenant list."""
122
+ key = str(root.resolve())
123
+ if key in self._tenants:
124
+ del self._tenants[key]
125
+ if self._active_tenant == key:
126
+ self._active_tenant = None
127
+ self._save_state()
128
+ return True
129
+ return False
130
+
131
+ def discover_projects(
132
+ self, near: Path | None = None, max_depth: int = 2, limit: int = 25
133
+ ) -> list[Path]:
134
+ """Find Patchi projects on disk near a reference directory.
135
+
136
+ Search bases (deduplicated):
137
+ - the reference dir itself and its parent (siblings pattern:
138
+ ~/repos/project-a while viewing project-b)
139
+ - one level of children of the parent (workspace folders)
140
+
141
+ Only directories containing .patchi/ count. Bounded scan — never
142
+ walks the whole filesystem, and never scans the system temp tree
143
+ (test fixtures would flood the results).
144
+ """
145
+ import tempfile
146
+
147
+ base = (near or Path.cwd()).resolve()
148
+ candidates: dict[str, Path] = {}
149
+ roots_to_scan: list[Path] = []
150
+
151
+ for candidate_base in (base, base.parent, base.parent.parent):
152
+ if not candidate_base.is_dir():
153
+ continue
154
+ if candidate_base not in roots_to_scan:
155
+ roots_to_scan.append(candidate_base)
156
+
157
+ # The system temp tree is full of throwaway test fixtures — exclude it
158
+ # and everything beneath it from discovery.
159
+ try:
160
+ sys_temp = Path(tempfile.gettempdir()).resolve()
161
+
162
+ def _in_temp(p: Path) -> bool:
163
+ try:
164
+ return p == sys_temp or sys_temp in p.parents
165
+ except OSError:
166
+ return False
167
+
168
+ roots_to_scan = [r for r in roots_to_scan if not _in_temp(r)]
169
+ except Exception as _exc:
170
+ _log.warning('discover_projects failed: %s', _exc)
171
+
172
+ for scan_root in roots_to_scan:
173
+ try:
174
+ for child in sorted(scan_root.iterdir()):
175
+ if len(candidates) >= limit:
176
+ break
177
+ if not child.is_dir() or child.name.startswith("."):
178
+ continue
179
+ if _in_temp(child):
180
+ continue
181
+ if (child / ".patchi").is_dir():
182
+ key = str(child.resolve())
183
+ candidates.setdefault(key, child)
184
+ # Also the scan root itself may be a project
185
+ if (scan_root / ".patchi").is_dir():
186
+ key = str(scan_root.resolve())
187
+ candidates.setdefault(key, scan_root)
188
+ except (PermissionError, OSError):
189
+ continue
190
+
191
+ return list(candidates.values())
192
+
193
+ def update_stats(self, root: Path, **kwargs) -> None:
194
+ """Update tenant statistics."""
195
+ key = str(root.resolve())
196
+ if key in self._tenants:
197
+ for k, v in kwargs.items():
198
+ if hasattr(self._tenants[key], k):
199
+ setattr(self._tenants[key], k, v)
200
+ self._save_state()
201
+
202
+ def _init_project_state(self, root: Path) -> None:
203
+ """Initialize .patchi/ directory for a project."""
204
+ patchi_dir = root / ".patchi"
205
+ patchi_dir.mkdir(parents=True, exist_ok=True)
206
+
207
+ # Ensure required subdirs
208
+ for subdir in ["logs", "evidence", "snapshots", "cache"]:
209
+ (patchi_dir / subdir).mkdir(exist_ok=True)
210
+
211
+ # Initialize config if not present
212
+ config_file = patchi_dir / "config.json"
213
+ if not config_file.exists():
214
+ config_file.write_text("{}")
215
+
216
+ # Initialize memory (full .patchi structure + default memory files)
217
+ from patchi.core.config import init_project
218
+
219
+ init_project(root)
220
+
221
+ # Initialize cost tracker
222
+ from patchi.core.ai import cost_tracker
223
+
224
+ cost_tracker.init(root)
225
+
226
+ def _load_state(self) -> None:
227
+ """Load tenant state from disk."""
228
+ state_file = self._state_dir / "tenants.json"
229
+ try:
230
+ with open(state_file, encoding="utf-8") as f:
231
+ data = json.load(f)
232
+ for key, tdata in data.get("tenants", {}).items():
233
+ self._tenants[key] = TenantState(
234
+ root=Path(tdata["root"]),
235
+ name=tdata.get("name", ""),
236
+ active=tdata.get("active", True),
237
+ last_accessed=tdata.get("last_accessed", ""),
238
+ findings_count=tdata.get("findings_count", 0),
239
+ scan_count=tdata.get("scan_count", 0),
240
+ cost_estimate=tdata.get("cost_estimate", 0.0),
241
+ )
242
+ self._active_tenant = data.get("active_tenant")
243
+ except (FileNotFoundError, json.JSONDecodeError):
244
+ pass
245
+
246
+ def _save_state(self) -> None:
247
+ """Persist tenant state to disk."""
248
+ state_file = self._state_dir / "tenants.json"
249
+ data = {
250
+ "active_tenant": self._active_tenant,
251
+ "tenants": {k: v.to_dict() for k, v in self._tenants.items()},
252
+ }
253
+ try:
254
+ state_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
255
+ except Exception as e:
256
+ _log.warning("Failed to save tenant state: %s", e)
257
+
258
+
259
+ # ── Global Tenant Manager ────────────────────────────────────────────────────
260
+
261
+ _tenant_manager: TenantManager | None = None
262
+
263
+
264
+ def get_tenant_manager() -> TenantManager:
265
+ """Get the global tenant manager."""
266
+ global _tenant_manager
267
+ if _tenant_manager is None:
268
+ _tenant_manager = TenantManager()
269
+ return _tenant_manager
270
+
271
+
272
+ # ── Request-Scoped Tenant Context ───────────────────────────────────────────
273
+
274
+
275
+ @contextmanager
276
+ def tenant_context(root: Path):
277
+ """Context manager that scopes the active tenant for the current thread.
278
+
279
+ Used by web request handlers to ensure each request operates on the
280
+ correct project's state without global state corruption.
281
+
282
+ Usage::
283
+
284
+ with tenant_context(project_root):
285
+ # All memory/config calls use project_root
286
+ scan_results = mem.get_scan_results(project_root)
287
+ """
288
+ previous = getattr(_thread_local, "tenant_root", None)
289
+ _thread_local.tenant_root = root
290
+ try:
291
+ yield root
292
+ finally:
293
+ _thread_local.tenant_root = previous
294
+
295
+
296
+ def get_current_tenant_root() -> Path | None:
297
+ """Get the request-scoped tenant root, or None if not in a context."""
298
+ return getattr(_thread_local, "tenant_root", None)
299
+
300
+
301
+ # ── Per-Tenant Cost Tracking ────────────────────────────────────────────────
302
+
303
+ _tenant_costs: dict[str, float] = {} # root -> cumulative cost
304
+ tenant_costs_lock = threading.Lock()
305
+
306
+
307
+ def track_tenant_cost(root: Path, cost_usd: float) -> None:
308
+ """Record AI cost for a specific tenant."""
309
+ key = str(root.resolve())
310
+ with tenant_costs_lock:
311
+ _tenant_costs[key] = _tenant_costs.get(key, 0) + cost_usd
312
+
313
+
314
+ def get_tenant_cost(root: Path) -> float:
315
+ """Get cumulative AI cost for a tenant."""
316
+ key = str(root.resolve())
317
+ with tenant_costs_lock:
318
+ return _tenant_costs.get(key, 0.0)
319
+
320
+
321
+ def get_all_tenant_costs() -> dict[str, float]:
322
+ """Get costs for all tenants."""
323
+ with tenant_costs_lock:
324
+ return dict(_tenant_costs)
325
+
326
+
327
+ def check_tenant_cost_alert(root: Path, config: dict | None = None) -> dict | None:
328
+ """Check if per-tenant spending exceeds the configured limit.
329
+
330
+ Returns a dict with alert info if threshold exceeded, None otherwise.
331
+ Never raises — handles None, missing keys, and non-numeric values gracefully.
332
+
333
+ Config keys (under ``ai``):
334
+ cost_limit — dollar amount that triggers the alert (default 10.0)
335
+ cost_warn_pct — percentage of limit that triggers warning (default 80)
336
+ """
337
+ try:
338
+ cfg = config or {}
339
+ ai_cfg = cfg.get("ai") or {}
340
+
341
+ # Safely parse cost_limit — handle None, empty string, non-numeric
342
+ raw_limit = ai_cfg.get("cost_limit")
343
+ if raw_limit in (None, "", "null", "None"):
344
+ limit = 10.0
345
+ else:
346
+ try:
347
+ limit = float(raw_limit)
348
+ except (TypeError, ValueError):
349
+ limit = 10.0
350
+
351
+ # Safely parse cost_warn_pct
352
+ raw_pct = ai_cfg.get("cost_warn_pct")
353
+ if raw_pct in (None, "", "null", "None"):
354
+ warn_pct = 80.0
355
+ else:
356
+ try:
357
+ warn_pct = float(raw_pct)
358
+ except (TypeError, ValueError):
359
+ warn_pct = 80.0
360
+
361
+ if limit <= 0:
362
+ return None
363
+
364
+ spent = get_tenant_cost(root)
365
+ pct = (spent / limit) * 100 if limit > 0 else 0
366
+
367
+ if pct >= 100:
368
+ return {
369
+ "level": "critical",
370
+ "message": f"AI budget exhausted: ${spent:.2f} / ${limit:.2f}",
371
+ "spent": spent,
372
+ "limit": limit,
373
+ "pct": round(pct, 1),
374
+ }
375
+ elif pct >= warn_pct:
376
+ return {
377
+ "level": "warning",
378
+ "message": f"AI spending at {pct:.0f}% of budget: ${spent:.2f} / ${limit:.2f}",
379
+ "spent": spent,
380
+ "limit": limit,
381
+ "pct": round(pct, 1),
382
+ }
383
+ return None
384
+ except Exception:
385
+ # Never crash — cost alerts are non-critical
386
+ return None
@@ -0,0 +1,17 @@
1
+ from .test_agents import (
2
+ BrowserTestAgent,
3
+ RegressionAgent,
4
+ StressTestAgent,
5
+ TestCase,
6
+ TestSuite,
7
+ UnitTestAgent,
8
+ )
9
+
10
+ __all__ = [
11
+ "TestCase",
12
+ "TestSuite",
13
+ "UnitTestAgent",
14
+ "BrowserTestAgent",
15
+ "StressTestAgent",
16
+ "RegressionAgent",
17
+ ]