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,980 @@
1
+ """
2
+ `p hosted` — Hosted mode: monitor live applications in production.
3
+
4
+ Subcommands:
5
+ p hosted init — Interactive setup (log path, worker config)
6
+ p hosted worker — Start the background log-watching worker
7
+ p hosted guard — Start live guard with anomaly detection + watchlist
8
+ p hosted status — Show watchlist top threats and audit log summary
9
+ p hosted logs — Stream the audit log in real time
10
+ p hosted token add — Generate a new admin token (shown once)
11
+ p hosted token list — List all active tokens
12
+ p hosted token revoke <id> — Revoke a token by ID
13
+ p hosted disconnect — Clear hosted mode config
14
+
15
+ The hosted worker watches a log file (nginx/apache/uvicorn/gunicorn/caddy/cloudflare)
16
+ and runs statistical + ML anomaly detection on every new line.
17
+ Escalation fires through the notification system when thresholds are crossed.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ # ── Dispatch ───────────────────────────────────────────────────────────────────
23
+ import logging
24
+ import os
25
+ import platform
26
+ import time
27
+ from pathlib import Path
28
+
29
+ from rich.panel import Panel
30
+ from rich.prompt import Confirm, Prompt
31
+ from rich.table import Table
32
+ from rich.text import Text
33
+
34
+ from patchi.cli.console import con
35
+ from patchi.core.config import require_project_root
36
+
37
+ _log = logging.getLogger("patchi.cli.hosted_cmd")
38
+
39
+
40
+ _EXPERIMENTAL_BANNER = (
41
+ "[black on #FACC15] EXPERIMENTAL [/black on #FACC15] Hosted mode is in preview — "
42
+ "feedback appreciated! [dim]Report issues at patchi.dev/feedback[/dim]"
43
+ )
44
+
45
+ def _print_experimental_notice() -> None:
46
+ con.print()
47
+ con.print(_EXPERIMENTAL_BANNER)
48
+ con.print()
49
+
50
+
51
+ def run(args) -> None:
52
+ """Main entry from CLI router — dispatches to sub-handlers."""
53
+ _print_experimental_notice()
54
+ hosted_cmd = getattr(args, "hosted_cmd", None)
55
+ token_cmd = getattr(args, "token_cmd", None)
56
+
57
+ if not hosted_cmd or hosted_cmd == "status":
58
+ run_status(json_output=getattr(args, "json", False))
59
+ elif hosted_cmd == "init":
60
+ run_init()
61
+ elif hosted_cmd == "worker":
62
+ run_worker()
63
+ elif hosted_cmd == "daemon":
64
+ run_daemon(guard=getattr(args, "guard", False))
65
+ elif hosted_cmd == "stop":
66
+ run_stop()
67
+ elif hosted_cmd == "guard":
68
+ run_guard()
69
+ elif hosted_cmd == "logs":
70
+ run_logs()
71
+ elif hosted_cmd == "disconnect":
72
+ run_disconnect()
73
+ elif hosted_cmd == "block":
74
+ run_block(getattr(args, "ip", ""))
75
+ elif hosted_cmd == "unblock":
76
+ run_unblock(getattr(args, "ip", ""))
77
+ elif hosted_cmd == "token":
78
+ if not token_cmd or token_cmd == "list":
79
+ run_token_list()
80
+ elif token_cmd == "add":
81
+ run_token_add()
82
+ elif token_cmd == "revoke":
83
+ run_token_revoke(getattr(args, "id", ""))
84
+ else:
85
+ con.print(f"[red]Unknown token subcommand: {token_cmd!r}[/red]")
86
+ else:
87
+ con.print(f"[red]Unknown hosted subcommand: {hosted_cmd!r}[/red]")
88
+
89
+
90
+ # ── init ───────────────────────────────────────────────────────────────────────
91
+
92
+
93
+ def run_init(root: Path | None = None) -> None:
94
+ """Interactive hosted mode setup."""
95
+ try:
96
+ r = root or require_project_root()
97
+ except RuntimeError as e:
98
+ con.print(f"[red]{e}[/red]")
99
+ return
100
+
101
+ con.print()
102
+ con.print("[bold #C8621A]Hosted Mode Setup[/bold #C8621A]")
103
+ con.print("[dim]Connects Patchi to a live application's log stream.[/dim]")
104
+ con.print()
105
+
106
+ from patchi.core import config as cfg
107
+
108
+ # Log path
109
+ log_path = Prompt.ask(
110
+ " Log file path",
111
+ default="/var/log/nginx/access.log",
112
+ )
113
+
114
+ # Log format
115
+ log_format = Prompt.ask(
116
+ " Log format",
117
+ choices=["nginx", "apache", "caddy", "uvicorn", "gunicorn", "cloudflare", "json"],
118
+ default="nginx",
119
+ )
120
+
121
+ # Notification on escalation
122
+ escalate = Confirm.ask(" Enable notifications on threat escalation?", default=True)
123
+
124
+ # Save to config
125
+ cfg.set_value("hosted.log_path", log_path, r)
126
+ cfg.set_value("hosted.log_format", log_format, r)
127
+ cfg.set_value("hosted.escalate", escalate, r)
128
+ cfg.set_value("hosted.enabled", True, r)
129
+
130
+ # Create hosted dir
131
+ hosted_dir = r / ".patchi" / "hosted"
132
+ hosted_dir.mkdir(parents=True, exist_ok=True)
133
+
134
+ con.print()
135
+ con.print("[#4ADE80]✓[/#4ADE80] Hosted mode configured.")
136
+ con.print(f" [dim]Log: {log_path}[/dim]")
137
+ con.print(f" [dim]Format: {log_format}[/dim]")
138
+ con.print()
139
+ con.print(" Start monitoring: [bold]p hosted worker[/bold]")
140
+ con.print(" Live guard mode: [bold]p hosted guard[/bold]")
141
+ con.print()
142
+
143
+
144
+ # ── worker ─────────────────────────────────────────────────────────────────────
145
+
146
+
147
+ def run_worker(root: Path | None = None) -> None:
148
+ """Start the background log-watching worker."""
149
+ try:
150
+ r = root or require_project_root()
151
+ except RuntimeError as e:
152
+ con.print(f"[red]{e}[/red]")
153
+ return
154
+
155
+ from patchi.core import config as cfg
156
+
157
+ config = cfg.load(r)
158
+ log_path = config.get("hosted", {}).get("log_path", "")
159
+
160
+ if not log_path:
161
+ con.print("[red]Hosted mode not configured. Run: p hosted init[/red]")
162
+ return
163
+
164
+ log_file = Path(log_path)
165
+ if not log_file.exists():
166
+ con.print(f"[#FACC15]⚠ Log file not found: {log_path}[/#FACC15]")
167
+ con.print("[dim]The worker will wait for it to appear.[/dim]")
168
+
169
+ con.print()
170
+ con.print(f"[bold #C8621A]Hosted Worker[/bold #C8621A] [dim]tailing {log_path}[/dim]")
171
+ con.print("[dim]Press Ctrl+C to stop.[/dim]")
172
+ con.print()
173
+
174
+ from patchi.core.hosted.audit_log import write as audit_write
175
+ from patchi.core.hosted.log_parsers import parse_line
176
+
177
+ lines_parsed = 0
178
+ lines_failed = 0
179
+ last_report = time.monotonic()
180
+
181
+ try:
182
+ with _tail_file(log_file) as lines:
183
+ for raw_line in lines:
184
+ entry = parse_line(raw_line)
185
+ if entry:
186
+ lines_parsed += 1
187
+ audit_write(
188
+ r,
189
+ "log_line",
190
+ data={
191
+ "method": entry.method,
192
+ "path": entry.path,
193
+ "status": entry.status,
194
+ "ip": entry.ip,
195
+ "source": entry.source,
196
+ },
197
+ )
198
+ else:
199
+ lines_failed += 1
200
+
201
+ # Progress every 5s
202
+ now = time.monotonic()
203
+ if now - last_report >= 5:
204
+ con.print(
205
+ f" [dim]parsed {lines_parsed} lines | unrecognised {lines_failed}[/dim]"
206
+ )
207
+ last_report = now
208
+
209
+ except KeyboardInterrupt:
210
+ con.print()
211
+ con.print(f"[dim]Worker stopped. {lines_parsed} lines processed.[/dim]")
212
+ con.print()
213
+
214
+
215
+ # ── guard ──────────────────────────────────────────────────────────────────────
216
+
217
+
218
+ def run_guard(root: Path | None = None) -> None:
219
+ """Live guard: anomaly detection + watchlist scoring on the log stream."""
220
+ try:
221
+ r = root or require_project_root()
222
+ except RuntimeError as e:
223
+ con.print(f"[red]{e}[/red]")
224
+ return
225
+
226
+ from patchi.core import config as cfg
227
+
228
+ config = cfg.load(r)
229
+ log_path = config.get("hosted", {}).get("log_path", "")
230
+
231
+ if not log_path:
232
+ con.print("[red]Hosted mode not configured. Run: p hosted init[/red]")
233
+ return
234
+
235
+ from patchi.core.hosted import ip_reputation
236
+ from patchi.core.hosted.anomaly import MLDetector, StatisticalDetector
237
+ from patchi.core.hosted.audit_log import write as audit_write
238
+ from patchi.core.hosted.log_parsers import parse_line
239
+ from patchi.core.hosted.watchlist import WatchlistTracker
240
+
241
+ stat_detector = StatisticalDetector(config)
242
+ ml_detector = MLDetector()
243
+ escalate_fn = _make_escalation_fn(r, config)
244
+ tracker = WatchlistTracker(r, escalation_fn=escalate_fn)
245
+
246
+ con.print()
247
+ con.print(f"[bold #C8621A]Live Guard[/bold #C8621A] [dim]monitoring {log_path}[/dim]")
248
+ con.print("[dim]Statistical + ML anomaly detection active. Press Ctrl+C to stop.[/dim]")
249
+ con.print()
250
+
251
+ sev_colors = {"critical": "#FF4D6D", "high": "#FF8C42", "medium": "#FACC15"}
252
+ log_file = Path(log_path)
253
+
254
+ try:
255
+ with _tail_file(log_file) as lines:
256
+ for raw_line in lines:
257
+ entry = parse_line(raw_line)
258
+ if not entry:
259
+ continue
260
+
261
+ # Check IP reputation — flag blocked IPs
262
+ if entry.ip and ip_reputation.is_blocked(entry.ip, r):
263
+ con.print(
264
+ f" [#FF4D6D]BLOCKED [/#FF4D6D] [dim]{entry.ip}[/dim] — known threat IP"
265
+ )
266
+ audit_write(
267
+ r,
268
+ "blocked_ip_request",
269
+ data={
270
+ "ip": entry.ip,
271
+ "path": entry.path,
272
+ },
273
+ )
274
+ continue
275
+
276
+ findings = stat_detector.feed(entry) + ml_detector.feed(entry)
277
+
278
+ for finding in findings:
279
+ if finding.severity in sev_colors:
280
+ color = sev_colors[finding.severity]
281
+ ts = time.strftime("%H:%M:%S")
282
+ con.print(
283
+ f" [{color}]{finding.severity.upper():<8}[/{color}] "
284
+ f"[dim]{ts}[/dim] {finding.title}"
285
+ )
286
+ if finding.ip:
287
+ tracker.record(finding.ip, finding.detector, finding.title)
288
+ ip_reputation.record_hit(finding.ip, finding.detector, r)
289
+ audit_write(
290
+ r,
291
+ "anomaly_detected",
292
+ data={
293
+ "detector": finding.detector,
294
+ "severity": finding.severity,
295
+ "title": finding.title,
296
+ "ip": finding.ip,
297
+ },
298
+ )
299
+
300
+ except KeyboardInterrupt:
301
+ con.print()
302
+ con.print("[dim]Guard stopped.[/dim]")
303
+ con.print()
304
+
305
+
306
+ def _on_escalation(ip: str, score: float, severity: str) -> None:
307
+ """Called by WatchlistTracker when an IP crosses a threshold."""
308
+ color = "#FF4D6D" if severity == "critical" else "#FF8C42"
309
+ con.print(
310
+ f"\n [{color}]⚠ ESCALATION[/{color}] IP [bold]{ip}[/bold] "
311
+ f"score={score:.0f} severity={severity.upper()}"
312
+ )
313
+
314
+
315
+ def _make_escalation_fn(root: Path, config: dict):
316
+ """Create an escalation callback that respects config and sends notifications."""
317
+ escalate_enabled = config.get("hosted", {}).get("escalate", True)
318
+
319
+ def _escalation(ip: str, score: float, severity: str) -> None:
320
+ color = "#FF4D6D" if severity == "critical" else "#FF8C42"
321
+ con.print(
322
+ f"\n [{color}]⚠ ESCALATION[/{color}] IP [bold]{ip}[/bold] "
323
+ f"score={score:.0f} severity={severity.upper()}"
324
+ )
325
+ if escalate_enabled:
326
+ try:
327
+ from patchi.core.notifications.notifier import Notifier
328
+
329
+ n = Notifier(root)
330
+ n.send(
331
+ severity,
332
+ f"Hosted mode escalation: IP {ip}",
333
+ f"IP {ip} escalated to {severity.upper()} (score={score:.0f})",
334
+ )
335
+ except Exception as e:
336
+ _log.warning("_escalation failed: %s", e)
337
+
338
+ return _escalation
339
+
340
+
341
+ # ── status ─────────────────────────────────────────────────────────────────────
342
+
343
+
344
+ def run_status(root: Path | None = None, json_output: bool = False) -> None:
345
+ """Show watchlist top threats and audit log summary."""
346
+ try:
347
+ r = root or require_project_root()
348
+ except RuntimeError as e:
349
+ con.print(f"[red]{e}[/red]")
350
+ return
351
+
352
+ from patchi.core import config as cfg
353
+ from patchi.core.hosted.audit_log import read_recent
354
+ from patchi.core.hosted.watchlist import WatchlistTracker
355
+
356
+ config = cfg.load(r)
357
+ hosted = config.get("hosted", {})
358
+ enabled = hosted.get("enabled", False)
359
+ log_path = hosted.get("log_path", "")
360
+
361
+ if json_output:
362
+
363
+ tracker = WatchlistTracker(r)
364
+ top_ips = tracker.top(10) if enabled else []
365
+ entries = read_recent(r, 5) if enabled else []
366
+ return
367
+
368
+ con.print()
369
+ con.print("[bold #C8621A]Hosted Mode Status[/bold #C8621A]")
370
+ con.print()
371
+
372
+ status_text = "[#4ADE80]enabled[/#4ADE80]" if enabled else "[#6B7280]not configured[/#6B7280]"
373
+ con.print(f" Mode: {status_text}")
374
+ if log_path:
375
+ con.print(f" Log: [dim]{log_path}[/dim]")
376
+ con.print()
377
+
378
+ if not enabled:
379
+ con.print("[dim] Run 'p hosted init' to configure hosted mode.[/dim]")
380
+ con.print()
381
+ return
382
+
383
+ # Watchlist
384
+ tracker = WatchlistTracker(r)
385
+ top_ips = tracker.top(10)
386
+
387
+ if top_ips:
388
+ con.print("[bold #F2EDD6]Top Threat IPs[/bold #F2EDD6]")
389
+ table = Table(show_header=True, header_style="bold #C8621A", box=None, pad_edge=False)
390
+ table.add_column("IP", style="bold", width=18)
391
+ table.add_column("Score", justify="right", width=8)
392
+ table.add_column("Hits", justify="right", width=6)
393
+ table.add_column("Last seen", width=20)
394
+
395
+ for ip_data in top_ips:
396
+ score = ip_data.get("current_score", 0)
397
+ hits = ip_data.get("hit_count", 0)
398
+ last = ip_data.get("last_seen", 0)
399
+ last_s = time.strftime("%Y-%m-%d %H:%M", time.localtime(last)) if last else "—"
400
+ color = "#FF4D6D" if score >= 100 else "#FF8C42" if score >= 50 else "#FACC15"
401
+ table.add_row(
402
+ ip_data.get("ip", "?"),
403
+ Text(f"{score:.0f}", style=color),
404
+ str(hits),
405
+ last_s,
406
+ )
407
+ con.print(table)
408
+ con.print()
409
+
410
+ # Audit log
411
+ entries = read_recent(r, 5)
412
+ if entries:
413
+ con.print("[bold #F2EDD6]Recent Audit Events[/bold #F2EDD6]")
414
+ for e in entries:
415
+ ts = time.strftime("%H:%M:%S", time.localtime(e.get("timestamp", 0)))
416
+ event = e.get("event", "?")
417
+ data = e.get("data", {})
418
+ con.print(f" [dim]{ts}[/dim] [bold]{event}[/bold] [dim]{_fmt_data(data)}[/dim]")
419
+ con.print()
420
+
421
+
422
+ # ── logs ───────────────────────────────────────────────────────────────────────
423
+
424
+
425
+ def run_logs(root: Path | None = None, limit: int = 50) -> None:
426
+ """Stream the Patchi audit log in real time."""
427
+ try:
428
+ r = root or require_project_root()
429
+ except RuntimeError as e:
430
+ con.print(f"[red]{e}[/red]")
431
+ return
432
+
433
+ from patchi.core.hosted.audit_log import _log_path, read_recent
434
+
435
+ log_file = _log_path(r)
436
+
437
+ con.print()
438
+ con.print(f"[bold #C8621A]Hosted Audit Log[/bold #C8621A] [dim]{log_file}[/dim]")
439
+ con.print("[dim]Press Ctrl+C to stop.[/dim]")
440
+ con.print()
441
+
442
+ # Show last N entries first
443
+ entries = read_recent(r, limit)
444
+ for e in reversed(entries):
445
+ _print_log_entry(e)
446
+
447
+ # Then tail for new ones
448
+ try:
449
+ if log_file.exists():
450
+ with open(log_file, encoding="utf-8") as f:
451
+ f.seek(0, 2) # seek to end
452
+ while True:
453
+ line = f.readline()
454
+ if line:
455
+ try:
456
+ import json as _json
457
+
458
+ entry = _json.loads(line)
459
+ _print_log_entry(entry)
460
+ except Exception as e:
461
+ _log.warning("run_logs failed: %s", e)
462
+ else:
463
+ time.sleep(0.2)
464
+ else:
465
+ con.print("[dim]Audit log does not exist yet. Start the worker to populate it.[/dim]")
466
+ except KeyboardInterrupt:
467
+ con.print()
468
+
469
+
470
+ def _print_log_entry(entry: dict) -> None:
471
+ ts = time.strftime("%H:%M:%S", time.localtime(entry.get("timestamp", 0)))
472
+ event = entry.get("event", "?")
473
+ actor = entry.get("actor", "")
474
+ data = entry.get("data", {})
475
+ con.print(
476
+ f" [dim]{ts}[/dim] [bold #F2EDD6]{event:<20}[/bold #F2EDD6]"
477
+ f" [dim]{actor} {_fmt_data(data)}[/dim]"
478
+ )
479
+
480
+
481
+ # ── disconnect ─────────────────────────────────────────────────────────────────
482
+
483
+
484
+ def run_disconnect(root: Path | None = None) -> None:
485
+ """Clear hosted mode config."""
486
+ try:
487
+ r = root or require_project_root()
488
+ except RuntimeError as e:
489
+ con.print(f"[red]{e}[/red]")
490
+ return
491
+
492
+ confirmed = Confirm.ask(" Clear hosted mode configuration?", default=False)
493
+ if not confirmed:
494
+ con.print("[dim]Cancelled.[/dim]")
495
+ return
496
+
497
+ from patchi.core import config as cfg
498
+
499
+ config = cfg.load(r)
500
+ config.pop("hosted", None)
501
+ cfg.save(config, r)
502
+
503
+ con.print()
504
+ con.print("[#4ADE80]✓[/#4ADE80] Hosted mode configuration cleared.")
505
+ con.print()
506
+
507
+
508
+ # ── token commands ─────────────────────────────────────────────────────────────
509
+
510
+
511
+ def run_token_add(root: Path | None = None) -> None:
512
+ """Generate a new admin token and show it once."""
513
+ try:
514
+ r = root or require_project_root()
515
+ except RuntimeError as e:
516
+ con.print(f"[red]{e}[/red]")
517
+ return
518
+
519
+ from patchi.core.hosted.tokens import generate
520
+
521
+ name = Prompt.ask(" Token name (label for identification)", default="admin")
522
+ plaintext = generate(r, name)
523
+
524
+ con.print()
525
+ con.print("[#4ADE80]✓[/#4ADE80] Admin token generated.")
526
+ con.print()
527
+ con.print(
528
+ Panel(
529
+ f"[bold]{plaintext}[/bold]\n",
530
+ title=f"Token: {name}",
531
+ border_style="#C8621A",
532
+ padding=(1, 2),
533
+ )
534
+ )
535
+ con.print()
536
+
537
+
538
+ def run_token_list(root: Path | None = None) -> None:
539
+ """List all active admin tokens (names only — plaintext is never stored)."""
540
+ try:
541
+ r = root or require_project_root()
542
+ except RuntimeError as e:
543
+ con.print(f"[red]{e}[/red]")
544
+ return
545
+
546
+ from patchi.core.hosted.tokens import list_tokens
547
+
548
+ tokens = list_tokens(r)
549
+ con.print()
550
+ con.print("[bold #C8621A]Admin Tokens[/bold #C8621A]")
551
+ con.print()
552
+
553
+ if not tokens:
554
+ con.print("[dim] No tokens configured. Run: p hosted token add[/dim]")
555
+ con.print()
556
+ return
557
+
558
+ table = Table(show_header=True, header_style="bold #C8621A", box=None, pad_edge=False)
559
+ table.add_column("ID", width=10)
560
+ table.add_column("Name", style="bold #F2EDD6", width=20)
561
+ table.add_column("Created", width=22)
562
+ table.add_column("Last used", width=22)
563
+
564
+ for tok in tokens:
565
+ created = _fmt_ts(tok.get("created_at"))
566
+ last_used = _fmt_ts(tok.get("last_used")) if tok.get("last_used") else "Never"
567
+ table.add_row(tok.get("id", "?"), tok.get("name", "?"), created, last_used)
568
+
569
+ con.print(table)
570
+ con.print()
571
+
572
+
573
+ def run_token_revoke(token_id: str, root: Path | None = None) -> None:
574
+ """Revoke a token by ID."""
575
+ try:
576
+ r = root or require_project_root()
577
+ except RuntimeError as e:
578
+ con.print(f"[red]{e}[/red]")
579
+ return
580
+
581
+ from patchi.core.hosted.audit_log import write as audit_write
582
+ from patchi.core.hosted.tokens import revoke
583
+
584
+ ok = revoke(r, token_id)
585
+ con.print()
586
+ if ok:
587
+ audit_write(r, "token_revoked", data={"id": token_id})
588
+ con.print(f"[#4ADE80]✓[/#4ADE80] Token [bold]{token_id}[/bold] revoked.")
589
+ else:
590
+ con.print(f"[yellow]Token {token_id!r} not found.[/yellow]")
591
+ con.print()
592
+
593
+
594
+ # ── daemon ─────────────────────────────────────────────────────────────────────
595
+
596
+
597
+ def run_daemon(root: Path | None = None, guard: bool = False) -> None:
598
+ """
599
+ Start the hosted worker as a daemon with auto-restart, PID management,
600
+ and health checks. Runs in the foreground with auto-restart on crash.
601
+ If guard=True, also runs anomaly detection in the worker loop.
602
+ """
603
+ import signal
604
+
605
+ try:
606
+ r = root or require_project_root()
607
+ except RuntimeError as e:
608
+ con.print(f"[red]{e}[/red]")
609
+ return
610
+
611
+ from patchi.core import config as cfg
612
+
613
+ config = cfg.load(r)
614
+ log_path = config.get("hosted", {}).get("log_path", "")
615
+
616
+ if not log_path:
617
+ con.print("[red]Hosted mode not configured. Run: p hosted init[/red]")
618
+ return
619
+
620
+ # PID file management
621
+ hosted_dir = r / ".patchi" / "hosted"
622
+ hosted_dir.mkdir(parents=True, exist_ok=True)
623
+ pid_file = hosted_dir / "worker.pid"
624
+
625
+ # Check if already running
626
+ if pid_file.exists():
627
+ try:
628
+ old_pid = int(pid_file.read_text().strip())
629
+ # Check if process is still alive
630
+ if platform.system() == "Windows":
631
+ import subprocess
632
+
633
+ result = subprocess.run(
634
+ ["tasklist", "/FI", f"PID eq {old_pid}"], capture_output=True, text=True
635
+ )
636
+ if str(old_pid) in result.stdout:
637
+ con.print(f"[yellow]Worker already running (PID {old_pid}).[/yellow]")
638
+ con.print(
639
+ "[dim]Run 'p hosted stop' first, or delete .patchi/hosted/worker.pid[/dim]"
640
+ )
641
+ return
642
+ else:
643
+ os.kill(old_pid, 0) # Check if process exists
644
+ con.print(f"[yellow]Worker already running (PID {old_pid}).[/yellow]")
645
+ return
646
+ except (ValueError, ProcessLookupError, OSError):
647
+ pass # Stale PID file, proceed
648
+
649
+ # Write our PID
650
+ pid_file.write_text(str(os.getpid()), encoding="utf-8")
651
+
652
+ con.print()
653
+ con.print(f"[bold #C8621A]Hosted Daemon[/bold #C8621A] [dim]tailing {log_path}[/dim]")
654
+ if guard:
655
+ con.print("[dim]Guard mode: anomaly detection active. Press Ctrl+C to stop.[/dim]")
656
+ else:
657
+ con.print("[dim]Auto-restart enabled. Press Ctrl+C to stop.[/dim]")
658
+ con.print()
659
+
660
+ # Graceful shutdown handler
661
+ _shutdown = False
662
+
663
+ def _handle_signal(signum, frame):
664
+ nonlocal _shutdown
665
+ _shutdown = True
666
+ con.print("\n[yellow]Shutdown signal received...[/yellow]")
667
+
668
+ signal.signal(signal.SIGINT, _handle_signal)
669
+ if hasattr(signal, "SIGTERM"):
670
+ signal.signal(signal.SIGTERM, _handle_signal)
671
+
672
+ # Track health
673
+ last_health_check = time.monotonic()
674
+ last_restart = time.monotonic()
675
+ restart_count = 0
676
+ max_restarts_per_hour = 10
677
+
678
+ try:
679
+ while not _shutdown:
680
+ # Health check every 60s
681
+ now = time.monotonic()
682
+ if now - last_health_check >= 60:
683
+ _daemon_health_check(r, restart_count)
684
+ last_health_check = now
685
+
686
+ # Rate-limit restarts
687
+ if now - last_restart < 3600 and restart_count >= max_restarts_per_hour:
688
+ con.print(
689
+ f"[red]Too many restarts ({restart_count}/hour). Waiting 5 minutes...[/red]"
690
+ )
691
+ time.sleep(300)
692
+ restart_count = 0
693
+ last_restart = time.monotonic()
694
+ continue
695
+
696
+ # Run worker loop
697
+ try:
698
+ _run_worker_loop(r, log_path, guard=guard)
699
+ # If worker exits normally (EOF), wait and restart
700
+ if not _shutdown:
701
+ con.print("[dim]Worker exited (EOF). Restarting in 2s...[/dim]")
702
+ time.sleep(2)
703
+ restart_count += 1
704
+ last_restart = time.monotonic()
705
+ except Exception as e:
706
+ if not _shutdown:
707
+ con.print(f"[red]Worker crashed: {e}[/red]")
708
+ con.print("[dim]Restarting in 5s...[/dim]")
709
+ time.sleep(5)
710
+ restart_count += 1
711
+ last_restart = time.monotonic()
712
+
713
+ finally:
714
+ # Cleanup PID file
715
+ try:
716
+ pid_file.unlink(missing_ok=True)
717
+ except Exception as e:
718
+ _log.warning("run_daemon failed: %s", e)
719
+ con.print(f"[dim]Daemon stopped. {restart_count} restart(s) during this session.[/dim]")
720
+
721
+
722
+ def _run_worker_loop(root: Path, log_path: str, guard: bool = False) -> None:
723
+ """Run the worker's main loop. Returns when log file EOF or shutdown."""
724
+ from patchi.core import config as cfg
725
+ from patchi.core.hosted.audit_log import write as audit_write
726
+ from patchi.core.hosted.log_parsers import parse_line
727
+
728
+ stat_detector = None
729
+ ml_detector = None
730
+ tracker = None
731
+
732
+ if guard:
733
+ from patchi.core.hosted import ip_reputation
734
+ from patchi.core.hosted.anomaly import MLDetector, StatisticalDetector
735
+ from patchi.core.hosted.watchlist import WatchlistTracker
736
+
737
+ config = cfg.load(root)
738
+ stat_detector = StatisticalDetector(config)
739
+ ml_detector = MLDetector()
740
+ escalate_fn = _make_escalation_fn(root, config)
741
+ tracker = WatchlistTracker(root, escalation_fn=escalate_fn)
742
+
743
+ lines_parsed = 0
744
+ lines_failed = 0
745
+ last_report = time.monotonic()
746
+ last_activity = time.monotonic()
747
+
748
+ log_file = Path(log_path)
749
+ with _tail_file(log_file) as lines:
750
+ for raw_line in lines:
751
+ entry = parse_line(raw_line)
752
+ if entry:
753
+ lines_parsed += 1
754
+ last_activity = time.monotonic()
755
+ audit_write(
756
+ root,
757
+ "log_line",
758
+ data={
759
+ "method": entry.method,
760
+ "path": entry.path,
761
+ "status": entry.status,
762
+ "ip": entry.ip,
763
+ "source": entry.source,
764
+ },
765
+ )
766
+ # Guard mode: run anomaly detection
767
+ if guard and stat_detector:
768
+ if entry.ip and ip_reputation.is_blocked(entry.ip, root):
769
+ audit_write(
770
+ root,
771
+ "blocked_ip_request",
772
+ data={
773
+ "ip": entry.ip,
774
+ "path": entry.path,
775
+ },
776
+ )
777
+ else:
778
+ findings = stat_detector.feed(entry) + ml_detector.feed(entry)
779
+ for finding in findings:
780
+ if finding.ip:
781
+ tracker.record(finding.ip, finding.detector, finding.title)
782
+ ip_reputation.record_hit(finding.ip, finding.detector, root)
783
+ audit_write(
784
+ root,
785
+ "anomaly_detected",
786
+ data={
787
+ "detector": finding.detector,
788
+ "severity": finding.severity,
789
+ "title": finding.title,
790
+ "ip": finding.ip,
791
+ },
792
+ )
793
+ else:
794
+ lines_failed += 1
795
+
796
+ # Progress every 30s (less spam for daemon mode)
797
+ now = time.monotonic()
798
+ if now - last_report >= 30:
799
+ con.print(
800
+ f" [dim]parsed {lines_parsed} lines | "
801
+ f"unrecognised {lines_failed} | "
802
+ f"last activity {int(now - last_activity)}s ago[/dim]"
803
+ )
804
+ last_report = now
805
+
806
+
807
+ def _daemon_health_check(root: Path, restart_count: int) -> None:
808
+ """Periodic health check for the daemon."""
809
+ try:
810
+ import psutil
811
+
812
+ pid = os.getpid()
813
+ proc = psutil.Process(pid)
814
+ mem_mb = proc.memory_info().rss / (1024 * 1024)
815
+ cpu_pct = proc.cpu_percent(interval=0.1)
816
+ con.print(
817
+ f" [dim]health: mem {mem_mb:.0f}MB cpu {cpu_pct:.1f}% restarts {restart_count}[/dim]"
818
+ )
819
+ except ImportError:
820
+ # psutil not available, basic check
821
+ con.print(f" [dim]health: pid {os.getpid()} restarts {restart_count}[/dim]")
822
+ except Exception as e:
823
+ _log.warning("_daemon_health_check failed: %s", e)
824
+
825
+
826
+ # ── stop ───────────────────────────────────────────────────────────────────────
827
+
828
+
829
+ def run_stop(root: Path | None = None) -> None:
830
+ """Stop a running daemon by PID file."""
831
+ try:
832
+ r = root or require_project_root()
833
+ except RuntimeError as e:
834
+ con.print(f"[red]{e}[/red]")
835
+ return
836
+
837
+ pid_file = r / ".patchi" / "hosted" / "worker.pid"
838
+ if not pid_file.exists():
839
+ con.print("[dim]No running daemon found.[/dim]")
840
+ return
841
+
842
+ try:
843
+ pid = int(pid_file.read_text().strip())
844
+ except (ValueError, OSError):
845
+ con.print("[dim]Invalid PID file. Cleaning up.[/dim]")
846
+ pid_file.unlink(missing_ok=True)
847
+ return
848
+
849
+ try:
850
+ if platform.system() == "Windows":
851
+ import subprocess
852
+
853
+ subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True)
854
+ else:
855
+ import signal
856
+
857
+ os.kill(pid, signal.SIGTERM)
858
+ con.print(f"[#4ADE80]✓[/#4ADE80] Sent stop signal to PID {pid}")
859
+ except ProcessLookupError:
860
+ con.print(f"[dim]Process {pid} not found. Cleaning up PID file.[/dim]")
861
+ except Exception as e:
862
+ con.print(f"[red]Failed to stop: {e}[/red]")
863
+ finally:
864
+ pid_file.unlink(missing_ok=True)
865
+
866
+
867
+ # ── block / unblock ───────────────────────────────────────────────────────────
868
+
869
+
870
+ def run_block(ip: str, root: Path | None = None) -> None:
871
+ """Manually block an IP address."""
872
+ if not ip:
873
+ con.print("[red]Usage: p hosted block <ip>[/red]")
874
+ return
875
+ try:
876
+ r = root or require_project_root()
877
+ except RuntimeError as e:
878
+ con.print(f"[red]{e}[/red]")
879
+ return
880
+ from patchi.core.hosted import ip_reputation
881
+ from patchi.core.hosted.audit_log import write as audit_write
882
+
883
+ ip_reputation.block(ip, r)
884
+ audit_write(r, "ip_blocked", data={"ip": ip, "reason": "manual"})
885
+ con.print(f"[#4ADE80]✓[/#4ADE80] IP [bold]{ip}[/bold] blocked.")
886
+
887
+
888
+ def run_unblock(ip: str, root: Path | None = None) -> None:
889
+ """Unblock an IP address."""
890
+ if not ip:
891
+ con.print("[red]Usage: p hosted unblock <ip>[/red]")
892
+ return
893
+ try:
894
+ r = root or require_project_root()
895
+ except RuntimeError as e:
896
+ con.print(f"[red]{e}[/red]")
897
+ return
898
+ from patchi.core.hosted import ip_reputation
899
+ from patchi.core.hosted.audit_log import write as audit_write
900
+
901
+ ok = ip_reputation.unblock(ip, r)
902
+ if ok:
903
+ audit_write(r, "ip_unblocked", data={"ip": ip})
904
+ con.print(f"[#4ADE80]✓[/#4ADE80] IP [bold]{ip}[/bold] unblocked.")
905
+ else:
906
+ con.print(f"[dim]IP {ip} was not blocked.[/dim]")
907
+
908
+
909
+ # ── Utilities ──────────────────────────────────────────────────────────────────
910
+
911
+
912
+ def _fmt_ts(ts: float | None) -> str:
913
+ if not ts:
914
+ return "—"
915
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts))
916
+
917
+
918
+ def _fmt_data(data: dict) -> str:
919
+ parts = []
920
+ for k, v in list(data.items())[:3]:
921
+ parts.append(f"{k}={v!r}")
922
+ return " ".join(parts)
923
+
924
+
925
+ def _tail_file(path: Path):
926
+ """
927
+ Context manager that yields lines from a file, blocking when caught up.
928
+ If file does not exist, waits for it to appear (up to 30s).
929
+ Handles log file rotation by detecting inode/size changes.
930
+ """
931
+
932
+ class _Tailer:
933
+ def __enter__(self_inner):
934
+ waited = 0
935
+ while not path.exists() and waited < 30:
936
+ time.sleep(1)
937
+ waited += 1
938
+ if not path.exists():
939
+ raise FileNotFoundError(f"Log file still not found after 30s: {path}")
940
+ self_inner._f = open(path, encoding="utf-8", errors="replace")
941
+ self_inner._f.seek(0, 2) # jump to end
942
+ try:
943
+ self_inner._inode = path.stat().st_ino
944
+ self_inner._size = path.stat().st_size
945
+ except OSError:
946
+ self_inner._inode = None
947
+ self_inner._size = 0
948
+ return self_inner
949
+
950
+ def __exit__(self_inner, *args):
951
+ self_inner._f.close()
952
+
953
+ def _check_rotation(self_inner):
954
+ try:
955
+ stat = path.stat()
956
+ if self_inner._inode is not None and stat.st_ino != self_inner._inode:
957
+ self_inner._f.close()
958
+ self_inner._f = open(path, encoding="utf-8", errors="replace")
959
+ self_inner._inode = stat.st_ino
960
+ self_inner._size = stat.st_size
961
+ elif stat.st_size < self_inner._size:
962
+ self_inner._f.close()
963
+ self_inner._f = open(path, encoding="utf-8", errors="replace")
964
+ self_inner._inode = stat.st_ino
965
+ self_inner._size = stat.st_size
966
+ else:
967
+ self_inner._size = stat.st_size
968
+ except OSError:
969
+ pass
970
+
971
+ def __iter__(self_inner):
972
+ while True:
973
+ line = self_inner._f.readline()
974
+ if line:
975
+ yield line
976
+ else:
977
+ self_inner._check_rotation()
978
+ time.sleep(0.1)
979
+
980
+ return _Tailer()