alysis-code 0.13.0__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 (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,1076 @@
1
+ """Bounded loopback egress proxy for owned Chromium sessions.
2
+
3
+ The proxy is deliberately small and restrictive. Chromium sends HTTP proxy
4
+ requests to a loopback listener; this module resolves each destination, rejects
5
+ the entire answer set when any address is non-public, and connects to a captured
6
+ numeric address. The hostname is never passed to the dialer, closing the DNS
7
+ validation-to-connect race that URL-only request interception cannot close.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ipaddress
13
+ import re
14
+ import select
15
+ import socket
16
+ import threading
17
+ import time
18
+ from collections.abc import Callable, Iterable
19
+ from contextlib import suppress
20
+ from dataclasses import dataclass
21
+ from typing import Any, Protocol, TypeAlias
22
+ from urllib.parse import urlsplit
23
+
24
+ __all__ = [
25
+ "BrowserEgressProxy",
26
+ "BrowserEgressProxyCleanupError",
27
+ "BrowserEgressProxyError",
28
+ "BrowserEgressProxyLimitError",
29
+ "BrowserEgressProxyLimits",
30
+ "BrowserEgressProxyProtocolError",
31
+ "BrowserEgressProxySecurityError",
32
+ "start_browser_egress_proxy",
33
+ ]
34
+
35
+ AddressResolver: TypeAlias = Callable[[str, int], Iterable[str]]
36
+
37
+
38
+ class ConnectedSocket(Protocol):
39
+ def recv(self, size: int) -> bytes: ...
40
+
41
+ def sendall(self, data: bytes) -> None: ...
42
+
43
+ def settimeout(self, value: float | None) -> None: ...
44
+
45
+ def shutdown(self, how: int) -> None: ...
46
+
47
+ def close(self) -> None: ...
48
+
49
+ def fileno(self) -> int: ...
50
+
51
+
52
+ NumericDialer: TypeAlias = Callable[[str, int, float], ConnectedSocket]
53
+ OwnedPreviewOriginAllowed: TypeAlias = Callable[[str, str, int], bool]
54
+
55
+ _TOKEN_RE = re.compile(rb"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
56
+ _DNS_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
57
+ _METHODS = {b"GET", b"HEAD", b"POST", b"PUT", b"PATCH", b"DELETE", b"OPTIONS"}
58
+ _NAT64_NETWORKS = (
59
+ ipaddress.ip_network("64:ff9b::/96"),
60
+ ipaddress.ip_network("64:ff9b:1::/48"),
61
+ )
62
+ _RELAY_CHUNK_BYTES = 64 * 1024
63
+ _POLL_SECONDS = 0.1
64
+
65
+
66
+ class BrowserEgressProxyError(RuntimeError):
67
+ """Base error with a message safe for user-visible diagnostics."""
68
+
69
+
70
+ class BrowserEgressProxyCleanupError(BrowserEgressProxyError):
71
+ pass
72
+
73
+
74
+ class BrowserEgressProxySecurityError(BrowserEgressProxyError):
75
+ pass
76
+
77
+
78
+ class BrowserEgressProxyLimitError(BrowserEgressProxyError):
79
+ pass
80
+
81
+
82
+ class BrowserEgressProxyProtocolError(BrowserEgressProxyError):
83
+ pass
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class BrowserEgressProxyLimits:
88
+ max_header_bytes: int = 64 * 1024
89
+ max_header_line_bytes: int = 8 * 1024
90
+ max_header_count: int = 100
91
+ max_connections: int = 32
92
+ max_resolutions: int = 4
93
+ max_resolved_addresses: int = 16
94
+ dns_timeout_seconds: float = 2.0
95
+ connect_timeout_seconds: float = 5.0
96
+ header_timeout_seconds: float = 5.0
97
+ idle_timeout_seconds: float = 30.0
98
+ connection_lifetime_seconds: float = 300.0
99
+ max_bytes_per_connection: int = 128 * 1024 * 1024
100
+ max_bytes_total: int = 1024 * 1024 * 1024
101
+
102
+ def __post_init__(self) -> None:
103
+ integer_limits = (
104
+ (self.max_header_bytes, 1024, 1024 * 1024, "header byte"),
105
+ (self.max_header_line_bytes, 256, self.max_header_bytes, "header line"),
106
+ (self.max_header_count, 1, 1000, "header count"),
107
+ (self.max_connections, 1, 256, "connection"),
108
+ (self.max_resolutions, 1, 32, "resolution"),
109
+ (self.max_resolved_addresses, 1, 128, "resolved address"),
110
+ (self.max_bytes_per_connection, 1024, 2**40, "connection byte"),
111
+ (self.max_bytes_total, self.max_bytes_per_connection, 2**44, "total byte"),
112
+ )
113
+ for value, minimum, maximum, label in integer_limits:
114
+ if (
115
+ isinstance(value, bool)
116
+ or not isinstance(value, int)
117
+ or not minimum <= value <= maximum
118
+ ):
119
+ raise BrowserEgressProxyLimitError(
120
+ f"Browser egress proxy {label} limit is outside its safe range."
121
+ )
122
+ time_limits = (
123
+ (self.dns_timeout_seconds, 0.05, 30.0, "DNS"),
124
+ (self.connect_timeout_seconds, 0.05, 60.0, "connect"),
125
+ (self.header_timeout_seconds, 0.05, 60.0, "header"),
126
+ (self.idle_timeout_seconds, 0.05, 600.0, "idle"),
127
+ (self.connection_lifetime_seconds, 0.05, 3600.0, "lifetime"),
128
+ )
129
+ for value, minimum, maximum, label in time_limits:
130
+ if (
131
+ isinstance(value, bool)
132
+ or not isinstance(value, int | float)
133
+ or not minimum <= float(value) <= maximum
134
+ ):
135
+ raise BrowserEgressProxyLimitError(
136
+ f"Browser egress proxy {label} timeout is outside its safe range."
137
+ )
138
+
139
+
140
+ @dataclass(frozen=True, slots=True)
141
+ class _ParsedRequest:
142
+ method: bytes
143
+ scheme: str
144
+ host: str
145
+ port: int
146
+ upstream_header: bytes | None
147
+ buffered_body: bytes
148
+ connect_tunnel: bool
149
+
150
+
151
+ class _RequestFailure(Exception):
152
+ def __init__(self, status: int) -> None:
153
+ super().__init__()
154
+ self.status = status
155
+
156
+
157
+ class BrowserEgressProxy:
158
+ """A single-session, loopback-only validating HTTP/CONNECT proxy."""
159
+
160
+ def __init__(
161
+ self,
162
+ *,
163
+ limits: BrowserEgressProxyLimits | None = None,
164
+ resolver: AddressResolver | None = None,
165
+ dialer: NumericDialer | None = None,
166
+ allow_local_destinations: bool = False,
167
+ local_destinations_loopback_only: bool = False,
168
+ owned_preview_origin_allowed: OwnedPreviewOriginAllowed | None = None,
169
+ ) -> None:
170
+ if not isinstance(allow_local_destinations, bool):
171
+ raise BrowserEgressProxySecurityError(
172
+ "Browser egress proxy local-destination policy is invalid."
173
+ )
174
+ if not isinstance(local_destinations_loopback_only, bool):
175
+ raise BrowserEgressProxySecurityError(
176
+ "Browser egress proxy loopback policy is invalid."
177
+ )
178
+ if local_destinations_loopback_only and not allow_local_destinations:
179
+ raise BrowserEgressProxySecurityError(
180
+ "Browser egress proxy loopback policy requires explicit local access."
181
+ )
182
+ if owned_preview_origin_allowed is not None and not callable(owned_preview_origin_allowed):
183
+ raise BrowserEgressProxySecurityError(
184
+ "Browser egress proxy preview-origin policy is invalid."
185
+ )
186
+ self._limits = limits or BrowserEgressProxyLimits()
187
+ self._resolver = resolver or _default_resolver
188
+ self._dialer = dialer
189
+ self._allow_local_destinations = allow_local_destinations
190
+ self._local_destinations_loopback_only = local_destinations_loopback_only
191
+ self._owned_preview_origin_allowed = owned_preview_origin_allowed
192
+ self._lock = threading.RLock()
193
+ self._close_lock = threading.Lock()
194
+ self._stop = threading.Event()
195
+ self._resolution_slots = threading.BoundedSemaphore(self._limits.max_resolutions)
196
+ self._listener: socket.socket | None = None
197
+ self._listener_thread: threading.Thread | None = None
198
+ self._workers: set[threading.Thread] = set()
199
+ self._helpers: set[threading.Thread] = set()
200
+ self._active_sockets: set[ConnectedSocket] = set()
201
+ self._started = False
202
+ self._closed = False
203
+ self._port = 0
204
+ self._total_bytes = 0
205
+ self._terminal_error: BrowserEgressProxyError | None = None
206
+ self._denied_endpoints: set[tuple[str, int]] = set()
207
+
208
+ @property
209
+ def host(self) -> str:
210
+ return "127.0.0.1"
211
+
212
+ @property
213
+ def port(self) -> int:
214
+ with self._lock:
215
+ if not self._started or self._port <= 0:
216
+ raise BrowserEgressProxyError("Browser egress proxy has not started.")
217
+ return self._port
218
+
219
+ @property
220
+ def proxy_url(self) -> str:
221
+ return f"http://{self.host}:{self.port}"
222
+
223
+ @property
224
+ def terminal_error(self) -> BrowserEgressProxyError | None:
225
+ with self._lock:
226
+ return self._terminal_error
227
+
228
+ @property
229
+ def healthy(self) -> bool:
230
+ with self._lock:
231
+ thread = self._listener_thread
232
+ return bool(
233
+ self._started
234
+ and not self._closed
235
+ and self._terminal_error is None
236
+ and thread is not None
237
+ and thread.is_alive()
238
+ )
239
+
240
+ def start(self) -> BrowserEgressProxy:
241
+ with self._lock:
242
+ if self._closed:
243
+ raise BrowserEgressProxyError("Browser egress proxy is already closed.")
244
+ if self._started:
245
+ return self
246
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
247
+ try:
248
+ if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
249
+ listener.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
250
+ listener.bind((self.host, 0))
251
+ listener.listen(self._limits.max_connections)
252
+ listener.settimeout(_POLL_SECONDS)
253
+ port = listener.getsockname()[1]
254
+ if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
255
+ raise OSError("invalid listener port")
256
+ except BaseException as exc:
257
+ listener.close()
258
+ raise BrowserEgressProxyError(
259
+ "Browser egress proxy could not bind its loopback listener."
260
+ ) from exc
261
+ self._listener = listener
262
+ self._port = port
263
+ self._denied_endpoints.add((self.host, port))
264
+ self._started = True
265
+ thread = threading.Thread(
266
+ target=self._accept_loop,
267
+ name="alysis-browser-proxy-listener",
268
+ daemon=True,
269
+ )
270
+ self._listener_thread = thread
271
+ try:
272
+ thread.start()
273
+ except BaseException as exc:
274
+ self._started = False
275
+ self._listener = None
276
+ listener.close()
277
+ raise BrowserEgressProxyError(
278
+ "Browser egress proxy listener could not start."
279
+ ) from exc
280
+ return self
281
+
282
+ def deny_endpoint(self, host: str, port: int) -> None:
283
+ """Permanently deny an owned control endpoint, including in local mode."""
284
+
285
+ try:
286
+ normalized_host, normalized_port = _normalize_host_port(host, port)
287
+ except _RequestFailure as exc:
288
+ raise BrowserEgressProxySecurityError(
289
+ "Browser egress proxy denied endpoint is invalid."
290
+ ) from exc
291
+ with self._lock:
292
+ self._denied_endpoints.add((normalized_host, normalized_port))
293
+
294
+ def close(self, *, timeout: float = 2.0) -> None:
295
+ if (
296
+ isinstance(timeout, bool)
297
+ or not isinstance(timeout, int | float)
298
+ or not 0.05 <= float(timeout) <= 30.0
299
+ ):
300
+ raise BrowserEgressProxyLimitError("Browser egress proxy close timeout is invalid.")
301
+ deadline = time.monotonic() + float(timeout)
302
+ with self._close_lock:
303
+ with self._lock:
304
+ self._closed = True
305
+ self._stop.set()
306
+ listener = self._listener
307
+ self._listener = None
308
+ sockets = tuple(self._active_sockets)
309
+ listener_thread = self._listener_thread
310
+ _close_socket(listener)
311
+ for item in sockets:
312
+ _close_socket(item)
313
+ current = threading.current_thread()
314
+ if listener_thread is not None and listener_thread is not current:
315
+ listener_thread.join(timeout=max(0.0, deadline - time.monotonic()))
316
+ while True:
317
+ with self._lock:
318
+ workers = tuple(item for item in self._workers if item is not current)
319
+ helpers = tuple(item for item in self._helpers if item is not current)
320
+ background_threads = workers + helpers
321
+ if not background_threads or time.monotonic() >= deadline:
322
+ break
323
+ for thread in background_threads:
324
+ thread.join(timeout=min(_POLL_SECONDS, max(0.0, deadline - time.monotonic())))
325
+ with self._lock:
326
+ listener_alive = bool(
327
+ listener_thread is not None
328
+ and listener_thread is not current
329
+ and listener_thread.is_alive()
330
+ )
331
+ workers_alive = any(
332
+ item is not current and item.is_alive() for item in self._workers
333
+ )
334
+ helpers_alive = any(
335
+ item is not current and item.is_alive() for item in self._helpers
336
+ )
337
+ sockets_remaining = bool(self._active_sockets)
338
+ if listener_alive or workers_alive or helpers_alive or sockets_remaining:
339
+ error = BrowserEgressProxyCleanupError(
340
+ "Browser egress proxy cleanup did not finish before its deadline."
341
+ )
342
+ if self._terminal_error is None:
343
+ self._terminal_error = error
344
+ raise error
345
+
346
+ def __enter__(self) -> BrowserEgressProxy:
347
+ return self.start()
348
+
349
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
350
+ self.close()
351
+
352
+ def _accept_loop(self) -> None:
353
+ while not self._stop.is_set():
354
+ with self._lock:
355
+ listener = self._listener
356
+ if listener is None:
357
+ return
358
+ try:
359
+ client, peer = listener.accept()
360
+ except TimeoutError:
361
+ continue
362
+ except OSError:
363
+ if self._stop.is_set():
364
+ return
365
+ self._fail_proxy("Browser egress proxy listener failed.")
366
+ return
367
+ peer_host = peer[0] if isinstance(peer, tuple) and peer else None
368
+ if peer_host != self.host:
369
+ _close_socket(client)
370
+ continue
371
+ client.settimeout(_POLL_SECONDS)
372
+ with self._lock:
373
+ if self._stop.is_set():
374
+ _close_socket(client)
375
+ return
376
+ if len(self._workers) >= self._limits.max_connections:
377
+ overloaded = True
378
+ else:
379
+ overloaded = False
380
+ worker = threading.Thread(
381
+ target=self._serve_client,
382
+ args=(client,),
383
+ name="alysis-browser-proxy-client",
384
+ daemon=True,
385
+ )
386
+ self._workers.add(worker)
387
+ self._active_sockets.add(client)
388
+ if overloaded:
389
+ _send_error_and_drain(client, 503, max_drain_bytes=self._limits.max_header_bytes)
390
+ continue
391
+ try:
392
+ worker.start()
393
+ except BaseException:
394
+ with self._lock:
395
+ self._workers.discard(worker)
396
+ self._active_sockets.discard(client)
397
+ _close_socket(client)
398
+
399
+ def _serve_client(self, client: socket.socket) -> None:
400
+ upstream: ConnectedSocket | None = None
401
+ try:
402
+ raw_header, buffered = self._read_header(client)
403
+ parsed = _parse_request(raw_header, buffered, limits=self._limits)
404
+ addresses = self._resolve_public(
405
+ parsed.host,
406
+ parsed.port,
407
+ scheme=parsed.scheme,
408
+ )
409
+ upstream = self._connect(addresses, parsed.port)
410
+ with self._lock:
411
+ if self._stop.is_set():
412
+ raise _RequestFailure(503)
413
+ self._active_sockets.add(upstream)
414
+ upstream.settimeout(_POLL_SECONDS)
415
+ transferred = 0
416
+ if parsed.connect_tunnel:
417
+ response = b"HTTP/1.1 200 Connection Established\r\n\r\n"
418
+ transferred = self._send_counted(client, response, transferred)
419
+ elif parsed.upstream_header is not None:
420
+ transferred = self._send_counted(upstream, parsed.upstream_header, transferred)
421
+ if parsed.buffered_body:
422
+ transferred = self._send_counted(upstream, parsed.buffered_body, transferred)
423
+ self._relay(client, upstream, transferred=transferred)
424
+ except _RequestFailure as exc:
425
+ if upstream is None:
426
+ _send_error_and_drain(
427
+ client, exc.status, max_drain_bytes=self._limits.max_header_bytes
428
+ )
429
+ except BaseException:
430
+ if upstream is None:
431
+ _send_error_and_drain(client, 502, max_drain_bytes=self._limits.max_header_bytes)
432
+ finally:
433
+ _close_socket(upstream)
434
+ _close_socket(client)
435
+ current = threading.current_thread()
436
+ with self._lock:
437
+ self._active_sockets.discard(client)
438
+ if upstream is not None:
439
+ self._active_sockets.discard(upstream)
440
+ self._workers.discard(current)
441
+
442
+ def _start_helper(self, target: Callable[[], None], *, name: str) -> threading.Thread:
443
+ def run() -> None:
444
+ try:
445
+ target()
446
+ finally:
447
+ with self._lock:
448
+ self._helpers.discard(threading.current_thread())
449
+
450
+ helper = threading.Thread(target=run, name=name, daemon=True)
451
+ with self._lock:
452
+ if self._stop.is_set():
453
+ raise _RequestFailure(503)
454
+ self._helpers.add(helper)
455
+ try:
456
+ helper.start()
457
+ except BaseException:
458
+ with self._lock:
459
+ self._helpers.discard(helper)
460
+ raise
461
+ return helper
462
+
463
+ def _read_header(self, client: socket.socket) -> tuple[bytes, bytes]:
464
+ deadline = time.monotonic() + self._limits.header_timeout_seconds
465
+ payload = bytearray()
466
+ while True:
467
+ if self._stop.is_set():
468
+ raise _RequestFailure(503)
469
+ marker = payload.find(b"\r\n\r\n")
470
+ if marker >= 0:
471
+ end = marker + 4
472
+ if end > self._limits.max_header_bytes:
473
+ raise _RequestFailure(431)
474
+ return bytes(payload[:end]), bytes(payload[end:])
475
+ if len(payload) >= self._limits.max_header_bytes:
476
+ raise _RequestFailure(431)
477
+ if time.monotonic() >= deadline:
478
+ raise _RequestFailure(408)
479
+ try:
480
+ chunk = client.recv(min(4096, self._limits.max_header_bytes - len(payload)))
481
+ except TimeoutError:
482
+ continue
483
+ except OSError as exc:
484
+ raise _RequestFailure(400) from exc
485
+ if not chunk:
486
+ raise _RequestFailure(400)
487
+ payload.extend(chunk)
488
+
489
+ def _resolve_public(self, host: str, port: int, *, scheme: str) -> tuple[str, ...]:
490
+ with self._lock:
491
+ if (host, port) in self._denied_endpoints:
492
+ raise _RequestFailure(403)
493
+ try:
494
+ literal = ipaddress.ip_address(host)
495
+ except ValueError:
496
+ literal = None
497
+ if literal is not None:
498
+ values = (str(literal),)
499
+ else:
500
+ if not self._resolution_slots.acquire(blocking=False):
501
+ raise _RequestFailure(503)
502
+ completed = threading.Event()
503
+ outcome: dict[str, Any] = {}
504
+
505
+ def resolve() -> None:
506
+ try:
507
+ values: list[str] = []
508
+ iterator = iter(self._resolver(host, port))
509
+ for _ in range(self._limits.max_resolved_addresses + 1):
510
+ try:
511
+ values.append(next(iterator))
512
+ except StopIteration:
513
+ break
514
+ outcome["values"] = tuple(values)
515
+ except BaseException as exc: # noqa: BLE001 - normalized below
516
+ outcome["error"] = exc
517
+ finally:
518
+ self._resolution_slots.release()
519
+ completed.set()
520
+
521
+ try:
522
+ self._start_helper(resolve, name="alysis-browser-proxy-dns")
523
+ except BaseException:
524
+ self._resolution_slots.release()
525
+ raise
526
+ deadline = time.monotonic() + self._limits.dns_timeout_seconds
527
+ while not completed.wait(_POLL_SECONDS):
528
+ if self._stop.is_set():
529
+ raise _RequestFailure(503)
530
+ if time.monotonic() >= deadline:
531
+ raise _RequestFailure(504)
532
+ if "error" in outcome:
533
+ raise _RequestFailure(502)
534
+ raw_values = outcome.get("values", ())
535
+ if not isinstance(raw_values, tuple):
536
+ raise _RequestFailure(502)
537
+ if len(raw_values) > self._limits.max_resolved_addresses:
538
+ raise _RequestFailure(403)
539
+ parsed_values: list[str] = []
540
+ try:
541
+ for value in raw_values:
542
+ address = ipaddress.ip_address(str(value))
543
+ normalized = str(address)
544
+ if normalized not in parsed_values:
545
+ parsed_values.append(normalized)
546
+ except ValueError as exc:
547
+ raise _RequestFailure(403) from exc
548
+ values = tuple(parsed_values)
549
+ with self._lock:
550
+ denied = any((value, port) in self._denied_endpoints for value in values)
551
+ owned_preview_allowed = False
552
+ if self._owned_preview_origin_allowed is not None:
553
+ try:
554
+ owned_preview_allowed = bool(self._owned_preview_origin_allowed(scheme, host, port))
555
+ except Exception:
556
+ owned_preview_allowed = False
557
+ owned_preview_addresses_safe = bool(values) and all(
558
+ _is_allowed_local_address(value) for value in values
559
+ )
560
+ if (
561
+ not values
562
+ or denied
563
+ or (
564
+ not self._allow_local_destinations
565
+ and not (owned_preview_allowed and owned_preview_addresses_safe)
566
+ and any(not _is_public_address(value) for value in values)
567
+ )
568
+ or (
569
+ self._allow_local_destinations
570
+ and self._local_destinations_loopback_only
571
+ and any(not _is_public_or_loopback_address(value) for value in values)
572
+ )
573
+ or (
574
+ self._allow_local_destinations
575
+ and not self._local_destinations_loopback_only
576
+ and any(not _is_allowed_local_address(value) for value in values)
577
+ )
578
+ ):
579
+ raise _RequestFailure(403)
580
+ return values
581
+
582
+ def _connect(self, addresses: tuple[str, ...], port: int) -> ConnectedSocket:
583
+ deadline = time.monotonic() + self._limits.connect_timeout_seconds
584
+ for address in addresses:
585
+ if self._stop.is_set():
586
+ raise _RequestFailure(503)
587
+ remaining = deadline - time.monotonic()
588
+ if remaining <= 0:
589
+ break
590
+ try:
591
+ if self._dialer is not None:
592
+ connected = self._dial_injected_bounded(address, port, remaining)
593
+ else:
594
+ connected = self._dial_numeric(address, port, remaining)
595
+ except (OSError, TimeoutError):
596
+ continue
597
+ if connected is None:
598
+ continue
599
+ return connected
600
+ raise _RequestFailure(502)
601
+
602
+ def _dial_injected_bounded(self, address: str, port: int, timeout: float) -> ConnectedSocket:
603
+ """Bound an injected dialer and close any socket returned after abandonment."""
604
+
605
+ if self._dialer is None:
606
+ raise OSError("dialer unavailable")
607
+ completed = threading.Event()
608
+ abandoned = threading.Event()
609
+ outcome: dict[str, Any] = {}
610
+ outcome_lock = threading.Lock()
611
+
612
+ def dial() -> None:
613
+ connected: ConnectedSocket | None = None
614
+ try:
615
+ connected = self._dialer(address, port, timeout)
616
+ if connected is None:
617
+ raise OSError("dialer returned no socket")
618
+ with outcome_lock:
619
+ should_close = abandoned.is_set() or self._stop.is_set()
620
+ if not should_close:
621
+ outcome["connected"] = connected
622
+ if should_close:
623
+ _close_socket(connected)
624
+ except BaseException as exc: # noqa: BLE001 - normalized by caller
625
+ with outcome_lock:
626
+ outcome["error"] = exc
627
+ finally:
628
+ completed.set()
629
+
630
+ self._start_helper(dial, name="alysis-browser-proxy-dial")
631
+ deadline = time.monotonic() + timeout
632
+ while not completed.wait(_POLL_SECONDS):
633
+ if self._stop.is_set() or time.monotonic() >= deadline:
634
+ with outcome_lock:
635
+ abandoned.set()
636
+ late_socket = outcome.pop("connected", None)
637
+ if late_socket is not None:
638
+ _close_socket(late_socket)
639
+ raise TimeoutError
640
+ with outcome_lock:
641
+ connected = outcome.get("connected")
642
+ error = outcome.get("error")
643
+ if connected is not None:
644
+ return connected
645
+ if isinstance(error, BaseException):
646
+ raise OSError("numeric dial failed") from error
647
+ raise OSError("numeric dial failed")
648
+
649
+ def _dial_numeric(self, address: str, port: int, timeout: float) -> socket.socket:
650
+ parsed = ipaddress.ip_address(address)
651
+ family = socket.AF_INET6 if isinstance(parsed, ipaddress.IPv6Address) else socket.AF_INET
652
+ connected = socket.socket(family, socket.SOCK_STREAM)
653
+ with self._lock:
654
+ if self._stop.is_set():
655
+ connected.close()
656
+ raise OSError("proxy stopping")
657
+ self._active_sockets.add(connected)
658
+ try:
659
+ connected.settimeout(timeout)
660
+ if family == socket.AF_INET6:
661
+ connected.connect((str(parsed), port, 0, 0))
662
+ else:
663
+ connected.connect((str(parsed), port))
664
+ return connected
665
+ except BaseException:
666
+ with self._lock:
667
+ self._active_sockets.discard(connected)
668
+ connected.close()
669
+ raise
670
+
671
+ def _send_counted(self, destination: ConnectedSocket, payload: bytes, transferred: int) -> int:
672
+ updated = self._consume_bytes(transferred, len(payload))
673
+ try:
674
+ destination.sendall(payload)
675
+ except (OSError, TimeoutError) as exc:
676
+ raise _RequestFailure(502) from exc
677
+ return updated
678
+
679
+ def _consume_bytes(self, transferred: int, amount: int) -> int:
680
+ updated = transferred + amount
681
+ if updated > self._limits.max_bytes_per_connection:
682
+ raise _RequestFailure(509)
683
+ with self._lock:
684
+ if self._total_bytes + amount > self._limits.max_bytes_total:
685
+ raise _RequestFailure(509)
686
+ self._total_bytes += amount
687
+ return updated
688
+
689
+ def _relay(
690
+ self, client: ConnectedSocket, upstream: ConnectedSocket, *, transferred: int
691
+ ) -> None:
692
+ started = time.monotonic()
693
+ last_activity = started
694
+ readable: list[ConnectedSocket] = [client, upstream]
695
+ while readable and not self._stop.is_set():
696
+ now = time.monotonic()
697
+ if now - started >= self._limits.connection_lifetime_seconds:
698
+ raise _RequestFailure(504)
699
+ if now - last_activity >= self._limits.idle_timeout_seconds:
700
+ raise _RequestFailure(504)
701
+ wait = min(
702
+ _POLL_SECONDS,
703
+ self._limits.connection_lifetime_seconds - (now - started),
704
+ self._limits.idle_timeout_seconds - (now - last_activity),
705
+ )
706
+ try:
707
+ ready, _, _ = select.select(readable, [], [], max(0.0, wait))
708
+ except (OSError, ValueError) as exc:
709
+ if self._stop.is_set():
710
+ return
711
+ raise _RequestFailure(502) from exc
712
+ for source in ready:
713
+ destination = upstream if source is client else client
714
+ try:
715
+ payload = source.recv(_RELAY_CHUNK_BYTES)
716
+ except TimeoutError:
717
+ continue
718
+ except OSError as exc:
719
+ raise _RequestFailure(502) from exc
720
+ if not payload:
721
+ readable.remove(source)
722
+ with suppress(OSError):
723
+ destination.shutdown(socket.SHUT_WR)
724
+ continue
725
+ transferred = self._send_counted(destination, payload, transferred)
726
+ last_activity = time.monotonic()
727
+
728
+ def _fail_proxy(self, message: str) -> None:
729
+ error = BrowserEgressProxyError(message)
730
+ with self._lock:
731
+ if self._terminal_error is None:
732
+ self._terminal_error = error
733
+ self._stop.set()
734
+ listener = self._listener
735
+ self._listener = None
736
+ sockets = tuple(self._active_sockets)
737
+ _close_socket(listener)
738
+ for item in sockets:
739
+ _close_socket(item)
740
+
741
+
742
+ def start_browser_egress_proxy(
743
+ *,
744
+ limits: BrowserEgressProxyLimits | None = None,
745
+ resolver: AddressResolver | None = None,
746
+ dialer: NumericDialer | None = None,
747
+ allow_local_destinations: bool = False,
748
+ local_destinations_loopback_only: bool = False,
749
+ ) -> BrowserEgressProxy:
750
+ return BrowserEgressProxy(
751
+ limits=limits,
752
+ resolver=resolver,
753
+ dialer=dialer,
754
+ allow_local_destinations=allow_local_destinations,
755
+ local_destinations_loopback_only=local_destinations_loopback_only,
756
+ ).start()
757
+
758
+
759
+ def _parse_request(
760
+ raw_header: bytes,
761
+ buffered: bytes,
762
+ *,
763
+ limits: BrowserEgressProxyLimits,
764
+ ) -> _ParsedRequest:
765
+ if not raw_header.endswith(b"\r\n\r\n") or b"\x00" in raw_header:
766
+ raise _RequestFailure(400)
767
+ lines = raw_header[:-4].split(b"\r\n")
768
+ if not lines or len(lines[0]) > limits.max_header_line_bytes:
769
+ raise _RequestFailure(414)
770
+ request_parts = lines[0].split(b" ")
771
+ if len(request_parts) != 3 or any(not item for item in request_parts):
772
+ raise _RequestFailure(400)
773
+ method, target, version = request_parts
774
+ if not _TOKEN_RE.fullmatch(method) or version != b"HTTP/1.1":
775
+ raise _RequestFailure(400)
776
+ if len(target) > limits.max_header_line_bytes or any(
777
+ value < 0x21 or value == 0x7F for value in target
778
+ ):
779
+ raise _RequestFailure(400)
780
+ if len(lines) - 1 > limits.max_header_count:
781
+ raise _RequestFailure(431)
782
+ headers: list[tuple[bytes, bytes]] = []
783
+ grouped: dict[bytes, list[bytes]] = {}
784
+ for line in lines[1:]:
785
+ if not line or len(line) > limits.max_header_line_bytes or line[:1] in b" \t":
786
+ raise _RequestFailure(400)
787
+ if b":" not in line:
788
+ raise _RequestFailure(400)
789
+ name, value = line.split(b":", 1)
790
+ if not _TOKEN_RE.fullmatch(name) or value[:1] in b" \t" and not value.strip(b" \t"):
791
+ raise _RequestFailure(400)
792
+ stripped = value.strip(b" \t")
793
+ if any(item < 0x20 and item != 0x09 or item == 0x7F for item in stripped):
794
+ raise _RequestFailure(400)
795
+ lowered = name.lower()
796
+ headers.append((lowered, stripped))
797
+ grouped.setdefault(lowered, []).append(stripped)
798
+ for singleton in (b"host", b"content-length", b"transfer-encoding", b"upgrade"):
799
+ if len(grouped.get(singleton, ())) > 1:
800
+ raise _RequestFailure(400)
801
+ if len(grouped.get(b"host", ())) != 1:
802
+ raise _RequestFailure(400)
803
+ if b"content-length" in grouped and b"transfer-encoding" in grouped:
804
+ raise _RequestFailure(400)
805
+ if b"content-length" in grouped:
806
+ raw_length = grouped[b"content-length"][0]
807
+ if not raw_length.isdigit():
808
+ raise _RequestFailure(400)
809
+ if b"transfer-encoding" in grouped and grouped[b"transfer-encoding"][0].lower() != b"chunked":
810
+ raise _RequestFailure(400)
811
+ connection_tokens = _connection_tokens(grouped.get(b"connection", ()))
812
+ if any(
813
+ item in {b"host", b"content-length", b"transfer-encoding"} for item in connection_tokens
814
+ ):
815
+ raise _RequestFailure(400)
816
+
817
+ if method == b"CONNECT":
818
+ if b"content-length" in grouped or b"transfer-encoding" in grouped or b"upgrade" in grouped:
819
+ raise _RequestFailure(400)
820
+ host, port = _parse_authority(target, default_port=None)
821
+ host_value, host_port = _parse_authority(grouped[b"host"][0], default_port=None)
822
+ if host != host_value or port != host_port:
823
+ raise _RequestFailure(400)
824
+ return _ParsedRequest(method, "https", host, port, None, buffered, True)
825
+ if method not in _METHODS:
826
+ raise _RequestFailure(405)
827
+ try:
828
+ target_text = target.decode("ascii")
829
+ parsed = urlsplit(target_text)
830
+ parsed_port = parsed.port
831
+ except (UnicodeError, ValueError) as exc:
832
+ raise _RequestFailure(400) from exc
833
+ scheme = parsed.scheme.lower()
834
+ if scheme not in {"http", "ws"} or not parsed.netloc or parsed.fragment:
835
+ raise _RequestFailure(400)
836
+ if parsed.username is not None or parsed.password is not None or "\\" in parsed.netloc:
837
+ raise _RequestFailure(400)
838
+ default_port = 80
839
+ host, port = _normalize_host_port(parsed.hostname, parsed_port or default_port)
840
+ host_value, host_port = _parse_authority(grouped[b"host"][0], default_port=default_port)
841
+ if host != host_value or port != host_port:
842
+ raise _RequestFailure(400)
843
+ upgrade = grouped.get(b"upgrade", [b""])[0].lower()
844
+ websocket = scheme == "ws" or upgrade == b"websocket"
845
+ if websocket != (upgrade == b"websocket" and b"upgrade" in connection_tokens):
846
+ raise _RequestFailure(400)
847
+ path = parsed.path or "/"
848
+ if parsed.query:
849
+ path = f"{path}?{parsed.query}"
850
+ try:
851
+ origin_target = path.encode("ascii")
852
+ except UnicodeError as exc:
853
+ raise _RequestFailure(400) from exc
854
+ blocked_headers = {
855
+ b"connection",
856
+ b"keep-alive",
857
+ b"proxy-authenticate",
858
+ b"proxy-authorization",
859
+ b"proxy-connection",
860
+ b"te",
861
+ b"trailer",
862
+ } | connection_tokens
863
+ output = [method + b" " + origin_target + b" HTTP/1.1"]
864
+ for name, value in headers:
865
+ if name in blocked_headers or name == b"upgrade":
866
+ continue
867
+ output.append(name + b": " + value)
868
+ if websocket:
869
+ output.extend((b"connection: Upgrade", b"upgrade: websocket"))
870
+ else:
871
+ output.append(b"connection: close")
872
+ return _ParsedRequest(
873
+ method,
874
+ scheme,
875
+ host,
876
+ port,
877
+ b"\r\n".join(output) + b"\r\n\r\n",
878
+ buffered,
879
+ False,
880
+ )
881
+
882
+
883
+ def _connection_tokens(values: list[bytes] | tuple[bytes, ...]) -> set[bytes]:
884
+ output: set[bytes] = set()
885
+ for value in values:
886
+ for item in value.split(b","):
887
+ token = item.strip().lower()
888
+ if not token or not _TOKEN_RE.fullmatch(token):
889
+ raise _RequestFailure(400)
890
+ output.add(token)
891
+ return output
892
+
893
+
894
+ def _parse_authority(value: bytes, *, default_port: int | None) -> tuple[str, int]:
895
+ try:
896
+ text = value.decode("ascii")
897
+ except UnicodeError as exc:
898
+ raise _RequestFailure(400) from exc
899
+ if (
900
+ not text
901
+ or any(ord(item) < 0x21 or ord(item) == 0x7F for item in text)
902
+ or any(item in text for item in "/?#@\\,")
903
+ ):
904
+ raise _RequestFailure(400)
905
+ if text.startswith("["):
906
+ end = text.find("]")
907
+ if end <= 1 or text.find("]", end + 1) >= 0:
908
+ raise _RequestFailure(400)
909
+ host_text = text[1:end]
910
+ suffix = text[end + 1 :]
911
+ if suffix:
912
+ if not suffix.startswith(":") or not suffix[1:].isdigit():
913
+ raise _RequestFailure(400)
914
+ port = int(suffix[1:])
915
+ elif default_port is not None:
916
+ port = default_port
917
+ else:
918
+ raise _RequestFailure(400)
919
+ try:
920
+ address = ipaddress.IPv6Address(host_text)
921
+ except ValueError as exc:
922
+ raise _RequestFailure(400) from exc
923
+ return _normalize_host_port(str(address), port)
924
+ if text.count(":") > 1:
925
+ raise _RequestFailure(400)
926
+ if ":" in text:
927
+ host_text, raw_port = text.rsplit(":", 1)
928
+ if not raw_port.isdigit():
929
+ raise _RequestFailure(400)
930
+ port = int(raw_port)
931
+ elif default_port is not None:
932
+ host_text = text
933
+ port = default_port
934
+ else:
935
+ raise _RequestFailure(400)
936
+ return _normalize_host_port(host_text, port)
937
+
938
+
939
+ def _normalize_host_port(host: str | None, port: int) -> tuple[str, int]:
940
+ if not host or isinstance(port, bool) or not 1 <= port <= 65535:
941
+ raise _RequestFailure(400)
942
+ normalized = host.rstrip(".").lower()
943
+ try:
944
+ address = ipaddress.ip_address(normalized)
945
+ except ValueError:
946
+ try:
947
+ normalized = normalized.encode("idna").decode("ascii")
948
+ except UnicodeError as exc:
949
+ raise _RequestFailure(400) from exc
950
+ if (
951
+ not normalized
952
+ or len(normalized) > 253
953
+ or any(not _DNS_LABEL_RE.fullmatch(label) for label in normalized.split("."))
954
+ ):
955
+ raise _RequestFailure(400) from None
956
+ else:
957
+ normalized = str(address)
958
+ return normalized, port
959
+
960
+
961
+ def _is_public_address(value: str) -> bool:
962
+ address = ipaddress.ip_address(value)
963
+ if _is_transition_address(address):
964
+ return False
965
+ return bool(address.is_global) and not any(
966
+ (
967
+ address.is_private,
968
+ address.is_loopback,
969
+ address.is_link_local,
970
+ address.is_multicast,
971
+ address.is_reserved,
972
+ address.is_unspecified,
973
+ )
974
+ )
975
+
976
+
977
+ def _is_allowed_local_address(value: str) -> bool:
978
+ address = ipaddress.ip_address(value)
979
+ if _is_transition_address(address):
980
+ return False
981
+ return not any(
982
+ (
983
+ address.is_multicast,
984
+ address.is_reserved,
985
+ address.is_unspecified,
986
+ )
987
+ )
988
+
989
+
990
+ def _is_public_or_loopback_address(value: str) -> bool:
991
+ address = ipaddress.ip_address(value)
992
+ if _is_transition_address(address):
993
+ return False
994
+ return bool(address.is_loopback or _is_public_address(value))
995
+
996
+
997
+ def _is_transition_address(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
998
+ return isinstance(address, ipaddress.IPv6Address) and (
999
+ address.ipv4_mapped is not None
1000
+ or address.sixtofour is not None
1001
+ or address.teredo is not None
1002
+ or any(address in network for network in _NAT64_NETWORKS)
1003
+ )
1004
+
1005
+
1006
+ def _default_resolver(host: str, port: int) -> Iterable[str]:
1007
+ return {
1008
+ item[4][0]
1009
+ for item in socket.getaddrinfo(
1010
+ host,
1011
+ port,
1012
+ family=socket.AF_UNSPEC,
1013
+ type=socket.SOCK_STREAM,
1014
+ )
1015
+ }
1016
+
1017
+
1018
+ def _send_error(client: ConnectedSocket, status: int) -> None:
1019
+ messages = {
1020
+ 400: b"Bad Request",
1021
+ 403: b"Forbidden",
1022
+ 405: b"Method Not Allowed",
1023
+ 408: b"Request Timeout",
1024
+ 414: b"URI Too Long",
1025
+ 431: b"Request Header Fields Too Large",
1026
+ 502: b"Bad Gateway",
1027
+ 503: b"Service Unavailable",
1028
+ 504: b"Gateway Timeout",
1029
+ 509: b"Bandwidth Limit Exceeded",
1030
+ }
1031
+ reason = messages.get(status, b"Bad Gateway")
1032
+ body = b"Managed browser request blocked."
1033
+ response = (
1034
+ b"HTTP/1.1 "
1035
+ + str(status).encode("ascii")
1036
+ + b" "
1037
+ + reason
1038
+ + b"\r\nContent-Type: text/plain\r\nContent-Length: "
1039
+ + str(len(body)).encode("ascii")
1040
+ + b"\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n"
1041
+ + body
1042
+ )
1043
+ with suppress(OSError, TimeoutError):
1044
+ client.sendall(response)
1045
+
1046
+
1047
+ def _send_error_and_drain(client: socket.socket, status: int, *, max_drain_bytes: int) -> None:
1048
+ """Deliver a bounded error without a Windows unread-data connection reset."""
1049
+
1050
+ _send_error(client, status)
1051
+ with suppress(OSError):
1052
+ client.shutdown(socket.SHUT_WR)
1053
+ try:
1054
+ client.settimeout(0.05)
1055
+ except OSError:
1056
+ return
1057
+ remaining = max_drain_bytes
1058
+ with suppress(OSError, TimeoutError):
1059
+ while remaining > 0:
1060
+ payload = client.recv(min(4096, remaining))
1061
+ if not payload:
1062
+ break
1063
+ remaining -= len(payload)
1064
+ if b"\r\n\r\n" in payload:
1065
+ break
1066
+ with suppress(OSError):
1067
+ client.close()
1068
+
1069
+
1070
+ def _close_socket(value: ConnectedSocket | socket.socket | None) -> None:
1071
+ if value is None:
1072
+ return
1073
+ with suppress(OSError):
1074
+ value.shutdown(socket.SHUT_RDWR)
1075
+ with suppress(OSError):
1076
+ value.close()