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,1516 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import hashlib
|
|
5
|
+
import importlib.util
|
|
6
|
+
import ipaddress
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import signal
|
|
12
|
+
import socket
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .discovery import CustomToolSpec
|
|
23
|
+
|
|
24
|
+
# NOTE: this module must stay free of package-relative imports at module scope.
|
|
25
|
+
# The tool worker loads it standalone via importlib.spec_from_file_location
|
|
26
|
+
# (see _WORKER_BOOTSTRAP_CODE below), which gives it no parent package, so a
|
|
27
|
+
# `from ..x import y` here raises ImportError before any tool can run.
|
|
28
|
+
# Anything from the package must be imported lazily, by absolute name, inside
|
|
29
|
+
# the function that needs it — the bootstrap puts the package root on sys.path,
|
|
30
|
+
# so absolute imports resolve in both the host and the worker.
|
|
31
|
+
|
|
32
|
+
_MAX_RESULT_BYTES = 24_000
|
|
33
|
+
_MAX_RESULT_STRING_CHARS = 2_000
|
|
34
|
+
_MAX_RESULT_DEPTH = 10
|
|
35
|
+
_MAX_STREAM_PREVIEW_CHARS = 1_200
|
|
36
|
+
_STREAM_TAIL_READ_BYTES = _MAX_STREAM_PREVIEW_CHARS * 8
|
|
37
|
+
_MINIMUM_PLATFORM_ENV = ("PATH", "HOME", "LANG", "TEMP", "TMP")
|
|
38
|
+
_WINDOWS_PLATFORM_ENV = ("SYSTEMROOT",)
|
|
39
|
+
_INJECTED_ENV_NAMES = (
|
|
40
|
+
"ALYSIS_WORKSPACE_ROOT",
|
|
41
|
+
"ALYSIS_SESSION_ID",
|
|
42
|
+
"ALYSIS_TOOL_PATH",
|
|
43
|
+
"ALYSIS_TOOL_SCOPE",
|
|
44
|
+
"ALYSIS_TOOL_NAME",
|
|
45
|
+
# The runtime also exports each of these under its pre-rebrand name, so a
|
|
46
|
+
# tool must not be able to request one as passthrough env either. Spelled
|
|
47
|
+
# out rather than derived from branding, to keep this module import-free.
|
|
48
|
+
"SYLLIPTOR_WORKSPACE_ROOT",
|
|
49
|
+
"SYLLIPTOR_SESSION_ID",
|
|
50
|
+
"SYLLIPTOR_TOOL_PATH",
|
|
51
|
+
"SYLLIPTOR_TOOL_SCOPE",
|
|
52
|
+
"SYLLIPTOR_TOOL_NAME",
|
|
53
|
+
)
|
|
54
|
+
_WORKER_BOOTSTRAP_CODE = (
|
|
55
|
+
"import importlib.util, sys; "
|
|
56
|
+
"sys.path.insert(0, {package_root!r}); "
|
|
57
|
+
"_spec = importlib.util.spec_from_file_location("
|
|
58
|
+
"'_alysis_custom_tool_runtime_worker', {runtime_path!r}); "
|
|
59
|
+
"assert _spec is not None and _spec.loader is not None; "
|
|
60
|
+
"_module = importlib.util.module_from_spec(_spec); "
|
|
61
|
+
"sys.modules[_spec.name] = _module; "
|
|
62
|
+
"_spec.loader.exec_module(_module); "
|
|
63
|
+
"sys.modules.pop(_spec.name, None); "
|
|
64
|
+
"raise SystemExit(_module._worker_main())"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CustomToolRuntimeError(RuntimeError):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class WorkerProcessTreeGuardError(RuntimeError):
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _CustomToolPolicyViolation(RuntimeError):
|
|
77
|
+
def __init__(self, *, error_type: str, message: str) -> None:
|
|
78
|
+
super().__init__(message)
|
|
79
|
+
self.error_type = error_type
|
|
80
|
+
self.message = message
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _RuntimeToolSpec:
|
|
84
|
+
def __init__(self, *, name: str, timeout_s: float) -> None:
|
|
85
|
+
self.name = name
|
|
86
|
+
self.timeout_s = timeout_s
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def run_custom_tool(
|
|
90
|
+
*,
|
|
91
|
+
spec: CustomToolSpec,
|
|
92
|
+
args: dict[str, Any],
|
|
93
|
+
workspace_root: Path,
|
|
94
|
+
session_id: str,
|
|
95
|
+
artifact_dir: Path | None = None,
|
|
96
|
+
artifact_reference_prefix: str | None = None,
|
|
97
|
+
) -> dict[str, Any]:
|
|
98
|
+
from alysis_code.branding import env_get
|
|
99
|
+
|
|
100
|
+
validated_args = _validate_input_args(spec.input_schema, args)
|
|
101
|
+
missing_env = [name for name in spec.required_env if not str(env_get(name) or "").strip()]
|
|
102
|
+
if missing_env:
|
|
103
|
+
missing = ", ".join(missing_env)
|
|
104
|
+
return _runtime_failure_payload(
|
|
105
|
+
spec=spec,
|
|
106
|
+
error_text=f"Missing required env vars: {missing}",
|
|
107
|
+
error_type="MissingEnvironment",
|
|
108
|
+
timeout=False,
|
|
109
|
+
elapsed_ms=0,
|
|
110
|
+
stream_info=_empty_stream_info(),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
source_bytes = spec.source_path.read_bytes()
|
|
115
|
+
except OSError as exc:
|
|
116
|
+
return _runtime_failure_payload(
|
|
117
|
+
spec=spec,
|
|
118
|
+
error_text=f"Unable to read custom tool source at execution time: {exc}",
|
|
119
|
+
error_type="ToolSourceUnavailable",
|
|
120
|
+
timeout=False,
|
|
121
|
+
elapsed_ms=0,
|
|
122
|
+
stream_info=_empty_stream_info(),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
actual_hash = hashlib.sha256(source_bytes).hexdigest()
|
|
126
|
+
if actual_hash != spec.file_hash:
|
|
127
|
+
return _runtime_failure_payload(
|
|
128
|
+
spec=spec,
|
|
129
|
+
error_text=(
|
|
130
|
+
"Custom tool source changed since discovery; refusing to execute. "
|
|
131
|
+
f"expected_sha256={spec.file_hash} actual_sha256={actual_hash}"
|
|
132
|
+
),
|
|
133
|
+
error_type="StaleToolHash",
|
|
134
|
+
timeout=False,
|
|
135
|
+
elapsed_ms=0,
|
|
136
|
+
stream_info=_empty_stream_info(),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if spec.source_scope == "project":
|
|
140
|
+
try:
|
|
141
|
+
trusted = _is_project_tool_trusted_at_execution(spec)
|
|
142
|
+
except RuntimeError as exc:
|
|
143
|
+
return _runtime_failure_payload(
|
|
144
|
+
spec=spec,
|
|
145
|
+
error_text=f"Unable to reload project custom tool trust state: {exc}",
|
|
146
|
+
error_type="TrustStateError",
|
|
147
|
+
timeout=False,
|
|
148
|
+
elapsed_ms=0,
|
|
149
|
+
stream_info=_empty_stream_info(),
|
|
150
|
+
)
|
|
151
|
+
if not trusted:
|
|
152
|
+
return _runtime_failure_payload(
|
|
153
|
+
spec=spec,
|
|
154
|
+
error_text="Project custom tool is no longer trusted at execution time.",
|
|
155
|
+
error_type="TrustRevoked",
|
|
156
|
+
timeout=False,
|
|
157
|
+
elapsed_ms=0,
|
|
158
|
+
stream_info=_empty_stream_info(),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return _run_tool_subprocess(
|
|
162
|
+
spec=spec,
|
|
163
|
+
args=validated_args,
|
|
164
|
+
workspace_root=workspace_root,
|
|
165
|
+
session_id=session_id,
|
|
166
|
+
source_bytes=source_bytes,
|
|
167
|
+
artifact_dir=artifact_dir,
|
|
168
|
+
artifact_reference_prefix=artifact_reference_prefix,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _run_tool_subprocess(
|
|
173
|
+
*,
|
|
174
|
+
spec: CustomToolSpec,
|
|
175
|
+
args: dict[str, Any],
|
|
176
|
+
workspace_root: Path,
|
|
177
|
+
session_id: str,
|
|
178
|
+
source_bytes: bytes,
|
|
179
|
+
artifact_dir: Path | None,
|
|
180
|
+
artifact_reference_prefix: str | None,
|
|
181
|
+
) -> dict[str, Any]:
|
|
182
|
+
resolved_workspace = workspace_root.resolve()
|
|
183
|
+
stdout_path, stderr_path, stdout_relpath, stderr_relpath = _tool_log_artifact_paths(
|
|
184
|
+
workspace_root=resolved_workspace,
|
|
185
|
+
session_id=session_id,
|
|
186
|
+
tool_name=spec.name,
|
|
187
|
+
artifact_dir=artifact_dir,
|
|
188
|
+
artifact_reference_prefix=artifact_reference_prefix,
|
|
189
|
+
)
|
|
190
|
+
with tempfile.TemporaryDirectory(prefix="alysis-custom-tool-") as temp_dir:
|
|
191
|
+
temp_root = Path(temp_dir)
|
|
192
|
+
sealed_source_path = temp_root / _sealed_source_filename(spec.source_path)
|
|
193
|
+
result_path = temp_root / "result.json"
|
|
194
|
+
sealed_source_path.write_bytes(source_bytes)
|
|
195
|
+
tool_env = _build_tool_env(
|
|
196
|
+
spec=spec,
|
|
197
|
+
workspace_root=resolved_workspace,
|
|
198
|
+
session_id=session_id,
|
|
199
|
+
)
|
|
200
|
+
worker_payload = {
|
|
201
|
+
"sealed_source_path": os.fspath(sealed_source_path),
|
|
202
|
+
"original_source_path": os.fspath(spec.source_path),
|
|
203
|
+
"original_tool_dir": os.fspath(spec.source_path.parent.resolve()),
|
|
204
|
+
"relative_tool_path": spec.relative_tool_path,
|
|
205
|
+
"file_hash": spec.file_hash,
|
|
206
|
+
"tool_name": spec.name,
|
|
207
|
+
"tool_scope": spec.source_scope,
|
|
208
|
+
"capabilities": spec.capabilities.to_dict(),
|
|
209
|
+
"timeout_s": spec.timeout_s,
|
|
210
|
+
"workspace_root": os.fspath(resolved_workspace),
|
|
211
|
+
"session_id": session_id,
|
|
212
|
+
"args": args,
|
|
213
|
+
"result_path": os.fspath(result_path),
|
|
214
|
+
"tool_env": tool_env,
|
|
215
|
+
}
|
|
216
|
+
started_at = time.perf_counter()
|
|
217
|
+
proc: subprocess.Popen[bytes] | None = None
|
|
218
|
+
guard: _WorkerProcessTreeGuard | None = None
|
|
219
|
+
try:
|
|
220
|
+
with stdout_path.open("wb") as stdout_handle, stderr_path.open("wb") as stderr_handle:
|
|
221
|
+
try:
|
|
222
|
+
proc = _popen_worker_process(
|
|
223
|
+
stdout_handle=stdout_handle,
|
|
224
|
+
stderr_handle=stderr_handle,
|
|
225
|
+
env=_build_worker_bootstrap_env(tool_env),
|
|
226
|
+
cwd=temp_root,
|
|
227
|
+
)
|
|
228
|
+
except OSError as exc:
|
|
229
|
+
elapsed_ms = int((time.perf_counter() - started_at) * 1000)
|
|
230
|
+
return _runtime_failure_payload(
|
|
231
|
+
spec=spec,
|
|
232
|
+
error_text=f"Unable to start custom tool worker process: {exc}",
|
|
233
|
+
error_type="WorkerSpawnError",
|
|
234
|
+
timeout=False,
|
|
235
|
+
elapsed_ms=elapsed_ms,
|
|
236
|
+
stream_info=_stream_info_from_artifacts(
|
|
237
|
+
workspace_root=resolved_workspace,
|
|
238
|
+
stdout_path=stdout_path,
|
|
239
|
+
stderr_path=stderr_path,
|
|
240
|
+
stdout_relpath=stdout_relpath,
|
|
241
|
+
stderr_relpath=stderr_relpath,
|
|
242
|
+
),
|
|
243
|
+
)
|
|
244
|
+
try:
|
|
245
|
+
guard = _create_worker_process_tree_guard(proc)
|
|
246
|
+
except WorkerProcessTreeGuardError as exc:
|
|
247
|
+
_terminate_immediate_worker(proc)
|
|
248
|
+
elapsed_ms = int((time.perf_counter() - started_at) * 1000)
|
|
249
|
+
return _runtime_failure_payload(
|
|
250
|
+
spec=spec,
|
|
251
|
+
error_text=f"Unable to establish custom tool process-tree guard: {exc}",
|
|
252
|
+
error_type="WorkerProcessTreeGuardError",
|
|
253
|
+
timeout=False,
|
|
254
|
+
elapsed_ms=elapsed_ms,
|
|
255
|
+
stream_info=_stream_info_from_artifacts(
|
|
256
|
+
workspace_root=resolved_workspace,
|
|
257
|
+
stdout_path=stdout_path,
|
|
258
|
+
stderr_path=stderr_path,
|
|
259
|
+
stdout_relpath=stdout_relpath,
|
|
260
|
+
stderr_relpath=stderr_relpath,
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
except BaseException:
|
|
264
|
+
_terminate_immediate_worker(proc)
|
|
265
|
+
raise
|
|
266
|
+
try:
|
|
267
|
+
_communicate_worker_process(
|
|
268
|
+
proc,
|
|
269
|
+
json.dumps(worker_payload, ensure_ascii=True).encode("utf-8"),
|
|
270
|
+
timeout_s=_subprocess_timeout_s(spec.timeout_s),
|
|
271
|
+
)
|
|
272
|
+
except subprocess.TimeoutExpired:
|
|
273
|
+
guard.terminate()
|
|
274
|
+
elapsed_ms = int((time.perf_counter() - started_at) * 1000)
|
|
275
|
+
return _runtime_failure_payload(
|
|
276
|
+
spec=spec,
|
|
277
|
+
error_text=f"Custom tool timed out after {spec.timeout_s:g}s",
|
|
278
|
+
error_type="TimeoutError",
|
|
279
|
+
timeout=True,
|
|
280
|
+
elapsed_ms=elapsed_ms,
|
|
281
|
+
stream_info=_stream_info_from_artifacts(
|
|
282
|
+
workspace_root=resolved_workspace,
|
|
283
|
+
stdout_path=stdout_path,
|
|
284
|
+
stderr_path=stderr_path,
|
|
285
|
+
stdout_relpath=stdout_relpath,
|
|
286
|
+
stderr_relpath=stderr_relpath,
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
except BaseException:
|
|
290
|
+
guard.terminate()
|
|
291
|
+
raise
|
|
292
|
+
else:
|
|
293
|
+
guard.cleanup_after_completion()
|
|
294
|
+
finally:
|
|
295
|
+
if guard is not None:
|
|
296
|
+
guard.close()
|
|
297
|
+
|
|
298
|
+
elapsed_ms = int((time.perf_counter() - started_at) * 1000)
|
|
299
|
+
stream_info = _stream_info_from_artifacts(
|
|
300
|
+
workspace_root=resolved_workspace,
|
|
301
|
+
stdout_path=stdout_path,
|
|
302
|
+
stderr_path=stderr_path,
|
|
303
|
+
stdout_relpath=stdout_relpath,
|
|
304
|
+
stderr_relpath=stderr_relpath,
|
|
305
|
+
)
|
|
306
|
+
if proc is None:
|
|
307
|
+
return _runtime_failure_payload(
|
|
308
|
+
spec=spec,
|
|
309
|
+
error_text="Custom tool worker process was not started",
|
|
310
|
+
error_type="WorkerSpawnError",
|
|
311
|
+
timeout=False,
|
|
312
|
+
elapsed_ms=elapsed_ms,
|
|
313
|
+
stream_info=stream_info,
|
|
314
|
+
)
|
|
315
|
+
if not result_path.exists():
|
|
316
|
+
detail = f"exit_code={proc.returncode}"
|
|
317
|
+
return _runtime_failure_payload(
|
|
318
|
+
spec=spec,
|
|
319
|
+
error_text=f"Custom tool worker did not write a result payload ({detail})",
|
|
320
|
+
error_type="WorkerProtocolError",
|
|
321
|
+
timeout=False,
|
|
322
|
+
elapsed_ms=elapsed_ms,
|
|
323
|
+
stream_info=stream_info,
|
|
324
|
+
)
|
|
325
|
+
try:
|
|
326
|
+
payload = json.loads(result_path.read_text(encoding="utf-8"))
|
|
327
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
328
|
+
return _runtime_failure_payload(
|
|
329
|
+
spec=spec,
|
|
330
|
+
error_text=f"Custom tool worker returned invalid result payload: {exc}",
|
|
331
|
+
error_type="WorkerProtocolError",
|
|
332
|
+
timeout=False,
|
|
333
|
+
elapsed_ms=elapsed_ms,
|
|
334
|
+
stream_info=stream_info,
|
|
335
|
+
)
|
|
336
|
+
if not isinstance(payload, dict):
|
|
337
|
+
return _runtime_failure_payload(
|
|
338
|
+
spec=spec,
|
|
339
|
+
error_text="Custom tool worker returned invalid result payload",
|
|
340
|
+
error_type="WorkerProtocolError",
|
|
341
|
+
timeout=False,
|
|
342
|
+
elapsed_ms=elapsed_ms,
|
|
343
|
+
stream_info=stream_info,
|
|
344
|
+
)
|
|
345
|
+
payload["elapsed_ms"] = elapsed_ms
|
|
346
|
+
payload.update(stream_info)
|
|
347
|
+
return payload
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _runtime_success_payload(
|
|
351
|
+
*,
|
|
352
|
+
spec: CustomToolSpec,
|
|
353
|
+
result: Any,
|
|
354
|
+
elapsed_ms: int,
|
|
355
|
+
stream_info: dict[str, Any],
|
|
356
|
+
) -> dict[str, Any]:
|
|
357
|
+
sanitized = _sanitize_json_value(result)
|
|
358
|
+
summary = f"Custom tool '{spec.name}' completed successfully."
|
|
359
|
+
if stream_info.get("stdout_artifact_path"):
|
|
360
|
+
summary += " stdout captured."
|
|
361
|
+
if stream_info.get("stderr_artifact_path"):
|
|
362
|
+
summary += " stderr captured."
|
|
363
|
+
preview = _preview_result(sanitized)
|
|
364
|
+
return {
|
|
365
|
+
"success": True,
|
|
366
|
+
"timeout": False,
|
|
367
|
+
"elapsed_ms": elapsed_ms,
|
|
368
|
+
"result": sanitized,
|
|
369
|
+
**stream_info,
|
|
370
|
+
"summary": summary,
|
|
371
|
+
"preview": preview,
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _runtime_failure_payload(
|
|
376
|
+
*,
|
|
377
|
+
spec: CustomToolSpec,
|
|
378
|
+
error_text: str,
|
|
379
|
+
error_type: str,
|
|
380
|
+
timeout: bool,
|
|
381
|
+
elapsed_ms: int,
|
|
382
|
+
stream_info: dict[str, Any],
|
|
383
|
+
) -> dict[str, Any]:
|
|
384
|
+
summary = f"Custom tool '{spec.name}' failed."
|
|
385
|
+
if timeout:
|
|
386
|
+
summary = f"Custom tool '{spec.name}' timed out."
|
|
387
|
+
if stream_info.get("stderr_artifact_path"):
|
|
388
|
+
summary += " stderr captured."
|
|
389
|
+
return {
|
|
390
|
+
"success": False,
|
|
391
|
+
"timeout": timeout,
|
|
392
|
+
"elapsed_ms": elapsed_ms,
|
|
393
|
+
"error": error_text,
|
|
394
|
+
"error_info": {
|
|
395
|
+
"type": error_type,
|
|
396
|
+
"message": error_text,
|
|
397
|
+
},
|
|
398
|
+
**stream_info,
|
|
399
|
+
"summary": summary,
|
|
400
|
+
"preview": stream_info.get("stderr_preview") or stream_info.get("stdout_preview") or "",
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _empty_stream_info() -> dict[str, Any]:
|
|
405
|
+
return {
|
|
406
|
+
"stdout_preview": "",
|
|
407
|
+
"stdout_truncated": False,
|
|
408
|
+
"stderr_preview": "",
|
|
409
|
+
"stderr_truncated": False,
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _tool_log_artifact_paths(
|
|
414
|
+
*,
|
|
415
|
+
workspace_root: Path,
|
|
416
|
+
session_id: str,
|
|
417
|
+
tool_name: str,
|
|
418
|
+
artifact_dir: Path | None = None,
|
|
419
|
+
artifact_reference_prefix: str | None = None,
|
|
420
|
+
) -> tuple[Path, Path, str, str]:
|
|
421
|
+
session_key = _sanitize_path_component(session_id) or "session"
|
|
422
|
+
tool_key = _sanitize_path_component(tool_name) or "tool"
|
|
423
|
+
rel_dir = Path(".alysis") / "runs" / session_key / "tool_logs"
|
|
424
|
+
logs_dir = artifact_dir if artifact_dir is not None else workspace_root / rel_dir
|
|
425
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
426
|
+
stem = f"{tool_key}-{uuid.uuid4().hex}"
|
|
427
|
+
stdout_name = f"{stem}.stdout.log"
|
|
428
|
+
stderr_name = f"{stem}.stderr.log"
|
|
429
|
+
if artifact_reference_prefix is not None:
|
|
430
|
+
reference_prefix = _normalize_artifact_reference_prefix(artifact_reference_prefix)
|
|
431
|
+
stdout_ref = f"{reference_prefix}/{stdout_name}" if reference_prefix else stdout_name
|
|
432
|
+
stderr_ref = f"{reference_prefix}/{stderr_name}" if reference_prefix else stderr_name
|
|
433
|
+
elif artifact_dir is not None:
|
|
434
|
+
stdout_ref = (logs_dir / stdout_name).as_posix()
|
|
435
|
+
stderr_ref = (logs_dir / stderr_name).as_posix()
|
|
436
|
+
else:
|
|
437
|
+
stdout_ref = (rel_dir / stdout_name).as_posix()
|
|
438
|
+
stderr_ref = (rel_dir / stderr_name).as_posix()
|
|
439
|
+
return logs_dir / stdout_name, logs_dir / stderr_name, stdout_ref, stderr_ref
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _normalize_artifact_reference_prefix(value: str) -> str:
|
|
443
|
+
parts: list[str] = []
|
|
444
|
+
for raw_part in str(value).strip().replace("\\", "/").split("/"):
|
|
445
|
+
part = raw_part.strip()
|
|
446
|
+
if not part or part == ".":
|
|
447
|
+
continue
|
|
448
|
+
if part == "..":
|
|
449
|
+
raise CustomToolRuntimeError("artifact_reference_prefix cannot traverse upward")
|
|
450
|
+
parts.append(part)
|
|
451
|
+
return "/".join(parts)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _stream_info_from_artifacts(
|
|
455
|
+
*,
|
|
456
|
+
workspace_root: Path,
|
|
457
|
+
stdout_path: Path,
|
|
458
|
+
stderr_path: Path,
|
|
459
|
+
stdout_relpath: str,
|
|
460
|
+
stderr_relpath: str,
|
|
461
|
+
) -> dict[str, Any]:
|
|
462
|
+
stdout_preview, stdout_truncated, stdout_has_content = _preview_artifact_tail(stdout_path)
|
|
463
|
+
stderr_preview, stderr_truncated, stderr_has_content = _preview_artifact_tail(stderr_path)
|
|
464
|
+
payload = {
|
|
465
|
+
"stdout_preview": stdout_preview,
|
|
466
|
+
"stdout_truncated": stdout_truncated,
|
|
467
|
+
"stderr_preview": stderr_preview,
|
|
468
|
+
"stderr_truncated": stderr_truncated,
|
|
469
|
+
}
|
|
470
|
+
if stdout_has_content:
|
|
471
|
+
payload["stdout_artifact_path"] = _normalize_artifact_relpath(
|
|
472
|
+
workspace_root=workspace_root,
|
|
473
|
+
artifact_path=stdout_path,
|
|
474
|
+
fallback_relpath=stdout_relpath,
|
|
475
|
+
)
|
|
476
|
+
if stderr_has_content:
|
|
477
|
+
payload["stderr_artifact_path"] = _normalize_artifact_relpath(
|
|
478
|
+
workspace_root=workspace_root,
|
|
479
|
+
artifact_path=stderr_path,
|
|
480
|
+
fallback_relpath=stderr_relpath,
|
|
481
|
+
)
|
|
482
|
+
return payload
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _preview_artifact_tail(path: Path) -> tuple[str, bool, bool]:
|
|
486
|
+
try:
|
|
487
|
+
size = path.stat().st_size
|
|
488
|
+
except OSError:
|
|
489
|
+
return "", False, False
|
|
490
|
+
if size <= 0:
|
|
491
|
+
return "", False, False
|
|
492
|
+
read_size = min(size, _STREAM_TAIL_READ_BYTES)
|
|
493
|
+
try:
|
|
494
|
+
with path.open("rb") as handle:
|
|
495
|
+
handle.seek(max(0, size - read_size))
|
|
496
|
+
raw = handle.read(read_size)
|
|
497
|
+
except OSError:
|
|
498
|
+
return "", False, False
|
|
499
|
+
text = raw.decode("utf-8", errors="replace")
|
|
500
|
+
truncated = size > read_size
|
|
501
|
+
if len(text) > _MAX_STREAM_PREVIEW_CHARS:
|
|
502
|
+
text = text[-_MAX_STREAM_PREVIEW_CHARS:]
|
|
503
|
+
truncated = True
|
|
504
|
+
return text, truncated, True
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _normalize_artifact_relpath(
|
|
508
|
+
*,
|
|
509
|
+
workspace_root: Path,
|
|
510
|
+
artifact_path: Path,
|
|
511
|
+
fallback_relpath: str,
|
|
512
|
+
) -> str:
|
|
513
|
+
try:
|
|
514
|
+
return artifact_path.relative_to(workspace_root).as_posix()
|
|
515
|
+
except ValueError:
|
|
516
|
+
return fallback_relpath
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _sanitize_path_component(value: str) -> str:
|
|
520
|
+
sanitized = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(value or "").strip())
|
|
521
|
+
sanitized = sanitized.strip(".-")
|
|
522
|
+
return sanitized[:80]
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _sealed_source_filename(source_path: Path) -> str:
|
|
526
|
+
suffix = source_path.suffix if source_path.suffix == ".py" else ".py"
|
|
527
|
+
stem = _sanitize_path_component(source_path.stem) or "tool"
|
|
528
|
+
return f"{stem}-{uuid.uuid4().hex}{suffix}"
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _build_tool_env(
|
|
532
|
+
*,
|
|
533
|
+
spec: CustomToolSpec,
|
|
534
|
+
workspace_root: Path,
|
|
535
|
+
session_id: str,
|
|
536
|
+
) -> dict[str, str]:
|
|
537
|
+
from alysis_code.branding import env_get
|
|
538
|
+
|
|
539
|
+
env: dict[str, str] = {}
|
|
540
|
+
for key in _MINIMUM_PLATFORM_ENV:
|
|
541
|
+
value = os.environ.get(key)
|
|
542
|
+
if str(value or "").strip():
|
|
543
|
+
env[key] = str(value)
|
|
544
|
+
if os.name == "nt": # pragma: no cover - exercised on Windows
|
|
545
|
+
for key in _WINDOWS_PLATFORM_ENV:
|
|
546
|
+
value = os.environ.get(key)
|
|
547
|
+
if str(value or "").strip():
|
|
548
|
+
env[key] = str(value)
|
|
549
|
+
for key in _dedupe_env_names([*spec.required_env, *spec.capabilities.secret_refs]):
|
|
550
|
+
value = env_get(key)
|
|
551
|
+
if str(value or "").strip():
|
|
552
|
+
env[key] = str(value)
|
|
553
|
+
env.update(
|
|
554
|
+
{
|
|
555
|
+
"ALYSIS_WORKSPACE_ROOT": os.fspath(workspace_root.resolve()),
|
|
556
|
+
"ALYSIS_SESSION_ID": session_id,
|
|
557
|
+
"ALYSIS_TOOL_PATH": os.fspath(spec.source_path),
|
|
558
|
+
"ALYSIS_TOOL_SCOPE": spec.source_scope,
|
|
559
|
+
"ALYSIS_TOOL_NAME": spec.name,
|
|
560
|
+
}
|
|
561
|
+
)
|
|
562
|
+
# Custom tools are user-authored and may still read the pre-rebrand names.
|
|
563
|
+
# Imported lazily by absolute name: this module is also loaded standalone by
|
|
564
|
+
# the worker, where a package-relative import at module scope would fail.
|
|
565
|
+
from alysis_code.branding import with_legacy_env_aliases
|
|
566
|
+
|
|
567
|
+
return with_legacy_env_aliases(env)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _build_worker_bootstrap_env(tool_env: dict[str, str]) -> dict[str, str]:
|
|
571
|
+
return dict(tool_env)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _worker_command() -> list[str]:
|
|
575
|
+
package_root = os.fspath(Path(__file__).resolve().parents[2])
|
|
576
|
+
runtime_path = os.fspath(Path(__file__).resolve())
|
|
577
|
+
bootstrap_code = _WORKER_BOOTSTRAP_CODE.format(
|
|
578
|
+
package_root=package_root,
|
|
579
|
+
runtime_path=runtime_path,
|
|
580
|
+
)
|
|
581
|
+
return [sys.executable, "-I", "-c", bootstrap_code]
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _is_project_tool_trusted_at_execution(spec: CustomToolSpec) -> bool:
|
|
585
|
+
from .trust import is_project_tool_trusted
|
|
586
|
+
|
|
587
|
+
return is_project_tool_trusted(spec)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _popen_worker_process(
|
|
591
|
+
*,
|
|
592
|
+
stdout_handle: Any,
|
|
593
|
+
stderr_handle: Any,
|
|
594
|
+
env: dict[str, str],
|
|
595
|
+
cwd: Path,
|
|
596
|
+
) -> subprocess.Popen[bytes]:
|
|
597
|
+
kwargs: dict[str, Any] = {}
|
|
598
|
+
if os.name != "nt":
|
|
599
|
+
kwargs["start_new_session"] = True
|
|
600
|
+
return subprocess.Popen(
|
|
601
|
+
_worker_command(),
|
|
602
|
+
stdin=subprocess.PIPE,
|
|
603
|
+
stdout=stdout_handle,
|
|
604
|
+
stderr=stderr_handle,
|
|
605
|
+
env=env,
|
|
606
|
+
cwd=os.fspath(cwd),
|
|
607
|
+
**kwargs,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _communicate_worker_process(
|
|
612
|
+
proc: subprocess.Popen[bytes],
|
|
613
|
+
payload: bytes,
|
|
614
|
+
*,
|
|
615
|
+
timeout_s: float,
|
|
616
|
+
) -> None:
|
|
617
|
+
proc.communicate(input=payload, timeout=timeout_s)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _create_worker_process_tree_guard(
|
|
621
|
+
proc: subprocess.Popen[bytes],
|
|
622
|
+
) -> _WorkerProcessTreeGuard:
|
|
623
|
+
if os.name == "nt": # pragma: no cover - exercised on Windows
|
|
624
|
+
return _WindowsJobObjectGuard.create(proc)
|
|
625
|
+
return _PosixProcessGroupGuard(proc)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
class _WorkerProcessTreeGuard:
|
|
629
|
+
def terminate(self) -> None:
|
|
630
|
+
raise NotImplementedError
|
|
631
|
+
|
|
632
|
+
def cleanup_after_completion(self) -> None:
|
|
633
|
+
raise NotImplementedError
|
|
634
|
+
|
|
635
|
+
def close(self) -> None:
|
|
636
|
+
raise NotImplementedError
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
class _PosixProcessGroupGuard(_WorkerProcessTreeGuard):
|
|
640
|
+
def __init__(self, proc: subprocess.Popen[bytes]) -> None:
|
|
641
|
+
self._proc = proc
|
|
642
|
+
|
|
643
|
+
def terminate(self) -> None:
|
|
644
|
+
_terminate_posix_process_group(self._proc)
|
|
645
|
+
|
|
646
|
+
def cleanup_after_completion(self) -> None:
|
|
647
|
+
_terminate_posix_process_group(self._proc)
|
|
648
|
+
|
|
649
|
+
def close(self) -> None:
|
|
650
|
+
return
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
class _WindowsJobObjectGuard(_WorkerProcessTreeGuard):
|
|
654
|
+
def __init__(self, proc: subprocess.Popen[bytes], kernel32: Any, job_handle: Any) -> None:
|
|
655
|
+
self._proc = proc
|
|
656
|
+
self._kernel32 = kernel32
|
|
657
|
+
self._job_handle = job_handle
|
|
658
|
+
|
|
659
|
+
@classmethod
|
|
660
|
+
def create(cls, proc: subprocess.Popen[bytes]) -> _WindowsJobObjectGuard:
|
|
661
|
+
import ctypes
|
|
662
|
+
from ctypes import wintypes
|
|
663
|
+
|
|
664
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
665
|
+
|
|
666
|
+
class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
|
|
667
|
+
_fields_ = [
|
|
668
|
+
("PerProcessUserTimeLimit", ctypes.c_longlong),
|
|
669
|
+
("PerJobUserTimeLimit", ctypes.c_longlong),
|
|
670
|
+
("LimitFlags", wintypes.DWORD),
|
|
671
|
+
("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
672
|
+
("MaximumWorkingSetSize", ctypes.c_size_t),
|
|
673
|
+
("ActiveProcessLimit", wintypes.DWORD),
|
|
674
|
+
("Affinity", ctypes.c_size_t),
|
|
675
|
+
("PriorityClass", wintypes.DWORD),
|
|
676
|
+
("SchedulingClass", wintypes.DWORD),
|
|
677
|
+
]
|
|
678
|
+
|
|
679
|
+
class IO_COUNTERS(ctypes.Structure):
|
|
680
|
+
_fields_ = [
|
|
681
|
+
("ReadOperationCount", ctypes.c_ulonglong),
|
|
682
|
+
("WriteOperationCount", ctypes.c_ulonglong),
|
|
683
|
+
("OtherOperationCount", ctypes.c_ulonglong),
|
|
684
|
+
("ReadTransferCount", ctypes.c_ulonglong),
|
|
685
|
+
("WriteTransferCount", ctypes.c_ulonglong),
|
|
686
|
+
("OtherTransferCount", ctypes.c_ulonglong),
|
|
687
|
+
]
|
|
688
|
+
|
|
689
|
+
class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure):
|
|
690
|
+
_fields_ = [
|
|
691
|
+
("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION),
|
|
692
|
+
("IoInfo", IO_COUNTERS),
|
|
693
|
+
("ProcessMemoryLimit", ctypes.c_size_t),
|
|
694
|
+
("JobMemoryLimit", ctypes.c_size_t),
|
|
695
|
+
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
696
|
+
("PeakJobMemoryUsed", ctypes.c_size_t),
|
|
697
|
+
]
|
|
698
|
+
|
|
699
|
+
kernel32.CreateJobObjectW.argtypes = [wintypes.LPVOID, wintypes.LPCWSTR]
|
|
700
|
+
kernel32.CreateJobObjectW.restype = wintypes.HANDLE
|
|
701
|
+
kernel32.SetInformationJobObject.argtypes = [
|
|
702
|
+
wintypes.HANDLE,
|
|
703
|
+
ctypes.c_int,
|
|
704
|
+
wintypes.LPVOID,
|
|
705
|
+
wintypes.DWORD,
|
|
706
|
+
]
|
|
707
|
+
kernel32.SetInformationJobObject.restype = wintypes.BOOL
|
|
708
|
+
kernel32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
|
|
709
|
+
kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
|
|
710
|
+
kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT]
|
|
711
|
+
kernel32.TerminateJobObject.restype = wintypes.BOOL
|
|
712
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
713
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
714
|
+
|
|
715
|
+
job_handle = kernel32.CreateJobObjectW(None, None)
|
|
716
|
+
if not job_handle:
|
|
717
|
+
_raise_windows_guard_error("CreateJobObjectW")
|
|
718
|
+
try:
|
|
719
|
+
info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION()
|
|
720
|
+
info.BasicLimitInformation.LimitFlags = 0x00002000
|
|
721
|
+
if not kernel32.SetInformationJobObject(
|
|
722
|
+
job_handle,
|
|
723
|
+
9,
|
|
724
|
+
ctypes.byref(info),
|
|
725
|
+
ctypes.sizeof(info),
|
|
726
|
+
):
|
|
727
|
+
_raise_windows_guard_error("SetInformationJobObject")
|
|
728
|
+
process_handle = getattr(proc, "_handle", None)
|
|
729
|
+
if process_handle is None:
|
|
730
|
+
raise WorkerProcessTreeGuardError("worker process handle is unavailable")
|
|
731
|
+
if not kernel32.AssignProcessToJobObject(
|
|
732
|
+
job_handle,
|
|
733
|
+
wintypes.HANDLE(int(process_handle)),
|
|
734
|
+
):
|
|
735
|
+
_raise_windows_guard_error("AssignProcessToJobObject")
|
|
736
|
+
except BaseException:
|
|
737
|
+
kernel32.CloseHandle(job_handle)
|
|
738
|
+
raise
|
|
739
|
+
return cls(proc, kernel32, job_handle)
|
|
740
|
+
|
|
741
|
+
def terminate(self) -> None:
|
|
742
|
+
if self._job_handle:
|
|
743
|
+
self._kernel32.TerminateJobObject(self._job_handle, 1)
|
|
744
|
+
_wait_for_worker_exit(self._proc, timeout_s=1.0)
|
|
745
|
+
|
|
746
|
+
def cleanup_after_completion(self) -> None:
|
|
747
|
+
self.close()
|
|
748
|
+
_wait_for_worker_exit(self._proc, timeout_s=1.0)
|
|
749
|
+
|
|
750
|
+
def close(self) -> None:
|
|
751
|
+
if self._job_handle:
|
|
752
|
+
self._kernel32.CloseHandle(self._job_handle)
|
|
753
|
+
self._job_handle = None
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _raise_windows_guard_error(action: str) -> None:
|
|
757
|
+
import ctypes
|
|
758
|
+
|
|
759
|
+
error_code = ctypes.get_last_error()
|
|
760
|
+
raise WorkerProcessTreeGuardError(f"{action} failed: {ctypes.WinError(error_code)}")
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _terminate_immediate_worker(proc: subprocess.Popen[bytes]) -> None:
|
|
764
|
+
with contextlib.suppress(Exception):
|
|
765
|
+
if proc.stdin is not None:
|
|
766
|
+
proc.stdin.close()
|
|
767
|
+
if proc.poll() is None:
|
|
768
|
+
with contextlib.suppress(ProcessLookupError):
|
|
769
|
+
proc.terminate()
|
|
770
|
+
if not _wait_for_worker_exit(proc, timeout_s=0.5):
|
|
771
|
+
with contextlib.suppress(ProcessLookupError):
|
|
772
|
+
proc.kill()
|
|
773
|
+
_wait_for_worker_exit(proc, timeout_s=1.0)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _terminate_posix_process_group(proc: subprocess.Popen[bytes]) -> None:
|
|
777
|
+
pgid = proc.pid
|
|
778
|
+
if pgid <= 0:
|
|
779
|
+
_terminate_immediate_worker(proc)
|
|
780
|
+
return
|
|
781
|
+
if not _signal_posix_process_group(pgid, signal.SIGTERM):
|
|
782
|
+
_terminate_immediate_worker(proc)
|
|
783
|
+
return
|
|
784
|
+
_wait_for_worker_exit(proc, timeout_s=0.5)
|
|
785
|
+
if not _wait_for_posix_process_group_exit(pgid, timeout_s=0.5):
|
|
786
|
+
if not _signal_posix_process_group(pgid, signal.SIGKILL):
|
|
787
|
+
_terminate_immediate_worker(proc)
|
|
788
|
+
return
|
|
789
|
+
_wait_for_posix_process_group_exit(pgid, timeout_s=1.0)
|
|
790
|
+
if not _wait_for_worker_exit(proc, timeout_s=1.0):
|
|
791
|
+
_terminate_immediate_worker(proc)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _signal_posix_process_group(pgid: int, signum: int) -> bool:
|
|
795
|
+
try:
|
|
796
|
+
os.killpg(pgid, signum)
|
|
797
|
+
except ProcessLookupError:
|
|
798
|
+
return True
|
|
799
|
+
except PermissionError:
|
|
800
|
+
return False
|
|
801
|
+
return True
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _wait_for_posix_process_group_exit(pgid: int, *, timeout_s: float) -> bool:
|
|
805
|
+
deadline = time.monotonic() + timeout_s
|
|
806
|
+
while time.monotonic() < deadline:
|
|
807
|
+
if not _posix_process_group_exists(pgid):
|
|
808
|
+
return True
|
|
809
|
+
time.sleep(0.02)
|
|
810
|
+
return not _posix_process_group_exists(pgid)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _posix_process_group_exists(pgid: int) -> bool:
|
|
814
|
+
try:
|
|
815
|
+
os.killpg(pgid, 0)
|
|
816
|
+
except ProcessLookupError:
|
|
817
|
+
return False
|
|
818
|
+
except PermissionError:
|
|
819
|
+
return True
|
|
820
|
+
return True
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _wait_for_worker_exit(proc: subprocess.Popen[bytes], *, timeout_s: float) -> bool:
|
|
824
|
+
try:
|
|
825
|
+
proc.wait(timeout=timeout_s)
|
|
826
|
+
except subprocess.TimeoutExpired:
|
|
827
|
+
return False
|
|
828
|
+
return True
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
class _CustomToolAuditPolicy:
|
|
832
|
+
_BLOCKED_IMPORT_ROOTS = (
|
|
833
|
+
"alysis_code",
|
|
834
|
+
"ctypes",
|
|
835
|
+
"_ctypes",
|
|
836
|
+
"cffi",
|
|
837
|
+
"_cffi_backend",
|
|
838
|
+
)
|
|
839
|
+
_PROCESS_EVENTS = {
|
|
840
|
+
"subprocess.Popen",
|
|
841
|
+
"os.system",
|
|
842
|
+
"os.exec",
|
|
843
|
+
"os.fork",
|
|
844
|
+
"os.forkpty",
|
|
845
|
+
"os.posix_spawn",
|
|
846
|
+
"os.spawn",
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
def __init__(
|
|
850
|
+
self,
|
|
851
|
+
*,
|
|
852
|
+
capabilities: dict[str, Any],
|
|
853
|
+
workspace_root: Path,
|
|
854
|
+
original_tool_dir: Path,
|
|
855
|
+
result_path: Path,
|
|
856
|
+
) -> None:
|
|
857
|
+
self._capabilities = dict(capabilities)
|
|
858
|
+
self._workspace_root = workspace_root.resolve()
|
|
859
|
+
self._original_tool_dir = original_tool_dir.resolve()
|
|
860
|
+
self._result_path = result_path.resolve()
|
|
861
|
+
self._allowed_resolved_hosts: set[str] = set()
|
|
862
|
+
self._allowed_workspace_writes: set[tuple[str, str]] = set()
|
|
863
|
+
self._resolving_allowed_host = False
|
|
864
|
+
self.first_violation: _CustomToolPolicyViolation | None = None
|
|
865
|
+
|
|
866
|
+
def handle_event(self, event: str, args: tuple[Any, ...]) -> None:
|
|
867
|
+
try:
|
|
868
|
+
self._check_event(event, args)
|
|
869
|
+
except _CustomToolPolicyViolation as exc:
|
|
870
|
+
if self.first_violation is None:
|
|
871
|
+
self.first_violation = exc
|
|
872
|
+
raise
|
|
873
|
+
|
|
874
|
+
def _check_event(self, event: str, args: tuple[Any, ...]) -> None:
|
|
875
|
+
if event == "import" and args:
|
|
876
|
+
self._check_import(str(args[0]))
|
|
877
|
+
return
|
|
878
|
+
if event == "ctypes.dlopen":
|
|
879
|
+
self._deny_host_import("ctypes.dlopen")
|
|
880
|
+
if event == "open":
|
|
881
|
+
self._check_open(args)
|
|
882
|
+
return
|
|
883
|
+
if event in self._PROCESS_EVENTS:
|
|
884
|
+
self._check_process_spawn(event)
|
|
885
|
+
return
|
|
886
|
+
if event == "socket.connect":
|
|
887
|
+
self._check_socket_connect(args)
|
|
888
|
+
return
|
|
889
|
+
if event in {
|
|
890
|
+
"socket.getaddrinfo",
|
|
891
|
+
"socket.gethostbyname",
|
|
892
|
+
"socket.gethostbyaddr",
|
|
893
|
+
"socket.getnameinfo",
|
|
894
|
+
}:
|
|
895
|
+
self._check_socket_name_lookup(event, args)
|
|
896
|
+
return
|
|
897
|
+
self._check_filesystem_mutation(event, args)
|
|
898
|
+
|
|
899
|
+
def _check_import(self, module_name: str) -> None:
|
|
900
|
+
normalized = module_name.strip()
|
|
901
|
+
for root in self._BLOCKED_IMPORT_ROOTS:
|
|
902
|
+
if normalized == root or normalized.startswith(f"{root}."):
|
|
903
|
+
self._deny_host_import(normalized)
|
|
904
|
+
|
|
905
|
+
def _deny_host_import(self, module_name: str) -> None:
|
|
906
|
+
raise _CustomToolPolicyViolation(
|
|
907
|
+
error_type="HostImportBlocked",
|
|
908
|
+
message=f"Blocked custom tool import or native bypass attempt: {module_name}",
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
def _check_open(self, args: tuple[Any, ...]) -> None:
|
|
912
|
+
if len(args) < 3:
|
|
913
|
+
return
|
|
914
|
+
path, mode, flags = args[:3]
|
|
915
|
+
if not _open_event_is_write_like(mode=mode, flags=flags):
|
|
916
|
+
return
|
|
917
|
+
target = self._policy_path(path)
|
|
918
|
+
if target is None:
|
|
919
|
+
if self._write_scope() == "unrestricted":
|
|
920
|
+
return
|
|
921
|
+
self._deny_capability("filesystem write via open", "filesystem.write")
|
|
922
|
+
self._check_write_target(target, operation="filesystem write via open")
|
|
923
|
+
|
|
924
|
+
def _check_filesystem_mutation(self, event: str, args: tuple[Any, ...]) -> None:
|
|
925
|
+
targets = _filesystem_mutation_targets(event, args)
|
|
926
|
+
if not targets:
|
|
927
|
+
return
|
|
928
|
+
for target_raw in targets:
|
|
929
|
+
target = self._policy_path(target_raw)
|
|
930
|
+
if target is None:
|
|
931
|
+
if self._write_scope() == "unrestricted":
|
|
932
|
+
continue
|
|
933
|
+
self._deny_capability(event, "filesystem.write")
|
|
934
|
+
self._check_write_target(target, operation=event)
|
|
935
|
+
|
|
936
|
+
def _check_write_target(self, target: Path, *, operation: str) -> None:
|
|
937
|
+
if target == self._result_path:
|
|
938
|
+
return
|
|
939
|
+
write_scope = self._write_scope()
|
|
940
|
+
if write_scope == "unrestricted":
|
|
941
|
+
self._record_allowed_workspace_write(target, scope=write_scope)
|
|
942
|
+
return
|
|
943
|
+
if write_scope in {"none", "unspecified"}:
|
|
944
|
+
self._deny_capability(operation, "filesystem.write")
|
|
945
|
+
allowed_root: Path | None = None
|
|
946
|
+
if write_scope == "workspace":
|
|
947
|
+
allowed_root = self._workspace_root
|
|
948
|
+
elif write_scope == "tool_dir":
|
|
949
|
+
allowed_root = self._original_tool_dir
|
|
950
|
+
if allowed_root is None or not _path_is_within(target, allowed_root):
|
|
951
|
+
self._deny_capability(
|
|
952
|
+
f"{operation} outside {write_scope} scope: {target}",
|
|
953
|
+
"filesystem.write",
|
|
954
|
+
)
|
|
955
|
+
self._record_allowed_workspace_write(target, scope=write_scope)
|
|
956
|
+
|
|
957
|
+
def _write_scope(self) -> str:
|
|
958
|
+
return str(self._capabilities.get("filesystem_write_scope") or "unspecified")
|
|
959
|
+
|
|
960
|
+
def _record_allowed_workspace_write(self, target: Path, *, scope: str) -> None:
|
|
961
|
+
try:
|
|
962
|
+
rel_path = target.relative_to(self._workspace_root).as_posix()
|
|
963
|
+
except ValueError:
|
|
964
|
+
return
|
|
965
|
+
if rel_path:
|
|
966
|
+
self._allowed_workspace_writes.add((rel_path, scope))
|
|
967
|
+
|
|
968
|
+
def workspace_write_side_effects(self) -> list[dict[str, str]]:
|
|
969
|
+
return [
|
|
970
|
+
{"path": path, "scope": scope} for path, scope in sorted(self._allowed_workspace_writes)
|
|
971
|
+
]
|
|
972
|
+
|
|
973
|
+
def _check_socket_connect(self, args: tuple[Any, ...]) -> None:
|
|
974
|
+
address = args[1] if len(args) > 1 else None
|
|
975
|
+
host = _socket_connect_host(address)
|
|
976
|
+
network_access = str(self._capabilities.get("network_access") or "unspecified")
|
|
977
|
+
if network_access == "unrestricted":
|
|
978
|
+
return
|
|
979
|
+
if network_access in {"none", "unspecified"}:
|
|
980
|
+
self._deny_capability(
|
|
981
|
+
f"network socket connect to {host or address!r}",
|
|
982
|
+
"network_access",
|
|
983
|
+
)
|
|
984
|
+
if network_access == "local":
|
|
985
|
+
if host is not None and _host_is_loopback(host):
|
|
986
|
+
return
|
|
987
|
+
self._deny_capability(
|
|
988
|
+
f"network socket connect to non-loopback host {host or address!r}",
|
|
989
|
+
"network_access",
|
|
990
|
+
)
|
|
991
|
+
if network_access == "restricted":
|
|
992
|
+
allowed_hosts = {
|
|
993
|
+
str(hostname) for hostname in self._capabilities.get("network_hosts") or ()
|
|
994
|
+
}
|
|
995
|
+
if host is not None and (host in allowed_hosts or host in self._allowed_resolved_hosts):
|
|
996
|
+
return
|
|
997
|
+
self._deny_capability(
|
|
998
|
+
f"network socket connect to undeclared host {host or address!r}",
|
|
999
|
+
"network_hosts",
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
def _check_socket_name_lookup(self, event: str, args: tuple[Any, ...]) -> None:
|
|
1003
|
+
if self._resolving_allowed_host:
|
|
1004
|
+
return
|
|
1005
|
+
if event == "socket.getnameinfo":
|
|
1006
|
+
host = _socket_connect_host(args[0] if args else None)
|
|
1007
|
+
else:
|
|
1008
|
+
host = _socket_connect_host((args[0], None)) if args else None
|
|
1009
|
+
network_access = str(self._capabilities.get("network_access") or "unspecified")
|
|
1010
|
+
if network_access == "unrestricted":
|
|
1011
|
+
return
|
|
1012
|
+
if network_access in {"none", "unspecified"}:
|
|
1013
|
+
self._deny_capability(f"network name lookup for {host!r}", "network_access")
|
|
1014
|
+
if network_access == "local":
|
|
1015
|
+
if host is not None and _host_is_loopback(host):
|
|
1016
|
+
return
|
|
1017
|
+
self._deny_capability(
|
|
1018
|
+
f"network name lookup for non-loopback host {host!r}",
|
|
1019
|
+
"network_access",
|
|
1020
|
+
)
|
|
1021
|
+
if network_access == "restricted":
|
|
1022
|
+
allowed_hosts = {
|
|
1023
|
+
str(hostname) for hostname in self._capabilities.get("network_hosts") or ()
|
|
1024
|
+
}
|
|
1025
|
+
if host is not None and host in allowed_hosts:
|
|
1026
|
+
self._record_allowed_resolved_hosts(host)
|
|
1027
|
+
return
|
|
1028
|
+
self._deny_capability(
|
|
1029
|
+
f"network name lookup for undeclared host {host!r}",
|
|
1030
|
+
"network_hosts",
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
def _record_allowed_resolved_hosts(self, host: str) -> None:
|
|
1034
|
+
self._allowed_resolved_hosts.add(host)
|
|
1035
|
+
self._resolving_allowed_host = True
|
|
1036
|
+
try:
|
|
1037
|
+
for family, _, _, _, sockaddr in socket.getaddrinfo(host, None):
|
|
1038
|
+
if family in {socket.AF_INET, socket.AF_INET6}:
|
|
1039
|
+
resolved_host = _socket_connect_host(sockaddr)
|
|
1040
|
+
if resolved_host:
|
|
1041
|
+
self._allowed_resolved_hosts.add(resolved_host)
|
|
1042
|
+
except OSError:
|
|
1043
|
+
return
|
|
1044
|
+
finally:
|
|
1045
|
+
self._resolving_allowed_host = False
|
|
1046
|
+
|
|
1047
|
+
def _check_process_spawn(self, event: str) -> None:
|
|
1048
|
+
process_spawn = str(self._capabilities.get("process_spawn") or "unspecified")
|
|
1049
|
+
if process_spawn == "unrestricted":
|
|
1050
|
+
return
|
|
1051
|
+
self._deny_capability(event, "process_spawn")
|
|
1052
|
+
|
|
1053
|
+
def check_process_spawn_call(self, operation: str) -> None:
|
|
1054
|
+
self._check_process_spawn(operation)
|
|
1055
|
+
|
|
1056
|
+
def _policy_path(self, value: Any) -> Path | None:
|
|
1057
|
+
if isinstance(value, int):
|
|
1058
|
+
return None
|
|
1059
|
+
try:
|
|
1060
|
+
path = Path(os.fsdecode(value))
|
|
1061
|
+
except (TypeError, ValueError):
|
|
1062
|
+
return None
|
|
1063
|
+
if not path.is_absolute():
|
|
1064
|
+
path = Path.cwd() / path
|
|
1065
|
+
try:
|
|
1066
|
+
return path.resolve(strict=False)
|
|
1067
|
+
except OSError:
|
|
1068
|
+
return path.absolute()
|
|
1069
|
+
|
|
1070
|
+
def _deny_capability(self, operation: str, capability: str) -> None:
|
|
1071
|
+
raise _CustomToolPolicyViolation(
|
|
1072
|
+
error_type="CapabilityViolation",
|
|
1073
|
+
message=f"Denied custom tool operation {operation!r}; capability {capability} is not allowed",
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def _open_event_is_write_like(*, mode: Any, flags: Any) -> bool:
|
|
1078
|
+
mode_text = str(mode or "")
|
|
1079
|
+
if any(marker in mode_text for marker in ("w", "a", "x", "+")):
|
|
1080
|
+
return True
|
|
1081
|
+
if isinstance(flags, int):
|
|
1082
|
+
write_flags = (
|
|
1083
|
+
getattr(os, "O_WRONLY", 0)
|
|
1084
|
+
| getattr(os, "O_RDWR", 0)
|
|
1085
|
+
| getattr(os, "O_CREAT", 0)
|
|
1086
|
+
| getattr(os, "O_TRUNC", 0)
|
|
1087
|
+
| getattr(os, "O_APPEND", 0)
|
|
1088
|
+
)
|
|
1089
|
+
return bool(flags & write_flags)
|
|
1090
|
+
return False
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _filesystem_mutation_targets(event: str, args: tuple[Any, ...]) -> tuple[Any, ...]:
|
|
1094
|
+
if event in {"os.remove", "os.unlink", "os.mkdir", "os.rmdir"} and args:
|
|
1095
|
+
if len(args) >= 2 and _is_active_dir_fd(args[1]):
|
|
1096
|
+
return (None,)
|
|
1097
|
+
return (args[0],)
|
|
1098
|
+
if event in {"os.rename", "os.replace"} and len(args) >= 2:
|
|
1099
|
+
return (args[0], args[1])
|
|
1100
|
+
if event in {"os.symlink", "os.link"} and len(args) >= 2:
|
|
1101
|
+
return (args[1],)
|
|
1102
|
+
if event in {"os.truncate", "os.chmod", "os.chown", "os.utime"} and args:
|
|
1103
|
+
return (args[0],)
|
|
1104
|
+
if event == "shutil.copyfile" and len(args) >= 2:
|
|
1105
|
+
return (args[1],)
|
|
1106
|
+
if event in {"shutil.copymode", "shutil.copystat", "shutil.copytree"} and len(args) >= 2:
|
|
1107
|
+
return (args[1],)
|
|
1108
|
+
if event == "shutil.rmtree" and args:
|
|
1109
|
+
return (args[0],)
|
|
1110
|
+
if event == "shutil.move" and len(args) >= 2:
|
|
1111
|
+
return (args[0], args[1])
|
|
1112
|
+
return ()
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def _is_active_dir_fd(value: Any) -> bool:
|
|
1116
|
+
return isinstance(value, int) and value >= 0
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _path_is_within(path: Path, root: Path) -> bool:
|
|
1120
|
+
try:
|
|
1121
|
+
path.relative_to(root)
|
|
1122
|
+
except ValueError:
|
|
1123
|
+
return False
|
|
1124
|
+
return True
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def _socket_connect_host(address: Any) -> str | None:
|
|
1128
|
+
if isinstance(address, tuple) and address:
|
|
1129
|
+
host = address[0]
|
|
1130
|
+
else:
|
|
1131
|
+
host = address
|
|
1132
|
+
if isinstance(host, bytes):
|
|
1133
|
+
with contextlib.suppress(UnicodeDecodeError):
|
|
1134
|
+
return host.decode("utf-8")
|
|
1135
|
+
return None
|
|
1136
|
+
if isinstance(host, str):
|
|
1137
|
+
return host.strip()
|
|
1138
|
+
return None
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def _host_is_loopback(host: str) -> bool:
|
|
1142
|
+
if host.casefold() == "localhost":
|
|
1143
|
+
return True
|
|
1144
|
+
try:
|
|
1145
|
+
return ipaddress.ip_address(host).is_loopback
|
|
1146
|
+
except ValueError:
|
|
1147
|
+
return False
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def _policy_failure_payload(
|
|
1151
|
+
*,
|
|
1152
|
+
spec: _RuntimeToolSpec,
|
|
1153
|
+
violation: _CustomToolPolicyViolation,
|
|
1154
|
+
elapsed_ms: int,
|
|
1155
|
+
) -> dict[str, Any]:
|
|
1156
|
+
return _runtime_failure_payload(
|
|
1157
|
+
spec=spec,
|
|
1158
|
+
error_text=violation.message,
|
|
1159
|
+
error_type=violation.error_type,
|
|
1160
|
+
timeout=False,
|
|
1161
|
+
elapsed_ms=elapsed_ms,
|
|
1162
|
+
stream_info=_empty_stream_info(),
|
|
1163
|
+
)
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
@contextlib.contextmanager
|
|
1167
|
+
def _patched_spawn_functions(policy: _CustomToolAuditPolicy):
|
|
1168
|
+
spawn_names = [name for name in dir(os) if name.startswith("spawn")]
|
|
1169
|
+
originals = {name: getattr(os, name) for name in spawn_names}
|
|
1170
|
+
|
|
1171
|
+
def _blocked_spawn(name: str):
|
|
1172
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
1173
|
+
policy.check_process_spawn_call(f"os.{name}")
|
|
1174
|
+
return originals[name](*args, **kwargs)
|
|
1175
|
+
|
|
1176
|
+
return wrapper
|
|
1177
|
+
|
|
1178
|
+
try:
|
|
1179
|
+
for name in spawn_names:
|
|
1180
|
+
setattr(os, name, _blocked_spawn(name))
|
|
1181
|
+
yield
|
|
1182
|
+
finally:
|
|
1183
|
+
for name, original in originals.items():
|
|
1184
|
+
setattr(os, name, original)
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def _dedupe_env_names(names: list[str]) -> tuple[str, ...]:
|
|
1188
|
+
seen: set[str] = set()
|
|
1189
|
+
result: list[str] = []
|
|
1190
|
+
for name in names:
|
|
1191
|
+
key = str(name or "").strip()
|
|
1192
|
+
if not key or key in seen or key in _INJECTED_ENV_NAMES:
|
|
1193
|
+
continue
|
|
1194
|
+
seen.add(key)
|
|
1195
|
+
result.append(key)
|
|
1196
|
+
return tuple(result)
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def _subprocess_timeout_s(timeout_s: float) -> float:
|
|
1200
|
+
return max(0.25, float(timeout_s) + 0.25)
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
@contextlib.contextmanager
|
|
1204
|
+
def _worker_tool_execution_context(
|
|
1205
|
+
*,
|
|
1206
|
+
tool_env: dict[str, str],
|
|
1207
|
+
workspace_root: Path,
|
|
1208
|
+
):
|
|
1209
|
+
old_cwd = Path.cwd()
|
|
1210
|
+
old_env = dict(os.environ)
|
|
1211
|
+
old_sys_path = list(sys.path)
|
|
1212
|
+
old_dont_write_bytecode = sys.dont_write_bytecode
|
|
1213
|
+
try:
|
|
1214
|
+
sys.dont_write_bytecode = True
|
|
1215
|
+
resolved_workspace = workspace_root.resolve()
|
|
1216
|
+
os.chdir(resolved_workspace)
|
|
1217
|
+
# Isolated startup keeps the workspace off sys.path until the trusted payload is read.
|
|
1218
|
+
# Restore the old import behavior only inside the scrubbed tool execution context.
|
|
1219
|
+
sys.path.insert(0, os.fspath(resolved_workspace))
|
|
1220
|
+
os.environ.clear()
|
|
1221
|
+
os.environ.update(tool_env)
|
|
1222
|
+
yield
|
|
1223
|
+
finally:
|
|
1224
|
+
os.chdir(old_cwd)
|
|
1225
|
+
sys.path[:] = old_sys_path
|
|
1226
|
+
sys.dont_write_bytecode = old_dont_write_bytecode
|
|
1227
|
+
os.environ.clear()
|
|
1228
|
+
os.environ.update(old_env)
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def _load_tool_module(source_path: Path) -> Any:
|
|
1232
|
+
module_name = f"_alysis_custom_tool_{uuid.uuid4().hex}"
|
|
1233
|
+
spec = importlib.util.spec_from_file_location(module_name, source_path)
|
|
1234
|
+
if spec is None or spec.loader is None:
|
|
1235
|
+
raise CustomToolRuntimeError(f"Unable to load custom tool: {source_path}")
|
|
1236
|
+
module = importlib.util.module_from_spec(spec)
|
|
1237
|
+
sys.modules[module_name] = module
|
|
1238
|
+
try:
|
|
1239
|
+
spec.loader.exec_module(module)
|
|
1240
|
+
finally:
|
|
1241
|
+
sys.modules.pop(module_name, None)
|
|
1242
|
+
return module
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
def _format_tool_exception(exc: BaseException) -> str:
|
|
1246
|
+
if isinstance(exc, SystemExit):
|
|
1247
|
+
code = exc.code
|
|
1248
|
+
return f"SystemExit: {code!r}"
|
|
1249
|
+
return f"{type(exc).__name__}: {exc}"
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def _preview_result(value: Any) -> str:
|
|
1253
|
+
try:
|
|
1254
|
+
preview = json.dumps(value, ensure_ascii=True, sort_keys=True)
|
|
1255
|
+
except TypeError:
|
|
1256
|
+
preview = repr(value)
|
|
1257
|
+
if len(preview) > 200:
|
|
1258
|
+
return preview[:200] + "..."
|
|
1259
|
+
return preview
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def _sanitize_json_value(value: Any, *, depth: int = 0) -> Any:
|
|
1263
|
+
if value is None or isinstance(value, bool):
|
|
1264
|
+
return value
|
|
1265
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
1266
|
+
return value
|
|
1267
|
+
if isinstance(value, float):
|
|
1268
|
+
if math.isfinite(value):
|
|
1269
|
+
return value
|
|
1270
|
+
return {"omitted": True, "reason": "non_finite_number"}
|
|
1271
|
+
if isinstance(value, str):
|
|
1272
|
+
if len(value) <= _MAX_RESULT_STRING_CHARS:
|
|
1273
|
+
return value
|
|
1274
|
+
return value[:_MAX_RESULT_STRING_CHARS] + "..."
|
|
1275
|
+
if depth >= _MAX_RESULT_DEPTH:
|
|
1276
|
+
return {"omitted": True, "reason": "max_depth_exceeded"}
|
|
1277
|
+
if isinstance(value, list):
|
|
1278
|
+
sanitized = [_sanitize_json_value(item, depth=depth + 1) for item in value]
|
|
1279
|
+
return _cap_serialized_payload(sanitized)
|
|
1280
|
+
if isinstance(value, dict):
|
|
1281
|
+
sanitized = {
|
|
1282
|
+
str(key): _sanitize_json_value(item, depth=depth + 1) for key, item in value.items()
|
|
1283
|
+
}
|
|
1284
|
+
return _cap_serialized_payload(sanitized)
|
|
1285
|
+
return {"omitted": True, "reason": "unsupported_type", "type": type(value).__name__}
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def _cap_serialized_payload(value: Any) -> Any:
|
|
1289
|
+
try:
|
|
1290
|
+
payload_size = len(
|
|
1291
|
+
json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
|
1292
|
+
)
|
|
1293
|
+
except TypeError:
|
|
1294
|
+
return {"omitted": True, "reason": "non_serializable"}
|
|
1295
|
+
if payload_size <= _MAX_RESULT_BYTES:
|
|
1296
|
+
return value
|
|
1297
|
+
return {"omitted": True, "reason": "result_too_large", "size_bytes": payload_size}
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def _validate_input_args(schema: dict[str, Any], args: dict[str, Any]) -> dict[str, Any]:
|
|
1301
|
+
if not isinstance(args, dict):
|
|
1302
|
+
raise CustomToolRuntimeError("Custom tool arguments must be an object")
|
|
1303
|
+
_validate_schema_value(schema=schema, value=args, path="$")
|
|
1304
|
+
return dict(args)
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def _validate_schema_value(*, schema: dict[str, Any], value: Any, path: str) -> None:
|
|
1308
|
+
if not isinstance(schema, dict):
|
|
1309
|
+
return
|
|
1310
|
+
if "const" in schema and value != schema["const"]:
|
|
1311
|
+
raise CustomToolRuntimeError(f"{path} must equal {schema['const']!r}")
|
|
1312
|
+
if "enum" in schema:
|
|
1313
|
+
enum_values = schema["enum"]
|
|
1314
|
+
if isinstance(enum_values, list) and value not in enum_values:
|
|
1315
|
+
raise CustomToolRuntimeError(f"{path} must be one of the declared enum values")
|
|
1316
|
+
expected_type = schema.get("type")
|
|
1317
|
+
if expected_type is not None:
|
|
1318
|
+
_validate_type(expected_type=expected_type, value=value, path=path)
|
|
1319
|
+
if isinstance(value, str):
|
|
1320
|
+
min_length = schema.get("minLength")
|
|
1321
|
+
max_length = schema.get("maxLength")
|
|
1322
|
+
if isinstance(min_length, int) and len(value) < min_length:
|
|
1323
|
+
raise CustomToolRuntimeError(f"{path} must be at least {min_length} chars")
|
|
1324
|
+
if isinstance(max_length, int) and len(value) > max_length:
|
|
1325
|
+
raise CustomToolRuntimeError(f"{path} must be at most {max_length} chars")
|
|
1326
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
1327
|
+
minimum = schema.get("minimum")
|
|
1328
|
+
maximum = schema.get("maximum")
|
|
1329
|
+
if isinstance(minimum, (int, float)) and value < minimum:
|
|
1330
|
+
raise CustomToolRuntimeError(f"{path} must be >= {minimum}")
|
|
1331
|
+
if isinstance(maximum, (int, float)) and value > maximum:
|
|
1332
|
+
raise CustomToolRuntimeError(f"{path} must be <= {maximum}")
|
|
1333
|
+
if isinstance(value, list):
|
|
1334
|
+
min_items = schema.get("minItems")
|
|
1335
|
+
max_items = schema.get("maxItems")
|
|
1336
|
+
if isinstance(min_items, int) and len(value) < min_items:
|
|
1337
|
+
raise CustomToolRuntimeError(f"{path} must contain at least {min_items} items")
|
|
1338
|
+
if isinstance(max_items, int) and len(value) > max_items:
|
|
1339
|
+
raise CustomToolRuntimeError(f"{path} must contain at most {max_items} items")
|
|
1340
|
+
item_schema = schema.get("items")
|
|
1341
|
+
if isinstance(item_schema, dict):
|
|
1342
|
+
for index, item in enumerate(value):
|
|
1343
|
+
_validate_schema_value(schema=item_schema, value=item, path=f"{path}[{index}]")
|
|
1344
|
+
if isinstance(value, dict):
|
|
1345
|
+
required = schema.get("required", [])
|
|
1346
|
+
if isinstance(required, list):
|
|
1347
|
+
for key in required:
|
|
1348
|
+
if isinstance(key, str) and key not in value:
|
|
1349
|
+
raise CustomToolRuntimeError(f"{path}.{key} is required")
|
|
1350
|
+
properties = schema.get("properties", {})
|
|
1351
|
+
additional = schema.get("additionalProperties", True)
|
|
1352
|
+
if isinstance(properties, dict):
|
|
1353
|
+
for key, item in value.items():
|
|
1354
|
+
child_path = f"{path}.{key}"
|
|
1355
|
+
child_schema = properties.get(key)
|
|
1356
|
+
if isinstance(child_schema, dict):
|
|
1357
|
+
_validate_schema_value(schema=child_schema, value=item, path=child_path)
|
|
1358
|
+
continue
|
|
1359
|
+
if additional is False:
|
|
1360
|
+
raise CustomToolRuntimeError(f"{child_path} is not allowed")
|
|
1361
|
+
if isinstance(additional, dict):
|
|
1362
|
+
_validate_schema_value(schema=additional, value=item, path=child_path)
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def _validate_type(*, expected_type: Any, value: Any, path: str) -> None:
|
|
1366
|
+
options = expected_type if isinstance(expected_type, list) else [expected_type]
|
|
1367
|
+
if any(_value_matches_type(option, value) for option in options):
|
|
1368
|
+
return
|
|
1369
|
+
rendered = ", ".join(str(option) for option in options)
|
|
1370
|
+
raise CustomToolRuntimeError(f"{path} must match type {rendered}")
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
def _value_matches_type(expected_type: Any, value: Any) -> bool:
|
|
1374
|
+
expected = str(expected_type)
|
|
1375
|
+
if expected == "object":
|
|
1376
|
+
return isinstance(value, dict)
|
|
1377
|
+
if expected == "array":
|
|
1378
|
+
return isinstance(value, list)
|
|
1379
|
+
if expected == "string":
|
|
1380
|
+
return isinstance(value, str)
|
|
1381
|
+
if expected == "integer":
|
|
1382
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
1383
|
+
if expected == "number":
|
|
1384
|
+
return (isinstance(value, int) and not isinstance(value, bool)) or isinstance(value, float)
|
|
1385
|
+
if expected == "boolean":
|
|
1386
|
+
return isinstance(value, bool)
|
|
1387
|
+
if expected == "null":
|
|
1388
|
+
return value is None
|
|
1389
|
+
return True
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def _worker_main() -> int:
|
|
1393
|
+
raw = sys.stdin.buffer.read()
|
|
1394
|
+
if not raw.strip():
|
|
1395
|
+
print("Missing worker payload", file=sys.stderr)
|
|
1396
|
+
return 1
|
|
1397
|
+
try:
|
|
1398
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
1399
|
+
except json.JSONDecodeError as exc:
|
|
1400
|
+
print(f"Invalid worker payload: {exc}", file=sys.stderr)
|
|
1401
|
+
return 1
|
|
1402
|
+
if not isinstance(payload, dict):
|
|
1403
|
+
print("Invalid worker payload shape", file=sys.stderr)
|
|
1404
|
+
return 1
|
|
1405
|
+
|
|
1406
|
+
result_path = Path(str(payload.get("result_path") or ""))
|
|
1407
|
+
sealed_source_path = Path(str(payload.get("sealed_source_path") or ""))
|
|
1408
|
+
original_tool_dir = Path(str(payload.get("original_tool_dir") or "."))
|
|
1409
|
+
workspace_root = Path(str(payload.get("workspace_root") or "."))
|
|
1410
|
+
capabilities_raw = payload.get("capabilities")
|
|
1411
|
+
capabilities = dict(capabilities_raw) if isinstance(capabilities_raw, dict) else {}
|
|
1412
|
+
tool_env_raw = payload.get("tool_env")
|
|
1413
|
+
tool_env = (
|
|
1414
|
+
{
|
|
1415
|
+
str(key): str(value)
|
|
1416
|
+
for key, value in tool_env_raw.items()
|
|
1417
|
+
if isinstance(tool_env_raw, dict) and isinstance(key, str)
|
|
1418
|
+
}
|
|
1419
|
+
if isinstance(tool_env_raw, dict)
|
|
1420
|
+
else {}
|
|
1421
|
+
)
|
|
1422
|
+
args_obj = payload.get("args")
|
|
1423
|
+
if not isinstance(args_obj, dict):
|
|
1424
|
+
args_obj = {}
|
|
1425
|
+
|
|
1426
|
+
spec = _RuntimeToolSpec(
|
|
1427
|
+
name=str(payload.get("tool_name") or ""),
|
|
1428
|
+
timeout_s=float(payload.get("timeout_s") or 15.0),
|
|
1429
|
+
)
|
|
1430
|
+
|
|
1431
|
+
policy = _CustomToolAuditPolicy(
|
|
1432
|
+
capabilities=capabilities,
|
|
1433
|
+
workspace_root=workspace_root,
|
|
1434
|
+
original_tool_dir=original_tool_dir,
|
|
1435
|
+
result_path=result_path,
|
|
1436
|
+
)
|
|
1437
|
+
sys.addaudithook(policy.handle_event)
|
|
1438
|
+
|
|
1439
|
+
started_at = time.perf_counter()
|
|
1440
|
+
try:
|
|
1441
|
+
with (
|
|
1442
|
+
_patched_spawn_functions(policy),
|
|
1443
|
+
_worker_tool_execution_context(tool_env=tool_env, workspace_root=workspace_root),
|
|
1444
|
+
):
|
|
1445
|
+
module = _load_tool_module(sealed_source_path)
|
|
1446
|
+
run_callable = getattr(module, "run", None)
|
|
1447
|
+
if not callable(run_callable):
|
|
1448
|
+
raise CustomToolRuntimeError("Custom tool does not define callable run(args)")
|
|
1449
|
+
result = run_callable(dict(args_obj))
|
|
1450
|
+
except BaseException as exc: # noqa: BLE001
|
|
1451
|
+
elapsed_ms = int((time.perf_counter() - started_at) * 1000)
|
|
1452
|
+
if isinstance(exc, _CustomToolPolicyViolation):
|
|
1453
|
+
return _write_worker_result(
|
|
1454
|
+
result_path,
|
|
1455
|
+
_policy_failure_payload(spec=spec, violation=exc, elapsed_ms=elapsed_ms),
|
|
1456
|
+
success=False,
|
|
1457
|
+
)
|
|
1458
|
+
if policy.first_violation is not None:
|
|
1459
|
+
return _write_worker_result(
|
|
1460
|
+
result_path,
|
|
1461
|
+
_policy_failure_payload(
|
|
1462
|
+
spec=spec,
|
|
1463
|
+
violation=policy.first_violation,
|
|
1464
|
+
elapsed_ms=elapsed_ms,
|
|
1465
|
+
),
|
|
1466
|
+
success=False,
|
|
1467
|
+
)
|
|
1468
|
+
payload_out = _runtime_failure_payload(
|
|
1469
|
+
spec=spec,
|
|
1470
|
+
error_text=_format_tool_exception(exc),
|
|
1471
|
+
error_type=type(exc).__name__,
|
|
1472
|
+
timeout=False,
|
|
1473
|
+
elapsed_ms=elapsed_ms,
|
|
1474
|
+
stream_info=_empty_stream_info(),
|
|
1475
|
+
)
|
|
1476
|
+
return _write_worker_result(result_path, payload_out, success=False)
|
|
1477
|
+
|
|
1478
|
+
elapsed_ms = int((time.perf_counter() - started_at) * 1000)
|
|
1479
|
+
if policy.first_violation is not None:
|
|
1480
|
+
return _write_worker_result(
|
|
1481
|
+
result_path,
|
|
1482
|
+
_policy_failure_payload(
|
|
1483
|
+
spec=spec,
|
|
1484
|
+
violation=policy.first_violation,
|
|
1485
|
+
elapsed_ms=elapsed_ms,
|
|
1486
|
+
),
|
|
1487
|
+
success=False,
|
|
1488
|
+
)
|
|
1489
|
+
payload_out = _runtime_success_payload(
|
|
1490
|
+
spec=spec,
|
|
1491
|
+
result=result,
|
|
1492
|
+
elapsed_ms=elapsed_ms,
|
|
1493
|
+
stream_info=_empty_stream_info(),
|
|
1494
|
+
)
|
|
1495
|
+
side_effects = policy.workspace_write_side_effects()
|
|
1496
|
+
if side_effects:
|
|
1497
|
+
payload_out["side_effects"] = {"workspace_writes": side_effects}
|
|
1498
|
+
return _write_worker_result(result_path, payload_out, success=True)
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
def _write_worker_result(result_path: Path, payload: dict[str, Any], *, success: bool) -> int:
|
|
1502
|
+
try:
|
|
1503
|
+
result_path.write_text(
|
|
1504
|
+
json.dumps(payload, ensure_ascii=True, separators=(",", ":")),
|
|
1505
|
+
encoding="utf-8",
|
|
1506
|
+
)
|
|
1507
|
+
except OSError as exc:
|
|
1508
|
+
print(f"Failed to write worker result payload: {exc}", file=sys.stderr)
|
|
1509
|
+
return 1
|
|
1510
|
+
return 0 if success else 0
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
if __name__ == "__main__":
|
|
1514
|
+
if "--worker" in sys.argv[1:]:
|
|
1515
|
+
raise SystemExit(_worker_main())
|
|
1516
|
+
raise SystemExit("custom tool runtime is not a public CLI entrypoint")
|