alysis-code 0.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,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)