coderio 0.4.1__tar.gz → 0.4.2__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 (192) hide show
  1. {coderio-0.4.1 → coderio-0.4.2}/CHANGELOG.md +100 -2
  2. {coderio-0.4.1 → coderio-0.4.2}/PKG-INFO +17 -5
  3. {coderio-0.4.1 → coderio-0.4.2}/README.md +17 -5
  4. {coderio-0.4.1 → coderio-0.4.2}/README_en.md +16 -4
  5. {coderio-0.4.1 → coderio-0.4.2}/pyproject.toml +1 -1
  6. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/agent/_deepagents_compat.py +39 -10
  7. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/agent/deep_loop.py +40 -6
  8. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/agent/hooks.py +75 -15
  9. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/cli/app.py +18 -3
  10. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/cli/run_cmd.py +111 -27
  11. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/config/loader.py +20 -8
  12. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/config/models.py +7 -1
  13. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/config/trust.py +69 -15
  14. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/models.py +9 -3
  15. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/tools/sandbox_runner.py +11 -0
  16. {coderio-0.4.1 → coderio-0.4.2}/tests/agent/test_deepagents_compat.py +80 -0
  17. {coderio-0.4.1 → coderio-0.4.2}/tests/agent/test_hooks.py +116 -0
  18. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_run_cmd.py +112 -5
  19. {coderio-0.4.1 → coderio-0.4.2}/tests/config/test_trust.py +79 -0
  20. {coderio-0.4.1 → coderio-0.4.2}/uv.lock +1 -1
  21. {coderio-0.4.1 → coderio-0.4.2}/.github/dependabot.yml +0 -0
  22. {coderio-0.4.1 → coderio-0.4.2}/.github/workflows/ci.yml +0 -0
  23. {coderio-0.4.1 → coderio-0.4.2}/.github/workflows/release.yml +0 -0
  24. {coderio-0.4.1 → coderio-0.4.2}/.gitignore +0 -0
  25. {coderio-0.4.1 → coderio-0.4.2}/CONTRIBUTING.md +0 -0
  26. {coderio-0.4.1 → coderio-0.4.2}/LICENSE +0 -0
  27. {coderio-0.4.1 → coderio-0.4.2}/THIRD_PARTY_LICENSES.md +0 -0
  28. {coderio-0.4.1 → coderio-0.4.2}/docs/coderio-architecture.md +0 -0
  29. {coderio-0.4.1 → coderio-0.4.2}/scripts/verify_deepagent_live.py +0 -0
  30. {coderio-0.4.1 → coderio-0.4.2}/scripts/verify_harness_live.py +0 -0
  31. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/__init__.py +0 -0
  32. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/agent/__init__.py +0 -0
  33. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/agent/command_review.py +0 -0
  34. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/agent/harness.py +0 -0
  35. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/agent/harness_middleware.py +0 -0
  36. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/agent/permission_middleware.py +0 -0
  37. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/agent/prompts.py +0 -0
  38. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/agent/skill_tool.py +0 -0
  39. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/agent/state.py +0 -0
  40. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/agent/stream.py +0 -0
  41. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/cli/__init__.py +0 -0
  42. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/cli/commands.py +0 -0
  43. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/cli/credentials.py +0 -0
  44. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/cli/mcp_cmd.py +0 -0
  45. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/cli/multimodal.py +0 -0
  46. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/cli/onboarding.py +0 -0
  47. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/cli/providers.py +0 -0
  48. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/cli/render.py +0 -0
  49. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/cli/repl.py +0 -0
  50. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/cli/skills_cmd.py +0 -0
  51. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/cli/stream.py +0 -0
  52. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/cli/tui.py +0 -0
  53. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/cli/tui_onboarding.py +0 -0
  54. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/cli/tui_screens.py +0 -0
  55. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/cli/tui_widgets.py +0 -0
  56. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/config/__init__.py +0 -0
  57. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/config/bootstrap.py +0 -0
  58. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/llm/__init__.py +0 -0
  59. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/llm/factory.py +0 -0
  60. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/llm/probe.py +0 -0
  61. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/mcp_loader.py +0 -0
  62. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/session/__init__.py +0 -0
  63. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/session/message.py +0 -0
  64. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/session/store.py +0 -0
  65. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/__init__.py +0 -0
  66. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/bundled/executing-plans/SKILL.md +0 -0
  67. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/LICENSE +0 -0
  68. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/README.md +0 -0
  69. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +0 -0
  70. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +0 -0
  71. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/code-review/SKILL.md +0 -0
  72. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/code-review/evals/evals.json +0 -0
  73. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/commit-message/SKILL.md +0 -0
  74. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/commit-message/evals/evals.json +0 -0
  75. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/debugging/SKILL.md +0 -0
  76. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/debugging/evals/evals.json +0 -0
  77. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/error-handling/SKILL.md +0 -0
  78. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/error-handling/evals/evals.json +0 -0
  79. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +0 -0
  80. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +0 -0
  81. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/naming/SKILL.md +0 -0
  82. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/naming/evals/evals.json +0 -0
  83. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +0 -0
  84. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +0 -0
  85. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/spec-writing/SKILL.md +0 -0
  86. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +0 -0
  87. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +0 -0
  88. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +0 -0
  89. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/testing/SKILL.md +0 -0
  90. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/testing/evals/evals.json +0 -0
  91. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +0 -0
  92. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +0 -0
  93. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/parser.py +0 -0
  94. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/skills/store.py +0 -0
  95. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/tools/__init__.py +0 -0
  96. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/tools/base.py +0 -0
  97. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/tools/bash.py +0 -0
  98. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/tools/command_policy.py +0 -0
  99. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/tools/edit_file.py +0 -0
  100. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/tools/glob_tool.py +0 -0
  101. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/tools/grep_tool.py +0 -0
  102. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/tools/linux_sandbox.py +0 -0
  103. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/tools/list_dir.py +0 -0
  104. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/tools/multi_edit.py +0 -0
  105. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/tools/note.py +0 -0
  106. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/tools/permission.py +0 -0
  107. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/tools/read_file.py +0 -0
  108. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/tools/todo.py +0 -0
  109. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/tools/web_fetch.py +0 -0
  110. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/tools/web_search.py +0 -0
  111. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/tools/win_job.py +0 -0
  112. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/tools/win_sandbox.py +0 -0
  113. {coderio-0.4.1 → coderio-0.4.2}/src/coderio/tools/write_file.py +0 -0
  114. {coderio-0.4.1 → coderio-0.4.2}/tests/__init__.py +0 -0
  115. {coderio-0.4.1 → coderio-0.4.2}/tests/agent/__init__.py +0 -0
  116. {coderio-0.4.1 → coderio-0.4.2}/tests/agent/conftest.py +0 -0
  117. {coderio-0.4.1 → coderio-0.4.2}/tests/agent/test_command_review.py +0 -0
  118. {coderio-0.4.1 → coderio-0.4.2}/tests/agent/test_deep_integration.py +0 -0
  119. {coderio-0.4.1 → coderio-0.4.2}/tests/agent/test_deep_loop.py +0 -0
  120. {coderio-0.4.1 → coderio-0.4.2}/tests/agent/test_deep_loop_unit.py +0 -0
  121. {coderio-0.4.1 → coderio-0.4.2}/tests/agent/test_harness.py +0 -0
  122. {coderio-0.4.1 → coderio-0.4.2}/tests/agent/test_harness_middleware.py +0 -0
  123. {coderio-0.4.1 → coderio-0.4.2}/tests/agent/test_perf_baseline.py +0 -0
  124. {coderio-0.4.1 → coderio-0.4.2}/tests/agent/test_prompts.py +0 -0
  125. {coderio-0.4.1 → coderio-0.4.2}/tests/agent/test_skill_tool.py +0 -0
  126. {coderio-0.4.1 → coderio-0.4.2}/tests/agent/test_state.py +0 -0
  127. {coderio-0.4.1 → coderio-0.4.2}/tests/agent/test_stream.py +0 -0
  128. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/__init__.py +0 -0
  129. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_app.py +0 -0
  130. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_command_menu.py +0 -0
  131. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_commands.py +0 -0
  132. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_credentials.py +0 -0
  133. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_input_isolation.py +0 -0
  134. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_live_thinking.py +0 -0
  135. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_mcp_cmd.py +0 -0
  136. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_multimodal.py +0 -0
  137. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_onboarding.py +0 -0
  138. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_onboarding_logic.py +0 -0
  139. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_providers.py +0 -0
  140. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_render.py +0 -0
  141. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_repl_loop.py +0 -0
  142. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_repl_reset.py +0 -0
  143. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_skills_cmd.py +0 -0
  144. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_status_bar.py +0 -0
  145. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_stream.py +0 -0
  146. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_truncation.py +0 -0
  147. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_tui_onboarding.py +0 -0
  148. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_tui_picker.py +0 -0
  149. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_tui_screens.py +0 -0
  150. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_tui_startup.py +0 -0
  151. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_tui_widgets.py +0 -0
  152. {coderio-0.4.1 → coderio-0.4.2}/tests/cli/test_ux_features.py +0 -0
  153. {coderio-0.4.1 → coderio-0.4.2}/tests/config/__init__.py +0 -0
  154. {coderio-0.4.1 → coderio-0.4.2}/tests/config/test_bootstrap.py +0 -0
  155. {coderio-0.4.1 → coderio-0.4.2}/tests/config/test_loader.py +0 -0
  156. {coderio-0.4.1 → coderio-0.4.2}/tests/config/test_models.py +0 -0
  157. {coderio-0.4.1 → coderio-0.4.2}/tests/conftest.py +0 -0
  158. {coderio-0.4.1 → coderio-0.4.2}/tests/e2e/__init__.py +0 -0
  159. {coderio-0.4.1 → coderio-0.4.2}/tests/llm/__init__.py +0 -0
  160. {coderio-0.4.1 → coderio-0.4.2}/tests/llm/test_factory.py +0 -0
  161. {coderio-0.4.1 → coderio-0.4.2}/tests/llm/test_probe.py +0 -0
  162. {coderio-0.4.1 → coderio-0.4.2}/tests/session/__init__.py +0 -0
  163. {coderio-0.4.1 → coderio-0.4.2}/tests/session/test_message.py +0 -0
  164. {coderio-0.4.1 → coderio-0.4.2}/tests/session/test_store.py +0 -0
  165. {coderio-0.4.1 → coderio-0.4.2}/tests/skills/__init__.py +0 -0
  166. {coderio-0.4.1 → coderio-0.4.2}/tests/skills/test_bundled.py +0 -0
  167. {coderio-0.4.1 → coderio-0.4.2}/tests/skills/test_parser.py +0 -0
  168. {coderio-0.4.1 → coderio-0.4.2}/tests/skills/test_skill_tools.py +0 -0
  169. {coderio-0.4.1 → coderio-0.4.2}/tests/skills/test_store.py +0 -0
  170. {coderio-0.4.1 → coderio-0.4.2}/tests/test_mcp_loader.py +0 -0
  171. {coderio-0.4.1 → coderio-0.4.2}/tests/test_p0_stability.py +0 -0
  172. {coderio-0.4.1 → coderio-0.4.2}/tests/test_repl.py +0 -0
  173. {coderio-0.4.1 → coderio-0.4.2}/tests/test_version.py +0 -0
  174. {coderio-0.4.1 → coderio-0.4.2}/tests/tools/__init__.py +0 -0
  175. {coderio-0.4.1 → coderio-0.4.2}/tests/tools/test_bash.py +0 -0
  176. {coderio-0.4.1 → coderio-0.4.2}/tests/tools/test_command_policy.py +0 -0
  177. {coderio-0.4.1 → coderio-0.4.2}/tests/tools/test_concrete_gates.py +0 -0
  178. {coderio-0.4.1 → coderio-0.4.2}/tests/tools/test_edit_prefix.py +0 -0
  179. {coderio-0.4.1 → coderio-0.4.2}/tests/tools/test_file_tools.py +0 -0
  180. {coderio-0.4.1 → coderio-0.4.2}/tests/tools/test_glob_tool.py +0 -0
  181. {coderio-0.4.1 → coderio-0.4.2}/tests/tools/test_grep_tool.py +0 -0
  182. {coderio-0.4.1 → coderio-0.4.2}/tests/tools/test_list_dir.py +0 -0
  183. {coderio-0.4.1 → coderio-0.4.2}/tests/tools/test_multi_edit.py +0 -0
  184. {coderio-0.4.1 → coderio-0.4.2}/tests/tools/test_note.py +0 -0
  185. {coderio-0.4.1 → coderio-0.4.2}/tests/tools/test_permission.py +0 -0
  186. {coderio-0.4.1 → coderio-0.4.2}/tests/tools/test_registry.py +0 -0
  187. {coderio-0.4.1 → coderio-0.4.2}/tests/tools/test_sandbox.py +0 -0
  188. {coderio-0.4.1 → coderio-0.4.2}/tests/tools/test_search_tools.py +0 -0
  189. {coderio-0.4.1 → coderio-0.4.2}/tests/tools/test_todo.py +0 -0
  190. {coderio-0.4.1 → coderio-0.4.2}/tests/tools/test_web.py +0 -0
  191. {coderio-0.4.1 → coderio-0.4.2}/tests/tools/test_web_fetch.py +0 -0
  192. {coderio-0.4.1 → coderio-0.4.2}/tests/tools/test_win_job.py +0 -0
@@ -6,6 +6,106 @@ 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.2] — 2026-08-18
10
+
11
+ ### Security — v3 audit short-term batch (#7/#8/#9/#11/#14, all runtime-verified)
12
+ - **#7 · headless default permission full → plan**: a headless entry that
13
+ silently allowed everything was a zero-confirmation door. `coderio run` now
14
+ defaults to read-only plan; full requires the explicit
15
+ `--dangerously-skip-permissions` flag (Claude Code's name for the same
16
+ escape hatch). confirm/auto_edit fail fast without a TTY (the gate is lazy,
17
+ so input() would otherwise EOFError mid-execution); invalid --permission
18
+ values are rejected up front.
19
+ - **#8 · project-layer skills join the trust gate**: a repo shipping ONLY
20
+ `.coderio/skills` previously loaded them with zero confirmation — yet skills
21
+ enter the system prompt and may carry tools.py that exec's on activation.
22
+ Skills now participate in discovery + content fingerprint (per-file hash;
23
+ any skill edit re-triggers the prompt), and the confirmation summary marks
24
+ skills carrying tools.py with "⚠ executes code". tools.py load failures are
25
+ logged, no longer silent.
26
+ - **#9 · trust store hardening**: the store gets owner-only permissions
27
+ (POSIX 0600 / Windows icacls, reusing the credentials helper); a corrupt
28
+ store is left untouched instead of being reset (the old reset destroyed
29
+ every other repo's trust entries); `SandboxFsConfig.deny_write` now
30
+ defaults to `["~/.coderio"]` so sandboxed commands can't rewrite the
31
+ config/credentials/trust store (explicit `deny_write = []` opts out;
32
+ Linux bwrap only — Windows sandbox is a no-op today).
33
+ - **#11 · research subagent execution-time enforcement**: PermissionMiddleware
34
+ + CommandReviewMiddleware now ride the research subagent (the tool
35
+ whitelist filters what the model SEES; these gate what actually RUNS).
36
+ Failover direction reversed: a whitelist that cannot be constructed — or
37
+ fails at runtime — degrades to a DENY-ALL middleware (zero tools), not to
38
+ the old no-middleware state that inherited every tool including
39
+ write/execute.
40
+ - **#14 · headless wall-clock timeout + exit codes**: `--timeout <seconds>`
41
+ (thread+join; SIGALRM doesn't exist on Windows) exits 124 on expiry;
42
+ agent execution failures exit 2 (distinct from config errors' 1); success
43
+ 0. Documented in README for CI use.
44
+
45
+ ### Fixed — hooks completion (2026-08-14 v3 audit P1/P2 items)
46
+ - **Timeout latency (v3 P1: timeout=2 hook took 12s)**: the post-kill pipe
47
+ drain waited 10s for an EOF that never comes on Windows (a pre-kill
48
+ grandchild holds the write end) — and the drained output was never consumed
49
+ anyway (hardcoded empty return). Now a 1s grace then abandon: a timeout=2
50
+ hook returns in ~3s. Windows grandchild leak documented as a known
51
+ limitation (the turn is no longer hostage; root fix = CREATE_SUSPENDED +
52
+ pre-assigned Job + stdin pipe through _create_process_with_token). Latency
53
+ regression test added (`elapsed < timeout + 2`), mutation-verified.
54
+ - **Per-event budget (v3 P1: N hooks × 60s on every tool call)**: fire() now
55
+ enforces a 30s total budget per event (overridable on HookRunner for
56
+ tests). Each hook's timeout tightens to min(spec.timeout, budget
57
+ remaining); exhaustion skips the remaining hooks with an error note
58
+ (fail-open, consistent with the module's positioning).
59
+ - **Subagents bypassed hooks (v3 #12)**: research and general-purpose
60
+ subagents both carry HooksMiddleware now (outermost, same order as the main
61
+ agent) — task() delegation can no longer sidestep PreToolUse/PostToolUse.
62
+ No middleware overhead when no hooks are configured.
63
+ - **Repo [[hooks]] silently dropped user hooks (v3 P2)**: _merge replaces
64
+ lists wholesale, so a repo's hooks config wiped the user's protective
65
+ hooks. Hooks now APPEND across layers — user hooks first (first-blocker-
66
+ wins: the user's deny reason is what the model sees). The first and only
67
+ list-merge in the loader; every other key keeps replace semantics.
68
+ - +7 tests (latency guard, budget skip/tighten, subagent middleware stacks ×3,
69
+ merge order). Mutation check: reintroducing the 10s drain turns the latency
70
+ test red (10.1s ≥ 4s bound) — the guard actually guards.
71
+
72
+ ### Fixed — 2026-08-18 self-audit (3 bugs + 3 warnings, all runtime-verified)
73
+ - **BUG A · `--permission full` had no flag gate (claim vs code mismatch)**:
74
+ the 0.4.1 commit message and CHANGELOG claimed "full requires
75
+ --dangerously-skip-permissions" — no such gate existed; `--permission full`
76
+ alone ran with full access (verified end-to-end by an audit agent that
77
+ unintentionally ran three real model turns through it). The gate is real
78
+ now: full without the flag exits 1. +2 tests (negative and positive sides).
79
+ Fourth seam-class failure in project history (v1: deepagents↔coderio; v3:
80
+ config↔runtime, trust↔mcp_loader; this: message↔code) — new rule in the
81
+ audit report: every behavior claim in a commit message must have a
82
+ corresponding verified command output before release.
83
+ - **BUG B · Windows NUL bypassed the confirm-mode TTY check**: `</dev/null`
84
+ (the classic CI idiom) makes `sys.stdin.isatty()` return True on Windows
85
+ (MSVCRT treats character devices as ttys) — the check passed, then
86
+ `input()` read NUL, EOFError'd, and crashed the agent mid-execution.
87
+ confirm/auto_edit are no longer valid headless values AT ALL (rejected
88
+ unconditionally); the interactive TUI is the only prompting surface.
89
+ - **BUG C · the deny_write default was dead config for most users**:
90
+ SandboxFsConfig's `deny_write=["~/.coderio"]` default only applied when the
91
+ `[tools.sandbox_filesystem]` table EXISTED — users without the table got
92
+ fs_config=None, and bwrap's built-in layout had no deny_write, so sandboxed
93
+ commands could write ~/.coderio (config/credentials/TRUST STORE). The
94
+ sandbox runner now constructs the default config when fs_config is None;
95
+ explicit user configs (including `deny_write = []`) pass through unchanged.
96
+ - **hooks budget skip-count was dead code**: `self.specs[len(self.specs):]`
97
+ is always the empty slice, so the "N hook(s) skipped" message always
98
+ printed "remaining" — the count never appeared (and no test asserted it).
99
+ Fixed with enumerate-indexed slicing (verified: 4 hooks skipped →
100
+ "4 hook(s) skipped").
101
+ - **trust store `null` slipped the corruption guard**: JSON `null` parsed to
102
+ None and bypassed the `is not None` shape check, getting overwritten. All
103
+ non-dict JSON (including null) now leaves the store untouched.
104
+ - **nested typer.Exit produced a misleading second error line**: typer.Exit
105
+ is a RuntimeError subclass, so the session-load failure's clean exit was
106
+ re-caught by "Runtime setup failed" and printed twice. Nested exits now
107
+ pass through with their original code and message.
108
+
9
109
  ## [0.4.1] — 2026-08-17
10
110
 
11
111
  ### Added
@@ -67,8 +167,6 @@ All notable changes to coderio are documented here. The format follows
67
167
  solely on the token stream; a non-streamed final message vanished. The
68
168
  final result is now always printed (after a separator in non-quiet mode).
69
169
 
70
- ## [Unreleased]
71
-
72
170
  ## [0.4.0] — 2026-08-17
73
171
 
74
172
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: coderio
3
- Version: 0.4.1
3
+ Version: 0.4.2
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
@@ -81,7 +81,7 @@ Core philosophy: **skills are the playbook, the harness is the discipline, tools
81
81
  - **SSRF-protected web_fetch**: scheme allowlist (http/https only), private/loopback/link-local IP blocking (incl. cloud metadata endpoints), per-hop redirect validation, and a 1 MB response cap.
82
82
  - **Multi-layer OS sandbox** (Linux real isolation): `sandbox_mode = "job" | "write"` — Job Object resource limits on Windows; bubblewrap namespace isolation on Linux (read-only root, read-write workspace, optional network cutoff, Claude-Code-compatible filesystem 4-tuple `allow_write`/`deny_write`/`deny_read`/`allow_read`).
83
83
  - **MCP support**: connect external MCP servers via `.mcp.json` (Claude Code-compatible format); stdio and HTTP transports; project-level config overrides user-level. `coderio mcp` CLI manages entries; MCP tools are gated by the permission system like built-in tools.
84
- - **Repo-config trust confirmation**: first time a repository's `.coderio/config.toml` or `.mcp.json` is seen, coderio shows what it contains (permission mode, base_url, MCP commands) and requires explicit confirmation — content-hashed, so upstream edits re-trigger the prompt. Cloning a hostile repo no longer means silent arbitrary code execution.
84
+ - **Repo-config trust confirmation**: first time a repository's `.coderio/config.toml`, `.mcp.json`, **or project-layer skills** (with tools.py markers) is seen, coderio shows what it contains (permission mode, base_url, hook/MCP commands) and requires explicit confirmation — content-hashed, so upstream edits re-trigger the prompt. Cloning a hostile repo no longer means silent arbitrary code execution.
85
85
  - **Multiple providers + named profiles**: Zhipu GLM / StepFun coding plans (Anthropic protocol) + OpenAI-compatible endpoints; multiple profiles with runtime `/profile` switching.
86
86
  - **Headless mode**: `coderio run "task"` for one-shot, non-interactive runs (CI, scripting, benchmark harnesses).
87
87
 
@@ -314,7 +314,15 @@ command = "cat .hooks/conventions.txt"
314
314
 
315
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
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).
317
+ **Execution semantics**:
318
+ - **Timeouts**: 60s per hook by default (`timeout` configurable); **30s per-event total budget** — when all matching hooks together exceed it, the rest are skipped (fail-open); one slow hook can't eat the whole turn
319
+ - **Serial execution**: multiple hooks run in config order; the first blocker's reason wins, but every hook still runs (side effects preserved)
320
+ - **Environment inheritance**: hooks inherit the full environment (including API keys) — only configure hook commands you trust
321
+ - **Subagents are covered too**: research / general-purpose subagents both carry HooksMiddleware — `task()` delegation cannot bypass PreToolUse
322
+ - **Merge semantics**: user-level `[[hooks]]` + project-level `[[hooks]]` **append** (user first — under first-blocker-wins, the user's protective hook's reason takes priority); a repo's config never drops the user's protections
323
+ - Windows grandchild leak on timeout is a known limitation (the turn is no longer hostage; the root fix awaits the CREATE_SUSPENDED approach)
324
+
325
+ **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.
318
326
 
319
327
  Supported providers:
320
328
  | provider_id | Description | Protocol |
@@ -343,9 +351,13 @@ coderio
343
351
  coderio --provider bigmodel_coding_plan --model glm-5.2
344
352
 
345
353
  # Headless one-shot run (CI / scripting / benchmarks)
346
- coderio run "write a hello-world script and test it"
347
- coderio run "fix the failing test in tests/foo.py" --quiet # final result only
354
+ coderio run "write a hello-world script and test it" # default: plan (read-only)
355
+ coderio run "fix the failing test in tests/foo.py" --quiet # final result only
348
356
  coderio run "continue" --session-id <id> # resume a session
357
+ coderio run "run any command" --dangerously-skip-permissions # full access (explicit opt-in)
358
+ coderio run "task" --timeout 600 # wall-clock timeout (recommended for CI)
359
+
360
+ # headless exit codes: 0 success / 1 config error / 2 agent failure / 124 timeout
349
361
 
350
362
  # Manage skills (install pulls from GitHub; needs git on PATH)
351
363
  coderio skills list
@@ -36,7 +36,7 @@
36
36
  - **命令审查层**:shell(execute)命令不受 virtual_mode 约束,所以额外加了一层 `CommandReviewMiddleware`——内置黑名单挡住 `rm -rf /`、`mkfs`、fork bomb、`dd of=/dev/`、shutdown 等破坏性命令(即使 FULL 模式也挡),用户可在 config.toml 追加 `blocked_commands`。这不是真 OS 沙箱(混淆命令可绕过正则),但能挡住绝大多数意外破坏。`network_allowed = false` 可完全禁用 web 工具(离线模式)
37
37
  - **多 provider + 命名 profile**:智谱 GLM / 阶跃 StepFun 的 coding plan(Anthropic 协议)+ OpenAI 兼容;支持多套配置 profile,`/profile` 运行时切换
38
38
  - **MCP 支持**:通过 `.mcp.json`(与 Claude Code 格式兼容)接入外部 MCP 服务器,自动加载它们的工具。支持 stdio(本地进程)和 HTTP(远程)两种传输。项目级 `.mcp.json` 覆盖用户级同名服务器
39
- - **仓库配置信任确认**:首次在某仓库检测到 `.coderio/config.toml` 或 `.mcp.json` 时,展示其内容(权限模式、base_url、MCP 命令)并要求显式确认——按内容 hash 记忆,上游改配置会重新触发确认。克隆恶意仓库不再等于静默任意代码执行
39
+ - **仓库配置信任确认**:首次在某仓库检测到 `.coderio/config.toml`、`.mcp.json` **或项目级 skills**(含 tools.py 标注)时,展示其内容(权限模式、base_url、hook/MCP 命令)并要求显式确认——按内容 hash 记忆,上游改配置会重新触发确认。克隆恶意仓库不再等于静默任意代码执行
40
40
  - **web_fetch SSRF 防护**:scheme 白名单(仅 http/https)+ 私网/环回/链路本地 IP 拦截(含云元数据端点)+ 逐跳重定向校验 + 1MB 响应上限
41
41
  - **headless 模式**:`coderio run "任务"` 单次无交互运行(CI / 脚本 / benchmark 接入)
42
42
 
@@ -269,7 +269,15 @@ command = "cat .hooks/conventions.txt"
269
269
 
270
270
  **诚实定位**:hooks 是**扩展点不是安全边界**——超时/崩溃/非 2 退出码都 fail-open(一个坏 hook 绝不能搞死 agent loop)。硬策略请用权限审批 + 命令黑名单。项目级 hooks 写在 config.toml 里,自动被仓库信任门覆盖(克隆恶意仓库不会静默执行其 hooks)。
271
271
 
272
- **v1 事件集与不做的事**:SessionStart(每 session 一次,含 resume)/ UserPromptSubmit(可拒绝可注入)/ PreToolUse(可 deny)/ PostToolUse(追加反馈)/ Stop(通知型,不与 harness 续跑冲突)。不做:SessionEnd(无注入点)、Notification/SubagentStop/PreCompact、`updatedInput` 参数改写、hook allow 跳过权限弹窗、并行执行(v1 串行,多 hook 第一个 blocker 生效但全部执行)。
272
+ **执行语义**:
273
+ - **超时**:每个 hook 默认 60s(`timeout` 可配);**单事件总预算 30s**——同事件所有 hook 合计超预算后,剩余 hook 跳过(fail-open),一个慢 hook 吃不光整个 turn
274
+ - **串行执行**:多 hook 按配置顺序串行,第一个 blocker 的理由生效,但所有 hook 都会执行(副作用保留)
275
+ - **环境继承**:hook 继承完整环境变量(含 API key)——只配置你信任的 hook 命令
276
+ - **子 agent 同样受约束**:research / general-purpose 子 agent 都挂 HooksMiddleware,`task()` 委派无法绕过 PreToolUse
277
+ - **合并语义**:用户级 `[[hooks]]` + 项目级 `[[hooks]]` **追加合并**(用户在前——首个 blocker 的优先理由来自用户的防护 hook),项目配置不会剔除用户防护
278
+ - Windows 孙进程泄漏为已知 limitation(超时后 turn 不受挟持;根治待 CREATE_SUSPENDED 方案)
279
+
280
+ **v1 事件集与不做的事**:SessionStart(每 session 一次,含 resume)/ UserPromptSubmit(可拒绝可注入)/ PreToolUse(可 deny)/ PostToolUse(追加反馈)/ Stop(通知型,不与 harness 续跑冲突)。不做:SessionEnd(无注入点)、Notification/SubagentStop/PreCompact、`updatedInput` 参数改写、hook allow 跳过权限弹窗。
273
281
 
274
282
  支持的 provider:
275
283
  | provider_id | 说明 | 协议 |
@@ -298,9 +306,13 @@ coderio
298
306
  coderio --provider bigmodel_coding_plan --model glm-5.2
299
307
 
300
308
  # headless 单次运行(CI / 脚本 / benchmark 接入)
301
- coderio run "写一个 hello-world 脚本并测试"
302
- coderio run "修复 tests/foo.py 里失败的测试" --quiet # 只输出最终结果
303
- coderio run "继续" --session-id <id> # 续跑会话
309
+ coderio run "写一个 hello-world 脚本并测试" # 默认 plan(只读)
310
+ coderio run "修复 tests/foo.py 里失败的测试" --quiet # 只输出最终结果
311
+ coderio run "继续" --session-id <id> # 续跑会话
312
+ coderio run "跑任意命令" --dangerously-skip-permissions # full 权限(显式选择加入)
313
+ coderio run "任务" --timeout 600 # 墙钟超时(CI 建议)
314
+
315
+ # headless 退出码:0 成功 / 1 配置错误 / 2 agent 执行失败 / 124 超时
304
316
 
305
317
  # 管理 skill(install 从 GitHub 拉取,需要 git 在 PATH)
306
318
  coderio skills list
@@ -36,7 +36,7 @@ Core philosophy: **skills are the playbook, the harness is the discipline, tools
36
36
  - **SSRF-protected web_fetch**: scheme allowlist (http/https only), private/loopback/link-local IP blocking (incl. cloud metadata endpoints), per-hop redirect validation, and a 1 MB response cap.
37
37
  - **Multi-layer OS sandbox** (Linux real isolation): `sandbox_mode = "job" | "write"` — Job Object resource limits on Windows; bubblewrap namespace isolation on Linux (read-only root, read-write workspace, optional network cutoff, Claude-Code-compatible filesystem 4-tuple `allow_write`/`deny_write`/`deny_read`/`allow_read`).
38
38
  - **MCP support**: connect external MCP servers via `.mcp.json` (Claude Code-compatible format); stdio and HTTP transports; project-level config overrides user-level. `coderio mcp` CLI manages entries; MCP tools are gated by the permission system like built-in tools.
39
- - **Repo-config trust confirmation**: first time a repository's `.coderio/config.toml` or `.mcp.json` is seen, coderio shows what it contains (permission mode, base_url, MCP commands) and requires explicit confirmation — content-hashed, so upstream edits re-trigger the prompt. Cloning a hostile repo no longer means silent arbitrary code execution.
39
+ - **Repo-config trust confirmation**: first time a repository's `.coderio/config.toml`, `.mcp.json`, **or project-layer skills** (with tools.py markers) is seen, coderio shows what it contains (permission mode, base_url, hook/MCP commands) and requires explicit confirmation — content-hashed, so upstream edits re-trigger the prompt. Cloning a hostile repo no longer means silent arbitrary code execution.
40
40
  - **Multiple providers + named profiles**: Zhipu GLM / StepFun coding plans (Anthropic protocol) + OpenAI-compatible endpoints; multiple profiles with runtime `/profile` switching.
41
41
  - **Headless mode**: `coderio run "task"` for one-shot, non-interactive runs (CI, scripting, benchmark harnesses).
42
42
 
@@ -269,7 +269,15 @@ command = "cat .hooks/conventions.txt"
269
269
 
270
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
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).
272
+ **Execution semantics**:
273
+ - **Timeouts**: 60s per hook by default (`timeout` configurable); **30s per-event total budget** — when all matching hooks together exceed it, the rest are skipped (fail-open); one slow hook can't eat the whole turn
274
+ - **Serial execution**: multiple hooks run in config order; the first blocker's reason wins, but every hook still runs (side effects preserved)
275
+ - **Environment inheritance**: hooks inherit the full environment (including API keys) — only configure hook commands you trust
276
+ - **Subagents are covered too**: research / general-purpose subagents both carry HooksMiddleware — `task()` delegation cannot bypass PreToolUse
277
+ - **Merge semantics**: user-level `[[hooks]]` + project-level `[[hooks]]` **append** (user first — under first-blocker-wins, the user's protective hook's reason takes priority); a repo's config never drops the user's protections
278
+ - Windows grandchild leak on timeout is a known limitation (the turn is no longer hostage; the root fix awaits the CREATE_SUSPENDED approach)
279
+
280
+ **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.
273
281
 
274
282
  Supported providers:
275
283
  | provider_id | Description | Protocol |
@@ -298,9 +306,13 @@ coderio
298
306
  coderio --provider bigmodel_coding_plan --model glm-5.2
299
307
 
300
308
  # Headless one-shot run (CI / scripting / benchmarks)
301
- coderio run "write a hello-world script and test it"
302
- coderio run "fix the failing test in tests/foo.py" --quiet # final result only
309
+ coderio run "write a hello-world script and test it" # default: plan (read-only)
310
+ coderio run "fix the failing test in tests/foo.py" --quiet # final result only
303
311
  coderio run "continue" --session-id <id> # resume a session
312
+ coderio run "run any command" --dangerously-skip-permissions # full access (explicit opt-in)
313
+ coderio run "task" --timeout 600 # wall-clock timeout (recommended for CI)
314
+
315
+ # headless exit codes: 0 success / 1 config error / 2 agent failure / 124 timeout
304
316
 
305
317
  # Manage skills (install pulls from GitHub; needs git on PATH)
306
318
  coderio skills list
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
 
6
6
  [project]
7
7
  name = "coderio"
8
- version = "0.4.1"
8
+ version = "0.4.2"
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"
@@ -97,22 +97,48 @@ class _ToolWhitelistMiddleware(AgentMiddleware):
97
97
 
98
98
  Wraps the same ModelRequest.override + filter mechanism as
99
99
  ``_ToolExclusionMiddleware`` but inverts the predicate.
100
+
101
+ Runtime failures degrade to an EMPTY tool set (deny-all), never to the
102
+ unfiltered original — a broken research subagent is preferable to a
103
+ research subagent that can suddenly see write/execute tools (v3 audit #11
104
+ reversed the old fail-open direction).
100
105
  """
101
106
 
102
107
  def __init__(self, *, allowed: frozenset[str]) -> None:
103
108
  self._allowed = allowed
104
109
 
105
110
  def wrap_model_call(self, request, handler):
106
- if self._allowed:
111
+ try:
107
112
  filtered = [t for t in request.tools if _tool_name(t) in self._allowed]
108
- request = request.override(tools=filtered)
109
- return handler(request)
113
+ return handler(request.override(tools=filtered))
114
+ except Exception as e: # noqa: BLE001 — degrade to deny-all, never fail-open
115
+ _log.warning("tool whitelist failed at runtime (degrading to NO tools): %s", e)
116
+ return handler(request.override(tools=[]))
110
117
 
111
118
  async def awrap_model_call(self, request, handler):
112
- if self._allowed:
119
+ try:
113
120
  filtered = [t for t in request.tools if _tool_name(t) in self._allowed]
114
- request = request.override(tools=filtered)
115
- return await handler(request)
121
+ return await handler(request.override(tools=filtered))
122
+ except Exception as e: # noqa: BLE001 — degrade to deny-all, never fail-open
123
+ _log.warning("tool whitelist failed at runtime (degrading to NO tools): %s", e)
124
+ return await handler(request.override(tools=[]))
125
+
126
+
127
+ class _DenyAllToolsMiddleware(AgentMiddleware):
128
+ """Last-resort middleware: the model sees ZERO tools.
129
+
130
+ Returned when the whitelist middleware itself cannot be constructed — the
131
+ research subagent becomes useless but stays harmless (v3 audit #11: the
132
+ old fallback was an EMPTY middleware list, i.e. the subagent inherited
133
+ EVERY tool including write/execute — fail-open in the most dangerous
134
+ direction).
135
+ """
136
+
137
+ def wrap_model_call(self, request, handler):
138
+ return handler(request.override(tools=[]))
139
+
140
+ async def awrap_model_call(self, request, handler):
141
+ return await handler(request.override(tools=[]))
116
142
 
117
143
 
118
144
  def make_research_subagent_middleware():
@@ -126,8 +152,8 @@ def make_research_subagent_middleware():
126
152
 
127
153
  Returns a list with a _ToolWhitelistMiddleware. If the deepagents
128
154
  AgentMiddleware base class API is unavailable (very old or very new
129
- version), returns an empty list — the subagent will inherit all tools
130
- (less safe, but won't crash).
155
+ version), falls back to _DenyAllToolsMiddleware — the subagent sees no
156
+ tools rather than all of them (fail-closed; v3 audit #11).
131
157
  """
132
158
  # Read-only tools the research subagent is allowed to use. Add new tools
133
159
  # here ONLY after confirming they're safe for a read-only agent.
@@ -135,5 +161,8 @@ def make_research_subagent_middleware():
135
161
  try:
136
162
  return [_ToolWhitelistMiddleware(allowed=allowed)]
137
163
  except Exception as e: # noqa: BLE001 — AgentMiddleware API may differ
138
- _log.warning("Could not build _ToolWhitelistMiddleware: %s", e)
139
- return []
164
+ _log.warning("Could not build _ToolWhitelistMiddleware (deny-all fallback): %s", e)
165
+ try:
166
+ return [_DenyAllToolsMiddleware()]
167
+ except Exception: # noqa: BLE001 — even the fallback construction failed
168
+ return []
@@ -298,14 +298,38 @@ def _build_extra_tools(tools, skill_store, active_skills):
298
298
  return extra
299
299
 
300
300
 
301
- def _build_research_subagent():
301
+ def _build_research_subagent(gate=None, command_policy=None, hook_runner=None):
302
302
  """Return the research subagent spec (read-only, physically isolated).
303
303
 
304
304
  Tool exclusion uses the compat layer (_deepagents_compat) so that a
305
305
  deepagents API change degrades gracefully instead of crashing.
306
+
307
+ HooksMiddleware (2026-08-14 v3 audit #12): without it, ``task()``-delegated
308
+ work bypassed the user's PreToolUse/PostToolUse hooks entirely. The runner
309
+ is shared with the main agent (stateless across fire() calls).
310
+
311
+ Permission + CommandReview (v3 audit #11): execution-time enforcement on
312
+ top of the model-visibility whitelist — the whitelist filters what the
313
+ model SEES, these middlewares gate what actually RUNS. Belt and braces:
314
+ a whitelist bug or a future tool name collision can no longer turn the
315
+ "read-only" subagent into a writing one.
306
316
  """
307
317
  from coderio.agent._deepagents_compat import make_research_subagent_middleware
308
318
 
319
+ middleware = make_research_subagent_middleware()
320
+ if hook_runner is not None and hook_runner.specs:
321
+ from coderio.agent.hooks import HooksMiddleware
322
+
323
+ middleware.insert(0, HooksMiddleware(hook_runner))
324
+ if gate is not None:
325
+ from coderio.agent.permission_middleware import PermissionMiddleware
326
+
327
+ middleware.append(PermissionMiddleware(gate))
328
+ from coderio.agent.command_review import CommandReviewMiddleware
329
+ from coderio.tools.command_policy import CommandPolicy
330
+
331
+ policy = command_policy or CommandPolicy.default()
332
+ middleware.append(CommandReviewMiddleware(policy, gate=gate))
309
333
  return {
310
334
  "name": "research",
311
335
  "description": (
@@ -330,11 +354,11 @@ def _build_research_subagent():
330
354
  "- The calling agent only sees your final message, not your "
331
355
  "intermediate tool calls — make sure your answer is complete."
332
356
  ),
333
- "middleware": make_research_subagent_middleware(),
357
+ "middleware": middleware,
334
358
  }
335
359
 
336
360
 
337
- def _build_general_purpose_subagent(gate, command_policy, stream=None):
361
+ def _build_general_purpose_subagent(gate, command_policy, stream=None, hook_runner=None):
338
362
  """Return the general-purpose subagent spec with coderio's security middleware.
339
363
 
340
364
  SECURITY FIX (2026-08-14 report P0-2): deepagents auto-injects a
@@ -359,13 +383,23 @@ def _build_general_purpose_subagent(gate, command_policy, stream=None):
359
383
  is per-agent, not shared — the main gate can't see subagent tool calls
360
384
  anyway), whose force-continue applies inside the subagent's loop. This
361
385
  makes the subagent itself refuse to say "done" with unverified writes.
386
+
387
+ 2026-08-14 v3 audit #12: also inject HooksMiddleware (outermost, same as
388
+ the main agent) — without it, task()-delegated work bypassed the user's
389
+ PreToolUse/PostToolUse hooks. The runner is shared (stateless fire()).
362
390
  """
363
391
  from coderio.agent.command_review import CommandReviewMiddleware
364
392
  from coderio.agent.harness_middleware import HarnessMiddleware
365
393
  from coderio.tools.command_policy import CommandPolicy
366
394
 
367
395
  middleware = []
368
- # Harness first (same order as the main agent — see run_deep_agent): it
396
+ # Hooks OUTERMOST (same order as the main agent): a hook deny happens
397
+ # before any other layer — including this subagent's permission prompts.
398
+ if hook_runner is not None and hook_runner.specs:
399
+ from coderio.agent.hooks import HooksMiddleware
400
+
401
+ middleware.append(HooksMiddleware(hook_runner))
402
+ # Harness next (same order as the main agent — see run_deep_agent): it
369
403
  # observes every tool call the permission/command layers let through.
370
404
  middleware.append(HarnessMiddleware(stream=stream))
371
405
  if gate is not None:
@@ -553,8 +587,8 @@ def run_deep_agent(
553
587
  "middleware": middleware,
554
588
  "backend": backend,
555
589
  "subagents": [
556
- _build_research_subagent(),
557
- _build_general_purpose_subagent(gate, command_policy, stream=stream),
590
+ _build_research_subagent(gate=gate, command_policy=command_policy, hook_runner=hook_runner),
591
+ _build_general_purpose_subagent(gate, command_policy, stream=stream, hook_runner=hook_runner),
558
592
  ],
559
593
  }
560
594
  if sp:
@@ -50,6 +50,11 @@ _TOOL_EVENTS = frozenset({"PreToolUse", "PostToolUse"})
50
50
 
51
51
  DEFAULT_TIMEOUT = 60 # seconds; generous for linters, short enough not to brick a turn
52
52
 
53
+ # Per-EVENT total budget (v3 audit P1: serial hooks × 60s each would hang every
54
+ # tool call). Covers ALL matching hooks for one event fire; exhaustion skips
55
+ # the rest (fail-open). Overridable per HookRunner for tests.
56
+ DEFAULT_EVENT_BUDGET = 30.0
57
+
53
58
  # stdout beyond this is dropped rather than injected (context budget guard).
54
59
  _MAX_CONTEXT_CHARS = 10_000
55
60
 
@@ -108,12 +113,18 @@ class HookRunner:
108
113
  _sessions_started: set[str] = set()
109
114
 
110
115
  def __init__(
111
- self, specs: list[HookSpec] | None, project_dir: str, session_id: str = "", permission_mode: str = ""
116
+ self,
117
+ specs: list[HookSpec] | None,
118
+ project_dir: str,
119
+ session_id: str = "",
120
+ permission_mode: str = "",
121
+ event_budget: float = DEFAULT_EVENT_BUDGET,
112
122
  ) -> None:
113
123
  self.specs = [s for s in (specs or []) if s.event in HOOK_EVENTS]
114
124
  self.project_dir = project_dir
115
125
  self.session_id = session_id
116
126
  self.permission_mode = permission_mode
127
+ self.event_budget = event_budget
117
128
 
118
129
  def has_event(self, event: str) -> bool:
119
130
  return any(s.event == event for s in self.specs)
@@ -125,16 +136,41 @@ class HookRunner:
125
136
  multi-hook semantics non-deterministic; determinism wins for a coding
126
137
  agent. First blocker wins; later hooks still run (their side effects
127
138
  are the point — e.g. logging — even when the event is already denied).
139
+
140
+ A per-EVENT budget (v3 audit P1: N hooks × 60s hanging on EVERY tool
141
+ call) caps the total time all matching hooks may take; when exhausted,
142
+ remaining hooks are skipped with an error note (fail-open — consistent
143
+ with the module's positioning: budget exhaustion never blocks).
128
144
  """
145
+ import time
146
+
129
147
  tool_name = str(payload.get("tool_name", ""))
130
148
  outcome = HookOutcome()
149
+ deadline = time.monotonic() + self.event_budget
131
150
  try:
132
- for spec in self.specs:
151
+ for i, spec in enumerate(self.specs):
133
152
  if spec.event != event:
134
153
  continue
135
154
  if event in _TOOL_EVENTS and not spec.matches(tool_name):
136
155
  continue
137
- self._run_one(spec, event, payload, outcome)
156
+ remaining = deadline - time.monotonic()
157
+ if remaining <= 0:
158
+ # Count the matching hooks NOT yet reached (self-audit
159
+ # 2026-08-18: the old slice self.specs[len(self.specs):]
160
+ # was always empty — the count never appeared). Filter by
161
+ # matcher too: a same-event hook whose matcher wouldn't
162
+ # have run anyway is not "skipped" (third-party audit
163
+ # caught the overcount).
164
+ pending = [
165
+ s
166
+ for s in self.specs[i:]
167
+ if s.event == event and (event not in _TOOL_EVENTS or s.matches(tool_name))
168
+ ]
169
+ outcome.error += (
170
+ f"event budget ({self.event_budget}s) exhausted — {len(pending)} hook(s) skipped (fail-open); "
171
+ )
172
+ break
173
+ self._run_one(spec, event, payload, outcome, remaining)
138
174
  except Exception as e: # noqa: BLE001 — a hook-layer bug must never break the turn
139
175
  outcome.error += f"hook engine error (fail-open): {e}; "
140
176
  # CONSUME the error (2026-08-14 v3 audit P1): a broken hook (missing
@@ -147,7 +183,14 @@ class HookRunner:
147
183
 
148
184
  # ------------------------------------------------------------------ execution
149
185
 
150
- def _run_one(self, spec: HookSpec, event: str, payload: dict[str, Any], outcome: HookOutcome) -> None:
186
+ def _run_one(
187
+ self,
188
+ spec: HookSpec,
189
+ event: str,
190
+ payload: dict[str, Any],
191
+ outcome: HookOutcome,
192
+ remaining: float,
193
+ ) -> None:
151
194
  stdin_json = json.dumps(
152
195
  {
153
196
  "session_id": self.session_id,
@@ -159,13 +202,13 @@ class HookRunner:
159
202
  default=str,
160
203
  )
161
204
  try:
162
- exit_code, stdout, stderr, timed_out = self._spawn(spec, stdin_json)
205
+ exit_code, stdout, stderr, timed_out, effective_timeout = self._spawn(spec, stdin_json, remaining)
163
206
  except Exception as e: # noqa: BLE001 — a broken hook must never crash the agent
164
207
  outcome.error += f"hook {spec.command!r} failed to run: {e} (fail-open); "
165
208
  return
166
209
 
167
210
  if timed_out:
168
- outcome.error += f"hook {spec.command!r} timed out after {spec.timeout}s (fail-open); "
211
+ outcome.error += f"hook {spec.command!r} timed out after {effective_timeout}s (fail-open); "
169
212
  return
170
213
 
171
214
  # New names, not reusing stdout/stderr: mypy pins a variable's type to
@@ -196,12 +239,16 @@ class HookRunner:
196
239
  else:
197
240
  outcome.context += ("\n" if outcome.context else "") + text
198
241
 
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).
242
+ def _spawn(self, spec: HookSpec, stdin_json: str, remaining: float) -> tuple[int, bytes, bytes, bool, int]:
243
+ """Run the hook command with JSON on stdin.
244
+
245
+ Returns (exit_code, stdout, stderr, timed_out, effective_timeout).
246
+ effective_timeout = min(spec.timeout, event-budget remaining) — the
247
+ per-event budget tightens each hook so one slow hook can't starve the
248
+ rest of the turn (v3 audit P1).
201
249
 
202
250
  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.
251
+ so POSIX-style commands behave; POSIX uses /bin/sh.
205
252
  """
206
253
  import os
207
254
 
@@ -235,16 +282,29 @@ class HookRunner:
235
282
  env=env,
236
283
  start_new_session=(sys.platform != "win32"),
237
284
  )
285
+ # ceil, not int(): remaining ≈ budget-ε on the first hook, and int()
286
+ # truncation would cut a 2s budget to 1s (caught by the CI OS matrix —
287
+ # deterministic on POSIX, timing-jitter-lucky on Windows).
288
+ import math
289
+
290
+ effective_timeout = max(1, min(spec.timeout, math.ceil(remaining)))
238
291
  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
292
+ stdout, stderr = proc.communicate(input=stdin_json.encode("utf-8"), timeout=effective_timeout)
293
+ return proc.returncode, stdout or b"", stderr or b"", False, effective_timeout
241
294
  except subprocess.TimeoutExpired:
242
295
  kill_process_tree(proc)
296
+ # One-second grace, then abandon (v3 audit P1: the old 10s drain
297
+ # waited out pipe EOF that never comes when a pre-kill grandchild
298
+ # holds the write end on Windows — a timeout=2 hook took 12s). The
299
+ # output of a TIMED-OUT hook is never consumed anyway (hardcoded
300
+ # empty return), so there is nothing to wait for. Known limitation:
301
+ # kill_process_tree may miss Windows grandchildren forked before
302
+ # the job assignment; they leak but can no longer stall the turn.
243
303
  try:
244
- proc.communicate(timeout=10)
245
- except Exception: # noqa: S110 — the kill already ran; drain best-effort
304
+ proc.communicate(timeout=1)
305
+ except Exception: # noqa: S110 — grace expired; the kill already ran
246
306
  pass
247
- return 124, b"", b"", True
307
+ return 124, b"", b"", True, effective_timeout
248
308
 
249
309
 
250
310
  class HooksMiddleware(AgentMiddleware):
@@ -183,19 +183,32 @@ def run_cmd(
183
183
  provider: str = typer.Option(None, "--provider", help="Override provider_id."),
184
184
  model: str = typer.Option(None, "--model", help="Override model name."),
185
185
  permission: str = typer.Option(
186
- "full",
186
+ "plan",
187
187
  "--permission",
188
188
  "-p",
189
- help="Permission tier: full (default, headless can't answer prompts), plan, confirm, auto_edit.",
189
+ help="Permission tier: plan (default, read-only) or full (needs --dangerously-skip-permissions).",
190
190
  ),
191
191
  session_id: str = typer.Option(None, "--session-id", help="Resume a prior session by id."),
192
192
  quiet: bool = typer.Option(False, "--quiet", "-q", help="Print only the final result."),
193
+ skip_permissions: bool = typer.Option(
194
+ False,
195
+ "--dangerously-skip-permissions",
196
+ help="Run with full permissions (no prompts). Explicit opt-in — headless default is read-only plan mode.",
197
+ ),
198
+ timeout: int = typer.Option(
199
+ 0,
200
+ "--timeout",
201
+ help="Wall-clock limit in seconds (0 = unlimited). On timeout: exit 124. Recommended for CI.",
202
+ ),
193
203
  ):
194
204
  """One-shot headless agent run (CI / scripting / benchmarks).
195
205
 
196
206
  Streams tokens to stdout by default; tool progress goes to stderr. Never
197
207
  prompts interactively — untrusted repo config or missing credentials are
198
- hard errors. The command blacklist still applies in full mode.
208
+ hard errors. Default permission is plan (read-only); use
209
+ --dangerously-skip-permissions for full access. Exit codes: 0 success,
210
+ 1 config error, 2 agent failure, 124 timeout. The command blacklist
211
+ still applies in every mode.
199
212
  """
200
213
  from coderio.cli.run_cmd import run_headless
201
214
 
@@ -206,6 +219,8 @@ def run_cmd(
206
219
  permission=permission,
207
220
  session_id=session_id,
208
221
  quiet=quiet,
222
+ skip_permissions=skip_permissions,
223
+ timeout=timeout,
209
224
  )
210
225
 
211
226