sliceagent 0.1.7__tar.gz → 0.1.9__tar.gz

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 (206) hide show
  1. {sliceagent-0.1.7 → sliceagent-0.1.9}/CHANGELOG.md +46 -0
  2. {sliceagent-0.1.7 → sliceagent-0.1.9}/PKG-INFO +1 -1
  3. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/__init__.py +1 -1
  4. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/cli.py +56 -15
  5. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/config.py +6 -1
  6. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/llm.py +13 -3
  7. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/neocortex.py +4 -2
  8. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/onboarding.py +57 -7
  9. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/policy.py +28 -4
  10. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/tools.py +9 -3
  11. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/tui.py +9 -2
  12. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_bughunt_fixes.py +78 -1
  13. sliceagent-0.1.9/tests/test_config_journey.py +260 -0
  14. sliceagent-0.1.7/tests/test_config_journey.py +0 -119
  15. {sliceagent-0.1.7 → sliceagent-0.1.9}/.dockerignore +0 -0
  16. {sliceagent-0.1.7 → sliceagent-0.1.9}/.env.example +0 -0
  17. {sliceagent-0.1.7 → sliceagent-0.1.9}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  18. {sliceagent-0.1.7 → sliceagent-0.1.9}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  19. {sliceagent-0.1.7 → sliceagent-0.1.9}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  20. {sliceagent-0.1.7 → sliceagent-0.1.9}/.github/dependabot.yml +0 -0
  21. {sliceagent-0.1.7 → sliceagent-0.1.9}/.github/workflows/ci.yml +0 -0
  22. {sliceagent-0.1.7 → sliceagent-0.1.9}/.github/workflows/publish.yml +0 -0
  23. {sliceagent-0.1.7 → sliceagent-0.1.9}/.gitignore +0 -0
  24. {sliceagent-0.1.7 → sliceagent-0.1.9}/CODE_OF_CONDUCT.md +0 -0
  25. {sliceagent-0.1.7 → sliceagent-0.1.9}/CONTRIBUTING.md +0 -0
  26. {sliceagent-0.1.7 → sliceagent-0.1.9}/Dockerfile +0 -0
  27. {sliceagent-0.1.7 → sliceagent-0.1.9}/LICENSE +0 -0
  28. {sliceagent-0.1.7 → sliceagent-0.1.9}/NOTICE +0 -0
  29. {sliceagent-0.1.7 → sliceagent-0.1.9}/QUICKSTART.md +0 -0
  30. {sliceagent-0.1.7 → sliceagent-0.1.9}/README.md +0 -0
  31. {sliceagent-0.1.7 → sliceagent-0.1.9}/SECURITY.md +0 -0
  32. {sliceagent-0.1.7 → sliceagent-0.1.9}/examples/plugins/hello/__init__.py +0 -0
  33. {sliceagent-0.1.7 → sliceagent-0.1.9}/examples/plugins/hello/plugin.toml +0 -0
  34. {sliceagent-0.1.7 → sliceagent-0.1.9}/install.sh +0 -0
  35. {sliceagent-0.1.7 → sliceagent-0.1.9}/pyproject.toml +0 -0
  36. {sliceagent-0.1.7 → sliceagent-0.1.9}/scripts/gen_config_reference.py +0 -0
  37. {sliceagent-0.1.7 → sliceagent-0.1.9}/scripts/run_tests.sh +0 -0
  38. {sliceagent-0.1.7 → sliceagent-0.1.9}/sliceagent.toml.example +0 -0
  39. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/__main__.py +0 -0
  40. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/access.py +0 -0
  41. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/agents.py +0 -0
  42. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/background_review.py +0 -0
  43. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/binsniff.py +0 -0
  44. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/clock.py +0 -0
  45. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/code_grep.py +0 -0
  46. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/code_index.py +0 -0
  47. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/context_overflow.py +0 -0
  48. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/envspec.py +0 -0
  49. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/errors.py +0 -0
  50. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/events.py +0 -0
  51. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/finding_types.py +0 -0
  52. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/flags.py +0 -0
  53. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/fuzzy.py +0 -0
  54. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/guardrails.py +0 -0
  55. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/guidance.py +0 -0
  56. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/hippocampus.py +0 -0
  57. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/hooks.py +0 -0
  58. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/interfaces.py +0 -0
  59. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/loop.py +0 -0
  60. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/mcp_client.py +0 -0
  61. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/mcp_security.py +0 -0
  62. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/memory.py +0 -0
  63. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/metrics.py +0 -0
  64. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/model_catalog.py +0 -0
  65. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/monitor.py +0 -0
  66. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/oracle.py +0 -0
  67. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/pagetable.py +0 -0
  68. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/pfc.py +0 -0
  69. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/plugins.py +0 -0
  70. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/procman.py +0 -0
  71. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/prompt.py +0 -0
  72. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/records.py +0 -0
  73. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/recovery.py +0 -0
  74. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/regions.py +0 -0
  75. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/registry.py +0 -0
  76. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/retriever.py +0 -0
  77. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/safety.py +0 -0
  78. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/sandbox.py +0 -0
  79. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/scheduler.py +0 -0
  80. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/search_index.py +0 -0
  81. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/seed.py +0 -0
  82. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/sensory_cortex.py +0 -0
  83. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/session.py +0 -0
  84. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/skill_provenance.py +0 -0
  85. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/skill_usage.py +0 -0
  86. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/skills.py +0 -0
  87. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/subagent.py +0 -0
  88. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/subdir_hints.py +0 -0
  89. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/swap.py +0 -0
  90. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/taskstate.py +0 -0
  91. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/telemetry.py +0 -0
  92. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/terminal.py +0 -0
  93. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/text_utils.py +0 -0
  94. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/tool_summary.py +0 -0
  95. {sliceagent-0.1.7 → sliceagent-0.1.9}/src/sliceagent/web.py +0 -0
  96. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/README.md +0 -0
  97. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_active_focus.py +0 -0
  98. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_agents.py +0 -0
  99. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_ask_echo.py +0 -0
  100. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_ask_user.py +0 -0
  101. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_background_review.py +0 -0
  102. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_binary_view.py +0 -0
  103. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_bound_is_relevance.py +0 -0
  104. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_breadth_e2e.py +0 -0
  105. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_bugfix_2b.py +0 -0
  106. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_bugfix_breadth_wave.py +0 -0
  107. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_bugfix_current_request.py +0 -0
  108. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_bugfix_linenum.py +0 -0
  109. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_bugfix_loop_wave.py +0 -0
  110. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_bugfix_memory_wave.py +0 -0
  111. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_bugfix_p0a.py +0 -0
  112. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_bugfix_p0b.py +0 -0
  113. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_bugfix_p0c.py +0 -0
  114. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_bugfix_selfreview.py +0 -0
  115. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_bugfix_selfreview_siblings.py +0 -0
  116. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_bugfix_skills_h1.py +0 -0
  117. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_bugfix_slice_wave.py +0 -0
  118. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_bugfix_subagent_wave.py +0 -0
  119. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_bugfix_tools_wave.py +0 -0
  120. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_cache_manifest.py +0 -0
  121. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_checkpoint.py +0 -0
  122. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_cli_smoke.py +0 -0
  123. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_closure.py +0 -0
  124. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_code_grep.py +0 -0
  125. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_code_index.py +0 -0
  126. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_consolidate.py +0 -0
  127. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_context_overflow.py +0 -0
  128. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_conversation.py +0 -0
  129. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_coresidency.py +0 -0
  130. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_echo_before_blocking.py +0 -0
  131. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_episode.py +0 -0
  132. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_errors_backoff.py +0 -0
  133. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_esc_sentinel.py +0 -0
  134. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_exec_env.py +0 -0
  135. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_finding_types.py +0 -0
  136. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_flags.py +0 -0
  137. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_fuzzy.py +0 -0
  138. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_ghost_index.py +0 -0
  139. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_guardrails.py +0 -0
  140. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_guidance.py +0 -0
  141. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_history.py +0 -0
  142. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_invariant_fixes.py +0 -0
  143. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_live_composer.py +0 -0
  144. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_llm_streaming.py +0 -0
  145. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_llm_usage_cache.py +0 -0
  146. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_llm_watchdog.py +0 -0
  147. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_log_redaction.py +0 -0
  148. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_loop_overflow.py +0 -0
  149. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_mcp_output_cap.py +0 -0
  150. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_mcp_runtime.py +0 -0
  151. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_memory.py +0 -0
  152. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_memory_persist.py +0 -0
  153. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_menu_select.py +0 -0
  154. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_metrics.py +0 -0
  155. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_mining.py +0 -0
  156. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_mission_tier.py +0 -0
  157. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_model_catalog.py +0 -0
  158. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_monitor.py +0 -0
  159. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_onboarding.py +0 -0
  160. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_pageout_compaction.py +0 -0
  161. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_permission_patterns.py +0 -0
  162. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_pinned_composer.py +0 -0
  163. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_plan_tier.py +0 -0
  164. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_procman.py +0 -0
  165. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_product_features.py +0 -0
  166. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_progress_status.py +0 -0
  167. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_prompt_ab.py +0 -0
  168. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_provenance.py +0 -0
  169. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_provider_lineup.py +0 -0
  170. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_proxy_interrupt.py +0 -0
  171. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_reach.py +0 -0
  172. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_readonly_subagent.py +0 -0
  173. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_recall_search.py +0 -0
  174. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_records.py +0 -0
  175. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_refault.py +0 -0
  176. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_registry_validation.py +0 -0
  177. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_reliability.py +0 -0
  178. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_repo_map_bounded.py +0 -0
  179. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_requirements.py +0 -0
  180. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_review_fixes.py +0 -0
  181. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_route_lexical.py +0 -0
  182. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_safety.py +0 -0
  183. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_seal_markdown.py +0 -0
  184. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_search_index.py +0 -0
  185. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_skill_meta.py +0 -0
  186. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_skill_metadata.py +0 -0
  187. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_subdir_hints.py +0 -0
  188. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_task_agnostic.py +0 -0
  189. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_task_state_roundtrip.py +0 -0
  190. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_telemetry_convergence.py +0 -0
  191. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_terminal.py +0 -0
  192. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_tool_dedup.py +0 -0
  193. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_tool_result_ok.py +0 -0
  194. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_tools_robust.py +0 -0
  195. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_topic_switch.py +0 -0
  196. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_trajectory.py +0 -0
  197. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_tui.py +0 -0
  198. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_tui_menus.py +0 -0
  199. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_tui_render.py +0 -0
  200. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_tui_widgets.py +0 -0
  201. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_turn_budget.py +0 -0
  202. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_verification_agent.py +0 -0
  203. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_web.py +0 -0
  204. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_workspace.py +0 -0
  205. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_world_region.py +0 -0
  206. {sliceagent-0.1.7 → sliceagent-0.1.9}/tests/test_world_state.py +0 -0
@@ -5,6 +5,52 @@ this project aims for [Semantic Versioning](https://semver.org/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.1.9] — 2026-07-03
9
+
10
+ Bug-hunt round 2 (deep-core lenses, full 3-vote adversarial verify). Five confirmed fixes.
11
+
12
+ ### Security
13
+ - The durable debug log's `_scrub_args` now redacts secrets in NESTED dict/list tool arguments
14
+ (e.g. an MCP call's `{config:{api_key:…}}` or `{headers:{Authorization:…}}`) — top-level-only
15
+ redaction leaked them to `~/.sliceagent/logs/**/durable-log.jsonl` in plaintext.
16
+ - `AGENT_PROXY=none` (documented "force a DIRECT connection") now truly forces direct: the httpx
17
+ client is built with `trust_env=False`, so an ambient `HTTPS_PROXY` can no longer silently route
18
+ your API traffic through a proxy you told sliceagent to bypass (both first build and `/model` hops).
19
+
20
+ ### Fixed
21
+ - `str_replace`/`edit_file` no longer flip a whole file to CRLF when it's mostly-LF but contains a
22
+ single embedded `\r\n` (a byte literal, an HTTP fixture, a merge artifact). CRLF is now detected by
23
+ DOMINANCE, not mere presence — uniformly-CRLF Windows files are still preserved.
24
+ - Session-end consolidation no longer crashes (and silently discards ALL of a session's promoted
25
+ lessons/skills) when the episodic log contains one malformed record.
26
+ - `/undo` and `/cwd` (and `/plugins`) no longer corrupt output or crash Rich with a MarkupError when a
27
+ path contains brackets — e.g. a Next.js `app/[id]/page.tsx` route or `~/proj/[slug]`.
28
+
29
+ ## [0.1.8] — 2026-07-02
30
+
31
+ Launch-day bug-hunt round: a security fix plus config-robustness and /model correctness.
32
+
33
+ ### Security
34
+ - `run_command` in the default *teenager* mode no longer auto-runs exec/write commands disguised as
35
+ "read-only": `env <program>`, `sort -o FILE`, `date -s`, `tree -o FILE`, `uniq IN OUT`, and
36
+ `git grep --open-files-in-pager` now take the confirm path (arbitrary code-exec / file-overwrite
37
+ confirm-bypass). Read-only siblings like `du -s` / `grep -o` still auto-run.
38
+
39
+ ### Fixed
40
+ - Boot no longer cross-wires a prefs-pinned provider's key with the DEFAULT provider's endpoint (an
41
+ env `gpt-5.5` @ OpenAI pin on a DeepSeek-default config used to 401 every call on relaunch).
42
+ - A prefs `provider`/model pin whose `[providers.<id>]` table was removed from config.toml is now
43
+ dropped at boot instead of forcing a model onto the wrong endpoint.
44
+ - The wizard no longer silently ERASES other providers' API keys when config.toml is unparseable —
45
+ the old file is moved to a non-clobbering `.bak` first (keys recoverable).
46
+ - `sliceagent config` / `config --use` / the init wizard no longer crash on a non-UTF-8 config.toml
47
+ or a scalar value under `[providers]` (they degrade).
48
+ - `llm.switch()` (a `/model` provider hop) closes the replaced HTTP client — no more fd leak per hop.
49
+ - The `/model` reasoning menu offers `high` (not a misleading `max`) for OpenRouter models, and offers
50
+ the levels the *target* provider supports when the pick rebinds the endpoint.
51
+ - The wizard's typed provider fallback rejects an unknown choice instead of silently configuring
52
+ OpenRouter.
53
+
8
54
  ## [0.1.7] — 2026-07-02
9
55
 
10
56
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sliceagent
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: A coding agent with a new context-engineering framework: bounded, deterministic, reconstructed context (slice architecture) — built for long-horizon work.
5
5
  Author: TT-Wang
6
6
  License-Expression: MIT
@@ -1,3 +1,3 @@
1
1
  """sliceagent — a memory-native coding agent (deterministic, bounded, reconstructed context)."""
2
2
 
3
- __version__ = "0.1.7"
3
+ __version__ = "0.1.9"
@@ -50,7 +50,15 @@ def log_sink(root: str = ".", path: str | None = None):
50
50
  path = path or os.path.join(state_dir("logs", root_key(root)), "durable-log.jsonl")
51
51
 
52
52
  def _scrub_args(args: dict) -> dict: # redact string values (edit_file content, inline tokens)
53
- return {k: (redact_text(v) if isinstance(v, str) else v) for k, v in (args or {}).items()}
53
+ def _rec(v): # RECURSE a secret nested in a dict/list arg (e.g. an
54
+ if isinstance(v, str): # MCP tool's {config:{api_key:…}} / {headers:{Authorization:…}})
55
+ return redact_text(v) # must not reach the on-disk log in plaintext (top-level-only
56
+ if isinstance(v, dict): # redaction leaked it; sibling hippocampus._clamp already recurses)
57
+ return {k: _rec(x) for k, x in v.items()}
58
+ if isinstance(v, (list, tuple)):
59
+ return [_rec(x) for x in v]
60
+ return v
61
+ return _rec(args or {})
54
62
 
55
63
  def sink(e: Event) -> None:
56
64
  rec = None
@@ -151,6 +159,21 @@ def _ws_name(path: str) -> str:
151
159
  return "~" if p == os.path.realpath(os.path.expanduser("~")) else (os.path.basename(p) or p)
152
160
 
153
161
 
162
+ def _env_from_config(c, pid: str | None = None) -> None:
163
+ """Populate LLM_API_KEY/LLM_BASE_URL from config (ENV still wins). A prefs-pinned provider `pid`
164
+ binds AS A UNIT: its key with ITS OWN endpoint — an absent base_url means the SDK default (OpenAI),
165
+ NOT the default provider's base_url (that fallback cross-wired an openai key onto the deepseek
166
+ endpoint after `/model`-hop + restart when default_provider pointed elsewhere)."""
167
+ tbl = (c.providers() or {}).get(pid) if pid else None
168
+ if isinstance(tbl, dict) and tbl.get("api_key"):
169
+ key, base = tbl["api_key"], tbl.get("base_url") or ""
170
+ else: # no/unusable pin → the default provider's own pairing
171
+ key, base = c.api_key, c.base_url
172
+ for _env, _val in (("LLM_API_KEY", key), ("LLM_BASE_URL", base)):
173
+ if not os.environ.get(_env) and _val:
174
+ os.environ[_env] = _val
175
+
176
+
154
177
  def main() -> None:
155
178
  # subcommands (onboarding / discovery) are handled BEFORE any key gate, so `sliceagent init` runs on a
156
179
  # machine with nothing configured yet. A bare `sliceagent` (or one with non-subcommand args) falls through.
@@ -165,14 +188,6 @@ def main() -> None:
165
188
  from .config import load_config, load_prefs
166
189
  _boot_prefs = load_prefs() # the last /model choice may pin a PROVIDER (endpoint+key), not just a model
167
190
 
168
- def _env_from_config(c, pid: str | None = None) -> None:
169
- tbl = (c.providers() or {}).get(pid) if pid else None
170
- key = (tbl or {}).get("api_key") or c.api_key
171
- base = ((tbl or {}).get("base_url") if tbl and tbl.get("api_key") else None) or c.base_url
172
- for _env, _val in (("LLM_API_KEY", key), ("LLM_BASE_URL", base)):
173
- if not os.environ.get(_env) and _val:
174
- os.environ[_env] = _val
175
-
176
191
  def _key_present() -> bool:
177
192
  return bool(os.environ.get("LLM_API_KEY") or os.environ.get("OPENAI_API_KEY")
178
193
  or os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("DEEPSEEK_API_KEY")
@@ -189,6 +204,14 @@ def main() -> None:
189
204
  return run_init() == 0
190
205
 
191
206
  cfg = load_config()
207
+ # A prefs `provider` pin whose [providers.<id>] table was since removed from config.toml is STALE:
208
+ # its key/endpoint already fall back to the default provider (see _env_from_config), but its pinned
209
+ # MODEL would still win at the resolution below and get sent to the wrong endpoint (model_not_found
210
+ # every turn). Drop the whole stale pin so model/endpoint resolve together from the live config.
211
+ if _boot_prefs.get("provider") and _boot_prefs["provider"] not in (cfg.providers() or {}):
212
+ from .config import save_prefs as _sp
213
+ _sp({"provider": None, "model": None}) # None DELETES (see save_prefs)
214
+ _boot_prefs.pop("provider", None); _boot_prefs.pop("model", None)
192
215
  _env_from_config(cfg, _boot_prefs.get("provider"))
193
216
  if not _key_present() and _first_run_setup("Welcome! No API key configured yet"):
194
217
  cfg = load_config() # the wizard just wrote ~/.sliceagent/config.toml — pick it up
@@ -468,6 +491,18 @@ def main() -> None:
468
491
  ans = input(f" ⚠ allow {name} {str(detail)[:60]!r}? ({reason}) [y]es/[n]o/[a]lways: ").strip().lower()
469
492
  return {"y": "yes", "yes": "yes", "a": "always", "always": "always"}.get(ans, "no")
470
493
 
494
+ def _live_pid():
495
+ """The configured provider the LIVE llm is actually bound to (endpoint+key match), or None.
496
+ Prefs must pin what's IN EFFECT, not the last menu pick — a typed `/model <name>` keeps the
497
+ endpoint, so blindly re-saving the old pin resurrected a dead endpoint+model pairing at boot."""
498
+ lbase = getattr(llm, "_base_url", "") or ""
499
+ lkey = getattr(llm.client, "api_key", None)
500
+ for p, t in (cfg.providers() or {}).items():
501
+ if isinstance(t, dict) and t.get("api_key") and t["api_key"] == lkey \
502
+ and (t.get("base_url") or "") == lbase:
503
+ return p
504
+ return None
505
+
471
506
  def _handle_slash(line): # TUI navigation palette — wired to existing session ops
472
507
  nonlocal cfg # /config hot-reloads the config after the in-session wizard
473
508
  parts = line.split(maxsplit=1)
@@ -539,12 +574,15 @@ def main() -> None:
539
574
  except Exception:
540
575
  _console.print(f" no such topic: {arg}", markup=False)
541
576
  elif cmd == "/undo":
542
- _console.print(" " + base_tools.undo_last()) # revert the last file edit
577
+ # markup=False: undo_last() embeds the edited file PATH; a Next.js-style '[id]'/'[...slug]'
578
+ # segment is parsed as a Rich tag → corrupted output or a MarkupError crash.
579
+ _console.print(" " + base_tools.undo_last(), markup=False) # revert the last file edit
543
580
  elif cmd == "/plugins":
544
581
  tools = sorted(e.name for e in base_tools.registry._tools.values()
545
582
  if getattr(e, "source", "") == "plugin")
546
- _console.print(f" plugin dirs: {', '.join(cfg.plugin_dirs) or '(none configured)'}")
547
- _console.print(f" plugin tools ({len(tools)}): {', '.join(tools) or '(none loaded)'}")
583
+ # markup=False: plugin dirs are filesystem PATHS that may contain '[...]' (same Rich-tag hazard)
584
+ _console.print(f" plugin dirs: {', '.join(cfg.plugin_dirs) or '(none configured)'}", markup=False)
585
+ _console.print(f" plugin tools ({len(tools)}): {', '.join(tools) or '(none loaded)'}", markup=False)
548
586
  elif cmd == "/mcp":
549
587
  configured = list(cfg.mcp_servers.keys())
550
588
  mtools = sorted(e.name for e in base_tools.registry._tools.values()
@@ -595,8 +633,9 @@ def main() -> None:
595
633
  _kw = {"base_url": _tbl.get("base_url") or "", "api_key": _tbl["api_key"]}
596
634
  llm.switch(model=_model, reasoning=_reasoning, **_kw)
597
635
  _stats["model"] = llm.model
636
+ # pin the provider ACTUALLY in effect (None DELETES a stale pin — see save_prefs)
598
637
  save_prefs({"model": llm.model, "reasoning": llm.reasoning,
599
- **({"provider": _pid} if _pid else {})})
638
+ "provider": _pid or _live_pid()})
600
639
  note = _reasoning_note(llm)
601
640
  _console.print(f" ✓ model → [bold]{llm.model}[/]"
602
641
  + (f" @ [bold]{_pid}[/]" if _pid else "")
@@ -619,7 +658,9 @@ def main() -> None:
619
658
  _console.print(" effort must be one of: fast | full | high | max"); return True
620
659
  llm.switch(model=name, reasoning=eff)
621
660
  _stats["model"] = llm.model
622
- save_prefs({"model": llm.model, "reasoning": llm.reasoning})
661
+ # typed /model keeps the endpoint — re-resolve the pin against the LIVE binding so a
662
+ # stale provider pin can't resurrect the old endpoint under this model at next boot
663
+ save_prefs({"model": llm.model, "reasoning": llm.reasoning, "provider": _live_pid()})
623
664
  note = _reasoning_note(llm)
624
665
  _console.print(f" ✓ model → [bold]{llm.model}[/]"
625
666
  + (f" · reasoning [bold]{llm.reasoning}[/]" if eff else "")
@@ -640,7 +681,7 @@ def main() -> None:
640
681
  if not arg:
641
682
  _console.print(f" workspace: {base_tools.root()}", markup=False)
642
683
  else:
643
- _console.print(" ✓ " + _reroot(arg))
684
+ _console.print(" ✓ " + _reroot(arg), markup=False) # path may contain '[...]' → Rich markup
644
685
  else:
645
686
  _console.print(f" unknown command {cmd} (/help)", markup=False)
646
687
  return True
@@ -52,12 +52,17 @@ def load_prefs() -> dict:
52
52
 
53
53
 
54
54
  def save_prefs(updates: dict) -> None:
55
- """Merge non-empty `updates` into the prefs sidecar (atomic write). Best-effort; never raises."""
55
+ """Merge non-empty `updates` into the prefs sidecar (atomic write); an explicit None DELETES the
56
+ key (a stale `provider` pin must be removable — merge-only let an old endpoint pin resurrect at
57
+ the next boot under a model it doesn't serve). Best-effort; never raises."""
56
58
  try:
57
59
  import json
58
60
  path = _prefs_path()
59
61
  os.makedirs(os.path.dirname(path), exist_ok=True)
60
62
  cur = load_prefs()
63
+ for k, v in updates.items():
64
+ if v is None:
65
+ cur.pop(k, None)
61
66
  cur.update({k: v for k, v in updates.items() if v})
62
67
  tmp = path + ".tmp"
63
68
  with open(tmp, "w", encoding="utf-8") as f:
@@ -208,7 +208,11 @@ class OpenAILLM:
208
208
  or os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY"))
209
209
  use_proxy = bool(proxy) and proxy != "none"
210
210
  self.proxy_used = proxy if use_proxy else "direct" # exposed so the CLI can announce the route (A4)
211
- http_client = httpx.Client(proxy=proxy, timeout=timeout) if use_proxy else httpx.Client(timeout=timeout)
211
+ # trust_env=False: the proxy is resolved EXPLICITLY above (AGENT_PROXY/HTTPS_PROXY/HTTP_PROXY →
212
+ # _choose_proxy), so httpx must NOT ALSO auto-read ambient proxy env — otherwise AGENT_PROXY=none
213
+ # ("force direct") still routes through an ambient HTTPS_PROXY (httpx defaults trust_env=True).
214
+ http_client = (httpx.Client(proxy=proxy, timeout=timeout, trust_env=False) if use_proxy
215
+ else httpx.Client(timeout=timeout, trust_env=False))
212
216
 
213
217
  # Enforce the request timeout at the SDK layer too. The openai SDK applies its OWN per-request
214
218
  # timeout (default ~600s) which OVERRIDES the httpx client's, so without passing it here a
@@ -278,12 +282,18 @@ class OpenAILLM:
278
282
  use_proxy = bool(proxy) and proxy != "none"
279
283
  self.proxy_used = proxy if use_proxy else "direct"
280
284
  timeout = getattr(self, "_timeout", 60.0)
281
- http_client = (httpx.Client(proxy=proxy, timeout=timeout) if use_proxy
282
- else httpx.Client(timeout=timeout))
285
+ http_client = (httpx.Client(proxy=proxy, timeout=timeout, trust_env=False) if use_proxy
286
+ else httpx.Client(timeout=timeout, trust_env=False)) # explicit proxy only (see __init__)
283
287
  ckw: dict = {"api_key": new_key}
284
288
  if new_base:
285
289
  ckw["base_url"] = new_base
290
+ old_client = getattr(self, "client", None)
286
291
  self.client = OpenAI(http_client=http_client, timeout=timeout, max_retries=2, **ckw)
292
+ if old_client is not None: # close the replaced connection pool — /model hops leaked fds
293
+ try:
294
+ old_client.close()
295
+ except Exception: # noqa: BLE001 — cleanup must never break the switch
296
+ pass
287
297
  self._base_url = new_base or ""
288
298
  self._drop_reasoning_effort = False
289
299
  if model:
@@ -87,6 +87,8 @@ def _is_secret(text: str) -> bool:
87
87
  def _by_task(records: list[dict]) -> list[list[dict]]:
88
88
  groups: dict[str, list[dict]] = {}
89
89
  for r in records:
90
+ if not isinstance(r, dict): # a malformed episodic line (null/list/partial write, old schema)
91
+ continue # must not crash consolidation → silently DISCARD ALL lessons this session
90
92
  groups.setdefault(r.get("task_id", ""), []).append(r)
91
93
  return [sorted(g, key=lambda r: r.get("turn", 0)) for g in groups.values()]
92
94
 
@@ -98,7 +100,7 @@ def promote_episodes(records: list[dict]) -> list[dict]:
98
100
  # pass 1: collect the corrective pitfall of each task + count signatures (frequency, #4)
99
101
  cand = []
100
102
  for recs in _by_task(records):
101
- rmeta = [r.get("record", {}) for r in recs]
103
+ rmeta = [(r.get("record") if isinstance(r.get("record"), dict) else {}) for r in recs] # {"record": null} → {} (not None)
102
104
  had_fail = any(m.get("meta", {}).get("failing") for m in rmeta)
103
105
  last = rmeta[-1].get("meta", {})
104
106
  if not (had_fail and last.get("stop_reason") == "end_turn" and not last.get("failing")):
@@ -222,7 +224,7 @@ def promote_procedures(records: list[dict], *, min_actions: int = PROC_MIN_ACTIO
222
224
  EverOS rule), capped. Pure. Returns {kind, name, description, steps, files, freq, tags}."""
223
225
  cand = []
224
226
  for recs in _by_task(records):
225
- rmeta = [r.get("record", {}) for r in recs]
227
+ rmeta = [(r.get("record") if isinstance(r.get("record"), dict) else {}) for r in recs] # {"record": null} → {} (not None)
226
228
  if any(m.get("meta", {}).get("failing") for m in rmeta):
227
229
  continue # corrective → a fact, not a procedure
228
230
  last = rmeta[-1].get("meta", {})
@@ -140,10 +140,42 @@ def _read_config(path: str) -> dict:
140
140
  try:
141
141
  with open(path, "rb") as f:
142
142
  return tomllib.load(f)
143
- except (OSError, tomllib.TOMLDecodeError):
143
+ except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
144
+ # sibling parity with config.py:_read_toml — a non-UTF-8 or malformed config must DEGRADE, not
145
+ # crash `sliceagent config` / `config --use` / the init wizard with a traceback.
144
146
  return {}
145
147
 
146
148
 
149
+ def _backup_if_corrupt(path: str) -> "str | None":
150
+ """A rewrite reads the existing config to MERGE into; but _read_config returns {} for BOTH 'missing'
151
+ and 'unparseable', so a rewrite over a corrupt file would silently ERASE every provider key + section
152
+ it couldn't parse. Guard the writers: if `path` exists and is non-empty but does NOT parse, move it
153
+ aside to a non-clobbering .bak first (the keys survive, recoverable) and return the backup path; else
154
+ None (absent / empty / valid — nothing to protect)."""
155
+ import tomllib
156
+ try:
157
+ with open(path, "rb") as f:
158
+ raw = f.read()
159
+ except OSError:
160
+ return None
161
+ if not raw.strip():
162
+ return None
163
+ try:
164
+ tomllib.loads(raw.decode("utf-8"))
165
+ return None # parses fine → a normal merge is safe
166
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
167
+ pass
168
+ bak, n = path + ".bak", 0
169
+ while os.path.exists(bak): # never clobber an earlier backup
170
+ n += 1
171
+ bak = f"{path}.bak.{n}"
172
+ try:
173
+ os.replace(path, bak)
174
+ return bak
175
+ except OSError:
176
+ return None
177
+
178
+
147
179
  def _atomic_write(path: str, body: str) -> None:
148
180
  """ATOMIC 0600 write (the file holds an API key — never leave it half-written): write a temp in the same
149
181
  dir, fsync, then os.replace(); on ANY failure remove the temp so no key-bearing fragment is left behind."""
@@ -170,8 +202,10 @@ def _atomic_write(path: str, body: str) -> None:
170
202
  pass
171
203
 
172
204
 
173
- def _save_provider(path: str, *, pid: str, model: str, api_key: str, base_url: str) -> None:
174
- """Merge a provider into the config: add/update [providers.<pid>], set it as the default, keep the rest."""
205
+ def _save_provider(path: str, *, pid: str, model: str, api_key: str, base_url: str) -> "str | None":
206
+ """Merge a provider into the config: add/update [providers.<pid>], set it as the default, keep the rest.
207
+ Returns the path of a backup taken when the existing config was unparseable (else None)."""
208
+ bak = _backup_if_corrupt(path) # never silently erase keys in a corrupt config
175
209
  data = _read_config(path)
176
210
  provs = data.setdefault("providers", {})
177
211
  if not isinstance(provs, dict): # a corrupt non-dict providers must not crash on provs[pid]=
@@ -186,6 +220,7 @@ def _save_provider(path: str, *, pid: str, model: str, api_key: str, base_url: s
186
220
  agent["default_provider"] = pid
187
221
  agent["model"] = model # keep top-level model in sync (back-compat)
188
222
  _atomic_write(path, _emit_toml(data))
223
+ return bak
189
224
 
190
225
 
191
226
  def _test_key(model: str, api_key: str, base_url: str, llm_factory) -> tuple[bool, str]:
@@ -250,8 +285,20 @@ def run_init(*, inp=input, getpw=None, llm_factory=None, home=None) -> int:
250
285
  for k in keys:
251
286
  _id, label, _base, model0 = PROVIDERS[k]
252
287
  out(f" {k}. {label}" + (f" ({model0})" if model0 else ""))
253
- choice = inp(" > ").strip() or "1"
254
- pid, label, base_url, model = PROVIDERS.get(choice, PROVIDERS["1"])
288
+ # an unrecognized answer must NOT silently become provider 1 (a typed "openai" used to
289
+ # configure OpenRouter without a word) — accept a number OR a provider id, else re-ask.
290
+ choice = None
291
+ for _attempt in range(3):
292
+ raw = inp(" > ").strip() or "1"
293
+ if raw in PROVIDERS:
294
+ choice = raw; break
295
+ byname = next((k for k in keys if PROVIDERS[k][0] == raw.lower()), None)
296
+ if byname:
297
+ choice = byname; break
298
+ out(f" not a valid choice: {raw!r} — enter 1-{len(PROVIDERS)} or a provider name")
299
+ if choice is None:
300
+ out(" no valid choice — aborting."); return 1
301
+ pid, label, base_url, model = PROVIDERS[choice]
255
302
  if pid == "custom":
256
303
  base_url = inp(" Base URL (OpenAI-compatible, e.g. https://host/v1): ").strip()
257
304
  # RE-CONFIGURING an ALREADY-SAVED provider (re-running `sliceagent init` to update the model,
@@ -303,7 +350,9 @@ def run_init(*, inp=input, getpw=None, llm_factory=None, home=None) -> int:
303
350
  if cont not in ("y", "yes"):
304
351
  out(" Not saved. Re-run `sliceagent init` to try again."); return 1
305
352
 
306
- _save_provider(path, pid=pid, model=model, api_key=key, base_url=base_url)
353
+ bak = _save_provider(path, pid=pid, model=model, api_key=key, base_url=base_url)
354
+ if bak:
355
+ out(f" ⚠ the existing config didn't parse — moved it to {bak} (its keys are recoverable there).")
307
356
  out(f"\n Saved provider '{pid}' (model {model}) → {path} (0600)")
308
357
  out(" Ready. Run: sliceagent\n")
309
358
  return 0
@@ -356,7 +405,8 @@ def run_config(argv=None, *, home=None, env=None) -> int:
356
405
  out(" providers (* = default · `config --use <id>` to switch):")
357
406
  for pid, p in provs.items():
358
407
  mark = "*" if pid == default else " "
359
- out(f" {mark} {pid} ({(p or {}).get('model', '?')})")
408
+ model = p.get("model", "?") if isinstance(p, dict) else "?" # a scalar under [providers] must not crash
409
+ out(f" {mark} {pid} ({model})")
360
410
  out(" set values:")
361
411
  any_set = False
362
412
  for e in REGISTRY:
@@ -96,11 +96,30 @@ def ask_mutations(name: str, args: dict) -> Optional[ToolDecision]:
96
96
  # mode runs it without a confirm prompt. Deny-by-default: an unknown verb still asks. The catastrophic floor
97
97
  # (no_dangerous_commands) runs BEFORE this, so e.g. `cat /etc/passwd` is still denied.
98
98
  _RO_VERBS = frozenset((
99
- "ls", "pwd", "cat", "head", "tail", "wc", "echo", "which", "type", "env", "printenv", "date",
99
+ "ls", "pwd", "cat", "head", "tail", "wc", "echo", "which", "type", "printenv", "date",
100
100
  "whoami", "hostname", "uname", "id", "groups", "tree", "stat", "file", "du", "df", "realpath",
101
- "dirname", "basename", "grep", "rg", "egrep", "fgrep", "sort", "uniq", "nl", "cut", "column",
101
+ "dirname", "basename", "grep", "rg", "egrep", "fgrep", "sort", "nl", "cut", "column",
102
102
  "cksum", "md5", "md5sum", "sha1sum", "sha256sum", "true",
103
103
  ))
104
+ # `env` and `uniq` were REMOVED from the allowlist: `env <program>` executes an arbitrary program, and
105
+ # `uniq [IN] OUT` OVERWRITES its optional second positional — neither is provably read-only, so they now
106
+ # take the normal confirm path. A few remaining allowlisted verbs stay read-only ONLY without a specific
107
+ # WRITE/EXEC option; deny-by-default when that option is present (verb-scoped so read-only siblings like
108
+ # `du -s` / `grep -o` keep auto-running). Value-taking flags match `-o`, `-o=…`, `-oFILE`, `--output=…`.
109
+ _UNSAFE_OPTS = {
110
+ "sort": ("-o", "--output"), # -o FILE / --output=FILE overwrite a file
111
+ "date": ("-s", "--set"), # set the system clock (a mutation)
112
+ "tree": ("-o",), # -o FILE writes the listing to a file
113
+ }
114
+
115
+
116
+ def _has_unsafe_opt(verb: str, toks: list) -> bool:
117
+ bad = _UNSAFE_OPTS.get(verb, ())
118
+ for t in toks[1:]:
119
+ for b in bad:
120
+ if t == b or t.startswith(b + "=") or (len(b) == 2 and len(t) > 2 and t.startswith(b)):
121
+ return True
122
+ return False
104
123
  # git subcommands with NO mutating form (excludes branch/tag/config/remote/stash/reflog, which can write).
105
124
  _RO_GIT_SUB = frozenset((
106
125
  "status", "log", "diff", "show", "rev-parse", "ls-files", "ls-tree", "describe", "blame",
@@ -127,10 +146,15 @@ def _is_readonly_command(cmd: str) -> bool:
127
146
  verb = os.path.basename(toks[0])
128
147
  if verb == "git":
129
148
  sub = next((t for t in toks[1:] if not t.startswith("-")), "")
130
- return sub in _RO_GIT_SUB
149
+ if sub not in _RO_GIT_SUB:
150
+ return False
151
+ # `git grep --open-files-in-pager[=<cmd>]` / `-O[<cmd>]` runs a PAGER program (arbitrary exec).
152
+ if sub == "grep" and any(t.startswith("--open-files-in-pager") or t.startswith("-O") for t in toks):
153
+ return False
154
+ return True
131
155
  if verb == "find":
132
156
  return not any(t in _FIND_MUTATORS for t in toks)
133
- return verb in _RO_VERBS
157
+ return verb in _RO_VERBS and not _has_unsafe_opt(verb, toks)
134
158
 
135
159
 
136
160
  def ask_commands(name: str, args: dict) -> Optional[ToolDecision]:
@@ -747,14 +747,20 @@ class LocalToolHost:
747
747
 
748
748
  @staticmethod
749
749
  def _detect_crlf(full: str) -> bool:
750
- """True if the existing file uses Windows CRLF line endings (sample the head). Used to PRESERVE
750
+ """True if the existing file is DOMINANTLY Windows CRLF (sample the head). Used to PRESERVE
751
751
  line endings on edit: the model emits '\\n', and writing that to a CRLF file rewrites every line
752
- ending — a huge spurious diff / corruption on Windows-authored repos."""
752
+ ending — a huge spurious diff / corruption on Windows-authored repos. DOMINANCE (not mere
753
+ presence): a mostly-LF file with one embedded '\\r\\n' (a byte literal, an HTTP fixture, a merge
754
+ artifact) must NOT be flipped whole-file to CRLF — while a uniformly-CRLF file with one stray LF
755
+ still counts as CRLF. crlf ≥ (bare-LF) covers both, and keeps the pinned uniform cases."""
753
756
  try:
754
757
  with open(full, "rb") as f:
755
- return b"\r\n" in f.read(65536)
758
+ head = f.read(65536)
756
759
  except OSError:
757
760
  return False
761
+ crlf = head.count(b"\r\n")
762
+ lf_only = head.count(b"\n") - crlf # LFs that are NOT part of a CRLF
763
+ return crlf > 0 and crlf >= lf_only
758
764
 
759
765
  @staticmethod
760
766
  def _preserve_eol(text: str, crlf: bool) -> str:
@@ -864,12 +864,16 @@ def _model_candidates(llm, cfg):
864
864
 
865
865
  def _reasoning_levels(model, base_url):
866
866
  """Reasoning levels valid for a model, derived from its capability (provider-aware). Effort-capable
867
- models (gpt-5/o-series) expose all four; others only fast/full (high/max would degrade to default)."""
867
+ models (gpt-5/o-series) expose all four; OpenRouter exposes fast/full/high (its unified reasoning
868
+ object maps max→high, so offering max would be a lie); others only fast/full (high/max would
869
+ degrade to default)."""
868
870
  from .model_catalog import capability
869
871
  full4 = [("fast", "minimal reasoning — fastest, cheapest"),
870
872
  ("full", "provider default reasoning"),
871
873
  ("high", "deeper reasoning (effort=high, /v1/responses)"),
872
874
  ("max", "deepest reasoning (effort=xhigh)")]
875
+ if "openrouter" in (base_url or "").lower(): # unified reasoning object honors effort WITH tools
876
+ return full4[:2] + [("high", "deeper reasoning (unified reasoning, effort=high)")]
873
877
  if capability(model, base_url).supports_reasoning_effort:
874
878
  return full4
875
879
  return full4[:2] # fast | full only — the model has no effort knob
@@ -888,7 +892,10 @@ def select_model_reasoning(llm, cfg, *, pt_input=None, pt_output=None):
888
892
  if pick is None:
889
893
  return None
890
894
  model, _grp, pid = cands[pick]
891
- base = getattr(llm, "_base_url", "") if model == llm.model else ""
895
+ if pid: # the pick will REBIND to this provider offer the levels ITS endpoint supports
896
+ base = ((cfg.providers() or {}).get(pid) or {}).get("base_url") or ""
897
+ else:
898
+ base = getattr(llm, "_base_url", "") if model == llm.model else ""
892
899
  levels = _reasoning_levels(model, base)
893
900
  lvl_rows = [(name, desc) for name, desc in levels]
894
901
  lvl_cur = next((i for i, (n, _) in enumerate(levels) if n == llm.reasoning), -1)
@@ -918,7 +918,10 @@ def cost_chart_renders_flat_vs_rising():
918
918
  import sys
919
919
  root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
920
920
  sys.path.insert(0, os.path.join(root, "evals"))
921
- import realenv_multiturn as rem # importable WITHOUT the heavy swebench dep
921
+ try:
922
+ import realenv_multiturn as rem # importable WITHOUT the heavy swebench dep
923
+ except ModuleNotFoundError:
924
+ return # evals/ is a local-only dev tree, not shipped in the installed package (CI) — skip, don't fail
922
925
  rows = [{"turn": i, "peak_in": 6000, "transcript": 6000 * i} for i in range(1, 13)]
923
926
  chart = rem.render_cost_chart(rows)
924
927
  assert "sliceagent" in chart and "transcript" in chart and "12×" in chart, chart
@@ -1344,6 +1347,80 @@ def model_switch_warns_when_the_endpoint_cant_serve_it():
1344
1347
  assert "OpenAI" not in _reasoning_note(LLM("gpt-5.5", "", "high"))
1345
1348
 
1346
1349
 
1350
+ # ── Round 2 (2026-07-03) — deep-core hunt confirmed fixes ─────────────────────────────────────
1351
+ @check
1352
+ def durable_log_redacts_secrets_in_NESTED_tool_args():
1353
+ # HIGH: _scrub_args redacted only top-level string values → a secret in a nested dict/list arg
1354
+ # (an MCP tool's {config:{api_key:…}} / {headers:{Authorization:…}}) hit the on-disk log in plaintext.
1355
+ from sliceagent.cli import log_sink
1356
+ from sliceagent.events import ToolResult
1357
+ d = tempfile.mkdtemp(); path = os.path.join(d, "log.jsonl")
1358
+ sink = log_sink(root=d, path=path)
1359
+ secret = "sk-verylongsecretkey1234567890abcdefghij"
1360
+ tok = "ghp_1234567890abcdefghijklmnopqrstuvwx"
1361
+ sink(ToolResult("mcp_call", {"config": {"api_key": secret}, "items": [tok]}, "ok", False))
1362
+ body = open(path).read()
1363
+ assert secret not in body, "a secret in a NESTED dict arg must be redacted before the durable log"
1364
+ assert tok not in body, "a secret in a NESTED list arg must be redacted too"
1365
+
1366
+
1367
+ @check
1368
+ def detect_crlf_is_dominance_not_mere_presence():
1369
+ from sliceagent.tools import LocalToolHost
1370
+ h = LocalToolHost(tempfile.mkdtemp())
1371
+ def probe(name, data):
1372
+ p = os.path.join(h.root(), name); open(p, "wb").write(data); return h._detect_crlf(p)
1373
+ assert probe("u_crlf", b"a\r\nb\r\nc\r\n") is True # uniform CRLF → CRLF (pinned)
1374
+ assert probe("u_lf", b"a\nb\nc\n") is False # uniform LF → LF (pinned)
1375
+ assert probe("mixed", b'x = b"h\r\n"\np = 8080\nd = 1\n') is False # mostly-LF + 1 CRLF → LF (the fix)
1376
+ assert probe("winstray", b"a\r\nb\r\nc\r\nd\n") is True # CRLF-dominant + 1 stray LF → CRLF
1377
+
1378
+
1379
+ @check
1380
+ def consolidation_survives_a_malformed_episodic_record():
1381
+ from sliceagent.neocortex import promote_episodes, promote_procedures
1382
+ good = [{"task_id": "t", "turn": 1, "record": {"meta": {"failing": True}}},
1383
+ {"task_id": "t", "turn": 2, "record": {"meta": {"failing": False, "stop_reason": "end_turn"}}}]
1384
+ for bad in (None, [1, 2], "str", 42, {"record": None}): # each a malformed line among good ones
1385
+ promote_episodes(good + [bad]) # must not raise (was AttributeError → lost ALL lessons)
1386
+ promote_procedures(good + [bad])
1387
+
1388
+
1389
+ @check
1390
+ def direct_llm_client_ignores_ambient_proxy_env():
1391
+ import sliceagent.llm as _llm
1392
+ saved = {k: os.environ.get(k) for k in ("AGENT_PROXY", "HTTPS_PROXY", "HTTP_PROXY", "LLM_API_KEY")}
1393
+ try:
1394
+ os.environ.update({"AGENT_PROXY": "none", "HTTPS_PROXY": "http://ambient:7890", "LLM_API_KEY": "k"})
1395
+ llm = _llm.OpenAILLM(model="deepseek-chat", base_url="https://api.deepseek.com/v1")
1396
+ assert llm.proxy_used == "direct"
1397
+ assert llm.client._client.trust_env is False, "AGENT_PROXY=none must truly force direct (no ambient proxy)"
1398
+ llm.switch(model="gpt-5.5", base_url="", api_key="k2")
1399
+ assert llm.client._client.trust_env is False, "the switch() direct client must ignore ambient proxy too"
1400
+ finally:
1401
+ for k, v in saved.items():
1402
+ os.environ.pop(k, None) if v is None else os.environ.__setitem__(k, v)
1403
+
1404
+
1405
+ @check
1406
+ def slash_handlers_print_bracketed_paths_without_markup_crash():
1407
+ # /undo and /cwd echo a workspace PATH; a Next.js '[id]' segment is a Rich tag → MarkupError.
1408
+ from io import StringIO
1409
+
1410
+ from rich.console import Console
1411
+ con = Console(file=StringIO(), force_terminal=False)
1412
+ # a real /cwd echo: `_reroot("~/proj/[/]")` returns "not a directory: ~/proj/[/]" — '[/]' is a Rich
1413
+ # closing tag with nothing to close (the verifiers' exact repro).
1414
+ con.print(" ✓ not a directory: ~/proj/[/]", markup=False) # must not raise
1415
+ con.print(" Undid the last edit to app/[id]/page.tsx (1 change).", markup=False)
1416
+ raised = False
1417
+ try:
1418
+ con.print("not a directory: ~/proj/[/]") # markup ON → the MarkupError the fix avoids
1419
+ except Exception: # noqa: BLE001
1420
+ raised = True
1421
+ assert raised, "sanity: '[/]' DOES break Rich with markup on (so markup=False is load-bearing)"
1422
+
1423
+
1347
1424
  def main():
1348
1425
  failed = 0
1349
1426
  for fn in CHECKS: