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,859 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import ctypes
|
|
5
|
+
import getpass
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import secrets
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
from contextlib import suppress
|
|
14
|
+
from ctypes import wintypes
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Literal
|
|
18
|
+
|
|
19
|
+
from cryptography.exceptions import InvalidTag
|
|
20
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
21
|
+
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
|
22
|
+
|
|
23
|
+
from ..atomic_io import _fsync_dir
|
|
24
|
+
from .errors import (
|
|
25
|
+
McpTokenStoreCorruptError,
|
|
26
|
+
McpTokenStoreError,
|
|
27
|
+
McpTokenStoreMigrationError,
|
|
28
|
+
McpTokenStoreUnavailableError,
|
|
29
|
+
McpTokenStoreVersionError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
CURRENT_ENVELOPE_VERSION = 2
|
|
33
|
+
TOKEN_STORE_AAD = b"alysis-mcp-oauth-store"
|
|
34
|
+
# Envelopes written before the Sylliptor -> Alysis Code rename were sealed with
|
|
35
|
+
# the old AAD. AES-GCM authenticates it, so decryption of an existing store
|
|
36
|
+
# fails outright unless we retry with this value. New writes always use the
|
|
37
|
+
# current AAD, so a store re-seals itself on the next token refresh.
|
|
38
|
+
LEGACY_TOKEN_STORE_AAD = b"sylliptor-mcp-oauth-store"
|
|
39
|
+
KEY_SOURCE_KEYRING = "keyring"
|
|
40
|
+
KEY_SOURCE_WEAK_FALLBACK = "weak-derived-fallback"
|
|
41
|
+
KEY_SOURCE_DPAPI = "dpapi"
|
|
42
|
+
KEY_SOURCE_FILESYSTEM = "filesystem-random"
|
|
43
|
+
|
|
44
|
+
_ENVELOPE_KEYS = frozenset({"version", "key_source", "nonce", "ciphertext"})
|
|
45
|
+
_KEYRING_SERVICE = "alysis-code"
|
|
46
|
+
_LEGACY_KEYRING_SERVICE = "sylliptor-agent-cli"
|
|
47
|
+
_KEYRING_ACCOUNT = "mcp-oauth-token-store-master-key"
|
|
48
|
+
_WEAK_SALT_FILE_NAME = "mcp_oauth_tokens.salt"
|
|
49
|
+
_DPAPI_KEY_FILE_NAME = "mcp_oauth_tokens.dpapi"
|
|
50
|
+
_AES_KEY_BYTES = 32
|
|
51
|
+
_AES_GCM_NONCE_BYTES = 12
|
|
52
|
+
_CRYPTPROTECT_UI_FORBIDDEN = 0x1
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
# Library loggers must not fall through to logging.lastResort, which writes
|
|
56
|
+
# WARNING records directly to stderr and corrupts prompt_toolkit full-screen
|
|
57
|
+
# applications. Configured application/test handlers still receive records via
|
|
58
|
+
# normal propagation.
|
|
59
|
+
logger.addHandler(logging.NullHandler())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class _KeyMaterial:
|
|
64
|
+
source: str
|
|
65
|
+
key: bytes
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class TokenPayloadLoadResult:
|
|
70
|
+
payload: dict[str, Any]
|
|
71
|
+
legacy_plaintext: bool = False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class KeyringOutcome:
|
|
76
|
+
"""Whether the OS keyring could own the credential-store master key."""
|
|
77
|
+
|
|
78
|
+
available: bool
|
|
79
|
+
reason: str | None = None
|
|
80
|
+
error_type: str | None = None
|
|
81
|
+
backend: str | None = None
|
|
82
|
+
detail: str | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_KEYRING_OUTCOME: KeyringOutcome | None = None
|
|
86
|
+
_KEYRING_OUTCOME_LOCK = threading.Lock()
|
|
87
|
+
_WARNED_KEYRING_KEYS: set[tuple[str | None, str | None, str | None]] = set()
|
|
88
|
+
# A backend whose priority is not positive is a placeholder rather than a real
|
|
89
|
+
# secret service. ``keyring.backends.null`` silently discards writes and
|
|
90
|
+
# ``keyring.backends.fail`` raises on every call; neither may own the envelope
|
|
91
|
+
# key source, or the store becomes undecryptable on the next read. Both are named
|
|
92
|
+
# explicitly because the priority check alone would not survive them gaining a
|
|
93
|
+
# positive priority upstream.
|
|
94
|
+
_UNUSABLE_BACKEND_NAMES = frozenset(
|
|
95
|
+
{
|
|
96
|
+
"keyring.backends.null.Keyring",
|
|
97
|
+
"keyring.backends.fail.Keyring",
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def last_keyring_outcome() -> KeyringOutcome | None:
|
|
103
|
+
"""Return the most recent keyring result observed by this process."""
|
|
104
|
+
|
|
105
|
+
return _KEYRING_OUTCOME
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def reset_keyring_observations() -> None:
|
|
109
|
+
"""Forget recorded keyring results. For test isolation, not runtime use."""
|
|
110
|
+
|
|
111
|
+
global _KEYRING_OUTCOME
|
|
112
|
+
with _KEYRING_OUTCOME_LOCK:
|
|
113
|
+
_KEYRING_OUTCOME = None
|
|
114
|
+
_WARNED_KEYRING_KEYS.clear()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _record_keyring_outcome(outcome: KeyringOutcome) -> None:
|
|
118
|
+
"""Remember the keyring result and warn once per distinct degradation.
|
|
119
|
+
|
|
120
|
+
Every store read and write consults the keyring, so an unconditional warning
|
|
121
|
+
would flood a long session. Keying on the reason still reports a *different*
|
|
122
|
+
degradation later in the same process.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
global _KEYRING_OUTCOME
|
|
126
|
+
with _KEYRING_OUTCOME_LOCK:
|
|
127
|
+
_KEYRING_OUTCOME = outcome
|
|
128
|
+
should_warn = False
|
|
129
|
+
if not outcome.available:
|
|
130
|
+
key = (outcome.reason, outcome.error_type, outcome.backend)
|
|
131
|
+
should_warn = key not in _WARNED_KEYRING_KEYS
|
|
132
|
+
if should_warn:
|
|
133
|
+
_WARNED_KEYRING_KEYS.add(key)
|
|
134
|
+
if not should_warn:
|
|
135
|
+
return
|
|
136
|
+
logger.warning(
|
|
137
|
+
"OS keyring is unavailable for the Alysis Code credential store; using a local "
|
|
138
|
+
"encrypted master key instead. reason=%s error_type=%s backend=%s",
|
|
139
|
+
outcome.reason,
|
|
140
|
+
outcome.error_type,
|
|
141
|
+
outcome.backend,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def keyring_backend_name() -> str | None:
|
|
146
|
+
"""Return the active keyring backend's qualified class name, if resolvable."""
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
import keyring
|
|
150
|
+
|
|
151
|
+
backend = keyring.get_keyring()
|
|
152
|
+
except Exception: # noqa: BLE001 - keyring resolution varies by OS and install
|
|
153
|
+
return None
|
|
154
|
+
return f"{type(backend).__module__}.{type(backend).__name__}"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _keyring_backend_priority(backend: object) -> float | None:
|
|
158
|
+
try:
|
|
159
|
+
return float(type(backend).priority) # type: ignore[attr-defined]
|
|
160
|
+
except Exception: # noqa: BLE001 - unusable backends raise from `priority`
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def keyring_availability(*, probe_read: bool = True) -> KeyringOutcome:
|
|
165
|
+
"""Report whether the OS keyring can hold the master key. Never writes.
|
|
166
|
+
|
|
167
|
+
Resolving the backend is enough to catch the cases that matter in spawned,
|
|
168
|
+
headless, and CI contexts, where no real secret service exists. ``probe_read``
|
|
169
|
+
additionally attempts one read, which detects a backend that resolves but
|
|
170
|
+
cannot serve — at the cost of possibly prompting the user to unlock a locked
|
|
171
|
+
desktop keyring. Diagnostics ask for it; a status payload must not, or a
|
|
172
|
+
routine probe could block on a dialog.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
import keyring
|
|
177
|
+
except Exception as exc: # noqa: BLE001 - keyring is optional at runtime
|
|
178
|
+
return KeyringOutcome(
|
|
179
|
+
available=False,
|
|
180
|
+
reason="import-failed",
|
|
181
|
+
error_type=exc.__class__.__name__,
|
|
182
|
+
)
|
|
183
|
+
try:
|
|
184
|
+
backend = keyring.get_keyring()
|
|
185
|
+
except Exception as exc: # noqa: BLE001
|
|
186
|
+
return KeyringOutcome(
|
|
187
|
+
available=False,
|
|
188
|
+
reason="backend-unresolved",
|
|
189
|
+
error_type=exc.__class__.__name__,
|
|
190
|
+
)
|
|
191
|
+
name = f"{type(backend).__module__}.{type(backend).__name__}"
|
|
192
|
+
priority = _keyring_backend_priority(backend)
|
|
193
|
+
# An unresolvable priority is keyring's own signal that a backend is not
|
|
194
|
+
# viable, so treat it as unusable: falling back to a local encrypted key is
|
|
195
|
+
# always safe, while wrongly trusting the keyring loses the credentials.
|
|
196
|
+
if name in _UNUSABLE_BACKEND_NAMES or priority is None or priority <= 0:
|
|
197
|
+
return KeyringOutcome(
|
|
198
|
+
available=False,
|
|
199
|
+
reason="unusable-backend",
|
|
200
|
+
backend=name,
|
|
201
|
+
detail="The active keyring backend does not store secrets.",
|
|
202
|
+
)
|
|
203
|
+
if probe_read:
|
|
204
|
+
try:
|
|
205
|
+
_get_keyring_password(_KEYRING_SERVICE, _KEYRING_ACCOUNT)
|
|
206
|
+
except Exception as exc: # noqa: BLE001
|
|
207
|
+
return KeyringOutcome(
|
|
208
|
+
available=False,
|
|
209
|
+
reason="read-failed",
|
|
210
|
+
error_type=exc.__class__.__name__,
|
|
211
|
+
backend=name,
|
|
212
|
+
)
|
|
213
|
+
return KeyringOutcome(available=True, backend=name)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def fallback_key_source() -> str:
|
|
217
|
+
"""Return the non-keyring key source this platform uses. Probes nothing."""
|
|
218
|
+
|
|
219
|
+
if _platform_system().lower() == "windows":
|
|
220
|
+
return KEY_SOURCE_DPAPI
|
|
221
|
+
return KEY_SOURCE_FILESYSTEM
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def planned_key_source() -> str:
|
|
225
|
+
"""Return the key source a write would choose, creating no key material."""
|
|
226
|
+
|
|
227
|
+
if keyring_availability().available:
|
|
228
|
+
return KEY_SOURCE_KEYRING
|
|
229
|
+
return fallback_key_source()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def stored_key_source(store_path: Path) -> str | None:
|
|
233
|
+
"""Return the key source recorded in an existing envelope, without decrypting."""
|
|
234
|
+
|
|
235
|
+
if not store_path.exists():
|
|
236
|
+
return None
|
|
237
|
+
try:
|
|
238
|
+
raw_payload = _read_json_file(store_path)
|
|
239
|
+
except McpTokenStoreError:
|
|
240
|
+
return None
|
|
241
|
+
if not isinstance(raw_payload, dict):
|
|
242
|
+
return None
|
|
243
|
+
source = raw_payload.get("key_source")
|
|
244
|
+
return source if isinstance(source, str) and source else None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def load_token_payload(path: Path) -> dict[str, Any]:
|
|
248
|
+
return load_token_payload_result(path).payload
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def load_token_payload_result(path: Path) -> TokenPayloadLoadResult:
|
|
252
|
+
if not path.exists():
|
|
253
|
+
return TokenPayloadLoadResult({})
|
|
254
|
+
raw_payload = _read_json_file(path)
|
|
255
|
+
classification = _classify_store_payload(raw_payload)
|
|
256
|
+
if classification == "envelope":
|
|
257
|
+
payload = _decrypt_envelope(path, raw_payload)
|
|
258
|
+
return TokenPayloadLoadResult(payload)
|
|
259
|
+
if classification == "invalid":
|
|
260
|
+
raise McpTokenStoreCorruptError(f"Invalid OAuth token store format: {path}")
|
|
261
|
+
return TokenPayloadLoadResult(raw_payload, legacy_plaintext=True)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def migrate_legacy_token_payload(path: Path, payload: dict[str, Any]) -> None:
|
|
265
|
+
try:
|
|
266
|
+
_write_encrypted_payload(path, payload)
|
|
267
|
+
except Exception as exc:
|
|
268
|
+
raise McpTokenStoreMigrationError(
|
|
269
|
+
f"Failed to migrate legacy plaintext OAuth token store: {path}"
|
|
270
|
+
) from exc
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def save_token_payload(path: Path, payload: dict[str, Any]) -> None:
|
|
274
|
+
_write_encrypted_payload(path, payload)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def weak_fallback_salt_path(store_path: Path) -> Path:
|
|
278
|
+
return store_path.with_name(_WEAK_SALT_FILE_NAME)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def dpapi_master_key_path(store_path: Path) -> Path:
|
|
282
|
+
return store_path.with_name(_DPAPI_KEY_FILE_NAME)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def filesystem_master_key_path(store_path: Path) -> Path:
|
|
286
|
+
"""Return the per-store random master-key path used without an OS keyring."""
|
|
287
|
+
|
|
288
|
+
return store_path.with_suffix(".key")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _read_json_file(path: Path) -> Any:
|
|
292
|
+
try:
|
|
293
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
294
|
+
except json.JSONDecodeError as exc:
|
|
295
|
+
raise McpTokenStoreCorruptError(f"Malformed OAuth token store JSON: {path}") from exc
|
|
296
|
+
except OSError as exc:
|
|
297
|
+
raise McpTokenStoreUnavailableError(f"Failed to read OAuth token store: {path}") from exc
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _classify_store_payload(payload: object) -> Literal["envelope", "legacy", "invalid"]:
|
|
301
|
+
if not isinstance(payload, dict):
|
|
302
|
+
return "invalid"
|
|
303
|
+
keys = frozenset(payload)
|
|
304
|
+
marker_keys = keys & _ENVELOPE_KEYS
|
|
305
|
+
if not marker_keys:
|
|
306
|
+
return "legacy"
|
|
307
|
+
marker_values = tuple(payload[key] for key in marker_keys)
|
|
308
|
+
if all(_looks_like_legacy_record_payload(value) for value in marker_values):
|
|
309
|
+
return "legacy"
|
|
310
|
+
if all(_looks_like_envelope_marker_value(key, payload[key]) for key in marker_keys):
|
|
311
|
+
return "envelope"
|
|
312
|
+
return "invalid"
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _looks_like_legacy_record_payload(value: object) -> bool:
|
|
316
|
+
if not isinstance(value, dict):
|
|
317
|
+
return False
|
|
318
|
+
return all(
|
|
319
|
+
isinstance(value.get(key), str) for key in ("access_token", "token_type", "expires_at")
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _looks_like_envelope_marker_value(key: str, value: object) -> bool:
|
|
324
|
+
if key == "version":
|
|
325
|
+
return isinstance(value, int)
|
|
326
|
+
return isinstance(value, str)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _validate_envelope(payload: dict[str, Any], *, path: Path) -> tuple[int, str, bytes, bytes]:
|
|
330
|
+
if frozenset(payload) != _ENVELOPE_KEYS:
|
|
331
|
+
raise McpTokenStoreCorruptError(f"Malformed OAuth token store envelope: {path}")
|
|
332
|
+
version = payload.get("version")
|
|
333
|
+
if not isinstance(version, int):
|
|
334
|
+
raise McpTokenStoreCorruptError(f"OAuth token store envelope version is invalid: {path}")
|
|
335
|
+
if version > CURRENT_ENVELOPE_VERSION:
|
|
336
|
+
raise McpTokenStoreVersionError(
|
|
337
|
+
f"OAuth token store envelope version {version} is newer than supported "
|
|
338
|
+
f"version {CURRENT_ENVELOPE_VERSION}: {path}"
|
|
339
|
+
)
|
|
340
|
+
if version < 1:
|
|
341
|
+
raise McpTokenStoreCorruptError(f"OAuth token store envelope version is invalid: {path}")
|
|
342
|
+
key_source = payload.get("key_source")
|
|
343
|
+
if key_source not in _allowed_key_sources(version):
|
|
344
|
+
raise McpTokenStoreCorruptError(f"OAuth token store key source is invalid: {path}")
|
|
345
|
+
try:
|
|
346
|
+
nonce = base64.b64decode(_require_string(payload.get("nonce")), validate=True)
|
|
347
|
+
ciphertext = base64.b64decode(_require_string(payload.get("ciphertext")), validate=True)
|
|
348
|
+
except (ValueError, TypeError) as exc:
|
|
349
|
+
raise McpTokenStoreCorruptError(
|
|
350
|
+
f"OAuth token store envelope encoding is invalid: {path}"
|
|
351
|
+
) from exc
|
|
352
|
+
if len(nonce) != _AES_GCM_NONCE_BYTES:
|
|
353
|
+
raise McpTokenStoreCorruptError(f"OAuth token store envelope nonce is invalid: {path}")
|
|
354
|
+
return version, key_source, nonce, ciphertext
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _require_string(value: object) -> str:
|
|
358
|
+
if not isinstance(value, str) or not value:
|
|
359
|
+
raise ValueError("expected non-empty string")
|
|
360
|
+
return value
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _allowed_key_sources(version: int) -> frozenset[str]:
|
|
364
|
+
if version == 1:
|
|
365
|
+
return frozenset({KEY_SOURCE_KEYRING, KEY_SOURCE_WEAK_FALLBACK, KEY_SOURCE_DPAPI})
|
|
366
|
+
return frozenset({KEY_SOURCE_KEYRING, KEY_SOURCE_FILESYSTEM, KEY_SOURCE_DPAPI})
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _decrypt_envelope(path: Path, envelope: dict[str, Any]) -> dict[str, Any]:
|
|
370
|
+
version, stored_source, nonce, ciphertext = _validate_envelope(envelope, path=path)
|
|
371
|
+
key_material = _key_material_for_source(stored_source, path=path)
|
|
372
|
+
try:
|
|
373
|
+
try:
|
|
374
|
+
plaintext = AESGCM(key_material.key).decrypt(nonce, ciphertext, TOKEN_STORE_AAD)
|
|
375
|
+
except InvalidTag:
|
|
376
|
+
# Pre-rebrand envelope: same key, old AAD. Any other failure mode
|
|
377
|
+
# (wrong key, real corruption) still surfaces as InvalidTag below.
|
|
378
|
+
plaintext = AESGCM(key_material.key).decrypt(nonce, ciphertext, LEGACY_TOKEN_STORE_AAD)
|
|
379
|
+
logger.info("mcp_token_store_legacy_aad path=%s", path)
|
|
380
|
+
except InvalidTag as exc:
|
|
381
|
+
raise McpTokenStoreCorruptError(f"OAuth token store authentication failed: {path}") from exc
|
|
382
|
+
except ValueError as exc:
|
|
383
|
+
raise McpTokenStoreCorruptError(f"OAuth token store decryption failed: {path}") from exc
|
|
384
|
+
try:
|
|
385
|
+
payload = json.loads(plaintext.decode("utf-8"))
|
|
386
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
387
|
+
raise McpTokenStoreCorruptError(
|
|
388
|
+
f"OAuth token store decrypted payload is invalid: {path}"
|
|
389
|
+
) from exc
|
|
390
|
+
if not isinstance(payload, dict):
|
|
391
|
+
raise McpTokenStoreCorruptError(f"OAuth token store decrypted payload is invalid: {path}")
|
|
392
|
+
_maybe_rewrite_envelope_after_read(
|
|
393
|
+
path,
|
|
394
|
+
payload,
|
|
395
|
+
version=version,
|
|
396
|
+
stored_source=stored_source,
|
|
397
|
+
)
|
|
398
|
+
return payload
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _maybe_rewrite_envelope_after_read(
|
|
402
|
+
path: Path,
|
|
403
|
+
payload: dict[str, Any],
|
|
404
|
+
*,
|
|
405
|
+
version: int,
|
|
406
|
+
stored_source: str,
|
|
407
|
+
) -> None:
|
|
408
|
+
if version >= CURRENT_ENVELOPE_VERSION and stored_source == KEY_SOURCE_KEYRING:
|
|
409
|
+
return
|
|
410
|
+
try:
|
|
411
|
+
preferred = _preferred_key_material(path)
|
|
412
|
+
except (McpTokenStoreError, OSError) as exc:
|
|
413
|
+
logger.warning(
|
|
414
|
+
"Skipped OAuth credential store rotation after successful decrypt: "
|
|
415
|
+
"path=%s version=%s key_source=%s error_type=%s",
|
|
416
|
+
path,
|
|
417
|
+
version,
|
|
418
|
+
stored_source,
|
|
419
|
+
exc.__class__.__name__,
|
|
420
|
+
)
|
|
421
|
+
return
|
|
422
|
+
if version >= CURRENT_ENVELOPE_VERSION and preferred.source == stored_source:
|
|
423
|
+
return
|
|
424
|
+
try:
|
|
425
|
+
_write_encrypted_payload(path, payload, key_material=preferred)
|
|
426
|
+
except (McpTokenStoreError, OSError) as exc:
|
|
427
|
+
logger.warning(
|
|
428
|
+
"Skipped OAuth credential store rewrite after successful decrypt: "
|
|
429
|
+
"path=%s version=%s key_source=%s preferred_key_source=%s error_type=%s",
|
|
430
|
+
path,
|
|
431
|
+
version,
|
|
432
|
+
stored_source,
|
|
433
|
+
preferred.source,
|
|
434
|
+
exc.__class__.__name__,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _write_encrypted_payload(
|
|
439
|
+
path: Path,
|
|
440
|
+
payload: dict[str, Any],
|
|
441
|
+
*,
|
|
442
|
+
key_material: _KeyMaterial | None = None,
|
|
443
|
+
) -> None:
|
|
444
|
+
key_material = key_material or _preferred_key_material(path)
|
|
445
|
+
if key_material.source not in _allowed_key_sources(CURRENT_ENVELOPE_VERSION):
|
|
446
|
+
raise McpTokenStoreUnavailableError(
|
|
447
|
+
"Refusing to write an OAuth credential envelope with a legacy key source."
|
|
448
|
+
)
|
|
449
|
+
plaintext = json.dumps(
|
|
450
|
+
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
451
|
+
).encode("utf-8")
|
|
452
|
+
nonce = secrets.token_bytes(_AES_GCM_NONCE_BYTES)
|
|
453
|
+
ciphertext = AESGCM(key_material.key).encrypt(nonce, plaintext, TOKEN_STORE_AAD)
|
|
454
|
+
envelope = {
|
|
455
|
+
"version": CURRENT_ENVELOPE_VERSION,
|
|
456
|
+
"key_source": key_material.source,
|
|
457
|
+
"nonce": base64.b64encode(nonce).decode("ascii"),
|
|
458
|
+
"ciphertext": base64.b64encode(ciphertext).decode("ascii"),
|
|
459
|
+
}
|
|
460
|
+
encoded = (
|
|
461
|
+
json.dumps(envelope, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + "\n"
|
|
462
|
+
).encode("utf-8")
|
|
463
|
+
try:
|
|
464
|
+
_secure_atomic_write_bytes(path, encoded)
|
|
465
|
+
except OSError as exc:
|
|
466
|
+
raise McpTokenStoreUnavailableError(f"Failed to write OAuth token store: {path}") from exc
|
|
467
|
+
logger.info(
|
|
468
|
+
"Wrote encrypted OAuth credential store: path=%s key_source=%s",
|
|
469
|
+
path,
|
|
470
|
+
key_material.source,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _preferred_key_material(path: Path) -> _KeyMaterial:
|
|
475
|
+
keyring_material = _try_keyring_material(required=False)
|
|
476
|
+
if keyring_material is not None:
|
|
477
|
+
return keyring_material
|
|
478
|
+
if _platform_system().lower() == "windows":
|
|
479
|
+
return _dpapi_key_material(path)
|
|
480
|
+
return _filesystem_key_material(path)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _key_material_for_source(source: str, *, path: Path) -> _KeyMaterial:
|
|
484
|
+
if source == KEY_SOURCE_KEYRING:
|
|
485
|
+
try:
|
|
486
|
+
material = _try_keyring_material(required=True)
|
|
487
|
+
except McpTokenStoreUnavailableError as exc:
|
|
488
|
+
_record_keyring_outcome(
|
|
489
|
+
KeyringOutcome(
|
|
490
|
+
available=False,
|
|
491
|
+
reason="entry-unavailable",
|
|
492
|
+
backend=keyring_backend_name(),
|
|
493
|
+
detail=str(exc),
|
|
494
|
+
)
|
|
495
|
+
)
|
|
496
|
+
raise
|
|
497
|
+
if material is None:
|
|
498
|
+
raise McpTokenStoreUnavailableError(
|
|
499
|
+
"OS keyring is unavailable for MCP OAuth token store."
|
|
500
|
+
)
|
|
501
|
+
return material
|
|
502
|
+
if source == KEY_SOURCE_DPAPI:
|
|
503
|
+
return _dpapi_key_material(path)
|
|
504
|
+
if source == KEY_SOURCE_FILESYSTEM:
|
|
505
|
+
return _filesystem_key_material(path)
|
|
506
|
+
if source == KEY_SOURCE_WEAK_FALLBACK:
|
|
507
|
+
return _weak_fallback_key_material(path)
|
|
508
|
+
raise McpTokenStoreCorruptError(f"Unsupported OAuth token store key source: {source}")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _read_keyring_master_key() -> str | None:
|
|
512
|
+
"""Read the master key, adopting a pre-rebrand entry if that is all there is.
|
|
513
|
+
|
|
514
|
+
The entry is looked up by service name, so the rename would otherwise
|
|
515
|
+
orphan it and every stored MCP OAuth token with it — silently, since a
|
|
516
|
+
missing entry is indistinguishable from a first run. When the legacy entry
|
|
517
|
+
is found it is copied to the new service name; the old one is left in place
|
|
518
|
+
so an older install on the same machine keeps working.
|
|
519
|
+
"""
|
|
520
|
+
encoded = _get_keyring_password(_KEYRING_SERVICE, _KEYRING_ACCOUNT)
|
|
521
|
+
if encoded:
|
|
522
|
+
return encoded
|
|
523
|
+
|
|
524
|
+
legacy = _get_keyring_password(_LEGACY_KEYRING_SERVICE, _KEYRING_ACCOUNT)
|
|
525
|
+
if not legacy:
|
|
526
|
+
return None
|
|
527
|
+
|
|
528
|
+
logger.info(
|
|
529
|
+
"mcp_token_store_keyring_migrated legacy=%s current=%s",
|
|
530
|
+
_LEGACY_KEYRING_SERVICE,
|
|
531
|
+
_KEYRING_SERVICE,
|
|
532
|
+
)
|
|
533
|
+
try:
|
|
534
|
+
_set_keyring_password(_KEYRING_SERVICE, _KEYRING_ACCOUNT, legacy)
|
|
535
|
+
except Exception as exc: # noqa: BLE001
|
|
536
|
+
# Copy failed (locked keychain, read-only backend). The legacy value is
|
|
537
|
+
# still usable for this process, so degrade to using it directly rather
|
|
538
|
+
# than locking the user out of their own tokens.
|
|
539
|
+
logger.warning("mcp_token_store_keyring_migration_failed error=%s", exc.__class__.__name__)
|
|
540
|
+
return legacy
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _try_keyring_material(*, required: bool) -> _KeyMaterial | None:
|
|
544
|
+
backend = keyring_backend_name()
|
|
545
|
+
try:
|
|
546
|
+
encoded = _read_keyring_master_key()
|
|
547
|
+
except Exception as exc: # noqa: BLE001
|
|
548
|
+
if required:
|
|
549
|
+
raise McpTokenStoreUnavailableError(
|
|
550
|
+
"Failed to read MCP OAuth key from OS keyring."
|
|
551
|
+
) from exc
|
|
552
|
+
_record_keyring_outcome(
|
|
553
|
+
KeyringOutcome(
|
|
554
|
+
available=False,
|
|
555
|
+
reason="read-failed",
|
|
556
|
+
error_type=exc.__class__.__name__,
|
|
557
|
+
backend=backend,
|
|
558
|
+
)
|
|
559
|
+
)
|
|
560
|
+
return None
|
|
561
|
+
if encoded:
|
|
562
|
+
try:
|
|
563
|
+
key = base64.b64decode(encoded, validate=True)
|
|
564
|
+
except ValueError as exc:
|
|
565
|
+
raise McpTokenStoreUnavailableError("MCP OAuth keyring entry is invalid.") from exc
|
|
566
|
+
if len(key) != _AES_KEY_BYTES:
|
|
567
|
+
raise McpTokenStoreUnavailableError("MCP OAuth keyring entry has invalid length.")
|
|
568
|
+
_record_keyring_outcome(KeyringOutcome(available=True, backend=backend))
|
|
569
|
+
return _KeyMaterial(KEY_SOURCE_KEYRING, key)
|
|
570
|
+
if required:
|
|
571
|
+
raise McpTokenStoreUnavailableError("MCP OAuth keyring entry is missing.")
|
|
572
|
+
key = secrets.token_bytes(_AES_KEY_BYTES)
|
|
573
|
+
material = base64.b64encode(key).decode("ascii")
|
|
574
|
+
try:
|
|
575
|
+
_set_keyring_password(_KEYRING_SERVICE, _KEYRING_ACCOUNT, material)
|
|
576
|
+
persisted = _get_keyring_password(_KEYRING_SERVICE, _KEYRING_ACCOUNT)
|
|
577
|
+
except Exception as exc: # noqa: BLE001
|
|
578
|
+
_record_keyring_outcome(
|
|
579
|
+
KeyringOutcome(
|
|
580
|
+
available=False,
|
|
581
|
+
reason="write-failed",
|
|
582
|
+
error_type=exc.__class__.__name__,
|
|
583
|
+
backend=backend,
|
|
584
|
+
)
|
|
585
|
+
)
|
|
586
|
+
return None
|
|
587
|
+
if persisted != material:
|
|
588
|
+
# Placeholder backends such as ``keyring.backends.null`` accept writes and
|
|
589
|
+
# discard them. Claiming the keyring key source here would produce an
|
|
590
|
+
# envelope that no later process can decrypt, so fall back instead.
|
|
591
|
+
_record_keyring_outcome(
|
|
592
|
+
KeyringOutcome(
|
|
593
|
+
available=False,
|
|
594
|
+
reason="write-not-persisted",
|
|
595
|
+
backend=backend,
|
|
596
|
+
detail="The keyring backend accepted the master key but did not store it.",
|
|
597
|
+
)
|
|
598
|
+
)
|
|
599
|
+
return None
|
|
600
|
+
_record_keyring_outcome(KeyringOutcome(available=True, backend=backend))
|
|
601
|
+
return _KeyMaterial(KEY_SOURCE_KEYRING, key)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _get_keyring_password(service: str, account: str) -> str | None:
|
|
605
|
+
import keyring
|
|
606
|
+
|
|
607
|
+
return keyring.get_password(service, account)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _set_keyring_password(service: str, account: str, password: str) -> None:
|
|
611
|
+
import keyring
|
|
612
|
+
|
|
613
|
+
keyring.set_password(service, account, password)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _weak_fallback_key_material(path: Path) -> _KeyMaterial:
|
|
617
|
+
"""Load legacy v1 key material for migration only.
|
|
618
|
+
|
|
619
|
+
New stores never use this deterministic derivation. Keeping the reader
|
|
620
|
+
allows existing encrypted credentials to be upgraded without signing users
|
|
621
|
+
out.
|
|
622
|
+
"""
|
|
623
|
+
|
|
624
|
+
salt = _load_or_create_salt(weak_fallback_salt_path(path))
|
|
625
|
+
identity = f"{platform.node()}\0{getpass.getuser()}".encode()
|
|
626
|
+
kdf = Scrypt(salt=salt, length=_AES_KEY_BYTES, n=2**14, r=8, p=1)
|
|
627
|
+
return _KeyMaterial(KEY_SOURCE_WEAK_FALLBACK, kdf.derive(identity))
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _filesystem_key_material(path: Path) -> _KeyMaterial:
|
|
631
|
+
key_path = filesystem_master_key_path(path)
|
|
632
|
+
key = (
|
|
633
|
+
_read_filesystem_master_key(key_path)
|
|
634
|
+
if key_path.exists()
|
|
635
|
+
else _create_filesystem_master_key(key_path)
|
|
636
|
+
)
|
|
637
|
+
return _KeyMaterial(KEY_SOURCE_FILESYSTEM, key)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _read_filesystem_master_key(path: Path) -> bytes:
|
|
641
|
+
try:
|
|
642
|
+
key = path.read_bytes()
|
|
643
|
+
except OSError as exc:
|
|
644
|
+
raise McpTokenStoreUnavailableError(
|
|
645
|
+
f"Failed to read filesystem OAuth credential key: {path}"
|
|
646
|
+
) from exc
|
|
647
|
+
if len(key) != _AES_KEY_BYTES:
|
|
648
|
+
raise McpTokenStoreCorruptError(
|
|
649
|
+
f"Filesystem OAuth credential key has invalid length: {path}"
|
|
650
|
+
)
|
|
651
|
+
_set_restrictive_permissions(path)
|
|
652
|
+
return key
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _create_filesystem_master_key(path: Path) -> bytes:
|
|
656
|
+
"""Create a fully-written random key without replacing a racing writer.
|
|
657
|
+
|
|
658
|
+
The temporary file is fsynced before an atomic hard link publishes it.
|
|
659
|
+
If another process wins the race, its completed key is loaded instead.
|
|
660
|
+
"""
|
|
661
|
+
|
|
662
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
663
|
+
key = secrets.token_bytes(_AES_KEY_BYTES)
|
|
664
|
+
fd, temp_name = tempfile.mkstemp(
|
|
665
|
+
dir=path.parent,
|
|
666
|
+
prefix=f".{path.name}.",
|
|
667
|
+
suffix=".tmp",
|
|
668
|
+
)
|
|
669
|
+
temp_path = Path(temp_name)
|
|
670
|
+
try:
|
|
671
|
+
if os.name != "nt":
|
|
672
|
+
os.fchmod(fd, 0o600)
|
|
673
|
+
handle = os.fdopen(fd, "wb")
|
|
674
|
+
fd = -1
|
|
675
|
+
with handle:
|
|
676
|
+
handle.write(key)
|
|
677
|
+
handle.flush()
|
|
678
|
+
os.fsync(handle.fileno())
|
|
679
|
+
try:
|
|
680
|
+
os.link(temp_path, path)
|
|
681
|
+
except FileExistsError:
|
|
682
|
+
return _read_filesystem_master_key(path)
|
|
683
|
+
except OSError as exc:
|
|
684
|
+
raise McpTokenStoreUnavailableError(
|
|
685
|
+
f"Failed to create filesystem OAuth credential key: {path}"
|
|
686
|
+
) from exc
|
|
687
|
+
_set_restrictive_permissions(path)
|
|
688
|
+
_fsync_dir(path.parent)
|
|
689
|
+
return key
|
|
690
|
+
finally:
|
|
691
|
+
if fd >= 0:
|
|
692
|
+
os.close(fd)
|
|
693
|
+
with suppress(FileNotFoundError):
|
|
694
|
+
temp_path.unlink()
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _load_or_create_salt(path: Path) -> bytes:
|
|
698
|
+
if path.exists():
|
|
699
|
+
try:
|
|
700
|
+
salt = path.read_bytes()
|
|
701
|
+
except OSError as exc:
|
|
702
|
+
raise McpTokenStoreUnavailableError(
|
|
703
|
+
f"Failed to read OAuth token-store salt: {path}"
|
|
704
|
+
) from exc
|
|
705
|
+
if len(salt) < 16:
|
|
706
|
+
raise McpTokenStoreCorruptError(f"OAuth token-store salt is invalid: {path}")
|
|
707
|
+
_set_restrictive_permissions(path)
|
|
708
|
+
return salt
|
|
709
|
+
salt = secrets.token_bytes(32)
|
|
710
|
+
try:
|
|
711
|
+
_secure_atomic_write_bytes(path, salt)
|
|
712
|
+
except OSError as exc:
|
|
713
|
+
raise McpTokenStoreUnavailableError(
|
|
714
|
+
f"Failed to write OAuth token-store salt: {path}"
|
|
715
|
+
) from exc
|
|
716
|
+
return salt
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _dpapi_key_material(path: Path) -> _KeyMaterial:
|
|
720
|
+
key_path = dpapi_master_key_path(path)
|
|
721
|
+
if key_path.exists():
|
|
722
|
+
try:
|
|
723
|
+
protected = key_path.read_bytes()
|
|
724
|
+
except OSError as exc:
|
|
725
|
+
raise McpTokenStoreUnavailableError(
|
|
726
|
+
f"Failed to read DPAPI MCP OAuth key: {key_path}"
|
|
727
|
+
) from exc
|
|
728
|
+
try:
|
|
729
|
+
key = _dpapi_unprotect(protected)
|
|
730
|
+
except Exception as exc: # noqa: BLE001
|
|
731
|
+
raise McpTokenStoreUnavailableError("Failed to unprotect DPAPI MCP OAuth key.") from exc
|
|
732
|
+
if len(key) != _AES_KEY_BYTES:
|
|
733
|
+
raise McpTokenStoreCorruptError(f"DPAPI MCP OAuth key has invalid length: {key_path}")
|
|
734
|
+
_set_restrictive_permissions(key_path)
|
|
735
|
+
return _KeyMaterial(KEY_SOURCE_DPAPI, key)
|
|
736
|
+
key = secrets.token_bytes(_AES_KEY_BYTES)
|
|
737
|
+
try:
|
|
738
|
+
protected = _dpapi_protect(key)
|
|
739
|
+
_secure_atomic_write_bytes(key_path, protected)
|
|
740
|
+
except Exception as exc: # noqa: BLE001
|
|
741
|
+
raise McpTokenStoreUnavailableError("Failed to create DPAPI MCP OAuth key.") from exc
|
|
742
|
+
return _KeyMaterial(KEY_SOURCE_DPAPI, key)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _platform_system() -> str:
|
|
746
|
+
return platform.system()
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
class _DataBlob(ctypes.Structure):
|
|
750
|
+
_fields_ = [
|
|
751
|
+
("cbData", ctypes.c_uint32),
|
|
752
|
+
("pbData", ctypes.POINTER(ctypes.c_ubyte)),
|
|
753
|
+
]
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _dpapi_protect(data: bytes) -> bytes:
|
|
757
|
+
if not _is_windows_os():
|
|
758
|
+
raise McpTokenStoreUnavailableError("DPAPI is only available on Windows.")
|
|
759
|
+
return _crypt_protect_data(data, protect=True)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _dpapi_unprotect(data: bytes) -> bytes:
|
|
763
|
+
if not _is_windows_os():
|
|
764
|
+
raise McpTokenStoreUnavailableError("DPAPI is only available on Windows.")
|
|
765
|
+
return _crypt_protect_data(data, protect=False)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _crypt_protect_data(data: bytes, *, protect: bool) -> bytes:
|
|
769
|
+
try:
|
|
770
|
+
crypt32, kernel32 = _load_windows_crypto_api()
|
|
771
|
+
except Exception as exc: # noqa: BLE001
|
|
772
|
+
raise McpTokenStoreUnavailableError("Failed to load Windows DPAPI.") from exc
|
|
773
|
+
in_buffer = ctypes.create_string_buffer(data)
|
|
774
|
+
in_blob = _DataBlob(len(data), ctypes.cast(in_buffer, ctypes.POINTER(ctypes.c_ubyte)))
|
|
775
|
+
out_blob = _DataBlob()
|
|
776
|
+
call = crypt32.CryptProtectData if protect else crypt32.CryptUnprotectData
|
|
777
|
+
ok = call(
|
|
778
|
+
ctypes.byref(in_blob),
|
|
779
|
+
None,
|
|
780
|
+
None,
|
|
781
|
+
None,
|
|
782
|
+
None,
|
|
783
|
+
_CRYPTPROTECT_UI_FORBIDDEN,
|
|
784
|
+
ctypes.byref(out_blob),
|
|
785
|
+
)
|
|
786
|
+
if not ok:
|
|
787
|
+
raise McpTokenStoreUnavailableError("Windows DPAPI operation failed.")
|
|
788
|
+
try:
|
|
789
|
+
return ctypes.string_at(out_blob.pbData, out_blob.cbData)
|
|
790
|
+
finally:
|
|
791
|
+
if out_blob.pbData:
|
|
792
|
+
kernel32.LocalFree(out_blob.pbData)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _load_windows_crypto_api() -> tuple[Any, Any]:
|
|
796
|
+
crypt32 = ctypes.WinDLL("crypt32", use_last_error=True)
|
|
797
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
798
|
+
_configure_windows_crypto_api(crypt32, kernel32)
|
|
799
|
+
return crypt32, kernel32
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _configure_windows_crypto_api(crypt32: Any, kernel32: Any) -> None:
|
|
803
|
+
data_blob_pointer = ctypes.POINTER(_DataBlob)
|
|
804
|
+
crypt32.CryptProtectData.argtypes = [
|
|
805
|
+
data_blob_pointer,
|
|
806
|
+
ctypes.c_wchar_p,
|
|
807
|
+
data_blob_pointer,
|
|
808
|
+
ctypes.c_void_p,
|
|
809
|
+
ctypes.c_void_p,
|
|
810
|
+
wintypes.DWORD,
|
|
811
|
+
data_blob_pointer,
|
|
812
|
+
]
|
|
813
|
+
crypt32.CryptProtectData.restype = wintypes.BOOL
|
|
814
|
+
crypt32.CryptUnprotectData.argtypes = [
|
|
815
|
+
data_blob_pointer,
|
|
816
|
+
ctypes.POINTER(ctypes.c_wchar_p),
|
|
817
|
+
data_blob_pointer,
|
|
818
|
+
ctypes.c_void_p,
|
|
819
|
+
ctypes.c_void_p,
|
|
820
|
+
wintypes.DWORD,
|
|
821
|
+
data_blob_pointer,
|
|
822
|
+
]
|
|
823
|
+
crypt32.CryptUnprotectData.restype = wintypes.BOOL
|
|
824
|
+
kernel32.LocalFree.argtypes = [ctypes.c_void_p]
|
|
825
|
+
kernel32.LocalFree.restype = ctypes.c_void_p
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _is_windows_os() -> bool:
|
|
829
|
+
return os.name == "nt"
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _secure_atomic_write_bytes(path: Path, data: bytes, *, mode: int = 0o600) -> None:
|
|
833
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
834
|
+
fd, temp_name = tempfile.mkstemp(
|
|
835
|
+
dir=path.parent,
|
|
836
|
+
prefix=f".{path.name}.",
|
|
837
|
+
suffix=".tmp",
|
|
838
|
+
)
|
|
839
|
+
temp_path = Path(temp_name)
|
|
840
|
+
try:
|
|
841
|
+
if os.name != "nt":
|
|
842
|
+
os.fchmod(fd, mode)
|
|
843
|
+
with os.fdopen(fd, "wb") as handle:
|
|
844
|
+
handle.write(data)
|
|
845
|
+
handle.flush()
|
|
846
|
+
os.fsync(handle.fileno())
|
|
847
|
+
os.replace(temp_path, path)
|
|
848
|
+
_set_restrictive_permissions(path, mode=mode)
|
|
849
|
+
_fsync_dir(path.parent)
|
|
850
|
+
finally:
|
|
851
|
+
with suppress(FileNotFoundError):
|
|
852
|
+
temp_path.unlink()
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _set_restrictive_permissions(path: Path, *, mode: int = 0o600) -> None:
|
|
856
|
+
if os.name == "nt":
|
|
857
|
+
return
|
|
858
|
+
with suppress(OSError):
|
|
859
|
+
os.chmod(path, mode)
|