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