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