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,315 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+ from urllib.parse import urlparse
8
+
9
+ from .error_text import sanitize_error_text_for_output
10
+
11
+ _NETWORK_MODEL_SIGNALS = (
12
+ "llm request failed",
13
+ "llm error",
14
+ "network",
15
+ "timeout",
16
+ "timed out",
17
+ "connection",
18
+ "dns",
19
+ "tls",
20
+ "certificate",
21
+ "api key",
22
+ "unauthorized",
23
+ "forbidden",
24
+ "rate limit",
25
+ "too many requests",
26
+ "base_url",
27
+ "can't reach",
28
+ "chat/completions",
29
+ "model provider",
30
+ "provider rejected",
31
+ )
32
+
33
+ _TOOL_TRANSCRIPT_SIGNAL_GROUPS = (
34
+ (
35
+ "assistant message with 'tool_calls' must be followed by tool messages",
36
+ 'assistant message with "tool_calls" must be followed by tool messages',
37
+ ),
38
+ (
39
+ "tool_call_id",
40
+ "did not have response messages",
41
+ ),
42
+ (
43
+ "tool transcript",
44
+ "missing",
45
+ ),
46
+ (
47
+ "tool transcript",
48
+ "malformed",
49
+ ),
50
+ (
51
+ "tool transcript",
52
+ "incomplete",
53
+ ),
54
+ )
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class LLMErrorDisplay:
59
+ title: str
60
+ guidance_lines: tuple[str, ...]
61
+
62
+
63
+ def is_tool_transcript_error(text: str) -> bool:
64
+ lowered = str(text or "").lower()
65
+ if not lowered:
66
+ return False
67
+ return any(all(token in lowered for token in group) for group in _TOOL_TRANSCRIPT_SIGNAL_GROUPS)
68
+
69
+
70
+ def is_network_or_model_error(text: str) -> bool:
71
+ lowered = str(text or "").lower()
72
+ if not lowered or is_tool_transcript_error(lowered):
73
+ return False
74
+ return any(token in lowered for token in _NETWORK_MODEL_SIGNALS)
75
+
76
+
77
+ def friendly_llm_error_message(error: Any) -> str:
78
+ """Return user-facing provider error text without raw exception wrappers."""
79
+ try:
80
+ from .llm.openai_compat import alysis_trial_error_message
81
+
82
+ trial_message = alysis_trial_error_message(error)
83
+ except Exception: # noqa: BLE001
84
+ trial_message = None
85
+ if trial_message:
86
+ return sanitize_error_text_for_output(trial_message)
87
+
88
+ text = sanitize_error_text_for_output(error).strip()
89
+ if not text:
90
+ return "The model provider returned an empty error."
91
+ if is_tool_transcript_error(text):
92
+ return text
93
+
94
+ status = _llm_error_status_code(text)
95
+ detail = _provider_error_detail(text)
96
+ lowered = f"{text}\n{detail}".casefold()
97
+ url = _first_url(detail)
98
+ url_suffix = f" See: {url}" if url else ""
99
+
100
+ if status in {401, 403} or _looks_like_auth_error(lowered):
101
+ status_suffix = f" ({status})" if status is not None else ""
102
+ return f"Your API key was rejected{status_suffix}. Update it with /config.{url_suffix}"
103
+
104
+ if _looks_like_connection_error(error, lowered):
105
+ host = _provider_host_from_error(error) or _provider_host_from_text(text)
106
+ host_suffix = f" at {host}" if host else ""
107
+ return (
108
+ f"Can't reach the model provider{host_suffix}. "
109
+ "Check the profile base URL in /config and try again."
110
+ )
111
+
112
+ if _looks_like_timeout_error(lowered):
113
+ return (
114
+ "The model provider did not respond before the request timed out. "
115
+ "Check the base URL/network, or increase the model timeout in /config."
116
+ )
117
+
118
+ if "tool_choice" in lowered:
119
+ status_suffix = f" ({status})" if status is not None else ""
120
+ return (
121
+ f"The provider rejected the tool_choice parameter{status_suffix}. "
122
+ "Use a tool-compatible model/provider, or retry with provider compatibility enabled."
123
+ )
124
+
125
+ unsupported_model = _unsupported_model_name(detail) or _unsupported_model_name(text)
126
+ if unsupported_model:
127
+ status_suffix = f" ({status})" if status is not None else ""
128
+ return (
129
+ f"The provider rejected model '{unsupported_model}' for this request"
130
+ f"{status_suffix}. Check the model id or provider profile in /config."
131
+ )
132
+
133
+ if status == 429 or "rate limit" in lowered or "too many requests" in lowered:
134
+ return "The model provider is rate-limiting this session (429). Wait a moment and retry."
135
+
136
+ if status is not None and detail and detail != text:
137
+ return f"The model provider rejected the request ({status}): {_single_line(detail)}"
138
+ return text
139
+
140
+
141
+ def classify_llm_error_display(text: str) -> LLMErrorDisplay:
142
+ # Transcript integrity errors often arrive inside a generic "LLM error 400" wrapper,
143
+ # so classify them before broader network/model guidance.
144
+ if is_tool_transcript_error(text):
145
+ return LLMErrorDisplay(
146
+ title="Tool Transcript Error",
147
+ guidance_lines=(
148
+ "This session's tool transcript is incomplete or malformed.",
149
+ "Retry in a new session. If it repeats, inspect resume/compaction around missing tool responses.",
150
+ ),
151
+ )
152
+ if is_network_or_model_error(text):
153
+ return LLMErrorDisplay(
154
+ title="Network/Model Error",
155
+ guidance_lines=(
156
+ "Check base URL, API key, and network connectivity.",
157
+ "Retry when ready. Use /status for current session settings.",
158
+ ),
159
+ )
160
+ return LLMErrorDisplay(title="LLM Error", guidance_lines=())
161
+
162
+
163
+ def _llm_error_status_code(text: str) -> int | None:
164
+ match = re.search(r"\bLLM\s+error\s+(\d{3})\b", str(text or ""), re.IGNORECASE)
165
+ if match is None:
166
+ return None
167
+ try:
168
+ return int(match.group(1))
169
+ except ValueError:
170
+ return None
171
+
172
+
173
+ def _llm_error_body(text: str) -> str:
174
+ raw = str(text or "").strip()
175
+ match = re.search(r"\bLLM\s+error\s+\d{3}\s*:\s*", raw, re.IGNORECASE)
176
+ if match is None:
177
+ return raw
178
+ return raw[match.end() :].strip()
179
+
180
+
181
+ def _json_error_payload(text: str) -> dict[str, Any]:
182
+ try:
183
+ payload = json.loads(str(text or "").strip())
184
+ except json.JSONDecodeError:
185
+ return {}
186
+ return payload if isinstance(payload, dict) else {}
187
+
188
+
189
+ def _provider_error_detail(text: str) -> str:
190
+ body = _llm_error_body(text)
191
+ payload = _json_error_payload(body)
192
+ if not payload:
193
+ return _single_line(body)
194
+ raw_error = payload.get("error")
195
+ error = raw_error if isinstance(raw_error, dict) else payload
196
+ message = error.get("message") if isinstance(error, dict) else None
197
+ if isinstance(message, str) and message.strip():
198
+ return _single_line(message)
199
+ code = error.get("code") if isinstance(error, dict) else None
200
+ if isinstance(code, str) and code.strip():
201
+ return _single_line(code)
202
+ return _single_line(body)
203
+
204
+
205
+ def _single_line(text: str) -> str:
206
+ return re.sub(r"\s+", " ", str(text or "").strip())
207
+
208
+
209
+ def _first_url(text: str) -> str | None:
210
+ match = re.search(r"https?://[^\s)>\]}\"']+", str(text or ""))
211
+ if match is None:
212
+ return None
213
+ return match.group(0).rstrip(".,;:")
214
+
215
+
216
+ def _looks_like_auth_error(lowered: str) -> bool:
217
+ return any(
218
+ token in lowered
219
+ for token in (
220
+ "api key",
221
+ "invalid_api_key",
222
+ "incorrect api key",
223
+ "unauthorized",
224
+ "authentication",
225
+ "forbidden",
226
+ )
227
+ )
228
+
229
+
230
+ def _looks_like_connection_error(error: Any, lowered: str) -> bool:
231
+ for exc in _exception_chain(error):
232
+ type_name = type(exc).__name__.casefold()
233
+ if type_name in {"connecterror", "connecttimeout"}:
234
+ return True
235
+ return any(
236
+ token in lowered
237
+ for token in (
238
+ "connection refused",
239
+ "connect error",
240
+ "connect timeout",
241
+ "connecttimeout",
242
+ "dns",
243
+ "name resolution",
244
+ "network is unreachable",
245
+ "temporary failure in name resolution",
246
+ )
247
+ )
248
+
249
+
250
+ def _looks_like_timeout_error(lowered: str) -> bool:
251
+ return any(
252
+ token in lowered
253
+ for token in (
254
+ "read timeout",
255
+ "readtimeout",
256
+ "timed out",
257
+ "timeout",
258
+ )
259
+ )
260
+
261
+
262
+ def _provider_host_from_error(error: Any) -> str | None:
263
+ for exc in _exception_chain(error):
264
+ request = getattr(exc, "request", None)
265
+ url = getattr(request, "url", None)
266
+ host = getattr(url, "host", None)
267
+ if isinstance(host, str) and host:
268
+ port = getattr(url, "port", None)
269
+ return f"{host}:{port}" if port is not None else host
270
+ return None
271
+
272
+
273
+ def _provider_host_from_text(text: str) -> str | None:
274
+ safe_label = re.search(
275
+ r"(?P<host>(?:\[[0-9a-f:]+\]|[A-Za-z0-9.-]+)(?::\d+)?)\s+"
276
+ r"\(endpoint\s+[0-9a-f]{12}\)",
277
+ str(text or ""),
278
+ re.IGNORECASE,
279
+ )
280
+ if safe_label is not None:
281
+ return safe_label.group("host")
282
+ url = _first_url(text)
283
+ if not url:
284
+ return None
285
+ try:
286
+ parsed = urlparse(url)
287
+ except ValueError:
288
+ return None
289
+ if not parsed.hostname:
290
+ return None
291
+ return f"{parsed.hostname}:{parsed.port}" if parsed.port is not None else parsed.hostname
292
+
293
+
294
+ def _exception_chain(error: Any) -> list[Any]:
295
+ chain: list[Any] = []
296
+ seen: set[int] = set()
297
+ current = error
298
+ while current is not None and id(current) not in seen:
299
+ seen.add(id(current))
300
+ chain.append(current)
301
+ cause = getattr(current, "__cause__", None)
302
+ context = getattr(current, "__context__", None)
303
+ current = cause if cause is not None else context
304
+ return chain
305
+
306
+
307
+ def _unsupported_model_name(text: str) -> str | None:
308
+ match = re.search(
309
+ r"(?:not\s+supported\s+model|unsupported\s+model|model\s+not\s+supported)\s+['\"]?([^'\"\s,.;}]+)",
310
+ str(text or ""),
311
+ re.IGNORECASE,
312
+ )
313
+ if match is None:
314
+ return None
315
+ return match.group(1).strip()
@@ -0,0 +1,326 @@
1
+ """Write-path secret redaction for Alysis Code log and telemetry sinks.
2
+
3
+ Background: a live ``ALYSIS_API_KEY`` value once reached a session JSONL file
4
+ in cleartext. A tool call dumped the process environment and the tool output was
5
+ logged verbatim. An archive-time scanner caught it after the fact, but by then
6
+ the credential had already been written to disk. This module closes that gap at
7
+ the write boundary: each Alysis Code log/telemetry sink funnels its final
8
+ serialized string through :func:`redact_log_text` immediately before the write,
9
+ so a captured secret value cannot reach disk in the first place.
10
+
11
+ The archive-time scanner in ``feedback_report`` is deliberately left untouched
12
+ and remains the second, independent layer.
13
+
14
+ Design constraints:
15
+
16
+ * Stdlib only, with no intra-package imports. Every sink module must be able to
17
+ import this one without creating a cycle and without dragging in heavy
18
+ third-party dependencies (httpx, pydantic, rich).
19
+ * Deterministic and idempotent: ``redact(redact(t)) == redact(t)``.
20
+ * Fast: one precompiled alternation covers every known spelling of every
21
+ captured value, and one further combined pass covers risky assignment
22
+ patterns, so a line costs two regex scans regardless of how many secrets are
23
+ known. Run ``python -m alysis_code.logging_redaction`` for a
24
+ micro-benchmark.
25
+
26
+ What is captured
27
+ ----------------
28
+ At construction the redactor snapshots the *values* of environment variables
29
+ whose names end in ``_API_KEY``, ``_TOKEN``, ``_SECRET`` or ``_PASSWORD`` (or
30
+ that are exactly ``API_KEY``/``TOKEN``/``SECRET``/``PASSWORD``), plus any
31
+ ``ALYSIS_*`` variable whose value looks credential-like. Values shorter than
32
+ ``MIN_VALUE_LENGTH`` are ignored: they collide with ordinary log prose far too
33
+ often to be replaced safely.
34
+
35
+ Known limitations (deliberate)
36
+ ------------------------------
37
+ * Base64 is matched only for the exact UTF-8 bytes of the value at alignment 0
38
+ (standard and URL-safe, padded and unpadded). A secret base64-encoded as part
39
+ of a *larger* blob lands at a shifted alignment and is not detected; the
40
+ archive-time scanner remains the backstop for that case.
41
+ * A secret deliberately split across two fields (for example the first half in
42
+ one JSON value and the second half in another) is not reassembled. Each half
43
+ is redacted only if it independently matches a captured value.
44
+ * The risky-pattern pass requires a high-entropy payload, so a low-entropy
45
+ placeholder such as ``password=aaaaaaaaaaaaaaaa`` is left alone on purpose.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import base64
51
+ import json
52
+ import math
53
+ import os
54
+ import re
55
+ import threading
56
+ from collections import Counter
57
+ from collections.abc import Mapping
58
+ from urllib.parse import quote, quote_plus
59
+
60
+ __all__ = [
61
+ "MIN_CREDENTIAL_LENGTH",
62
+ "MIN_VALUE_LENGTH",
63
+ "SecretRedactor",
64
+ "default_redactor",
65
+ "redact_log_text",
66
+ "reset_default_redactor",
67
+ ]
68
+
69
+ # Guillemets keep the marker visually distinct and, more importantly, outside the
70
+ # payload character classes below, which is what makes redaction idempotent by
71
+ # construction rather than by convention.
72
+ _PLACEHOLDER_PREFIX = "«redacted:"
73
+ _PLACEHOLDER_SUFFIX = "»"
74
+ _PATTERN_PLACEHOLDER = f"{_PLACEHOLDER_PREFIX}pattern{_PLACEHOLDER_SUFFIX}"
75
+
76
+ #: Values shorter than this are never captured: too collision-prone.
77
+ MIN_VALUE_LENGTH = 8
78
+ #: Length floor for the ``ALYSIS_*`` "looks like a credential" heuristic.
79
+ MIN_CREDENTIAL_LENGTH = 16
80
+ #: Shannon entropy floor, in bits per character.
81
+ MIN_ENTROPY_BITS_PER_CHAR = 3.0
82
+ #: Entropy floor for a payload found by the risky-pattern pass.
83
+ MIN_PATTERN_ENTROPY_BITS_PER_CHAR = 3.0
84
+
85
+ _SECRET_NAME_SUFFIXES = ("_API_KEY", "_TOKEN", "_SECRET", "_PASSWORD")
86
+ _SECRET_NAME_EXACT = frozenset({"API_KEY", "TOKEN", "SECRET", "PASSWORD"})
87
+ _HEURISTIC_NAME_PREFIX = "ALYSIS_"
88
+
89
+ # Characters a credential may plausibly be made of. Excludes whitespace, quotes
90
+ # and braces so that a JSON blob or an English sentence can never be mistaken
91
+ # for one value.
92
+ _TOKEN_CHARSET_RE = re.compile(r"[A-Za-z0-9._~+/=:-]+")
93
+ _PATHISH_RE = re.compile(r"(?:\A[./~]|\A[A-Za-z]:[\\/]|://)")
94
+ _HEXISH_RE = re.compile(r"[0-9a-fA-F]+")
95
+
96
+
97
+ def _shannon_entropy_bits_per_char(value: str) -> float:
98
+ """Return Shannon entropy of ``value`` in bits per character."""
99
+ if not value:
100
+ return 0.0
101
+ total = len(value)
102
+ return -sum((count / total) * math.log2(count / total) for count in Counter(value).values())
103
+
104
+
105
+ def _is_named_secret(name: str) -> bool:
106
+ upper = name.upper()
107
+ return upper in _SECRET_NAME_EXACT or upper.endswith(_SECRET_NAME_SUFFIXES)
108
+
109
+
110
+ def _looks_credential_like(value: str) -> bool:
111
+ """Heuristic for ``ALYSIS_*`` values whose name does not give them away.
112
+
113
+ Deliberately conservative about *shape* rather than length alone, so that
114
+ ordinary configuration such as a model id or a file path is not swept up.
115
+ """
116
+ if len(value) < MIN_CREDENTIAL_LENGTH:
117
+ return False
118
+ if not _TOKEN_CHARSET_RE.fullmatch(value):
119
+ return False
120
+ if _PATHISH_RE.search(value):
121
+ return False
122
+ if _shannon_entropy_bits_per_char(value) < MIN_ENTROPY_BITS_PER_CHAR:
123
+ return False
124
+ has_lower = any(char.islower() for char in value)
125
+ has_upper = any(char.isupper() for char in value)
126
+ has_digit = any(char.isdigit() for char in value)
127
+ if has_lower and has_upper and has_digit:
128
+ return True
129
+ # Long single-case hex digests are credential-shaped even though they only
130
+ # use two character classes.
131
+ return len(value) >= 32 and _HEXISH_RE.fullmatch(value) is not None
132
+
133
+
134
+ def _value_variants(value: str) -> list[str]:
135
+ """Every spelling of ``value`` that could plausibly be written to a log."""
136
+ variants = {value}
137
+ # JSON string escaping. Sinks serialize with ensure_ascii=True, but cover
138
+ # both settings so a non-ASCII secret is caught either way.
139
+ for ensure_ascii in (True, False):
140
+ variants.add(json.dumps(value, ensure_ascii=ensure_ascii)[1:-1])
141
+ # Percent-encoding, as seen in URLs and form bodies.
142
+ variants.add(quote(value, safe=""))
143
+ variants.add(quote_plus(value, safe=""))
144
+ # Base64 of the exact bytes at alignment 0 (see module docstring).
145
+ raw = value.encode("utf-8")
146
+ for encoded in (base64.b64encode(raw), base64.urlsafe_b64encode(raw)):
147
+ text = encoded.decode("ascii")
148
+ variants.add(text)
149
+ variants.add(text.rstrip("="))
150
+ return [variant for variant in variants if len(variant) >= MIN_VALUE_LENGTH]
151
+
152
+
153
+ # Assignment-style and Authorization-header patterns whose payload is long and
154
+ # high-entropy. The payload class excludes the guillemets used by the
155
+ # placeholders, so an already-redacted string can never be re-matched.
156
+ _RISKY_PATTERN_RE = re.compile(
157
+ r"""
158
+ (?:
159
+ \b(?:
160
+ api[-_]?key | access[-_]?key | secret[-_]?key | client[-_]?secret
161
+ | auth[-_]?token | access[-_]?token | refresh[-_]?token | api[-_]?token
162
+ | authorization | token | secret | password | passwd
163
+ )\b
164
+ \s* ["']? \s* [:=] \s* ["']? \s*
165
+ (?: (?: bearer | basic | token ) \s+ )?
166
+ (?P<assigned> [A-Za-z0-9._~+/=-]{16,})
167
+ |
168
+ \b bearer \s+ (?P<bearer> [A-Za-z0-9._~+/=-]{16,})
169
+ )
170
+ """,
171
+ re.VERBOSE | re.IGNORECASE,
172
+ )
173
+
174
+
175
+ def _replace_risky(match: re.Match) -> str:
176
+ assigned = match.group("assigned")
177
+ if assigned is not None:
178
+ payload, payload_start = assigned, match.start("assigned")
179
+ else:
180
+ payload, payload_start = match.group("bearer"), match.start("bearer")
181
+ if payload.startswith(_PLACEHOLDER_PREFIX):
182
+ return match.group(0)
183
+ if _shannon_entropy_bits_per_char(payload) < MIN_PATTERN_ENTROPY_BITS_PER_CHAR:
184
+ # A low-entropy payload is almost always a placeholder or an enum value;
185
+ # redacting it would only make real logs harder to read.
186
+ return match.group(0)
187
+ # The payload is always the tail of the match, so keeping everything before
188
+ # it preserves the surrounding syntax (including JSON quoting).
189
+ return match.group(0)[: payload_start - match.start()] + _PATTERN_PLACEHOLDER
190
+
191
+
192
+ class SecretRedactor:
193
+ """An immutable snapshot of the secret values known at construction time.
194
+
195
+ Pass ``environ`` explicitly to get a deterministic redactor in tests; the
196
+ default reads :data:`os.environ`.
197
+ """
198
+
199
+ def __init__(self, environ: Mapping[str, str] | None = None) -> None:
200
+ source = os.environ if environ is None else environ
201
+ replacements: dict[str, str] = {}
202
+ names: list[str] = []
203
+ for name, value in sorted(source.items()):
204
+ if not isinstance(value, str) or len(value) < MIN_VALUE_LENGTH:
205
+ continue
206
+ if _is_named_secret(name):
207
+ pass
208
+ elif name.startswith(_HEURISTIC_NAME_PREFIX) and _looks_credential_like(value):
209
+ pass
210
+ else:
211
+ continue
212
+ names.append(name)
213
+ placeholder = f"{_PLACEHOLDER_PREFIX}{name}{_PLACEHOLDER_SUFFIX}"
214
+ for variant in _value_variants(value):
215
+ # Iteration is sorted by name, so if two variables share a value
216
+ # the alphabetically-first name wins. Deterministic either way.
217
+ replacements.setdefault(variant, placeholder)
218
+ self.secret_names: tuple[str, ...] = tuple(names)
219
+ self._replacements = replacements
220
+ self._exact_re = self._compile(replacements)
221
+
222
+ @staticmethod
223
+ def _compile(replacements: Mapping[str, str]) -> re.Pattern | None:
224
+ if not replacements:
225
+ return None
226
+ # Longest first, so that when one variant is a prefix of another the
227
+ # longer (more specific) spelling wins at any given position.
228
+ ordered = sorted(replacements, key=lambda variant: (-len(variant), variant))
229
+ return re.compile("|".join(re.escape(variant) for variant in ordered))
230
+
231
+ def _replace_exact(self, match: re.Match) -> str:
232
+ return self._replacements[match.group(0)]
233
+
234
+ def redact(self, text: str) -> str:
235
+ """Return ``text`` with every known secret value and risky payload masked."""
236
+ if not text:
237
+ return text
238
+ if self._exact_re is not None:
239
+ text = self._exact_re.sub(self._replace_exact, text)
240
+ return _RISKY_PATTERN_RE.sub(_replace_risky, text)
241
+
242
+
243
+ _DEFAULT_LOCK = threading.Lock()
244
+ _DEFAULT_REDACTOR: SecretRedactor | None = None
245
+ _DEFAULT_SIGNATURE: tuple[tuple[str, str], ...] | None = None
246
+
247
+
248
+ def _environ_signature(source: Mapping[str, str]) -> tuple[tuple[str, str], ...]:
249
+ """Cheap fingerprint of the secret-bearing part of the environment."""
250
+ matches = [
251
+ (name, value)
252
+ for name, value in source.items()
253
+ if isinstance(value, str)
254
+ and len(value) >= MIN_VALUE_LENGTH
255
+ and (_is_named_secret(name) or name.startswith(_HEURISTIC_NAME_PREFIX))
256
+ ]
257
+ matches.sort()
258
+ return tuple(matches)
259
+
260
+
261
+ def default_redactor() -> SecretRedactor:
262
+ """Return the process-wide redactor, rebuilding it if the environment moved.
263
+
264
+ Credentials are often loaded into the environment after import, so a
265
+ build-once cache would miss exactly the value that matters. Comparing a
266
+ cheap signature keeps the compiled regex hot while staying correct.
267
+ """
268
+ global _DEFAULT_REDACTOR, _DEFAULT_SIGNATURE
269
+ signature = _environ_signature(os.environ)
270
+ with _DEFAULT_LOCK:
271
+ if _DEFAULT_REDACTOR is None or signature != _DEFAULT_SIGNATURE:
272
+ _DEFAULT_REDACTOR = SecretRedactor()
273
+ _DEFAULT_SIGNATURE = signature
274
+ return _DEFAULT_REDACTOR
275
+
276
+
277
+ def reset_default_redactor() -> None:
278
+ """Drop the cached redactor. Intended for tests."""
279
+ global _DEFAULT_REDACTOR, _DEFAULT_SIGNATURE
280
+ with _DEFAULT_LOCK:
281
+ _DEFAULT_REDACTOR = None
282
+ _DEFAULT_SIGNATURE = None
283
+
284
+
285
+ def redact_log_text(text: str) -> str:
286
+ """Redact ``text`` immediately before it is written to a log sink."""
287
+ if not text:
288
+ return text
289
+ return default_redactor().redact(text)
290
+
291
+
292
+ def _benchmark(size_mb: float = 100.0) -> None:
293
+ """Micro-benchmark the redaction budget: 100MB in well under a minute."""
294
+ import time
295
+
296
+ secret = "sk-syl-" + ("A7bQ9zX2mK4pL8vN" * 2)
297
+ environ = {"ALYSIS_API_KEY": secret, "PATH": "/usr/bin:/bin"}
298
+ redactor = SecretRedactor(environ)
299
+ block = (
300
+ "2026-08-21T10:00:00Z level=info msg=tool_result "
301
+ 'payload={"stdout": "HOME=/root\\nSHELL=/bin/bash\\n"} '
302
+ f"ALYSIS_API_KEY={secret} "
303
+ "Authorization: Bearer q1W2e3R4t5Y6u7I8o9P0a1S2d3F4g5H6 "
304
+ "ordinary log prose that must survive untouched\n"
305
+ )
306
+ repeats = max(1, int((size_mb * 1024 * 1024) / len(block)))
307
+ text = block * repeats
308
+ actual_mb = len(text) / (1024 * 1024)
309
+
310
+ start = time.perf_counter()
311
+ result = redactor.redact(text)
312
+ elapsed = time.perf_counter() - start
313
+
314
+ print(f"input : {actual_mb:.1f} MB ({repeats} blocks)")
315
+ print(f"elapsed : {elapsed:.3f} s")
316
+ print(f"throughput : {actual_mb / elapsed:.1f} MB/s")
317
+ print(f"secret leaked: {secret in result}")
318
+ print(f"projected 100MB: {elapsed * (100.0 / actual_mb):.2f} s")
319
+
320
+ start = time.perf_counter()
321
+ again = redactor.redact(result)
322
+ print(f"idempotent : {again == result} (second pass {time.perf_counter() - start:.3f} s)")
323
+
324
+
325
+ if __name__ == "__main__":
326
+ _benchmark()