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.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- 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()
|