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,1025 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import site
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass, fields, is_dataclass, replace
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
from typing import Protocol
|
|
16
|
+
|
|
17
|
+
from .branding import default_sandbox_docker_image
|
|
18
|
+
from .bwrap_etc import ensure_minimal_etc_dir
|
|
19
|
+
from .config import AppConfig, ConfigError
|
|
20
|
+
from .process_reaping import ProcessGroupRegistry, run_in_tracked_process_group
|
|
21
|
+
from .sandbox_settings import ShellSandboxSettings, resolve_shell_sandbox_settings
|
|
22
|
+
from .service_persistence import apply_non_interactive_defaults
|
|
23
|
+
|
|
24
|
+
_SENSITIVE_ENV_KEYS = {"ALYSIS_API_KEY", "OPENAI_API_KEY"}
|
|
25
|
+
_LOGGER = logging.getLogger("alysis_code.sandbox_runner")
|
|
26
|
+
_DEFAULT_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
|
27
|
+
_BWRAP_UNSHARE_CGROUP_SUPPORTED: bool | None = None
|
|
28
|
+
_PROTECTED_REPO_META = (".git", ".alysis", ".alysis_images", "alysis-feedback")
|
|
29
|
+
_BWRAP_SYSTEM_BIND_ROOTS = (Path("/usr"), Path("/bin"), Path("/lib"), Path("/lib64"))
|
|
30
|
+
_BWRAP_COMMON_TOOLCHAIN_COMMANDS = (
|
|
31
|
+
"python",
|
|
32
|
+
"python3",
|
|
33
|
+
"pip",
|
|
34
|
+
"pip3",
|
|
35
|
+
"pytest",
|
|
36
|
+
"py.test",
|
|
37
|
+
"ruff",
|
|
38
|
+
"mypy",
|
|
39
|
+
"pyright",
|
|
40
|
+
"tox",
|
|
41
|
+
"nox",
|
|
42
|
+
"uv",
|
|
43
|
+
"poetry",
|
|
44
|
+
"pipx",
|
|
45
|
+
"node",
|
|
46
|
+
"npm",
|
|
47
|
+
"npx",
|
|
48
|
+
"pnpm",
|
|
49
|
+
"yarn",
|
|
50
|
+
"bun",
|
|
51
|
+
"cargo",
|
|
52
|
+
"rustc",
|
|
53
|
+
"rustup",
|
|
54
|
+
"go",
|
|
55
|
+
"java",
|
|
56
|
+
"javac",
|
|
57
|
+
"jar",
|
|
58
|
+
"mvn",
|
|
59
|
+
"gradle",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ShellRunner(Protocol):
|
|
64
|
+
def run(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
root: Path,
|
|
68
|
+
cwd: Path,
|
|
69
|
+
cmd: str,
|
|
70
|
+
timeout_s: int,
|
|
71
|
+
) -> subprocess.CompletedProcess[str]: ...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _sanitized_env(*, clear_env: bool) -> dict[str, str]:
|
|
75
|
+
# Every shell backend's environment funnels through here, which makes it the
|
|
76
|
+
# one place to declare that nobody is at the keyboard.
|
|
77
|
+
if clear_env:
|
|
78
|
+
env: dict[str, str] = {"HOME": "/tmp/home"}
|
|
79
|
+
env["PATH"] = os.environ.get("PATH") or _DEFAULT_PATH
|
|
80
|
+
lang = os.environ.get("LANG")
|
|
81
|
+
if lang:
|
|
82
|
+
env["LANG"] = lang
|
|
83
|
+
return apply_non_interactive_defaults(env)
|
|
84
|
+
blocked = {key.upper() for key in _SENSITIVE_ENV_KEYS}
|
|
85
|
+
return apply_non_interactive_defaults(
|
|
86
|
+
{k: v for k, v in os.environ.items() if k.upper() not in blocked}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _container_env(clear_env: bool, *, allowlist: tuple[str, ...] = ()) -> dict[str, str]:
|
|
91
|
+
if clear_env:
|
|
92
|
+
env: dict[str, str] = {"HOME": "/tmp/home"}
|
|
93
|
+
lang = os.environ.get("LANG")
|
|
94
|
+
if lang:
|
|
95
|
+
env["LANG"] = lang
|
|
96
|
+
return env
|
|
97
|
+
env = _sanitized_env(clear_env=False)
|
|
98
|
+
blocked = {"PATH", "HOME"}
|
|
99
|
+
filtered = {k: v for k, v in env.items() if k.upper() not in blocked}
|
|
100
|
+
if allowlist:
|
|
101
|
+
allowed = {item.upper() for item in allowlist}
|
|
102
|
+
allowed.add("LANG")
|
|
103
|
+
filtered = {k: v for k, v in filtered.items() if k.upper() in allowed}
|
|
104
|
+
filtered["HOME"] = "/tmp/home"
|
|
105
|
+
return filtered
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _workspace_cwd(*, root: Path, cwd: Path) -> str:
|
|
109
|
+
root_abs = root.resolve()
|
|
110
|
+
cwd_abs = cwd.resolve()
|
|
111
|
+
try:
|
|
112
|
+
rel = cwd_abs.relative_to(root_abs)
|
|
113
|
+
except ValueError as e:
|
|
114
|
+
raise RuntimeError(f"cwd is outside root: {cwd_abs}") from e
|
|
115
|
+
if not rel.parts:
|
|
116
|
+
return "/workspace"
|
|
117
|
+
return os.fspath(PurePosixPath("/workspace", *rel.parts))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _protected_repo_paths(root: Path) -> list[tuple[Path, str]]:
|
|
121
|
+
root_abs = root.resolve()
|
|
122
|
+
protected: list[tuple[Path, str]] = []
|
|
123
|
+
for rel in _PROTECTED_REPO_META:
|
|
124
|
+
candidate = root_abs / rel
|
|
125
|
+
if not candidate.exists():
|
|
126
|
+
continue
|
|
127
|
+
# Never follow symlinked metadata roots to avoid binding host paths.
|
|
128
|
+
if candidate.is_symlink():
|
|
129
|
+
continue
|
|
130
|
+
host_path = candidate.resolve()
|
|
131
|
+
try:
|
|
132
|
+
host_path.relative_to(root_abs)
|
|
133
|
+
except ValueError:
|
|
134
|
+
continue
|
|
135
|
+
protected.append((host_path, rel))
|
|
136
|
+
return protected
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _path_is_under_any(path: Path, roots: tuple[Path, ...]) -> bool:
|
|
140
|
+
try:
|
|
141
|
+
resolved = path.resolve()
|
|
142
|
+
except OSError:
|
|
143
|
+
resolved = path
|
|
144
|
+
for root in roots:
|
|
145
|
+
try:
|
|
146
|
+
resolved.relative_to(root.resolve())
|
|
147
|
+
return True
|
|
148
|
+
except ValueError:
|
|
149
|
+
continue
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _path_is_inside_system_root(path: Path) -> bool:
|
|
154
|
+
return _path_is_under_any(path, _BWRAP_SYSTEM_BIND_ROOTS)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _path_is_lexically_under_any(path: Path, roots: tuple[Path, ...]) -> bool:
|
|
158
|
+
candidate = path if path.is_absolute() else path.absolute()
|
|
159
|
+
for root in roots:
|
|
160
|
+
root_abs = root if root.is_absolute() else root.absolute()
|
|
161
|
+
try:
|
|
162
|
+
candidate.relative_to(root_abs)
|
|
163
|
+
return True
|
|
164
|
+
except ValueError:
|
|
165
|
+
continue
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _dedupe_existing_paths(paths: list[Path]) -> tuple[Path, ...]:
|
|
170
|
+
seen: set[str] = set()
|
|
171
|
+
roots: list[Path] = []
|
|
172
|
+
for path in paths:
|
|
173
|
+
try:
|
|
174
|
+
resolved = path.resolve()
|
|
175
|
+
except OSError:
|
|
176
|
+
continue
|
|
177
|
+
if not resolved.exists() or _path_is_inside_system_root(resolved):
|
|
178
|
+
continue
|
|
179
|
+
if _path_is_under_any(resolved, tuple(roots)):
|
|
180
|
+
continue
|
|
181
|
+
key = os.fspath(resolved)
|
|
182
|
+
if key in seen:
|
|
183
|
+
continue
|
|
184
|
+
seen.add(key)
|
|
185
|
+
roots.append(resolved)
|
|
186
|
+
return tuple(roots)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _executable_symlink_mounts(executable: Path) -> tuple[tuple[Path, Path], ...]:
|
|
190
|
+
mounts: list[tuple[Path, Path]] = []
|
|
191
|
+
seen: set[str] = set()
|
|
192
|
+
|
|
193
|
+
def add_mount(host_path: Path, dest_path: Path) -> None:
|
|
194
|
+
try:
|
|
195
|
+
host_resolved = host_path.resolve()
|
|
196
|
+
except OSError:
|
|
197
|
+
return
|
|
198
|
+
if not host_resolved.exists() or _path_is_inside_system_root(host_resolved):
|
|
199
|
+
return
|
|
200
|
+
key = f"{os.fspath(host_resolved)}\0{dest_path.as_posix()}"
|
|
201
|
+
if key in seen:
|
|
202
|
+
return
|
|
203
|
+
seen.add(key)
|
|
204
|
+
mounts.append((host_resolved, dest_path))
|
|
205
|
+
|
|
206
|
+
current = executable
|
|
207
|
+
for _ in range(16):
|
|
208
|
+
try:
|
|
209
|
+
if not current.is_symlink():
|
|
210
|
+
break
|
|
211
|
+
target_raw = os.readlink(current)
|
|
212
|
+
except OSError:
|
|
213
|
+
break
|
|
214
|
+
target = Path(target_raw)
|
|
215
|
+
if not target.is_absolute():
|
|
216
|
+
target = current.parent / target
|
|
217
|
+
if not _path_is_lexically_under_any(target, (Path(sys.prefix), Path(sys.exec_prefix))):
|
|
218
|
+
add_mount(target, target)
|
|
219
|
+
current = target
|
|
220
|
+
return tuple(mounts)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _executable_symlink_parent_roots(executable: Path) -> tuple[Path, ...]:
|
|
224
|
+
roots: list[Path] = []
|
|
225
|
+
seen: set[str] = set()
|
|
226
|
+
|
|
227
|
+
def add_root(path: Path) -> None:
|
|
228
|
+
key = os.fspath(path)
|
|
229
|
+
if key in seen:
|
|
230
|
+
return
|
|
231
|
+
seen.add(key)
|
|
232
|
+
roots.append(path)
|
|
233
|
+
|
|
234
|
+
def add_symlink_ancestor_parent_roots(path: Path) -> None:
|
|
235
|
+
current = Path(path.anchor or "/")
|
|
236
|
+
for part in path.parts[1:-1]:
|
|
237
|
+
current = current / part
|
|
238
|
+
try:
|
|
239
|
+
is_link = current.is_symlink()
|
|
240
|
+
except OSError:
|
|
241
|
+
continue
|
|
242
|
+
if is_link:
|
|
243
|
+
add_root(current.parent)
|
|
244
|
+
|
|
245
|
+
current = executable
|
|
246
|
+
for _ in range(16):
|
|
247
|
+
try:
|
|
248
|
+
if not current.is_symlink():
|
|
249
|
+
break
|
|
250
|
+
target_raw = os.readlink(current)
|
|
251
|
+
except OSError:
|
|
252
|
+
break
|
|
253
|
+
target = Path(target_raw)
|
|
254
|
+
if not target.is_absolute():
|
|
255
|
+
target = current.parent / target
|
|
256
|
+
add_root(target.parent)
|
|
257
|
+
add_symlink_ancestor_parent_roots(target)
|
|
258
|
+
current = target
|
|
259
|
+
return tuple(roots)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _python_runtime_ro_bind_roots() -> tuple[Path, ...]:
|
|
263
|
+
executable = Path(sys.executable)
|
|
264
|
+
candidates = _dedupe_existing_paths(
|
|
265
|
+
[
|
|
266
|
+
Path(sys.prefix),
|
|
267
|
+
Path(sys.exec_prefix),
|
|
268
|
+
Path(sys.base_prefix),
|
|
269
|
+
Path(sys.base_exec_prefix),
|
|
270
|
+
executable.resolve().parent,
|
|
271
|
+
*_executable_symlink_parent_roots(executable),
|
|
272
|
+
]
|
|
273
|
+
)
|
|
274
|
+
return candidates
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _add_bwrap_dir(
|
|
278
|
+
args: list[str],
|
|
279
|
+
dest: PurePosixPath,
|
|
280
|
+
*,
|
|
281
|
+
created_dirs: set[str],
|
|
282
|
+
) -> None:
|
|
283
|
+
dest_s = dest.as_posix()
|
|
284
|
+
if dest_s in {"", ".", "/"} or dest.parent == dest or dest_s in created_dirs:
|
|
285
|
+
return
|
|
286
|
+
_add_bwrap_dir(args, dest.parent, created_dirs=created_dirs)
|
|
287
|
+
args.extend(["--dir", dest_s])
|
|
288
|
+
created_dirs.add(dest_s)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _add_bwrap_ro_bind(
|
|
292
|
+
args: list[str],
|
|
293
|
+
host_path: Path,
|
|
294
|
+
dest_path: Path,
|
|
295
|
+
*,
|
|
296
|
+
created_dirs: set[str],
|
|
297
|
+
bound_dests: set[str],
|
|
298
|
+
resolve_dest: bool = True,
|
|
299
|
+
) -> None:
|
|
300
|
+
host_resolved = host_path.resolve()
|
|
301
|
+
if not host_resolved.exists():
|
|
302
|
+
return
|
|
303
|
+
dest = PurePosixPath((dest_path.resolve() if resolve_dest else dest_path.absolute()).as_posix())
|
|
304
|
+
dest_s = dest.as_posix()
|
|
305
|
+
if dest_s in bound_dests:
|
|
306
|
+
return
|
|
307
|
+
_add_bwrap_dir(args, dest.parent, created_dirs=created_dirs)
|
|
308
|
+
args.extend(["--ro-bind", os.fspath(host_resolved), dest_s])
|
|
309
|
+
bound_dests.add(dest_s)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _add_python_runtime_bindings(args: list[str], *, created_dirs: set[str]) -> None:
|
|
313
|
+
bound_dests: set[str] = set()
|
|
314
|
+
for root in _python_runtime_ro_bind_roots():
|
|
315
|
+
_add_bwrap_ro_bind(
|
|
316
|
+
args,
|
|
317
|
+
root,
|
|
318
|
+
root,
|
|
319
|
+
created_dirs=created_dirs,
|
|
320
|
+
bound_dests=bound_dests,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _path_env_entries(path_value: str | None) -> tuple[Path, ...]:
|
|
325
|
+
entries: list[Path] = []
|
|
326
|
+
for raw in (path_value or "").split(os.pathsep):
|
|
327
|
+
if not raw.strip():
|
|
328
|
+
continue
|
|
329
|
+
entries.append(Path(raw.strip()))
|
|
330
|
+
return tuple(entries)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _toolchain_root_for_executable(executable: Path, *, command_name: str) -> Path:
|
|
334
|
+
resolved = executable.resolve()
|
|
335
|
+
parts = tuple(part.casefold() for part in resolved.parts)
|
|
336
|
+
if ".cargo" in parts:
|
|
337
|
+
index = parts.index(".cargo")
|
|
338
|
+
return Path(*resolved.parts[: index + 1])
|
|
339
|
+
if ".rustup" in parts:
|
|
340
|
+
index = parts.index(".rustup")
|
|
341
|
+
return Path(*resolved.parts[: index + 1])
|
|
342
|
+
if command_name in {"node", "npm", "npx", "pnpm", "yarn", "bun"}:
|
|
343
|
+
for marker in (".nvm", ".fnm", ".volta"):
|
|
344
|
+
if marker in parts:
|
|
345
|
+
index = parts.index(marker)
|
|
346
|
+
# Bind the version manager root. npm/yarn shims commonly jump between
|
|
347
|
+
# bin and lib/node_modules under the same versioned install.
|
|
348
|
+
if marker == ".nvm" and index + 3 < len(resolved.parts):
|
|
349
|
+
return Path(*resolved.parts[: index + 4])
|
|
350
|
+
return Path(*resolved.parts[: index + 1])
|
|
351
|
+
if resolved.parent.name == "bin":
|
|
352
|
+
return resolved.parent.parent
|
|
353
|
+
return resolved.parent
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _toolchain_extra_env(bind_roots: tuple[Path, ...]) -> dict[str, str]:
|
|
357
|
+
env: dict[str, str] = {}
|
|
358
|
+
cargo_home = Path(os.environ.get("CARGO_HOME") or Path.home() / ".cargo")
|
|
359
|
+
rustup_home = Path(os.environ.get("RUSTUP_HOME") or Path.home() / ".rustup")
|
|
360
|
+
python_userbase = Path(site.getuserbase())
|
|
361
|
+
if any(root == cargo_home.resolve() for root in bind_roots if root.exists()):
|
|
362
|
+
env["CARGO_HOME"] = os.fspath(cargo_home.resolve())
|
|
363
|
+
if rustup_home.exists():
|
|
364
|
+
env["RUSTUP_HOME"] = os.fspath(rustup_home.resolve())
|
|
365
|
+
if python_userbase.exists() and any(
|
|
366
|
+
root == python_userbase.resolve() for root in bind_roots if root.exists()
|
|
367
|
+
):
|
|
368
|
+
env["PYTHONUSERBASE"] = os.fspath(python_userbase.resolve())
|
|
369
|
+
return env
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _common_toolchain_ro_bind_roots(path_value: str | None) -> tuple[Path, ...]:
|
|
373
|
+
search_path = os.pathsep.join(os.fspath(path) for path in _path_env_entries(path_value))
|
|
374
|
+
candidates: list[Path] = []
|
|
375
|
+
for command_name in _BWRAP_COMMON_TOOLCHAIN_COMMANDS:
|
|
376
|
+
executable = shutil.which(command_name, path=search_path or None)
|
|
377
|
+
if not executable:
|
|
378
|
+
continue
|
|
379
|
+
executable_path = Path(executable)
|
|
380
|
+
if _path_is_inside_system_root(executable_path):
|
|
381
|
+
continue
|
|
382
|
+
candidates.append(
|
|
383
|
+
_toolchain_root_for_executable(executable_path, command_name=command_name)
|
|
384
|
+
)
|
|
385
|
+
if command_name in {"cargo", "rustc", "rustup"}:
|
|
386
|
+
cargo_home = Path(os.environ.get("CARGO_HOME") or Path.home() / ".cargo")
|
|
387
|
+
rustup_home = Path(os.environ.get("RUSTUP_HOME") or Path.home() / ".rustup")
|
|
388
|
+
candidates.extend([cargo_home, rustup_home])
|
|
389
|
+
return _dedupe_existing_paths(candidates)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _add_common_toolchain_bindings(
|
|
393
|
+
args: list[str],
|
|
394
|
+
*,
|
|
395
|
+
path_value: str | None,
|
|
396
|
+
created_dirs: set[str],
|
|
397
|
+
) -> dict[str, str]:
|
|
398
|
+
bound_dests: set[str] = set()
|
|
399
|
+
bind_roots = _common_toolchain_ro_bind_roots(path_value)
|
|
400
|
+
for root in bind_roots:
|
|
401
|
+
_add_bwrap_ro_bind(
|
|
402
|
+
args,
|
|
403
|
+
root,
|
|
404
|
+
root,
|
|
405
|
+
created_dirs=created_dirs,
|
|
406
|
+
bound_dests=bound_dests,
|
|
407
|
+
)
|
|
408
|
+
return _toolchain_extra_env(bind_roots)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _hardened_java_config_ro_bind_paths(*, etc_root: Path = Path("/etc")) -> tuple[Path, ...]:
|
|
412
|
+
paths: list[Path] = []
|
|
413
|
+
seen: set[str] = set()
|
|
414
|
+
try:
|
|
415
|
+
candidates = [*etc_root.glob("java-*"), etc_root / ".java"]
|
|
416
|
+
except OSError:
|
|
417
|
+
candidates = [etc_root / ".java"]
|
|
418
|
+
for candidate in sorted(candidates, key=os.fspath):
|
|
419
|
+
try:
|
|
420
|
+
resolved = candidate.resolve()
|
|
421
|
+
except OSError:
|
|
422
|
+
continue
|
|
423
|
+
if not resolved.exists() or not resolved.is_dir():
|
|
424
|
+
continue
|
|
425
|
+
resolved_key = os.fspath(resolved)
|
|
426
|
+
if resolved_key in seen:
|
|
427
|
+
continue
|
|
428
|
+
seen.add(resolved_key)
|
|
429
|
+
paths.append(candidate)
|
|
430
|
+
return tuple(paths)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _add_hardened_java_config_bindings(args: list[str]) -> None:
|
|
434
|
+
for path in _hardened_java_config_ro_bind_paths():
|
|
435
|
+
# bwrap consumes Linux namespace paths even when this pure argv builder
|
|
436
|
+
# is exercised by the Windows test harness.
|
|
437
|
+
path_s = path.as_posix()
|
|
438
|
+
args.extend(["--ro-bind", path_s, path_s])
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _bwrap_path_value(path_value: str | None) -> str:
|
|
442
|
+
entries: list[str] = []
|
|
443
|
+
for raw in (path_value or _DEFAULT_PATH).split(os.pathsep):
|
|
444
|
+
cleaned = raw.strip()
|
|
445
|
+
if cleaned and cleaned not in entries:
|
|
446
|
+
entries.append(cleaned)
|
|
447
|
+
return os.pathsep.join(entries)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@dataclass(frozen=True)
|
|
451
|
+
class HostShellRunner:
|
|
452
|
+
process_group_registry: ProcessGroupRegistry | None = None
|
|
453
|
+
close_stdin: bool = False
|
|
454
|
+
|
|
455
|
+
def run(
|
|
456
|
+
self,
|
|
457
|
+
*,
|
|
458
|
+
root: Path,
|
|
459
|
+
cwd: Path,
|
|
460
|
+
cmd: str,
|
|
461
|
+
timeout_s: int,
|
|
462
|
+
) -> subprocess.CompletedProcess[str]:
|
|
463
|
+
return run_in_tracked_process_group(
|
|
464
|
+
cmd,
|
|
465
|
+
shell=True,
|
|
466
|
+
cwd=str(cwd),
|
|
467
|
+
timeout=timeout_s,
|
|
468
|
+
registry=self.process_group_registry,
|
|
469
|
+
origin="shell_run:host",
|
|
470
|
+
command_label=cmd,
|
|
471
|
+
stdin=subprocess.DEVNULL if self.close_stdin else None,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
@dataclass(frozen=True)
|
|
476
|
+
class DisabledShellRunner:
|
|
477
|
+
reason: str = "Shell execution is disabled."
|
|
478
|
+
|
|
479
|
+
def run(
|
|
480
|
+
self,
|
|
481
|
+
*,
|
|
482
|
+
root: Path,
|
|
483
|
+
cwd: Path,
|
|
484
|
+
cmd: str,
|
|
485
|
+
timeout_s: int,
|
|
486
|
+
) -> subprocess.CompletedProcess[str]:
|
|
487
|
+
raise RuntimeError(self.reason)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class LazyShellRunner:
|
|
491
|
+
def __init__(self, loader: Callable[[], ShellRunner]) -> None:
|
|
492
|
+
self._loader = loader
|
|
493
|
+
self._runner: ShellRunner | None = None
|
|
494
|
+
self._load_error: Exception | None = None
|
|
495
|
+
|
|
496
|
+
def _resolve_runner(self) -> ShellRunner:
|
|
497
|
+
if self._runner is not None:
|
|
498
|
+
return self._runner
|
|
499
|
+
if self._load_error is not None:
|
|
500
|
+
raise self._load_error
|
|
501
|
+
try:
|
|
502
|
+
runner = self._loader()
|
|
503
|
+
except Exception as exc:
|
|
504
|
+
self._load_error = exc
|
|
505
|
+
raise
|
|
506
|
+
self._runner = runner
|
|
507
|
+
return runner
|
|
508
|
+
|
|
509
|
+
def run(
|
|
510
|
+
self,
|
|
511
|
+
*,
|
|
512
|
+
root: Path,
|
|
513
|
+
cwd: Path,
|
|
514
|
+
cmd: str,
|
|
515
|
+
timeout_s: int,
|
|
516
|
+
) -> subprocess.CompletedProcess[str]:
|
|
517
|
+
return self._resolve_runner().run(root=root, cwd=cwd, cmd=cmd, timeout_s=timeout_s)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _build_bwrap_argv(
|
|
521
|
+
*,
|
|
522
|
+
root: Path,
|
|
523
|
+
cwd: Path,
|
|
524
|
+
cmd: str,
|
|
525
|
+
network: str,
|
|
526
|
+
clear_env: bool,
|
|
527
|
+
profile: str,
|
|
528
|
+
unshare_cgroup: bool,
|
|
529
|
+
) -> tuple[list[str], dict[str, str]]:
|
|
530
|
+
"""Build bwrap argv + parent-process env. Pure function; no subprocess calls."""
|
|
531
|
+
|
|
532
|
+
root_abs = root.resolve()
|
|
533
|
+
workdir = _workspace_cwd(root=root_abs, cwd=cwd)
|
|
534
|
+
run_env = _sanitized_env(clear_env=clear_env)
|
|
535
|
+
path_value = _bwrap_path_value(run_env.get("PATH"))
|
|
536
|
+
args: list[str] = ["bwrap", "--die-with-parent"]
|
|
537
|
+
created_dirs: set[str] = set()
|
|
538
|
+
if clear_env:
|
|
539
|
+
args.append("--clearenv")
|
|
540
|
+
if profile == "hardened":
|
|
541
|
+
args.extend(["--unshare-pid", "--unshare-ipc", "--unshare-uts"])
|
|
542
|
+
if unshare_cgroup:
|
|
543
|
+
args.append("--unshare-cgroup")
|
|
544
|
+
else:
|
|
545
|
+
args.append("--unshare-pid")
|
|
546
|
+
|
|
547
|
+
args.extend(["--bind", os.fspath(root_abs), "/workspace"])
|
|
548
|
+
if profile == "hardened":
|
|
549
|
+
for host_path, rel_posix_path in _protected_repo_paths(root_abs):
|
|
550
|
+
args.extend(
|
|
551
|
+
[
|
|
552
|
+
"--ro-bind",
|
|
553
|
+
os.fspath(host_path),
|
|
554
|
+
f"/workspace/{rel_posix_path}",
|
|
555
|
+
]
|
|
556
|
+
)
|
|
557
|
+
if profile == "hardened":
|
|
558
|
+
for sys_dir in ("/usr", "/bin", "/lib", "/lib64"):
|
|
559
|
+
if Path(sys_dir).exists():
|
|
560
|
+
args.extend(["--ro-bind", sys_dir, sys_dir])
|
|
561
|
+
etc_dir = ensure_minimal_etc_dir(network=network)
|
|
562
|
+
args.extend(
|
|
563
|
+
[
|
|
564
|
+
"--dir",
|
|
565
|
+
"/etc",
|
|
566
|
+
"--ro-bind",
|
|
567
|
+
os.fspath(etc_dir / "passwd"),
|
|
568
|
+
"/etc/passwd",
|
|
569
|
+
"--ro-bind",
|
|
570
|
+
os.fspath(etc_dir / "group"),
|
|
571
|
+
"/etc/group",
|
|
572
|
+
"--ro-bind",
|
|
573
|
+
os.fspath(etc_dir / "nsswitch.conf"),
|
|
574
|
+
"/etc/nsswitch.conf",
|
|
575
|
+
"--ro-bind",
|
|
576
|
+
os.fspath(etc_dir / "hosts"),
|
|
577
|
+
"/etc/hosts",
|
|
578
|
+
"--ro-bind",
|
|
579
|
+
os.fspath(etc_dir / "resolv.conf"),
|
|
580
|
+
"/etc/resolv.conf",
|
|
581
|
+
]
|
|
582
|
+
)
|
|
583
|
+
if Path("/etc/alternatives").exists():
|
|
584
|
+
args.extend(["--ro-bind", "/etc/alternatives", "/etc/alternatives"])
|
|
585
|
+
_add_hardened_java_config_bindings(args)
|
|
586
|
+
_add_hardened_tls_bindings(args)
|
|
587
|
+
else:
|
|
588
|
+
for sys_dir in ("/usr", "/bin", "/lib", "/lib64", "/etc"):
|
|
589
|
+
if Path(sys_dir).exists():
|
|
590
|
+
args.extend(["--ro-bind", sys_dir, sys_dir])
|
|
591
|
+
|
|
592
|
+
args.extend(
|
|
593
|
+
[
|
|
594
|
+
"--proc",
|
|
595
|
+
"/proc",
|
|
596
|
+
"--dev",
|
|
597
|
+
"/dev",
|
|
598
|
+
"--tmpfs",
|
|
599
|
+
"/tmp",
|
|
600
|
+
"--dir",
|
|
601
|
+
"/tmp/home",
|
|
602
|
+
]
|
|
603
|
+
)
|
|
604
|
+
created_dirs.update({"/proc", "/dev", "/tmp", "/tmp/home"})
|
|
605
|
+
_add_python_runtime_bindings(args, created_dirs=created_dirs)
|
|
606
|
+
toolchain_env = _add_common_toolchain_bindings(
|
|
607
|
+
args,
|
|
608
|
+
path_value=path_value,
|
|
609
|
+
created_dirs=created_dirs,
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
args.extend(
|
|
613
|
+
[
|
|
614
|
+
"--chdir",
|
|
615
|
+
workdir,
|
|
616
|
+
"--setenv",
|
|
617
|
+
"HOME",
|
|
618
|
+
"/tmp/home",
|
|
619
|
+
]
|
|
620
|
+
)
|
|
621
|
+
if path_value:
|
|
622
|
+
args.extend(["--setenv", "PATH", path_value])
|
|
623
|
+
lang_value = run_env.get("LANG")
|
|
624
|
+
if lang_value:
|
|
625
|
+
args.extend(["--setenv", "LANG", lang_value])
|
|
626
|
+
for key, value in toolchain_env.items():
|
|
627
|
+
args.extend(["--setenv", key, value])
|
|
628
|
+
if network == "off":
|
|
629
|
+
args.append("--unshare-net")
|
|
630
|
+
args.extend(["sh", "-lc", cmd])
|
|
631
|
+
return args, run_env
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
@dataclass(frozen=True)
|
|
635
|
+
class BwrapShellRunner:
|
|
636
|
+
network: str = "off"
|
|
637
|
+
clear_env: bool = True
|
|
638
|
+
profile: str = "hardened"
|
|
639
|
+
process_group_registry: ProcessGroupRegistry | None = None
|
|
640
|
+
close_stdin: bool = False
|
|
641
|
+
|
|
642
|
+
def run(
|
|
643
|
+
self,
|
|
644
|
+
*,
|
|
645
|
+
root: Path,
|
|
646
|
+
cwd: Path,
|
|
647
|
+
cmd: str,
|
|
648
|
+
timeout_s: int,
|
|
649
|
+
) -> subprocess.CompletedProcess[str]:
|
|
650
|
+
args, run_env = _build_bwrap_argv(
|
|
651
|
+
root=root,
|
|
652
|
+
cwd=cwd,
|
|
653
|
+
cmd=cmd,
|
|
654
|
+
network=self.network,
|
|
655
|
+
clear_env=self.clear_env,
|
|
656
|
+
profile=self.profile,
|
|
657
|
+
unshare_cgroup=_supports_bwrap_unshare_cgroup(),
|
|
658
|
+
)
|
|
659
|
+
return run_in_tracked_process_group(
|
|
660
|
+
args,
|
|
661
|
+
cwd=os.fspath(root.resolve()),
|
|
662
|
+
env=run_env,
|
|
663
|
+
timeout=timeout_s,
|
|
664
|
+
registry=self.process_group_registry,
|
|
665
|
+
origin="shell_run:bwrap",
|
|
666
|
+
command_label=cmd,
|
|
667
|
+
stdin=subprocess.DEVNULL if self.close_stdin else None,
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _supports_bwrap_unshare_cgroup() -> bool:
|
|
672
|
+
global _BWRAP_UNSHARE_CGROUP_SUPPORTED
|
|
673
|
+
if _BWRAP_UNSHARE_CGROUP_SUPPORTED is not None:
|
|
674
|
+
return _BWRAP_UNSHARE_CGROUP_SUPPORTED
|
|
675
|
+
try:
|
|
676
|
+
cp = subprocess.run(
|
|
677
|
+
["bwrap", "--help"],
|
|
678
|
+
check=False,
|
|
679
|
+
capture_output=True,
|
|
680
|
+
text=True,
|
|
681
|
+
)
|
|
682
|
+
except OSError:
|
|
683
|
+
_BWRAP_UNSHARE_CGROUP_SUPPORTED = False
|
|
684
|
+
return False
|
|
685
|
+
help_text = (cp.stdout or "") + (cp.stderr or "")
|
|
686
|
+
_BWRAP_UNSHARE_CGROUP_SUPPORTED = cp.returncode == 0 and "--unshare-cgroup" in help_text
|
|
687
|
+
return _BWRAP_UNSHARE_CGROUP_SUPPORTED
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _add_hardened_tls_bindings(args: list[str]) -> None:
|
|
691
|
+
args.extend(["--dir", "/etc/ssl", "--dir", "/etc/ssl/certs"])
|
|
692
|
+
if Path("/etc/ssl/certs").exists():
|
|
693
|
+
args.extend(["--ro-bind", "/etc/ssl/certs", "/etc/ssl/certs"])
|
|
694
|
+
|
|
695
|
+
args.extend(["--dir", "/etc/pki", "--dir", "/etc/pki/tls", "--dir", "/etc/pki/tls/certs"])
|
|
696
|
+
if Path("/etc/pki/tls/certs").exists():
|
|
697
|
+
args.extend(["--ro-bind", "/etc/pki/tls/certs", "/etc/pki/tls/certs"])
|
|
698
|
+
|
|
699
|
+
args.extend(["--dir", "/etc/ca-certificates"])
|
|
700
|
+
if Path("/etc/ca-certificates").exists():
|
|
701
|
+
args.extend(["--ro-bind", "/etc/ca-certificates", "/etc/ca-certificates"])
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _build_docker_argv(
|
|
705
|
+
*,
|
|
706
|
+
root: Path,
|
|
707
|
+
cwd: Path,
|
|
708
|
+
cmd: str,
|
|
709
|
+
container_name: str,
|
|
710
|
+
network: str,
|
|
711
|
+
docker_image: str,
|
|
712
|
+
clear_env: bool,
|
|
713
|
+
pids_limit: int | None,
|
|
714
|
+
memory_limit: str | None,
|
|
715
|
+
cpus: str | None,
|
|
716
|
+
read_only_rootfs: bool,
|
|
717
|
+
protect_repo_meta: bool,
|
|
718
|
+
env_allowlist: tuple[str, ...],
|
|
719
|
+
published_ports: tuple[tuple[str, int, int], ...] = (),
|
|
720
|
+
) -> tuple[list[str], dict[str, str]]:
|
|
721
|
+
"""Build docker run argv + parent-process env. Pure function."""
|
|
722
|
+
|
|
723
|
+
root_abs = root.resolve()
|
|
724
|
+
workdir = _workspace_cwd(root=root_abs, cwd=cwd)
|
|
725
|
+
run_env = _sanitized_env(clear_env=clear_env)
|
|
726
|
+
container_env = _container_env(clear_env, allowlist=env_allowlist)
|
|
727
|
+
|
|
728
|
+
args: list[str] = [
|
|
729
|
+
"docker",
|
|
730
|
+
"run",
|
|
731
|
+
"--rm",
|
|
732
|
+
"--init",
|
|
733
|
+
"--name",
|
|
734
|
+
container_name,
|
|
735
|
+
"-v",
|
|
736
|
+
f"{os.fspath(root_abs)}:/workspace:rw",
|
|
737
|
+
"-w",
|
|
738
|
+
workdir,
|
|
739
|
+
]
|
|
740
|
+
if protect_repo_meta or read_only_rootfs:
|
|
741
|
+
for host_path, rel_posix_path in _protected_repo_paths(root_abs):
|
|
742
|
+
args.extend(
|
|
743
|
+
[
|
|
744
|
+
"-v",
|
|
745
|
+
f"{os.fspath(host_path)}:/workspace/{rel_posix_path}:ro",
|
|
746
|
+
]
|
|
747
|
+
)
|
|
748
|
+
if pids_limit is not None:
|
|
749
|
+
args.extend(["--pids-limit", str(pids_limit)])
|
|
750
|
+
if memory_limit is not None:
|
|
751
|
+
args.extend(["--memory", memory_limit])
|
|
752
|
+
if cpus is not None:
|
|
753
|
+
args.extend(["--cpus", cpus])
|
|
754
|
+
if read_only_rootfs:
|
|
755
|
+
args.extend(["--read-only", "--tmpfs", "/tmp:rw,exec,nosuid,nodev"])
|
|
756
|
+
for host, host_port, container_port in published_ports:
|
|
757
|
+
try:
|
|
758
|
+
publish_address = ipaddress.ip_address(host)
|
|
759
|
+
except ValueError as exc:
|
|
760
|
+
raise ValueError("Docker published-port host must be an IP address") from exc
|
|
761
|
+
if not publish_address.is_loopback:
|
|
762
|
+
raise ValueError("Docker published ports must bind to a loopback address")
|
|
763
|
+
if not (0 < host_port <= 65535 and 0 < container_port <= 65535):
|
|
764
|
+
raise ValueError("Docker published ports must be between 1 and 65535")
|
|
765
|
+
formatted_host = f"[{host}]" if ":" in host else host
|
|
766
|
+
args.extend(["--publish", f"{formatted_host}:{host_port}:{container_port}/tcp"])
|
|
767
|
+
if network == "off":
|
|
768
|
+
args.extend(["--network", "none"])
|
|
769
|
+
if os.name == "posix" and hasattr(os, "getuid") and hasattr(os, "getgid"):
|
|
770
|
+
args.extend(["--user", f"{os.getuid()}:{os.getgid()}"])
|
|
771
|
+
args.extend(["--cap-drop=ALL", "--security-opt", "no-new-privileges"])
|
|
772
|
+
for key, value in container_env.items():
|
|
773
|
+
args.extend(["-e", f"{key}={value}"])
|
|
774
|
+
args.extend([docker_image, "sh", "-lc", f"mkdir -p /tmp/home && {cmd}"])
|
|
775
|
+
return args, run_env
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _docker_cleanup_container(
|
|
779
|
+
container_name: str,
|
|
780
|
+
*,
|
|
781
|
+
cwd: str,
|
|
782
|
+
env: dict[str, str],
|
|
783
|
+
warning_callback: Callable[[str], None] | None,
|
|
784
|
+
reason: str,
|
|
785
|
+
quiet: bool = False,
|
|
786
|
+
) -> None:
|
|
787
|
+
"""Best-effort forced cleanup of a docker container by name."""
|
|
788
|
+
|
|
789
|
+
if not quiet:
|
|
790
|
+
_emit_warning(
|
|
791
|
+
(f"Docker shell sandbox {container_name} cleanup after {reason}; killing container."),
|
|
792
|
+
warning_callback=warning_callback,
|
|
793
|
+
)
|
|
794
|
+
for cleanup_args in (
|
|
795
|
+
["docker", "kill", "--signal=KILL", container_name],
|
|
796
|
+
["docker", "rm", "-f", container_name],
|
|
797
|
+
):
|
|
798
|
+
try:
|
|
799
|
+
subprocess.run(
|
|
800
|
+
cleanup_args,
|
|
801
|
+
check=False,
|
|
802
|
+
capture_output=True,
|
|
803
|
+
text=True,
|
|
804
|
+
timeout=5,
|
|
805
|
+
cwd=cwd,
|
|
806
|
+
env=env,
|
|
807
|
+
)
|
|
808
|
+
except Exception: # noqa: BLE001
|
|
809
|
+
# Docker cleanup is best-effort; callers must not fail while unwinding.
|
|
810
|
+
_LOGGER.exception("Docker cleanup command failed for container %s", container_name)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
@dataclass(frozen=True)
|
|
814
|
+
class DockerShellRunner:
|
|
815
|
+
network: str = "off"
|
|
816
|
+
docker_image: str = default_sandbox_docker_image("dev")
|
|
817
|
+
clear_env: bool = True
|
|
818
|
+
pids_limit: int | None = None
|
|
819
|
+
memory_limit: str | None = None
|
|
820
|
+
cpus: str | None = None
|
|
821
|
+
read_only_rootfs: bool = False
|
|
822
|
+
protect_repo_meta: bool = True
|
|
823
|
+
env_allowlist: tuple[str, ...] = ()
|
|
824
|
+
warning_callback: Callable[[str], None] | None = None
|
|
825
|
+
close_stdin: bool = False
|
|
826
|
+
|
|
827
|
+
def run(
|
|
828
|
+
self,
|
|
829
|
+
*,
|
|
830
|
+
root: Path,
|
|
831
|
+
cwd: Path,
|
|
832
|
+
cmd: str,
|
|
833
|
+
timeout_s: int,
|
|
834
|
+
) -> subprocess.CompletedProcess[str]:
|
|
835
|
+
root_abs = root.resolve()
|
|
836
|
+
container_name = f"alysis-sbx-{uuid.uuid4().hex[:12]}"
|
|
837
|
+
args, run_env = _build_docker_argv(
|
|
838
|
+
root=root_abs,
|
|
839
|
+
cwd=cwd,
|
|
840
|
+
cmd=cmd,
|
|
841
|
+
container_name=container_name,
|
|
842
|
+
network=self.network,
|
|
843
|
+
docker_image=self.docker_image,
|
|
844
|
+
clear_env=self.clear_env,
|
|
845
|
+
pids_limit=self.pids_limit,
|
|
846
|
+
memory_limit=self.memory_limit,
|
|
847
|
+
cpus=self.cpus,
|
|
848
|
+
read_only_rootfs=self.read_only_rootfs,
|
|
849
|
+
protect_repo_meta=self.protect_repo_meta,
|
|
850
|
+
env_allowlist=self.env_allowlist,
|
|
851
|
+
)
|
|
852
|
+
proc: subprocess.Popen[str] | None = None
|
|
853
|
+
container_killed = False
|
|
854
|
+
|
|
855
|
+
def cleanup_container(reason: str) -> None:
|
|
856
|
+
nonlocal container_killed
|
|
857
|
+
if container_killed:
|
|
858
|
+
return
|
|
859
|
+
container_killed = True
|
|
860
|
+
_docker_cleanup_container(
|
|
861
|
+
container_name,
|
|
862
|
+
cwd=os.fspath(root_abs),
|
|
863
|
+
env=run_env,
|
|
864
|
+
warning_callback=self.warning_callback,
|
|
865
|
+
reason=reason,
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
def kill_docker_cli() -> None:
|
|
869
|
+
if proc is None or proc.poll() is not None:
|
|
870
|
+
return
|
|
871
|
+
try:
|
|
872
|
+
proc.kill()
|
|
873
|
+
except OSError:
|
|
874
|
+
pass
|
|
875
|
+
try:
|
|
876
|
+
proc.wait(timeout=5)
|
|
877
|
+
except Exception: # noqa: BLE001
|
|
878
|
+
pass
|
|
879
|
+
|
|
880
|
+
try:
|
|
881
|
+
proc = subprocess.Popen(
|
|
882
|
+
args,
|
|
883
|
+
stdout=subprocess.PIPE,
|
|
884
|
+
stderr=subprocess.PIPE,
|
|
885
|
+
text=True,
|
|
886
|
+
cwd=os.fspath(root_abs),
|
|
887
|
+
env=run_env,
|
|
888
|
+
**({"stdin": subprocess.DEVNULL} if self.close_stdin else {}),
|
|
889
|
+
)
|
|
890
|
+
stdout, stderr = proc.communicate(timeout=timeout_s)
|
|
891
|
+
return subprocess.CompletedProcess(
|
|
892
|
+
args=args,
|
|
893
|
+
returncode=proc.returncode if proc.returncode is not None else 1,
|
|
894
|
+
stdout=stdout,
|
|
895
|
+
stderr=stderr,
|
|
896
|
+
)
|
|
897
|
+
except subprocess.TimeoutExpired:
|
|
898
|
+
cleanup_container("timeout")
|
|
899
|
+
kill_docker_cli()
|
|
900
|
+
raise
|
|
901
|
+
except BaseException:
|
|
902
|
+
if proc is not None:
|
|
903
|
+
cleanup_container("exception")
|
|
904
|
+
kill_docker_cli()
|
|
905
|
+
raise
|
|
906
|
+
finally:
|
|
907
|
+
if proc is not None and proc.poll() is None:
|
|
908
|
+
cleanup_container("unfinished process")
|
|
909
|
+
kill_docker_cli()
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _emit_warning(message: str, *, warning_callback: Callable[[str], None] | None) -> None:
|
|
913
|
+
if warning_callback is not None:
|
|
914
|
+
warning_callback(message)
|
|
915
|
+
return
|
|
916
|
+
print(f"[alysis] {message}", file=sys.stderr)
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def with_closed_stdin(runner: ShellRunner) -> ShellRunner:
|
|
920
|
+
"""Return an equivalent runner that never inherits the caller's stdin.
|
|
921
|
+
|
|
922
|
+
Automated callers such as verification must not be able to block on an
|
|
923
|
+
interactive prompt: nobody is watching the terminal, so a command that asks
|
|
924
|
+
a question would otherwise wait until its timeout. Runners that do not
|
|
925
|
+
execute anything (disabled/lazy) are returned unchanged.
|
|
926
|
+
"""
|
|
927
|
+
|
|
928
|
+
if is_dataclass(runner) and any(field.name == "close_stdin" for field in fields(runner)):
|
|
929
|
+
return replace(runner, close_stdin=True) # type: ignore[type-var]
|
|
930
|
+
return runner
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def build_shell_runner_from_settings(
|
|
934
|
+
settings: ShellSandboxSettings,
|
|
935
|
+
root: Path,
|
|
936
|
+
warning_callback: Callable[[str], None] | None = None,
|
|
937
|
+
*,
|
|
938
|
+
process_group_registry: ProcessGroupRegistry | None = None,
|
|
939
|
+
) -> ShellRunner:
|
|
940
|
+
_ = root.resolve()
|
|
941
|
+
|
|
942
|
+
# The Docker backend is deliberately not given the registry: `killpg` on the
|
|
943
|
+
# `docker run` CLI does not stop the container, so teardown must go through
|
|
944
|
+
# `_docker_cleanup_container` (which this runner already does on timeout and
|
|
945
|
+
# on exception). This mirrors DockerBackgroundRunner's "direct" termination
|
|
946
|
+
# mode.
|
|
947
|
+
if settings.mode == "off":
|
|
948
|
+
return HostShellRunner(process_group_registry=process_group_registry)
|
|
949
|
+
|
|
950
|
+
is_linux = platform.system().lower() == "linux"
|
|
951
|
+
has_bwrap = is_linux and shutil.which("bwrap") is not None
|
|
952
|
+
has_docker = shutil.which("docker") is not None
|
|
953
|
+
|
|
954
|
+
def fallback_or_error(*, reason: str) -> ShellRunner:
|
|
955
|
+
if settings.mode == "warn":
|
|
956
|
+
disabled_reason = f"Shell sandbox unavailable ({reason}); host fallback is disabled."
|
|
957
|
+
_emit_warning(disabled_reason, warning_callback=warning_callback)
|
|
958
|
+
return DisabledShellRunner(reason=disabled_reason)
|
|
959
|
+
raise ConfigError(
|
|
960
|
+
"Shell sandbox strict mode is enabled, but no usable backend is available: "
|
|
961
|
+
f"{reason}. Install bubblewrap (Linux) or Docker, or set "
|
|
962
|
+
"ALYSIS_SHELL_SANDBOX_MODE=off for explicit unsafe host execution. "
|
|
963
|
+
"Run `alysis doctor sandbox` for a guided diagnosis or "
|
|
964
|
+
"`alysis setup sandbox` after installing/starting Docker."
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
if settings.backend == "auto":
|
|
968
|
+
if has_bwrap:
|
|
969
|
+
return BwrapShellRunner(
|
|
970
|
+
network=settings.network,
|
|
971
|
+
clear_env=settings.clear_env,
|
|
972
|
+
profile=settings.bwrap_profile,
|
|
973
|
+
process_group_registry=process_group_registry,
|
|
974
|
+
)
|
|
975
|
+
if has_docker:
|
|
976
|
+
return DockerShellRunner(
|
|
977
|
+
network=settings.network,
|
|
978
|
+
docker_image=settings.docker_image,
|
|
979
|
+
clear_env=settings.clear_env,
|
|
980
|
+
pids_limit=settings.docker_pids_limit,
|
|
981
|
+
memory_limit=settings.docker_memory,
|
|
982
|
+
cpus=settings.docker_cpus,
|
|
983
|
+
read_only_rootfs=settings.docker_read_only,
|
|
984
|
+
protect_repo_meta=settings.protect_repo_meta,
|
|
985
|
+
env_allowlist=settings.docker_env_allowlist,
|
|
986
|
+
warning_callback=warning_callback,
|
|
987
|
+
)
|
|
988
|
+
return fallback_or_error(reason="auto backend could not find bwrap or docker")
|
|
989
|
+
|
|
990
|
+
if settings.backend == "bwrap":
|
|
991
|
+
if has_bwrap:
|
|
992
|
+
return BwrapShellRunner(
|
|
993
|
+
network=settings.network,
|
|
994
|
+
clear_env=settings.clear_env,
|
|
995
|
+
profile=settings.bwrap_profile,
|
|
996
|
+
process_group_registry=process_group_registry,
|
|
997
|
+
)
|
|
998
|
+
return fallback_or_error(reason="bwrap backend selected, but bubblewrap is not available")
|
|
999
|
+
|
|
1000
|
+
if settings.backend == "docker":
|
|
1001
|
+
if has_docker:
|
|
1002
|
+
return DockerShellRunner(
|
|
1003
|
+
network=settings.network,
|
|
1004
|
+
docker_image=settings.docker_image,
|
|
1005
|
+
clear_env=settings.clear_env,
|
|
1006
|
+
pids_limit=settings.docker_pids_limit,
|
|
1007
|
+
memory_limit=settings.docker_memory,
|
|
1008
|
+
cpus=settings.docker_cpus,
|
|
1009
|
+
read_only_rootfs=settings.docker_read_only,
|
|
1010
|
+
protect_repo_meta=settings.protect_repo_meta,
|
|
1011
|
+
env_allowlist=settings.docker_env_allowlist,
|
|
1012
|
+
warning_callback=warning_callback,
|
|
1013
|
+
)
|
|
1014
|
+
return fallback_or_error(reason="docker backend selected, but docker is not available")
|
|
1015
|
+
|
|
1016
|
+
raise ConfigError(f"Unhandled shell sandbox backend: {settings.backend}")
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def build_shell_runner(
|
|
1020
|
+
cfg: AppConfig,
|
|
1021
|
+
root: Path,
|
|
1022
|
+
warning_callback: Callable[[str], None] | None = None,
|
|
1023
|
+
) -> ShellRunner:
|
|
1024
|
+
settings = resolve_shell_sandbox_settings(cfg)
|
|
1025
|
+
return build_shell_runner_from_settings(settings, root, warning_callback=warning_callback)
|