From f4dadda775dd13bcae2b5e81bac1df2dcc5ba52a Mon Sep 17 00:00:00 2001 From: rustfuture <121799572+rustfuture@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:49:42 +0000 Subject: [PATCH 1/5] feat(hooks): add OpenCode, Kilo Code, Cline and pi adapters OpenCode, Kilo Code and pi get a small plugin file (embedded from integrations/) that relays tool calls and finished turns to reflex hook and applies the JSON verdict. Cline gets a PreToolUse script that calls reflex directly. Cline has no hook that can send the agent back, so it is guarded before tool calls only. --- crates/reflex-hooks/src/adapters/cline.rs | 362 ++++++++++++++++++ crates/reflex-hooks/src/adapters/js_bridge.rs | 84 ++++ crates/reflex-hooks/src/adapters/mod.rs | 19 +- crates/reflex-hooks/src/adapters/opencode.rs | 275 +++++++++++++ crates/reflex-hooks/src/adapters/pi.rs | 156 ++++++++ crates/reflex-hooks/src/adapters/util.rs | 163 ++++++++ crates/reflex-hooks/tests/cline_golden.rs | 8 + crates/reflex-hooks/tests/common/mod.rs | 113 ++++++ ...-tool__apply_patch_extension_shape.in.json | 16 + .../pre-tool__apply_patch_extension_shape.out | 1 + .../pre-tool__apply_patch_normal.in.json | 24 ++ .../cline/pre-tool__apply_patch_normal.out | 1 + ...pre-tool__apply_patch_object_input.in.json | 24 ++ .../pre-tool__apply_patch_object_input.out | 1 + ...pre-tool__apply_patch_string_input.in.json | 20 + .../pre-tool__apply_patch_string_input.out | 1 + .../cline/pre-tool__editor_env.in.json | 26 ++ .../fixtures/cline/pre-tool__editor_env.out | 1 + ...e-tool__editor_env_extension_shape.in.json | 17 + .../pre-tool__editor_env_extension_shape.out | 1 + .../cline/pre-tool__editor_normal.in.json | 28 ++ .../cline/pre-tool__editor_normal.out | 1 + ...ool__editor_normal_extension_shape.in.json | 17 + ...re-tool__editor_normal_extension_shape.out | 1 + ...tool__execute_command_legacy_rm_rf.in.json | 16 + ...pre-tool__execute_command_legacy_rm_rf.out | 1 + .../cline/pre-tool__malformed.in.json | 1 + .../fixtures/cline/pre-tool__malformed.out | 0 .../fixtures/cline/pre-tool__malformed.stderr | 1 + .../fixtures/cline/pre-tool__no_tool.in.json | 6 + .../fixtures/cline/pre-tool__no_tool.out | 1 + .../pre-tool__read_files_ignored.in.json | 28 ++ .../cline/pre-tool__read_files_ignored.out | 1 + .../pre-tool__run_commands_cargo_test.in.json | 26 ++ .../pre-tool__run_commands_cargo_test.out | 1 + ...re-tool__run_commands_redirect_env.in.json | 26 ++ .../pre-tool__run_commands_redirect_env.out | 1 + ...mands_redirect_env_extension_shape.in.json | 16 + ..._commands_redirect_env_extension_shape.out | 1 + .../pre-tool__run_commands_rm_rf_root.in.json | 26 ++ .../pre-tool__run_commands_rm_rf_root.out | 1 + ...run_commands_structured_force_push.in.json | 34 ++ ...ol__run_commands_structured_force_push.out | 1 + ...tool__write_to_file_legacy_secrets.in.json | 17 + ...pre-tool__write_to_file_legacy_secrets.out | 1 + .../cline/turn-end__unsupported.in.json | 3 + .../fixtures/cline/turn-end__unsupported.out | 0 .../cline/turn-end__unsupported.stderr | 1 + .../kilo/pre-tool__apply_patch_env.in.json | 8 + .../kilo/pre-tool__apply_patch_env.out | 1 + .../kilo/pre-tool__bash_cargo_test.in.json | 8 + .../kilo/pre-tool__bash_cargo_test.out | 1 + .../kilo/pre-tool__bash_force_push.in.json | 8 + .../kilo/pre-tool__bash_force_push.out | 1 + .../kilo/pre-tool__bash_redirect_env.in.json | 8 + .../kilo/pre-tool__bash_redirect_env.out | 1 + .../kilo/pre-tool__bash_rm_rf_root.in.json | 8 + .../kilo/pre-tool__bash_rm_rf_root.out | 1 + .../pre-tool__edit_nested_env_local.in.json | 10 + .../kilo/pre-tool__edit_nested_env_local.out | 1 + .../fixtures/kilo/pre-tool__malformed.in.json | 1 + .../fixtures/kilo/pre-tool__malformed.out | 0 .../fixtures/kilo/pre-tool__malformed.stderr | 1 + .../kilo/pre-tool__read_ignored.in.json | 8 + .../fixtures/kilo/pre-tool__read_ignored.out | 1 + .../fixtures/kilo/pre-tool__write_env.in.json | 9 + .../fixtures/kilo/pre-tool__write_env.out | 1 + .../kilo/pre-tool__write_missing_args.in.json | 6 + .../kilo/pre-tool__write_missing_args.out | 1 + .../kilo/pre-tool__write_normal.in.json | 9 + .../fixtures/kilo/pre-tool__write_normal.out | 1 + .../fixtures/kilo/turn-end__allow.in.json | 4 + .../tests/fixtures/kilo/turn-end__allow.out | 1 + .../fixtures/kilo/turn-end__allow.verdict | 1 + .../fixtures/kilo/turn-end__ask_human.in.json | 4 + .../fixtures/kilo/turn-end__ask_human.out | 1 + .../fixtures/kilo/turn-end__ask_human.verdict | 1 + .../fixtures/kilo/turn-end__malformed.in.json | 1 + .../fixtures/kilo/turn-end__malformed.out | 0 .../fixtures/kilo/turn-end__malformed.stderr | 1 + .../fixtures/kilo/turn-end__notify.in.json | 4 + .../tests/fixtures/kilo/turn-end__notify.out | 1 + .../fixtures/kilo/turn-end__notify.verdict | 1 + .../fixtures/kilo/turn-end__retry.in.json | 4 + .../tests/fixtures/kilo/turn-end__retry.out | 1 + .../fixtures/kilo/turn-end__retry.verdict | 1 + .../pre-tool__apply_patch_env.in.json | 8 + .../opencode/pre-tool__apply_patch_env.out | 1 + ...ool__apply_patch_move_into_secrets.in.json | 8 + ...re-tool__apply_patch_move_into_secrets.out | 1 + .../pre-tool__apply_patch_normal.in.json | 8 + .../opencode/pre-tool__apply_patch_normal.out | 1 + .../pre-tool__bash_cargo_test.in.json | 8 + .../opencode/pre-tool__bash_cargo_test.out | 1 + .../pre-tool__bash_force_push.in.json | 8 + .../opencode/pre-tool__bash_force_push.out | 1 + .../pre-tool__bash_redirect_env.in.json | 8 + .../opencode/pre-tool__bash_redirect_env.out | 1 + .../pre-tool__bash_rm_rf_root.in.json | 8 + .../opencode/pre-tool__bash_rm_rf_root.out | 1 + .../pre-tool__edit_nested_env_local.in.json | 10 + .../pre-tool__edit_nested_env_local.out | 1 + .../opencode/pre-tool__malformed.in.json | 1 + .../fixtures/opencode/pre-tool__malformed.out | 0 .../opencode/pre-tool__malformed.stderr | 1 + .../pre-tool__multiedit_workflow.in.json | 9 + .../opencode/pre-tool__multiedit_workflow.out | 1 + .../opencode/pre-tool__read_ignored.in.json | 8 + .../opencode/pre-tool__read_ignored.out | 1 + .../opencode/pre-tool__write_env.in.json | 9 + .../fixtures/opencode/pre-tool__write_env.out | 1 + .../pre-tool__write_missing_args.in.json | 6 + .../opencode/pre-tool__write_missing_args.out | 1 + .../opencode/pre-tool__write_normal.in.json | 9 + .../opencode/pre-tool__write_normal.out | 1 + .../fixtures/opencode/turn-end__allow.in.json | 4 + .../fixtures/opencode/turn-end__allow.out | 1 + .../fixtures/opencode/turn-end__allow.verdict | 1 + .../opencode/turn-end__ask_human.in.json | 4 + .../fixtures/opencode/turn-end__ask_human.out | 1 + .../opencode/turn-end__ask_human.verdict | 1 + .../opencode/turn-end__malformed.in.json | 1 + .../fixtures/opencode/turn-end__malformed.out | 0 .../opencode/turn-end__malformed.stderr | 1 + .../opencode/turn-end__notify.in.json | 4 + .../fixtures/opencode/turn-end__notify.out | 1 + .../opencode/turn-end__notify.verdict | 1 + .../fixtures/opencode/turn-end__retry.in.json | 4 + .../fixtures/opencode/turn-end__retry.out | 1 + .../fixtures/opencode/turn-end__retry.verdict | 1 + .../pi/pre-tool__bash_cargo_test.in.json | 8 + .../fixtures/pi/pre-tool__bash_cargo_test.out | 1 + .../pi/pre-tool__bash_force_push.in.json | 8 + .../fixtures/pi/pre-tool__bash_force_push.out | 1 + .../pi/pre-tool__bash_redirect_env.in.json | 8 + .../pi/pre-tool__bash_redirect_env.out | 1 + .../pi/pre-tool__bash_rm_rf_root.in.json | 8 + .../fixtures/pi/pre-tool__bash_rm_rf_root.out | 1 + .../pre-tool__edit_nested_env_local.in.json | 14 + .../pi/pre-tool__edit_nested_env_local.out | 1 + .../fixtures/pi/pre-tool__malformed.in.json | 1 + .../tests/fixtures/pi/pre-tool__malformed.out | 0 .../fixtures/pi/pre-tool__malformed.stderr | 1 + .../pre-tool__powershell_redirect_env.in.json | 8 + .../pi/pre-tool__powershell_redirect_env.out | 1 + .../pi/pre-tool__read_ignored.in.json | 8 + .../fixtures/pi/pre-tool__read_ignored.out | 1 + .../fixtures/pi/pre-tool__write_env.in.json | 9 + .../tests/fixtures/pi/pre-tool__write_env.out | 1 + .../pi/pre-tool__write_missing_args.in.json | 6 + .../pi/pre-tool__write_missing_args.out | 1 + .../pi/pre-tool__write_normal.in.json | 9 + .../fixtures/pi/pre-tool__write_normal.out | 1 + .../tests/fixtures/pi/turn-end__allow.in.json | 4 + .../tests/fixtures/pi/turn-end__allow.out | 1 + .../tests/fixtures/pi/turn-end__allow.verdict | 1 + .../fixtures/pi/turn-end__ask_human.in.json | 4 + .../tests/fixtures/pi/turn-end__ask_human.out | 1 + .../fixtures/pi/turn-end__ask_human.verdict | 1 + .../fixtures/pi/turn-end__malformed.in.json | 1 + .../tests/fixtures/pi/turn-end__malformed.out | 0 .../fixtures/pi/turn-end__malformed.stderr | 1 + .../fixtures/pi/turn-end__notify.in.json | 4 + .../tests/fixtures/pi/turn-end__notify.out | 1 + .../fixtures/pi/turn-end__notify.verdict | 1 + .../tests/fixtures/pi/turn-end__retry.in.json | 4 + .../tests/fixtures/pi/turn-end__retry.out | 1 + .../tests/fixtures/pi/turn-end__retry.verdict | 1 + .../reflex-hooks/tests/js_agents_install.rs | 313 +++++++++++++++ crates/reflex-hooks/tests/js_plugins_node.rs | 311 +++++++++++++++ crates/reflex-hooks/tests/kilo_golden.rs | 8 + crates/reflex-hooks/tests/opencode_golden.rs | 8 + crates/reflex-hooks/tests/pi_golden.rs | 8 + integrations/.gitattributes | 1 + integrations/cline/PreToolUse | 9 + integrations/cline/PreToolUse.ps1 | 9 + integrations/opencode/reflex.js | 72 ++++ integrations/pi/reflex.ts | 55 +++ 178 files changed, 2754 insertions(+), 2 deletions(-) create mode 100644 crates/reflex-hooks/src/adapters/cline.rs create mode 100644 crates/reflex-hooks/src/adapters/js_bridge.rs create mode 100644 crates/reflex-hooks/src/adapters/opencode.rs create mode 100644 crates/reflex-hooks/src/adapters/pi.rs create mode 100644 crates/reflex-hooks/src/adapters/util.rs create mode 100644 crates/reflex-hooks/tests/cline_golden.rs create mode 100644 crates/reflex-hooks/tests/common/mod.rs create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_extension_shape.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_extension_shape.out create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_normal.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_normal.out create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_object_input.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_object_input.out create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_string_input.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_string_input.out create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_env.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_env.out create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_env_extension_shape.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_env_extension_shape.out create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_normal.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_normal.out create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_normal_extension_shape.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_normal_extension_shape.out create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__execute_command_legacy_rm_rf.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__execute_command_legacy_rm_rf.out create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__malformed.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__malformed.out create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__malformed.stderr create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__no_tool.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__no_tool.out create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__read_files_ignored.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__read_files_ignored.out create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_cargo_test.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_cargo_test.out create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_redirect_env.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_redirect_env.out create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_redirect_env_extension_shape.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_redirect_env_extension_shape.out create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_rm_rf_root.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_rm_rf_root.out create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_structured_force_push.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_structured_force_push.out create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__write_to_file_legacy_secrets.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__write_to_file_legacy_secrets.out create mode 100644 crates/reflex-hooks/tests/fixtures/cline/turn-end__unsupported.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/turn-end__unsupported.out create mode 100644 crates/reflex-hooks/tests/fixtures/cline/turn-end__unsupported.stderr create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__apply_patch_env.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__apply_patch_env.out create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_cargo_test.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_cargo_test.out create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_force_push.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_force_push.out create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_redirect_env.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_redirect_env.out create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_rm_rf_root.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_rm_rf_root.out create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__edit_nested_env_local.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__edit_nested_env_local.out create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__malformed.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__malformed.out create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__malformed.stderr create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__read_ignored.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__read_ignored.out create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_env.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_env.out create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_missing_args.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_missing_args.out create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_normal.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_normal.out create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/turn-end__allow.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/turn-end__allow.out create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/turn-end__allow.verdict create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/turn-end__ask_human.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/turn-end__ask_human.out create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/turn-end__ask_human.verdict create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/turn-end__malformed.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/turn-end__malformed.out create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/turn-end__malformed.stderr create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/turn-end__notify.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/turn-end__notify.out create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/turn-end__notify.verdict create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/turn-end__retry.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/turn-end__retry.out create mode 100644 crates/reflex-hooks/tests/fixtures/kilo/turn-end__retry.verdict create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_env.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_env.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_move_into_secrets.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_move_into_secrets.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_normal.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_normal.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_cargo_test.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_cargo_test.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_force_push.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_force_push.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_redirect_env.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_redirect_env.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_rm_rf_root.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_rm_rf_root.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__edit_nested_env_local.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__edit_nested_env_local.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__malformed.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__malformed.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__malformed.stderr create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__multiedit_workflow.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__multiedit_workflow.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__read_ignored.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__read_ignored.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_env.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_env.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_missing_args.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_missing_args.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_normal.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_normal.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/turn-end__allow.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/turn-end__allow.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/turn-end__allow.verdict create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/turn-end__ask_human.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/turn-end__ask_human.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/turn-end__ask_human.verdict create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/turn-end__malformed.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/turn-end__malformed.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/turn-end__malformed.stderr create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/turn-end__notify.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/turn-end__notify.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/turn-end__notify.verdict create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/turn-end__retry.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/turn-end__retry.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/turn-end__retry.verdict create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_cargo_test.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_cargo_test.out create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_force_push.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_force_push.out create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_redirect_env.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_redirect_env.out create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_rm_rf_root.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_rm_rf_root.out create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__edit_nested_env_local.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__edit_nested_env_local.out create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__malformed.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__malformed.out create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__malformed.stderr create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__powershell_redirect_env.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__powershell_redirect_env.out create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__read_ignored.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__read_ignored.out create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_env.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_env.out create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_missing_args.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_missing_args.out create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_normal.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_normal.out create mode 100644 crates/reflex-hooks/tests/fixtures/pi/turn-end__allow.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/pi/turn-end__allow.out create mode 100644 crates/reflex-hooks/tests/fixtures/pi/turn-end__allow.verdict create mode 100644 crates/reflex-hooks/tests/fixtures/pi/turn-end__ask_human.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/pi/turn-end__ask_human.out create mode 100644 crates/reflex-hooks/tests/fixtures/pi/turn-end__ask_human.verdict create mode 100644 crates/reflex-hooks/tests/fixtures/pi/turn-end__malformed.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/pi/turn-end__malformed.out create mode 100644 crates/reflex-hooks/tests/fixtures/pi/turn-end__malformed.stderr create mode 100644 crates/reflex-hooks/tests/fixtures/pi/turn-end__notify.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/pi/turn-end__notify.out create mode 100644 crates/reflex-hooks/tests/fixtures/pi/turn-end__notify.verdict create mode 100644 crates/reflex-hooks/tests/fixtures/pi/turn-end__retry.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/pi/turn-end__retry.out create mode 100644 crates/reflex-hooks/tests/fixtures/pi/turn-end__retry.verdict create mode 100644 crates/reflex-hooks/tests/js_agents_install.rs create mode 100644 crates/reflex-hooks/tests/js_plugins_node.rs create mode 100644 crates/reflex-hooks/tests/kilo_golden.rs create mode 100644 crates/reflex-hooks/tests/opencode_golden.rs create mode 100644 crates/reflex-hooks/tests/pi_golden.rs create mode 100644 integrations/.gitattributes create mode 100644 integrations/cline/PreToolUse create mode 100644 integrations/cline/PreToolUse.ps1 create mode 100644 integrations/opencode/reflex.js create mode 100644 integrations/pi/reflex.ts diff --git a/crates/reflex-hooks/src/adapters/cline.rs b/crates/reflex-hooks/src/adapters/cline.rs new file mode 100644 index 0000000..d04b5de --- /dev/null +++ b/crates/reflex-hooks/src/adapters/cline.rs @@ -0,0 +1,362 @@ +//! Cline (https://cline.bot): the VS Code extension and the CLI, which share one hook +//! format. Hook reference: `docs/customization/hooks.mdx` and `sdk/examples/hooks/` in +//! https://github.com/cline/cline. +//! +//! Cline runs an executable file named after the event, gives it the event as JSON on +//! stdin and reads a JSON reply from stdout. Install writes one such file, `PreToolUse`, +//! that calls `reflex hook cline pre-tool`: +//! +//! | Scope | Unix | Windows | +//! |---------|----------------------------------------|----------------------------------------------| +//! | Project | `.clinerules/hooks/PreToolUse` | `.clinerules/hooks/PreToolUse.ps1` | +//! | User | `~/Documents/Cline/Hooks/PreToolUse` | `~/Documents/Cline/Hooks/PreToolUse.ps1` | +//! +//! Both the extension and the CLI read these directories (the CLI also reads +//! `.cline/hooks` and `~/.cline/hooks`, which are not used so that the hook does not +//! run twice). The script is `integrations/cline/PreToolUse` (sh) or `PreToolUse.ps1`; +//! the Windows one is untested, because the extension launches it through PowerShell. +//! +//! The tool call comes in one of two shapes: the CLI sends `tool_call.name` and +//! `tool_call.input`; the extension sends only `preToolUse.toolName` and +//! `preToolUse.parameters`, a map whose non-string values are JSON-encoded. Both are read. +//! Tools: `editor` (and the older `write_to_file`, `replace_in_file`, `delete_file`) with +//! `path`; `apply_patch`, whose input is the patch text, or `{ "input": patch }`; +//! `run_commands` (and the older `execute_command`), whose input is a string, a list of +//! strings or `{command, args}` entries, or an object holding `commands`, `command` or +//! `cmd`. +//! +//! | Verdict | Reply | +//! |---------|-------------------------------------------------------| +//! | Allow | `{}` | +//! | Block | `{"cancel":true,"errorMessage":"..."}` | +//! | Ask | the same, with a note that Cline cannot ask | +//! +//! `cancel` stops the tool call and ends the current task run, and the reason is shown. +//! Cline has no way for a hook to ask for confirmation: the reply's `review` field is +//! read by the CLI's hook runner but not acted on, and the extension's reply format has +//! no such field. So an `Ask` also cancels. +//! +//! There is no turn-end hook: `TaskComplete` runs after the task has finished, and its +//! reply is ignored, so tests cannot send the agent back to work. Use the git pre-commit +//! hook for that. +//! +//! Hooks must be switched on: tick "Enable Hooks" in the extension's settings; the CLI +//! ignores hooks in `--yolo` mode. A file that is not ours at the same path is left +//! alone, because Cline runs one file per event. + +use super::util::{self, ManagedFile}; +use super::{AgentAdapter, HookEvent, HookInput, ParseError, Probe, Rendered}; +use crate::config::Scope; +use crate::guard::Verdict; +use crate::install::{Files, InstallError, Plan, PlanCtx, HOOK_COMMAND_PREFIX}; +use serde_json::{json, Value}; +use std::path::PathBuf; + +pub struct Cline; + +pub const SCRIPT_UNIX: &str = include_str!("../../../../integrations/cline/PreToolUse"); +pub const SCRIPT_WINDOWS: &str = include_str!("../../../../integrations/cline/PreToolUse.ps1"); + +const ENABLE_NOTE: &str = "Cline only runs hooks that are switched on: in VS Code, tick \"Enable \ + Hooks\" in Cline's settings (Feature Settings); the Cline CLI skips hooks with --yolo. Cline \ + has no hook that can send the agent back when tests fail, so only the checks before a tool \ + call are installed."; + +const ASK_NOTE: &str = "Cline hooks cannot ask for confirmation, so the call was cancelled. \ + Run it yourself if you want it."; + +/// The script for the platform Reflex is running on. +fn script() -> &'static str { + if cfg!(windows) { + SCRIPT_WINDOWS + } else { + SCRIPT_UNIX + } +} + +fn hook_path(ctx: &PlanCtx) -> Result { + let dir = match ctx.scope { + Scope::Project => ctx.env.root.join(".clinerules").join("hooks"), + Scope::User => ctx + .env + .home + .clone() + .ok_or(InstallError::NoHome)? + .join("Documents") + .join("Cline") + .join("Hooks"), + }; + Ok(dir.join(if cfg!(windows) { + "PreToolUse.ps1" + } else { + "PreToolUse" + })) +} + +/// Parses `value` if it is a string holding a JSON object or array, which is how the +/// extension passes non-string parameters. +fn dejson(value: &Value) -> Value { + if let Value::String(text) = value { + if let Ok(parsed @ (Value::Object(_) | Value::Array(_))) = serde_json::from_str(text) { + return parsed; + } + } + value.clone() +} + +/// A word for a shell command line, quoted if it needs to be. +fn shell_word(word: &str) -> String { + let plain = !word.is_empty() + && word + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_./=:@%+,".contains(c)); + if plain { + word.to_string() + } else { + format!("'{}'", word.replace('\'', "'\\''")) + } +} + +/// One command as a string: a plain string, `{command}` or `{cmd}` (a shell command line), +/// or `{command, args}`, which Cline runs directly without a shell. +fn command_entry(entry: &Value) -> Option { + match entry { + Value::String(s) => Some(s.clone()), + Value::Object(map) => { + let command = map.get("command").and_then(Value::as_str); + match (command, map.get("args").map(dejson)) { + (Some(program), Some(Value::Array(args))) => { + let mut line = shell_word(program); + for arg in args.iter().filter_map(Value::as_str) { + line.push(' '); + line.push_str(&shell_word(arg)); + } + Some(line) + } + (Some(line), _) => Some(line.to_string()), + (None, _) => map.get("cmd").and_then(Value::as_str).map(str::to_string), + } + } + _ => None, + } +} + +/// Every command a `run_commands` input holds. +fn commands(input: &Value) -> Vec { + let input = dejson(input); + let list = match &input { + Value::Array(list) => list.clone(), + Value::Object(map) if map.contains_key("commands") => match dejson(&map["commands"]) { + Value::Array(list) => list, + single => vec![single], + }, + single => vec![single.clone()], + }; + list.iter().filter_map(command_entry).collect() +} + +/// The patch text of an `apply_patch` input. +fn patch_text(input: &Value) -> Option { + match dejson(input) { + Value::String(text) => Some(text), + other => util::string_field(&other, &["input", "patchText"]), + } +} + +fn classify(tool: &str, input: &Value) -> HookEvent { + match tool { + "editor" | "write_to_file" | "replace_in_file" | "delete_file" => { + match util::string_field(&dejson(input), &["path", "file_path", "filePath"]) { + Some(path) => HookEvent::PreWrite { paths: vec![path] }, + None => HookEvent::Ignore, + } + } + "apply_patch" => { + let paths = patch_text(input) + .map(|patch| util::patch_paths(&patch)) + .unwrap_or_default(); + if paths.is_empty() { + HookEvent::Ignore + } else { + HookEvent::PreWrite { paths } + } + } + "run_commands" | "execute_command" => { + let commands = commands(input); + if commands.is_empty() { + HookEvent::Ignore + } else { + HookEvent::PreShell { + command: commands.join("\n"), + } + } + } + _ => HookEvent::Ignore, + } +} + +impl AgentAdapter for Cline { + fn id(&self) -> &'static str { + "cline" + } + + fn label(&self) -> &'static str { + "Cline" + } + + fn hint(&self) -> &'static str { + "blocks protected files and risky commands; cannot run tests at the end of a task" + } + + fn scopes(&self) -> &'static [Scope] { + &[Scope::Project, Scope::User] + } + + fn detect(&self, probe: &Probe) -> bool { + probe.root.join(".clinerules").exists() + || probe.root.join(".cline").is_dir() + || (probe.on_path)("cline") + } + + fn parse(&self, event: &str, stdin: &str) -> Result { + if event != "pre-tool" { + return Err(ParseError(format!("unknown cline hook event `{event}`"))); + } + let payload: Value = serde_json::from_str(stdin) + .map_err(|e| ParseError(format!("cannot read hook input as JSON: {e}")))?; + let cwd = payload + .pointer("/workspaceRoots/0") + .and_then(Value::as_str) + .filter(|root| !root.is_empty()) + .map(PathBuf::from); + // The CLI sends the rich `tool_call`; the extension only `preToolUse`. + let call = match payload.pointer("/tool_call/name").and_then(Value::as_str) { + Some(name) => Some((name, payload.pointer("/tool_call/input"))), + None => payload + .pointer("/preToolUse/toolName") + .and_then(Value::as_str) + .map(|name| (name, payload.pointer("/preToolUse/parameters"))), + }; + let event = match call { + Some((tool, input)) => classify(tool, input.unwrap_or(&Value::Null)), + None => HookEvent::Ignore, + }; + Ok(HookInput { cwd, event }) + } + + fn render(&self, event: &HookEvent, verdict: &Verdict) -> Rendered { + let is_tool = matches!( + event, + HookEvent::PreWrite { .. } | HookEvent::PreShell { .. } + ); + let reply = match verdict { + Verdict::Block { reason } if is_tool => { + json!({ "cancel": true, "errorMessage": reason }) + } + Verdict::Ask { reason } if is_tool => { + json!({ "cancel": true, "errorMessage": format!("{reason} {ASK_NOTE}") }) + } + _ => json!({}), + }; + Rendered { + stdout: format!("{reply}\n"), + stderr: String::new(), + exit_code: 0, + } + } + + fn files(&self, ctx: &PlanCtx) -> Result, InstallError> { + Ok(vec![hook_path(ctx)?]) + } + + fn plan_install(&self, ctx: &PlanCtx, existing: &Files) -> Result { + let file = ManagedFile { + path: hook_path(ctx)?, + contents: script().to_string(), + executable: true, + }; + let by_hand = format!( + "Cline runs one PreToolUse file, so add this to it instead: `{HOOK_COMMAND_PREFIX} \ + cline pre-tool` (it reads Cline's JSON on stdin and prints the reply)." + ); + let mut plan = file.plan_install(existing, &by_hand); + if !plan.changes.is_empty() { + plan.notes.push(ENABLE_NOTE.to_string()); + } + Ok(plan) + } + + fn plan_uninstall(&self, ctx: &PlanCtx, existing: &Files) -> Result { + Ok(util::plan_uninstall(&hook_path(ctx)?, existing)) + } + + fn is_installed(&self, ctx: &PlanCtx, existing: &Files) -> bool { + hook_path(ctx).is_ok_and(|path| util::is_installed(&path, existing)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn detection_looks_at_cline_files_and_the_binary() { + let detect = |root: &std::path::Path, binary: &str| { + let on_path = |p: &str| p == binary; + Cline.detect(&Probe { + root, + on_path: &on_path, + }) + }; + let tmp = tempfile::tempdir().unwrap(); + assert!(!detect(tmp.path(), "")); + assert!(detect(tmp.path(), "cline")); + // `.clinerules` is a directory in new projects and a single file in old ones. + std::fs::write(tmp.path().join(".clinerules"), "rules").unwrap(); + assert!(detect(tmp.path(), "")); + let other = tempfile::tempdir().unwrap(); + std::fs::create_dir(other.path().join(".cline")).unwrap(); + assert!(detect(other.path(), "")); + } + + #[test] + fn run_commands_input_shapes() { + let cases = [ + (json!("ls"), vec!["ls"]), + (json!(["ls", "pwd"]), vec!["ls", "pwd"]), + (json!({"commands": ["ls", "pwd"]}), vec!["ls", "pwd"]), + (json!({"commands": "ls"}), vec!["ls"]), + (json!({"command": "ls -l"}), vec!["ls -l"]), + (json!({"cmd": "ls -l"}), vec!["ls -l"]), + // The extension JSON-encodes what is not a string. + (json!({"commands": "[\"ls\",\"pwd\"]"}), vec!["ls", "pwd"]), + ( + json!({"commands": [{"command": "rm", "args": ["-rf", "my dir"]}]}), + vec!["rm -rf 'my dir'"], + ), + ( + json!({"command": "git", "args": ["push", "--force"]}), + vec!["git push --force"], + ), + (json!({"commands": [1, null, {"args": []}]}), vec![]), + (json!(42), vec![]), + ]; + for (input, expected) in cases { + assert_eq!(commands(&input), expected, "{input}"); + } + } + + #[test] + fn scripts_call_reflex_and_allow_without_it() { + for text in [SCRIPT_UNIX, SCRIPT_WINDOWS] { + assert!(text.contains(super::util::MARKER)); + assert!(text.contains("reflex hook cline pre-tool")); + assert!( + text.contains("'{}'"), + "must answer {{}} when reflex is missing" + ); + } + assert!(SCRIPT_UNIX.starts_with("#!/bin/sh\n")); + } +} diff --git a/crates/reflex-hooks/src/adapters/js_bridge.rs b/crates/reflex-hooks/src/adapters/js_bridge.rs new file mode 100644 index 0000000..ebc9f47 --- /dev/null +++ b/crates/reflex-hooks/src/adapters/js_bridge.rs @@ -0,0 +1,84 @@ +//! The JSON that passes between `reflex hook` and the JS plugins of OpenCode, Kilo Code +//! and pi. Those agents run plugin code inside their own process instead of calling a +//! command with the agent's JSON, so the plugin is a thin relay and Rust stays the +//! decision maker. +//! +//! The plugin runs `reflex hook ` and writes one JSON object to stdin: +//! +//! ```text +//! pre-tool: {"cwd": "/work/proj", "session_id": "s1", "tool": "write", "args": {...}} +//! turn-end: {"cwd": "/work/proj", "session_id": "s1"} +//! ``` +//! +//! `tool` and `args` are the agent's own tool name and arguments; the adapter decides +//! which of them write files or run commands. Reflex answers with one JSON object on +//! stdout and exit code 0: +//! +//! | Verdict | Reply | The plugin | +//! |-----------------|----------------------------------------------|--------------------------------------| +//! | Allow | `{"action":"allow"}` | does nothing | +//! | Block | `{"action":"block","reason":"..."}` | refuses the tool call | +//! | Ask | `{"action":"ask","reason":"..."}` | asks the user, or refuses | +//! | RetryAgent | `{"action":"retry","reason":"..."}` | sends the agent back to work | +//! | AskHuman/Notify | `{"action":"notify","reason":"..."}` | shows the message, lets the agent stop | +//! +//! If reflex is missing, times out, or prints anything that is not a reply of this +//! shape (a warning on stderr with empty stdout is what a malformed request produces), +//! the plugin allows. + +use super::{HookEvent, HookInput, ParseError, Rendered}; +use crate::guard::Verdict; +use serde_json::{json, Value}; +use std::path::PathBuf; + +/// Turns the agent's tool name and arguments into the event the guard checks. +pub type Classify = fn(tool: &str, args: &Value) -> HookEvent; + +pub fn parse( + agent: &str, + event: &str, + stdin: &str, + classify: Classify, +) -> Result { + let request: Value = serde_json::from_str(stdin) + .map_err(|e| ParseError(format!("cannot read hook input as JSON: {e}")))?; + let text = |key: &str| request.get(key).and_then(Value::as_str); + let cwd = text("cwd").filter(|c| !c.is_empty()).map(PathBuf::from); + let event = match event { + "pre-tool" => classify( + text("tool").unwrap_or(""), + request.get("args").unwrap_or(&Value::Null), + ), + "turn-end" => HookEvent::TurnEnd { + session_id: text("session_id").unwrap_or("").to_string(), + }, + other => return Err(ParseError(format!("unknown {agent} hook event `{other}`"))), + }; + Ok(HookInput { cwd, event }) +} + +pub fn render(event: &HookEvent, verdict: &Verdict) -> Rendered { + let is_tool = matches!( + event, + HookEvent::PreWrite { .. } | HookEvent::PreShell { .. } + ); + let reply = match verdict { + Verdict::Block { reason } if is_tool => json!({ "action": "block", "reason": reason }), + Verdict::Ask { reason } if is_tool => json!({ "action": "ask", "reason": reason }), + Verdict::RetryAgent { reason } if !is_tool => { + json!({ "action": "retry", "reason": reason }) + } + Verdict::AskHuman { reason } if !is_tool => { + json!({ "action": "notify", "reason": reason }) + } + Verdict::Notify { message } if !is_tool => { + json!({ "action": "notify", "reason": message }) + } + _ => json!({ "action": "allow" }), + }; + Rendered { + stdout: format!("{reply}\n"), + stderr: String::new(), + exit_code: 0, + } +} diff --git a/crates/reflex-hooks/src/adapters/mod.rs b/crates/reflex-hooks/src/adapters/mod.rs index ccd27f0..a3c4db6 100644 --- a/crates/reflex-hooks/src/adapters/mod.rs +++ b/crates/reflex-hooks/src/adapters/mod.rs @@ -15,7 +15,12 @@ use crate::install::{Files, InstallError, Plan, PlanCtx}; use std::path::{Path, PathBuf}; pub mod claude_code; +pub mod cline; pub mod git; +mod js_bridge; +pub mod opencode; +pub mod pi; +mod util; /// What the agent is about to do, in agent-independent terms. #[derive(Debug, Clone, PartialEq, Eq)] @@ -97,7 +102,14 @@ pub trait AgentAdapter: Sync { fn is_installed(&self, ctx: &PlanCtx, existing: &Files) -> bool; } -static ADAPTERS: [&dyn AgentAdapter; 2] = [&claude_code::ClaudeCode, &git::GitHook]; +static ADAPTERS: [&dyn AgentAdapter; 6] = [ + &claude_code::ClaudeCode, + &opencode::OPENCODE, + &opencode::KILO, + &cline::Cline, + &pi::Pi, + &git::GitHook, +]; /// All known adapters, in the order the wizard lists them. pub fn registry() -> &'static [&'static dyn AgentAdapter] { @@ -115,7 +127,10 @@ mod tests { #[test] fn registry_ids_are_unique_and_findable() { let ids: Vec<&str> = registry().iter().map(|a| a.id()).collect(); - assert_eq!(ids, ["claude-code", "git"]); + assert_eq!( + ids, + ["claude-code", "opencode", "kilo", "cline", "pi", "git"] + ); for id in ids { assert_eq!(find(id).unwrap().id(), id); } diff --git a/crates/reflex-hooks/src/adapters/opencode.rs b/crates/reflex-hooks/src/adapters/opencode.rs new file mode 100644 index 0000000..27dcd16 --- /dev/null +++ b/crates/reflex-hooks/src/adapters/opencode.rs @@ -0,0 +1,275 @@ +//! OpenCode (https://opencode.ai) and Kilo Code (https://kilo.ai), which is a fork of +//! OpenCode. Two ids, `opencode` and `kilo`, share this file: the plugin API and the +//! tools are the same, only the directories differ. +//! +//! Install writes one plugin file, `integrations/opencode/reflex.js` (a short plain-JS file +//! embedded in the binary), to the agent's plugin directory: +//! +//! | Agent | Project scope | User scope | +//! |----------|-------------------------------|---------------------------------------| +//! | OpenCode | `.opencode/plugins/reflex.js` | `~/.config/opencode/plugins/reflex.js` | +//! | Kilo | `.kilo/plugin/reflex.js` | `~/.config/kilo/plugin/reflex.js` | +//! +//! Both load every file in those directories at startup. The plugin default-exports +//! `{ id, server }`, the module shape OpenCode and Kilo document for local files. It +//! talks to Reflex through the protocol in `js_bridge.rs`: +//! +//! * `tool.execute.before` sends the tool name and arguments; a `block` reply throws, +//! which refuses the call and shows the reason to the model. Tools: `write`, `edit` +//! and `multiedit` (`filePath`), `apply_patch` and `patch` (`patchText`, paths taken +//! from the patch headers) and `bash` (`command`). +//! * There is no confirmation dialog for plugins, so `ask` is answered by refusing the +//! call once with "ask the user first"; the same call is let through the second time. +//! * The `session.idle` event is the turn end. On `retry` the plugin sends the failure +//! to the session with `client.session.promptAsync`, so the agent keeps working; +//! Reflex counts the retries. On `notify` it logs and shows a toast. +//! +//! Not run for subagent sessions (checked through `parentID`), for sessions whose last +//! run ended in an error, or while another check of the same session is running. + +use super::js_bridge; +use super::util::{self, ManagedFile}; +use super::{AgentAdapter, HookEvent, HookInput, ParseError, Probe, Rendered}; +use crate::config::Scope; +use crate::guard::Verdict; +use crate::install::{Files, InstallError, Plan, PlanCtx, HOOK_COMMAND_PREFIX}; +use serde_json::Value; +use std::path::PathBuf; + +const PLUGIN: &str = include_str!("../../../../integrations/opencode/reflex.js"); + +/// The plugin as it is installed for the agent with this id. +pub fn plugin_source(agent_id: &str) -> String { + PLUGIN.replace("__AGENT__", agent_id) +} + +/// One of the two agents. `opencode::OPENCODE` and `opencode::KILO` go in the registry. +pub struct Flavor { + id: &'static str, + label: &'static str, + /// Directory below the project root and below the home directory. + project_dir: &'static [&'static str], + user_dir: &'static [&'static str], + detect: fn(&Probe) -> bool, +} + +pub static OPENCODE: Flavor = Flavor { + id: "opencode", + label: "OpenCode", + project_dir: &[".opencode", "plugins"], + user_dir: &[".config", "opencode", "plugins"], + detect: |probe| { + probe.root.join(".opencode").is_dir() + || probe.root.join("opencode.json").is_file() + || probe.root.join("opencode.jsonc").is_file() + || (probe.on_path)("opencode") + }, +}; + +pub static KILO: Flavor = Flavor { + id: "kilo", + label: "Kilo Code", + project_dir: &[".kilo", "plugin"], + user_dir: &[".config", "kilo", "plugin"], + detect: |probe| { + probe.root.join(".kilo").is_dir() + || probe.root.join(".kilocode").is_dir() + || (probe.on_path)("kilo") + }, +}; + +impl Flavor { + fn plugin_path(&self, ctx: &PlanCtx) -> Result { + let (base, dir) = match ctx.scope { + Scope::Project => (ctx.env.root.clone(), self.project_dir), + Scope::User => ( + ctx.env.home.clone().ok_or(InstallError::NoHome)?, + self.user_dir, + ), + }; + Ok(dir + .iter() + .fold(base, |path, part| path.join(part)) + .join("reflex.js")) + } +} + +/// Which tools write files or run commands, and where their paths or command are. +fn classify(tool: &str, args: &Value) -> HookEvent { + let paths = match tool { + "write" | "edit" | "multiedit" => util::string_field(args, &["filePath"]) + .map(|p| vec![p]) + .unwrap_or_default(), + "apply_patch" | "patch" => util::string_field(args, &["patchText"]) + .map(|patch| util::patch_paths(&patch)) + .unwrap_or_default(), + "bash" => { + return match util::string_field(args, &["command"]) { + Some(command) => HookEvent::PreShell { command }, + None => HookEvent::Ignore, + } + } + _ => Vec::new(), + }; + if paths.is_empty() { + HookEvent::Ignore + } else { + HookEvent::PreWrite { paths } + } +} + +impl AgentAdapter for Flavor { + fn id(&self) -> &'static str { + self.id + } + + fn label(&self) -> &'static str { + self.label + } + + fn hint(&self) -> &'static str { + "blocks protected files and risky commands, checks tests when a session goes idle" + } + + fn scopes(&self) -> &'static [Scope] { + &[Scope::Project, Scope::User] + } + + fn detect(&self, probe: &Probe) -> bool { + (self.detect)(probe) + } + + fn parse(&self, event: &str, stdin: &str) -> Result { + js_bridge::parse(self.id, event, stdin, classify) + } + + fn render(&self, event: &HookEvent, verdict: &Verdict) -> Rendered { + js_bridge::render(event, verdict) + } + + fn files(&self, ctx: &PlanCtx) -> Result, InstallError> { + Ok(vec![self.plugin_path(ctx)?]) + } + + fn plan_install(&self, ctx: &PlanCtx, existing: &Files) -> Result { + let file = ManagedFile { + path: self.plugin_path(ctx)?, + contents: plugin_source(self.id), + executable: false, + }; + let by_hand = format!( + "To use Reflex Control there, move it aside and run install again, or call \ + `{HOOK_COMMAND_PREFIX} {} pre-tool` from it.", + self.id + ); + Ok(file.plan_install(existing, &by_hand)) + } + + fn plan_uninstall(&self, ctx: &PlanCtx, existing: &Files) -> Result { + Ok(util::plan_uninstall(&self.plugin_path(ctx)?, existing)) + } + + fn is_installed(&self, ctx: &PlanCtx, existing: &Files) -> bool { + self.plugin_path(ctx) + .is_ok_and(|path| util::is_installed(&path, existing)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn detected(adapter: &dyn AgentAdapter, dirs: &[&str], files: &[&str], binary: &str) -> bool { + let tmp = tempfile::tempdir().unwrap(); + for dir in dirs { + std::fs::create_dir(tmp.path().join(dir)).unwrap(); + } + for file in files { + std::fs::write(tmp.path().join(file), "{}").unwrap(); + } + let on_path = |p: &str| p == binary; + adapter.detect(&Probe { + root: tmp.path(), + on_path: &on_path, + }) + } + + #[test] + fn detection_looks_at_each_agents_own_files_and_binary() { + assert!(!detected(&OPENCODE, &[], &[], "")); + assert!(detected(&OPENCODE, &[".opencode"], &[], "")); + assert!(detected(&OPENCODE, &[], &["opencode.json"], "")); + assert!(detected(&OPENCODE, &[], &[], "opencode")); + assert!(!detected(&OPENCODE, &[".kilo"], &[], "kilo")); + + assert!(!detected(&KILO, &[], &[], "")); + assert!(detected(&KILO, &[".kilo"], &[], "")); + assert!(detected(&KILO, &[".kilocode"], &[], "")); + assert!(detected(&KILO, &[], &[], "kilo")); + assert!(!detected( + &KILO, + &[".opencode"], + &["opencode.json"], + "opencode" + )); + } + + #[test] + fn only_the_shell_and_file_writing_tools_are_checked() { + let args = |v: Value| v; + let write = |paths: &[&str]| HookEvent::PreWrite { + paths: paths.iter().map(|p| p.to_string()).collect(), + }; + let cases = [ + ( + "write", + args(serde_json::json!({"filePath": "a"})), + write(&["a"]), + ), + ( + "edit", + args(serde_json::json!({"filePath": "a"})), + write(&["a"]), + ), + ( + "multiedit", + args(serde_json::json!({"filePath": "a"})), + write(&["a"]), + ), + ( + "apply_patch", + args( + serde_json::json!({"patchText": "*** Begin Patch\n*** Add File: x\n*** Delete File: y\n*** End Patch"}), + ), + write(&["x", "y"]), + ), + ( + "bash", + args(serde_json::json!({"command": "ls"})), + HookEvent::PreShell { + command: "ls".into(), + }, + ), + ("bash", args(serde_json::json!({})), HookEvent::Ignore), + ( + "write", + args(serde_json::json!({"path": "a"})), + HookEvent::Ignore, + ), + ( + "apply_patch", + args(serde_json::json!({"patchText": "nothing"})), + HookEvent::Ignore, + ), + ( + "read", + args(serde_json::json!({"filePath": "a"})), + HookEvent::Ignore, + ), + ("task", Value::Null, HookEvent::Ignore), + ]; + for (tool, args, expected) in cases { + assert_eq!(classify(tool, &args), expected, "{tool} {args}"); + } + } +} diff --git a/crates/reflex-hooks/src/adapters/pi.rs b/crates/reflex-hooks/src/adapters/pi.rs new file mode 100644 index 0000000..d37a50c --- /dev/null +++ b/crates/reflex-hooks/src/adapters/pi.rs @@ -0,0 +1,156 @@ +//! pi, the coding agent from https://github.com/badlogic/pi-mono (docs: +//! `packages/coding-agent/docs/extensions.md`). +//! +//! Install writes one extension file, `integrations/pi/reflex.ts` (a short plain-JS +//! file with a `.ts` name, which pi loads through jiti without compiling), to +//! `.pi/extensions/reflex.ts` (project scope) or `~/.pi/agent/extensions/reflex.ts` +//! (user scope). pi only loads project extensions once you have trusted the project. +//! The extension talks to Reflex through the protocol in `js_bridge.rs`: +//! +//! * `tool_call` sends the tool name and input; `block` returns `{ block: true, reason }`. +//! Tools: `write` and `edit` (`path`), `bash` and `powershell` (`command`). `ask` opens +//! pi's own confirm dialog (refused without a UI). +//! * `agent_before_settle` is the turn end, and only for runs that completed. On `retry` +//! the handler appends the failure as a `custom_message` entry and returns +//! `continue: true`, which makes pi send one more model request; Reflex counts the +//! retries, so the loop ends. On `notify` it shows a notification. +//! +//! pi blocks a tool whose `tool_call` handler throws, so the extension catches every +//! error and lets the call through. + +use super::js_bridge; +use super::util::{self, ManagedFile}; +use super::{AgentAdapter, HookEvent, HookInput, ParseError, Probe, Rendered}; +use crate::config::Scope; +use crate::guard::Verdict; +use crate::install::{Files, InstallError, Plan, PlanCtx, HOOK_COMMAND_PREFIX}; +use serde_json::Value; +use std::path::PathBuf; + +pub const EXTENSION: &str = include_str!("../../../../integrations/pi/reflex.ts"); + +pub struct Pi; + +/// Which tools write files or run commands, and where their path or command is. +fn classify(tool: &str, args: &Value) -> HookEvent { + match tool { + "write" | "edit" => match util::string_field(args, &["path"]) { + Some(path) => HookEvent::PreWrite { paths: vec![path] }, + None => HookEvent::Ignore, + }, + "bash" | "powershell" => match util::string_field(args, &["command"]) { + Some(command) => HookEvent::PreShell { command }, + None => HookEvent::Ignore, + }, + _ => HookEvent::Ignore, + } +} + +fn extension_path(ctx: &PlanCtx) -> Result { + let dir = match ctx.scope { + Scope::Project => ctx.env.root.join(".pi"), + Scope::User => ctx + .env + .home + .clone() + .ok_or(InstallError::NoHome)? + .join(".pi") + .join("agent"), + }; + Ok(dir.join("extensions").join("reflex.ts")) +} + +impl AgentAdapter for Pi { + fn id(&self) -> &'static str { + "pi" + } + + fn label(&self) -> &'static str { + "pi" + } + + fn hint(&self) -> &'static str { + "blocks protected files and risky commands, checks tests when the agent finishes" + } + + fn scopes(&self) -> &'static [Scope] { + &[Scope::Project, Scope::User] + } + + fn detect(&self, probe: &Probe) -> bool { + probe.root.join(".pi").is_dir() || (probe.on_path)("pi") + } + + fn parse(&self, event: &str, stdin: &str) -> Result { + js_bridge::parse("pi", event, stdin, classify) + } + + fn render(&self, event: &HookEvent, verdict: &Verdict) -> Rendered { + js_bridge::render(event, verdict) + } + + fn files(&self, ctx: &PlanCtx) -> Result, InstallError> { + Ok(vec![extension_path(ctx)?]) + } + + fn plan_install(&self, ctx: &PlanCtx, existing: &Files) -> Result { + let file = ManagedFile { + path: extension_path(ctx)?, + contents: EXTENSION.to_string(), + executable: false, + }; + let by_hand = format!( + "To use Reflex Control there, move it aside and run install again, or call \ + `{HOOK_COMMAND_PREFIX} pi pre-tool` from it." + ); + Ok(file.plan_install(existing, &by_hand)) + } + + fn plan_uninstall(&self, ctx: &PlanCtx, existing: &Files) -> Result { + Ok(util::plan_uninstall(&extension_path(ctx)?, existing)) + } + + fn is_installed(&self, ctx: &PlanCtx, existing: &Files) -> bool { + extension_path(ctx).is_ok_and(|path| util::is_installed(&path, existing)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn detection_looks_at_the_project_dir_and_the_binary() { + let tmp = tempfile::tempdir().unwrap(); + let probe = |on_path: &dyn Fn(&str) -> bool, root: &std::path::Path| { + Pi.detect(&Probe { root, on_path }) + }; + assert!(!probe(&|_| false, tmp.path())); + assert!(probe(&|p| p == "pi", tmp.path())); + std::fs::create_dir(tmp.path().join(".pi")).unwrap(); + assert!(probe(&|_| false, tmp.path())); + } + + #[test] + fn only_the_shell_and_file_writing_tools_are_checked() { + let write = |p: &str| HookEvent::PreWrite { + paths: vec![p.to_string()], + }; + let shell = |c: &str| HookEvent::PreShell { + command: c.to_string(), + }; + assert_eq!(classify("write", &json!({"path": "a"})), write("a")); + assert_eq!( + classify("edit", &json!({"path": "a", "edits": []})), + write("a") + ); + assert_eq!(classify("bash", &json!({"command": "ls"})), shell("ls")); + assert_eq!( + classify("powershell", &json!({"command": "ls"})), + shell("ls") + ); + assert_eq!(classify("write", &json!({})), HookEvent::Ignore); + assert_eq!(classify("read", &json!({"path": "a"})), HookEvent::Ignore); + } +} diff --git a/crates/reflex-hooks/src/adapters/util.rs b/crates/reflex-hooks/src/adapters/util.rs new file mode 100644 index 0000000..d08ab9a --- /dev/null +++ b/crates/reflex-hooks/src/adapters/util.rs @@ -0,0 +1,163 @@ +//! Helpers shared by the adapters whose integration is a single file that Reflex owns +//! (a plugin, an extension or a hook script), plus a few input-reading helpers. +//! +//! An agent that merges entries into a shared settings file (Claude Code) does its own +//! planning. An agent that loads one file per feature gets the same rules here: +//! * the file carries [`MARKER`] in its header, which is how install, uninstall and +//! `doctor` tell it from a file of the user's with the same name +//! * a file of ours is created or brought up to date; a file that is not ours is never +//! touched, and the plan says what to add by hand instead +//! * uninstall deletes the file only if it is ours + +use crate::install::{FileChange, Files, Plan}; +use serde_json::Value; +use std::path::{Path, PathBuf}; + +/// Present in the header of every file we write. +pub const MARKER: &str = "reflex-control managed hook"; + +pub fn is_ours(text: &str) -> bool { + text.contains(MARKER) +} + +/// One file Reflex owns. +pub struct ManagedFile { + pub path: PathBuf, + pub contents: String, + /// Make the file executable (unix). + pub executable: bool, +} + +impl ManagedFile { + /// Plans the install. `by_hand` tells the user what to do if a file of theirs is in + /// the way. + pub fn plan_install(&self, existing: &Files, by_hand: &str) -> Plan { + let mut plan = Plan::default(); + let before = existing.get(&self.path); + match before { + Some(text) if !is_ours(text) => plan.notes.push(format!( + "{} already exists and is not managed by Reflex Control, so it was not changed. {by_hand}", + self.path.display() + )), + Some(text) if *text == self.contents => {} + _ => { + let mut change = + FileChange::write(self.path.clone(), before, self.contents.clone()); + change.executable = self.executable; + plan.changes.push(change); + } + } + plan + } +} + +/// Deletes the file at `path` if it is ours. +pub fn plan_uninstall(path: &Path, existing: &Files) -> Plan { + let mut plan = Plan::default(); + if let Some(text) = existing.get(path).filter(|t| is_ours(t)) { + plan.changes + .push(FileChange::delete(path.to_path_buf(), text)); + } + plan +} + +pub fn is_installed(path: &Path, existing: &Files) -> bool { + existing.get(path).is_some_and(|t| is_ours(t)) +} + +/// The non-empty string under the first of `keys` that holds one. +pub fn string_field(input: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + input + .get(key)? + .as_str() + .filter(|s| !s.is_empty()) + .map(str::to_string) + }) +} + +/// The files a patch in the `*** Begin Patch` format touches: the `Add File`, `Update +/// File`, `Delete File` and `Move to` headers. Used by OpenCode, Kilo Code and Cline, +/// whose `apply_patch` tools take this format. +pub fn patch_paths(patch: &str) -> Vec { + const HEADERS: [&str; 4] = [ + "*** Add File:", + "*** Update File:", + "*** Delete File:", + "*** Move to:", + ]; + patch + .lines() + .filter_map(|line| { + let line = line.trim_end(); + HEADERS + .iter() + .find_map(|h| line.strip_prefix(h)) + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn patch_paths_reads_every_header() { + let patch = "*** Begin Patch\r\n*** Add File: a.txt\n+hi\n*** Update File: src/b.rs\n\ + *** Move to: src/c.rs\n@@\n-x\n+y\n*** Delete File: .env\n*** End Patch\n"; + assert_eq!( + patch_paths(patch), + ["a.txt", "src/b.rs", "src/c.rs", ".env"] + ); + assert!(patch_paths("just text\n*** Update File:\n").is_empty()); + } + + #[test] + fn string_field_takes_the_first_key_that_holds_text() { + let v = serde_json::json!({ "a": "", "b": 3, "c": "x", "d": "y" }); + assert_eq!( + string_field(&v, &["a", "b", "c", "d"]).as_deref(), + Some("x") + ); + assert_eq!(string_field(&v, &["a", "b"]), None); + } + + #[test] + fn managed_file_rules() { + let path = PathBuf::from("/p/reflex.js"); + let file = ManagedFile { + path: path.clone(), + contents: format!("// {MARKER}\nnew\n"), + executable: false, + }; + let mut files = Files::new(); + + // Missing: created. + let p = file.plan_install(&files, "by hand"); + assert_eq!(p.changes.len(), 1); + assert!(p.notes.is_empty()); + + // Same contents: nothing to do. + files.insert(path.clone(), file.contents.clone()); + assert!(file.plan_install(&files, "by hand").is_empty()); + + // Older version of ours: updated. + files.insert(path.clone(), format!("// {MARKER}\nold\n")); + assert_eq!(file.plan_install(&files, "by hand").changes.len(), 1); + + // The user's own file: untouched, with a note. + files.insert(path.clone(), "mine\n".into()); + let p = file.plan_install(&files, "by hand"); + assert!(p.changes.is_empty()); + assert!(p.notes[0].contains("by hand")); + assert!(plan_uninstall(&path, &files).is_empty()); + assert!(!is_installed(&path, &files)); + + // Uninstall removes ours. + files.insert(path.clone(), file.contents.clone()); + assert!(is_installed(&path, &files)); + assert_eq!(plan_uninstall(&path, &files).changes.len(), 1); + } +} diff --git a/crates/reflex-hooks/tests/cline_golden.rs b/crates/reflex-hooks/tests/cline_golden.rs new file mode 100644 index 0000000..27b700a --- /dev/null +++ b/crates/reflex-hooks/tests/cline_golden.rs @@ -0,0 +1,8 @@ +//! Golden tests for the cline adapter; see `common/mod.rs` for the fixture format. + +mod common; + +#[test] +fn cline_fixtures() { + common::run_fixtures("cline", "cline", 19); +} diff --git a/crates/reflex-hooks/tests/common/mod.rs b/crates/reflex-hooks/tests/common/mod.rs new file mode 100644 index 0000000..1288c5e --- /dev/null +++ b/crates/reflex-hooks/tests/common/mod.rs @@ -0,0 +1,113 @@ +//! Shared by the golden tests of the agents that have no test file of their own logic: +//! recorded hook input in, exact stdout and exit code out. Each case is a set of files +//! in `tests/fixtures//`: +//! +//! * `__.in.json`: what the agent (or its plugin) writes to the hook's stdin +//! * `__.out`: the exact stdout expected +//! * `__.stderr` (optional): text that stderr must start with +//! * `__.verdict` (turn-end cases): the turn-end verdict to feed in, as +//! `: `, since the real turn-end check needs git and a test suite + +use reflex_hooks::config::{Config, ConfigSource}; +use reflex_hooks::guard::{TestOutcome, Verdict}; +use reflex_hooks::runtime::{handle, Project, Runtime}; +use std::io; +use std::path::{Path, PathBuf}; + +struct Fixture { + stdin: String, + verdict: Verdict, +} + +impl Runtime for Fixture { + fn read_stdin(&self) -> io::Result { + Ok(self.stdin.clone()) + } + + fn current_dir(&self) -> PathBuf { + PathBuf::from("/work/proj") + } + + fn project(&self, _cwd: &Path) -> Result { + Ok(Project { + root: PathBuf::from("/work/proj"), + config: Config::default(), + source: ConfigSource::Defaults, + }) + } + + fn turn_end(&self, _project: &Project, _session_id: &str) -> Result { + Ok(self.verdict.clone()) + } + + fn staged_files(&self, _project: &Project) -> Result, String> { + Ok(Vec::new()) + } + + fn pre_commit_tests(&self, _project: &Project) -> Result { + Ok(TestOutcome::Passed) + } +} + +fn parse_verdict(text: &str) -> Verdict { + let text = text.trim_end_matches('\n'); + let (kind, rest) = text.split_once(':').unwrap_or((text, "")); + // Newlines are written as `\n` in the fixture so it stays one line. + let rest = rest.trim_start().replace("\\n", "\n"); + match kind { + "allow" => Verdict::Allow, + "retry" => Verdict::RetryAgent { reason: rest }, + "ask-human" => Verdict::AskHuman { reason: rest }, + "notify" => Verdict::Notify { message: rest }, + other => panic!("unknown verdict kind {other:?}"), + } +} + +/// Runs every case in `tests/fixtures//` through `reflex hook `. +pub fn run_fixtures(agent: &str, dir: &str, min_cases: usize) { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(dir); + let mut cases: Vec = std::fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.to_string_lossy().ends_with(".in.json")) + .collect(); + cases.sort(); + assert!( + cases.len() >= min_cases, + "fixtures went missing: {}", + cases.len() + ); + + for input in cases { + let file = input.file_name().unwrap().to_string_lossy().into_owned(); + let stem = file.trim_end_matches(".in.json"); + let (event, _) = stem + .split_once("__") + .expect("fixture name is __"); + let sibling = |ext: &str| dir.join(format!("{stem}.{ext}")); + + let verdict = std::fs::read_to_string(sibling("verdict")) + .map(|t| parse_verdict(&t)) + .unwrap_or(Verdict::Allow); + let rt = Fixture { + stdin: std::fs::read_to_string(&input).unwrap(), + verdict, + }; + + let got = handle(agent, event, &rt); + + let expected = std::fs::read_to_string(sibling("out")).unwrap(); + assert_eq!(got.stdout, expected, "stdout of {stem}"); + assert_eq!(got.exit_code, 0, "exit code of {stem}"); + match std::fs::read_to_string(sibling("stderr")) { + Ok(prefix) => assert!( + got.stderr.starts_with(prefix.trim_end()), + "stderr of {stem}: {:?}", + got.stderr + ), + Err(_) => assert_eq!(got.stderr, "", "stderr of {stem}"), + } + } +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_extension_shape.in.json b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_extension_shape.in.json new file mode 100644 index 0000000..b6c6aab --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_extension_shape.in.json @@ -0,0 +1,16 @@ +{ + "clineVersion": "3.40.0", + "hookName": "PreToolUse", + "timestamp": "1768473000000", + "taskId": "conv-123", + "workspaceRoots": [ + "/work/proj" + ], + "userId": "user", + "preToolUse": { + "toolName": "apply_patch", + "parameters": { + "input": "*** Begin Patch\n*** Update File: src/a.rs\n*** Move to: secrets/a.rs\n@@\n-x\n+y\n*** End Patch" + } + } +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_extension_shape.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_extension_shape.out new file mode 100644 index 0000000..fcdfdb9 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_extension_shape.out @@ -0,0 +1 @@ +{"cancel":true,"errorMessage":"Reflex Control: this edit writes to `secrets/a.rs`, which is protected (matches `secrets/**`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_normal.in.json b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_normal.in.json new file mode 100644 index 0000000..b20994c --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_normal.in.json @@ -0,0 +1,24 @@ +{ + "hookName": "tool_call", + "clineVersion": "1.0.0", + "timestamp": "2026-01-15T10:30:00Z", + "taskId": "conv-123", + "workspaceRoots": [ + "/work/proj" + ], + "userId": "user", + "iteration": 1, + "tool_call": { + "id": "call-1", + "name": "apply_patch", + "input": { + "input": "*** Begin Patch\n*** Add File: src/new.rs\n+fn main() {}\n*** Update File: src/lib.rs\n@@\n-x\n+y\n*** End Patch" + } + }, + "preToolUse": { + "toolName": "apply_patch", + "parameters": { + "input": "*** Begin Patch\n*** Add File: src/new.rs\n+fn main() {}\n*** Update File: src/lib.rs\n@@\n-x\n+y\n*** End Patch" + } + } +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_normal.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_normal.out new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_normal.out @@ -0,0 +1 @@ +{} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_object_input.in.json b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_object_input.in.json new file mode 100644 index 0000000..4638705 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_object_input.in.json @@ -0,0 +1,24 @@ +{ + "hookName": "tool_call", + "clineVersion": "1.0.0", + "timestamp": "2026-01-15T10:30:00Z", + "taskId": "conv-123", + "workspaceRoots": [ + "/work/proj" + ], + "userId": "user", + "iteration": 1, + "tool_call": { + "id": "call-1", + "name": "apply_patch", + "input": { + "input": "*** Begin Patch\n*** Update File: config/.env\n@@\n-A=1\n+A=2\n*** End Patch" + } + }, + "preToolUse": { + "toolName": "apply_patch", + "parameters": { + "input": "*** Begin Patch\n*** Update File: config/.env\n@@\n-A=1\n+A=2\n*** End Patch" + } + } +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_object_input.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_object_input.out new file mode 100644 index 0000000..cfbbfcd --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_object_input.out @@ -0,0 +1 @@ +{"cancel":true,"errorMessage":"Reflex Control: this edit writes to `config/.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_string_input.in.json b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_string_input.in.json new file mode 100644 index 0000000..831181a --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_string_input.in.json @@ -0,0 +1,20 @@ +{ + "hookName": "tool_call", + "clineVersion": "1.0.0", + "timestamp": "2026-01-15T10:30:00Z", + "taskId": "conv-123", + "workspaceRoots": [ + "/work/proj" + ], + "userId": "user", + "iteration": 1, + "tool_call": { + "id": "c", + "name": "apply_patch", + "input": "*** Begin Patch\n*** Update File: config/.env\n@@\n-A=1\n+A=2\n*** End Patch" + }, + "preToolUse": { + "toolName": "apply_patch", + "parameters": {} + } +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_string_input.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_string_input.out new file mode 100644 index 0000000..cfbbfcd --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_string_input.out @@ -0,0 +1 @@ +{"cancel":true,"errorMessage":"Reflex Control: this edit writes to `config/.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_env.in.json b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_env.in.json new file mode 100644 index 0000000..90be591 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_env.in.json @@ -0,0 +1,26 @@ +{ + "hookName": "tool_call", + "clineVersion": "1.0.0", + "timestamp": "2026-01-15T10:30:00Z", + "taskId": "conv-123", + "workspaceRoots": [ + "/work/proj" + ], + "userId": "user", + "iteration": 1, + "tool_call": { + "id": "call-1", + "name": "editor", + "input": { + "path": "/work/proj/.env", + "new_text": "KEY=1" + } + }, + "preToolUse": { + "toolName": "editor", + "parameters": { + "path": "/work/proj/.env", + "new_text": "KEY=1" + } + } +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_env.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_env.out new file mode 100644 index 0000000..1caee12 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_env.out @@ -0,0 +1 @@ +{"cancel":true,"errorMessage":"Reflex Control: this edit writes to `/work/proj/.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_env_extension_shape.in.json b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_env_extension_shape.in.json new file mode 100644 index 0000000..628d44f --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_env_extension_shape.in.json @@ -0,0 +1,17 @@ +{ + "clineVersion": "3.40.0", + "hookName": "PreToolUse", + "timestamp": "1768473000000", + "taskId": "conv-123", + "workspaceRoots": [ + "/work/proj" + ], + "userId": "user", + "preToolUse": { + "toolName": "editor", + "parameters": { + "path": "/work/proj/.env", + "new_text": "KEY=1" + } + } +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_env_extension_shape.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_env_extension_shape.out new file mode 100644 index 0000000..1caee12 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_env_extension_shape.out @@ -0,0 +1 @@ +{"cancel":true,"errorMessage":"Reflex Control: this edit writes to `/work/proj/.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_normal.in.json b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_normal.in.json new file mode 100644 index 0000000..5b6ebe5 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_normal.in.json @@ -0,0 +1,28 @@ +{ + "hookName": "tool_call", + "clineVersion": "1.0.0", + "timestamp": "2026-01-15T10:30:00Z", + "taskId": "conv-123", + "workspaceRoots": [ + "/work/proj" + ], + "userId": "user", + "iteration": 1, + "tool_call": { + "id": "call-1", + "name": "editor", + "input": { + "path": "/work/proj/src/main.rs", + "old_text": "a", + "new_text": "b" + } + }, + "preToolUse": { + "toolName": "editor", + "parameters": { + "path": "/work/proj/src/main.rs", + "old_text": "a", + "new_text": "b" + } + } +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_normal.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_normal.out new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_normal.out @@ -0,0 +1 @@ +{} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_normal_extension_shape.in.json b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_normal_extension_shape.in.json new file mode 100644 index 0000000..7f98c21 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_normal_extension_shape.in.json @@ -0,0 +1,17 @@ +{ + "clineVersion": "3.40.0", + "hookName": "PreToolUse", + "timestamp": "1768473000000", + "taskId": "conv-123", + "workspaceRoots": [ + "/work/proj" + ], + "userId": "user", + "preToolUse": { + "toolName": "editor", + "parameters": { + "path": "/work/proj/src/main.rs", + "new_text": "b" + } + } +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_normal_extension_shape.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_normal_extension_shape.out new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_normal_extension_shape.out @@ -0,0 +1 @@ +{} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__execute_command_legacy_rm_rf.in.json b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__execute_command_legacy_rm_rf.in.json new file mode 100644 index 0000000..fb81194 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__execute_command_legacy_rm_rf.in.json @@ -0,0 +1,16 @@ +{ + "clineVersion": "3.40.0", + "hookName": "PreToolUse", + "timestamp": "1768473000000", + "taskId": "conv-123", + "workspaceRoots": [ + "/work/proj" + ], + "userId": "user", + "preToolUse": { + "toolName": "execute_command", + "parameters": { + "command": "rm -rf /" + } + } +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__execute_command_legacy_rm_rf.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__execute_command_legacy_rm_rf.out new file mode 100644 index 0000000..ad531ef --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__execute_command_legacy_rm_rf.out @@ -0,0 +1 @@ +{"cancel":true,"errorMessage":"Reflex Control: this command recursively deletes a broad target. Confirm before it runs. Cline hooks cannot ask for confirmation, so the call was cancelled. Run it yourself if you want it."} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__malformed.in.json b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__malformed.in.json new file mode 100644 index 0000000..e8d8251 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__malformed.in.json @@ -0,0 +1 @@ +{ this is not json \ No newline at end of file diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__malformed.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__malformed.out new file mode 100644 index 0000000..e69de29 diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__malformed.stderr b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__malformed.stderr new file mode 100644 index 0000000..a1b0025 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__malformed.stderr @@ -0,0 +1 @@ +reflex: warning: diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__no_tool.in.json b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__no_tool.in.json new file mode 100644 index 0000000..70d8b57 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__no_tool.in.json @@ -0,0 +1,6 @@ +{ + "hookName": "tool_call", + "workspaceRoots": [ + "/work/proj" + ] +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__no_tool.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__no_tool.out new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__no_tool.out @@ -0,0 +1 @@ +{} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__read_files_ignored.in.json b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__read_files_ignored.in.json new file mode 100644 index 0000000..617b8fc --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__read_files_ignored.in.json @@ -0,0 +1,28 @@ +{ + "hookName": "tool_call", + "clineVersion": "1.0.0", + "timestamp": "2026-01-15T10:30:00Z", + "taskId": "conv-123", + "workspaceRoots": [ + "/work/proj" + ], + "userId": "user", + "iteration": 1, + "tool_call": { + "id": "call-1", + "name": "read_files", + "input": { + "files": [ + { + "path": "/work/proj/.env" + } + ] + } + }, + "preToolUse": { + "toolName": "read_files", + "parameters": { + "files": "[{\"path\": \"/work/proj/.env\"}]" + } + } +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__read_files_ignored.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__read_files_ignored.out new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__read_files_ignored.out @@ -0,0 +1 @@ +{} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_cargo_test.in.json b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_cargo_test.in.json new file mode 100644 index 0000000..f52d7d8 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_cargo_test.in.json @@ -0,0 +1,26 @@ +{ + "hookName": "tool_call", + "clineVersion": "1.0.0", + "timestamp": "2026-01-15T10:30:00Z", + "taskId": "conv-123", + "workspaceRoots": [ + "/work/proj" + ], + "userId": "user", + "iteration": 1, + "tool_call": { + "id": "call-1", + "name": "run_commands", + "input": { + "commands": [ + "cargo test" + ] + } + }, + "preToolUse": { + "toolName": "run_commands", + "parameters": { + "commands": "[\"cargo test\"]" + } + } +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_cargo_test.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_cargo_test.out new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_cargo_test.out @@ -0,0 +1 @@ +{} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_redirect_env.in.json b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_redirect_env.in.json new file mode 100644 index 0000000..1c40467 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_redirect_env.in.json @@ -0,0 +1,26 @@ +{ + "hookName": "tool_call", + "clineVersion": "1.0.0", + "timestamp": "2026-01-15T10:30:00Z", + "taskId": "conv-123", + "workspaceRoots": [ + "/work/proj" + ], + "userId": "user", + "iteration": 1, + "tool_call": { + "id": "call-1", + "name": "run_commands", + "input": { + "commands": [ + "echo KEY=1 > .env" + ] + } + }, + "preToolUse": { + "toolName": "run_commands", + "parameters": { + "commands": "[\"echo KEY=1 > .env\"]" + } + } +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_redirect_env.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_redirect_env.out new file mode 100644 index 0000000..0a17ca5 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_redirect_env.out @@ -0,0 +1 @@ +{"cancel":true,"errorMessage":"Reflex Control: this command writes to `.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_redirect_env_extension_shape.in.json b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_redirect_env_extension_shape.in.json new file mode 100644 index 0000000..2cbc577 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_redirect_env_extension_shape.in.json @@ -0,0 +1,16 @@ +{ + "clineVersion": "3.40.0", + "hookName": "PreToolUse", + "timestamp": "1768473000000", + "taskId": "conv-123", + "workspaceRoots": [ + "/work/proj" + ], + "userId": "user", + "preToolUse": { + "toolName": "run_commands", + "parameters": { + "commands": "[\"ls\", \"echo KEY=1 > .env\"]" + } + } +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_redirect_env_extension_shape.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_redirect_env_extension_shape.out new file mode 100644 index 0000000..0a17ca5 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_redirect_env_extension_shape.out @@ -0,0 +1 @@ +{"cancel":true,"errorMessage":"Reflex Control: this command writes to `.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_rm_rf_root.in.json b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_rm_rf_root.in.json new file mode 100644 index 0000000..8a0a690 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_rm_rf_root.in.json @@ -0,0 +1,26 @@ +{ + "hookName": "tool_call", + "clineVersion": "1.0.0", + "timestamp": "2026-01-15T10:30:00Z", + "taskId": "conv-123", + "workspaceRoots": [ + "/work/proj" + ], + "userId": "user", + "iteration": 1, + "tool_call": { + "id": "call-1", + "name": "run_commands", + "input": { + "commands": [ + "rm -rf /" + ] + } + }, + "preToolUse": { + "toolName": "run_commands", + "parameters": { + "commands": "[\"rm -rf /\"]" + } + } +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_rm_rf_root.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_rm_rf_root.out new file mode 100644 index 0000000..ad531ef --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_rm_rf_root.out @@ -0,0 +1 @@ +{"cancel":true,"errorMessage":"Reflex Control: this command recursively deletes a broad target. Confirm before it runs. Cline hooks cannot ask for confirmation, so the call was cancelled. Run it yourself if you want it."} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_structured_force_push.in.json b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_structured_force_push.in.json new file mode 100644 index 0000000..a09136e --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_structured_force_push.in.json @@ -0,0 +1,34 @@ +{ + "hookName": "tool_call", + "clineVersion": "1.0.0", + "timestamp": "2026-01-15T10:30:00Z", + "taskId": "conv-123", + "workspaceRoots": [ + "/work/proj" + ], + "userId": "user", + "iteration": 1, + "tool_call": { + "id": "call-1", + "name": "run_commands", + "input": { + "commands": [ + { + "command": "git", + "args": [ + "push", + "--force", + "origin", + "main" + ] + } + ] + } + }, + "preToolUse": { + "toolName": "run_commands", + "parameters": { + "commands": "[{\"command\": \"git\", \"args\": [\"push\", \"--force\", \"origin\", \"main\"]}]" + } + } +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_structured_force_push.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_structured_force_push.out new file mode 100644 index 0000000..25f0fa5 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__run_commands_structured_force_push.out @@ -0,0 +1 @@ +{"cancel":true,"errorMessage":"Reflex Control: this command force-pushes and can overwrite remote history. Confirm before it runs. Cline hooks cannot ask for confirmation, so the call was cancelled. Run it yourself if you want it."} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__write_to_file_legacy_secrets.in.json b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__write_to_file_legacy_secrets.in.json new file mode 100644 index 0000000..f94bc4d --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__write_to_file_legacy_secrets.in.json @@ -0,0 +1,17 @@ +{ + "clineVersion": "3.40.0", + "hookName": "PreToolUse", + "timestamp": "1768473000000", + "taskId": "conv-123", + "workspaceRoots": [ + "/work/proj" + ], + "userId": "user", + "preToolUse": { + "toolName": "write_to_file", + "parameters": { + "path": "secrets/token.txt", + "content": "x" + } + } +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__write_to_file_legacy_secrets.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__write_to_file_legacy_secrets.out new file mode 100644 index 0000000..1eb7d28 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__write_to_file_legacy_secrets.out @@ -0,0 +1 @@ +{"cancel":true,"errorMessage":"Reflex Control: this edit writes to `secrets/token.txt`, which is protected (matches `secrets/**`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/cline/turn-end__unsupported.in.json b/crates/reflex-hooks/tests/fixtures/cline/turn-end__unsupported.in.json new file mode 100644 index 0000000..d2f6301 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/turn-end__unsupported.in.json @@ -0,0 +1,3 @@ +{ + "hookName": "agent_end" +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/turn-end__unsupported.out b/crates/reflex-hooks/tests/fixtures/cline/turn-end__unsupported.out new file mode 100644 index 0000000..e69de29 diff --git a/crates/reflex-hooks/tests/fixtures/cline/turn-end__unsupported.stderr b/crates/reflex-hooks/tests/fixtures/cline/turn-end__unsupported.stderr new file mode 100644 index 0000000..9f4bff5 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/turn-end__unsupported.stderr @@ -0,0 +1 @@ +reflex: warning: unknown cline hook event diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__apply_patch_env.in.json b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__apply_patch_env.in.json new file mode 100644 index 0000000..5af84df --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__apply_patch_env.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "apply_patch", + "args": { + "patchText": "*** Begin Patch\n*** Update File: config/.env\n@@\n-A=1\n+A=2\n*** End Patch" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__apply_patch_env.out b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__apply_patch_env.out new file mode 100644 index 0000000..6cbc0ea --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__apply_patch_env.out @@ -0,0 +1 @@ +{"action":"block","reason":"Reflex Control: this edit writes to `config/.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_cargo_test.in.json b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_cargo_test.in.json new file mode 100644 index 0000000..2a7ea80 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_cargo_test.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "bash", + "args": { + "command": "cargo test" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_cargo_test.out b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_cargo_test.out new file mode 100644 index 0000000..8cfdb52 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_cargo_test.out @@ -0,0 +1 @@ +{"action":"allow"} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_force_push.in.json b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_force_push.in.json new file mode 100644 index 0000000..cd01889 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_force_push.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "bash", + "args": { + "command": "git push --force origin main" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_force_push.out b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_force_push.out new file mode 100644 index 0000000..5596fce --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_force_push.out @@ -0,0 +1 @@ +{"action":"ask","reason":"Reflex Control: this command force-pushes and can overwrite remote history. Confirm before it runs."} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_redirect_env.in.json b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_redirect_env.in.json new file mode 100644 index 0000000..fb566e1 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_redirect_env.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "bash", + "args": { + "command": "echo KEY=1 > .env" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_redirect_env.out b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_redirect_env.out new file mode 100644 index 0000000..a46b130 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_redirect_env.out @@ -0,0 +1 @@ +{"action":"block","reason":"Reflex Control: this command writes to `.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_rm_rf_root.in.json b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_rm_rf_root.in.json new file mode 100644 index 0000000..75e43e1 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_rm_rf_root.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "bash", + "args": { + "command": "rm -rf /" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_rm_rf_root.out b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_rm_rf_root.out new file mode 100644 index 0000000..59bc030 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__bash_rm_rf_root.out @@ -0,0 +1 @@ +{"action":"ask","reason":"Reflex Control: this command recursively deletes a broad target. Confirm before it runs."} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__edit_nested_env_local.in.json b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__edit_nested_env_local.in.json new file mode 100644 index 0000000..6eaa69d --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__edit_nested_env_local.in.json @@ -0,0 +1,10 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "edit", + "args": { + "filePath": "/work/proj/app/.env.local", + "oldString": "a", + "newString": "b" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__edit_nested_env_local.out b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__edit_nested_env_local.out new file mode 100644 index 0000000..8d850b7 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__edit_nested_env_local.out @@ -0,0 +1 @@ +{"action":"block","reason":"Reflex Control: this edit writes to `/work/proj/app/.env.local`, which is protected (matches `.env.*`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__malformed.in.json b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__malformed.in.json new file mode 100644 index 0000000..e8d8251 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__malformed.in.json @@ -0,0 +1 @@ +{ this is not json \ No newline at end of file diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__malformed.out b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__malformed.out new file mode 100644 index 0000000..e69de29 diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__malformed.stderr b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__malformed.stderr new file mode 100644 index 0000000..a1b0025 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__malformed.stderr @@ -0,0 +1 @@ +reflex: warning: diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__read_ignored.in.json b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__read_ignored.in.json new file mode 100644 index 0000000..1065dd2 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__read_ignored.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "read", + "args": { + "filePath": "/work/proj/.env" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__read_ignored.out b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__read_ignored.out new file mode 100644 index 0000000..8cfdb52 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__read_ignored.out @@ -0,0 +1 @@ +{"action":"allow"} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_env.in.json b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_env.in.json new file mode 100644 index 0000000..1880c07 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_env.in.json @@ -0,0 +1,9 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "write", + "args": { + "filePath": "/work/proj/.env", + "content": "KEY=1" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_env.out b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_env.out new file mode 100644 index 0000000..b2269d0 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_env.out @@ -0,0 +1 @@ +{"action":"block","reason":"Reflex Control: this edit writes to `/work/proj/.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_missing_args.in.json b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_missing_args.in.json new file mode 100644 index 0000000..77ce7b3 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_missing_args.in.json @@ -0,0 +1,6 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "write", + "args": {} +} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_missing_args.out b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_missing_args.out new file mode 100644 index 0000000..8cfdb52 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_missing_args.out @@ -0,0 +1 @@ +{"action":"allow"} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_normal.in.json b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_normal.in.json new file mode 100644 index 0000000..bfa61ac --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_normal.in.json @@ -0,0 +1,9 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "write", + "args": { + "filePath": "/work/proj/src/main.rs", + "content": "fn main() {}" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_normal.out b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_normal.out new file mode 100644 index 0000000..8cfdb52 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__write_normal.out @@ -0,0 +1 @@ +{"action":"allow"} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/turn-end__allow.in.json b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__allow.in.json new file mode 100644 index 0000000..988b987 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__allow.in.json @@ -0,0 +1,4 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1" +} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/turn-end__allow.out b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__allow.out new file mode 100644 index 0000000..8cfdb52 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__allow.out @@ -0,0 +1 @@ +{"action":"allow"} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/turn-end__allow.verdict b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__allow.verdict new file mode 100644 index 0000000..f5da834 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__allow.verdict @@ -0,0 +1 @@ +allow diff --git a/crates/reflex-hooks/tests/fixtures/kilo/turn-end__ask_human.in.json b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__ask_human.in.json new file mode 100644 index 0000000..988b987 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__ask_human.in.json @@ -0,0 +1,4 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1" +} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/turn-end__ask_human.out b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__ask_human.out new file mode 100644 index 0000000..d908d1f --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__ask_human.out @@ -0,0 +1 @@ +{"action":"notify","reason":"Reflex Control: tests are still failing (`cargo test`) after 2 automatic fix attempts, so please take a look."} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/turn-end__ask_human.verdict b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__ask_human.verdict new file mode 100644 index 0000000..d2f90e5 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__ask_human.verdict @@ -0,0 +1 @@ +ask-human: Reflex Control: tests are still failing (`cargo test`) after 2 automatic fix attempts, so please take a look. diff --git a/crates/reflex-hooks/tests/fixtures/kilo/turn-end__malformed.in.json b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__malformed.in.json new file mode 100644 index 0000000..e7c3e7f --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__malformed.in.json @@ -0,0 +1 @@ +not json at all \ No newline at end of file diff --git a/crates/reflex-hooks/tests/fixtures/kilo/turn-end__malformed.out b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__malformed.out new file mode 100644 index 0000000..e69de29 diff --git a/crates/reflex-hooks/tests/fixtures/kilo/turn-end__malformed.stderr b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__malformed.stderr new file mode 100644 index 0000000..a1b0025 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__malformed.stderr @@ -0,0 +1 @@ +reflex: warning: diff --git a/crates/reflex-hooks/tests/fixtures/kilo/turn-end__notify.in.json b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__notify.in.json new file mode 100644 index 0000000..988b987 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__notify.in.json @@ -0,0 +1,4 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1" +} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/turn-end__notify.out b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__notify.out new file mode 100644 index 0000000..52751ab --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__notify.out @@ -0,0 +1 @@ +{"action":"notify","reason":"Reflex Control: large change (1200 lines changed, limit 800), please review."} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/turn-end__notify.verdict b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__notify.verdict new file mode 100644 index 0000000..a53edb8 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__notify.verdict @@ -0,0 +1 @@ +notify: Reflex Control: large change (1200 lines changed, limit 800), please review. diff --git a/crates/reflex-hooks/tests/fixtures/kilo/turn-end__retry.in.json b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__retry.in.json new file mode 100644 index 0000000..988b987 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__retry.in.json @@ -0,0 +1,4 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1" +} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/turn-end__retry.out b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__retry.out new file mode 100644 index 0000000..392ee1f --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__retry.out @@ -0,0 +1 @@ +{"action":"retry","reason":"Reflex Control: the tests failed (`cargo test`). This is fix attempt 1 of 2. Fix the failures, then finish again. Last 40 lines of output:\n\nassertion failed"} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/turn-end__retry.verdict b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__retry.verdict new file mode 100644 index 0000000..349302e --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/kilo/turn-end__retry.verdict @@ -0,0 +1 @@ +retry: Reflex Control: the tests failed (`cargo test`). This is fix attempt 1 of 2. Fix the failures, then finish again. Last 40 lines of output:\n\nassertion failed diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_env.in.json b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_env.in.json new file mode 100644 index 0000000..5af84df --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_env.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "apply_patch", + "args": { + "patchText": "*** Begin Patch\n*** Update File: config/.env\n@@\n-A=1\n+A=2\n*** End Patch" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_env.out b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_env.out new file mode 100644 index 0000000..6cbc0ea --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_env.out @@ -0,0 +1 @@ +{"action":"block","reason":"Reflex Control: this edit writes to `config/.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_move_into_secrets.in.json b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_move_into_secrets.in.json new file mode 100644 index 0000000..eb71dc5 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_move_into_secrets.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "apply_patch", + "args": { + "patchText": "*** Begin Patch\n*** Update File: src/a.rs\n*** Move to: secrets/a.rs\n@@\n-x\n+y\n*** End Patch" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_move_into_secrets.out b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_move_into_secrets.out new file mode 100644 index 0000000..62f5f29 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_move_into_secrets.out @@ -0,0 +1 @@ +{"action":"block","reason":"Reflex Control: this edit writes to `secrets/a.rs`, which is protected (matches `secrets/**`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_normal.in.json b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_normal.in.json new file mode 100644 index 0000000..c28e3af --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_normal.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "apply_patch", + "args": { + "patchText": "*** Begin Patch\n*** Add File: src/new.rs\n+fn main() {}\n*** Update File: src/lib.rs\n@@\n-x\n+y\n*** End Patch" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_normal.out b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_normal.out new file mode 100644 index 0000000..8cfdb52 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_normal.out @@ -0,0 +1 @@ +{"action":"allow"} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_cargo_test.in.json b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_cargo_test.in.json new file mode 100644 index 0000000..2a7ea80 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_cargo_test.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "bash", + "args": { + "command": "cargo test" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_cargo_test.out b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_cargo_test.out new file mode 100644 index 0000000..8cfdb52 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_cargo_test.out @@ -0,0 +1 @@ +{"action":"allow"} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_force_push.in.json b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_force_push.in.json new file mode 100644 index 0000000..cd01889 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_force_push.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "bash", + "args": { + "command": "git push --force origin main" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_force_push.out b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_force_push.out new file mode 100644 index 0000000..5596fce --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_force_push.out @@ -0,0 +1 @@ +{"action":"ask","reason":"Reflex Control: this command force-pushes and can overwrite remote history. Confirm before it runs."} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_redirect_env.in.json b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_redirect_env.in.json new file mode 100644 index 0000000..fb566e1 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_redirect_env.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "bash", + "args": { + "command": "echo KEY=1 > .env" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_redirect_env.out b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_redirect_env.out new file mode 100644 index 0000000..a46b130 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_redirect_env.out @@ -0,0 +1 @@ +{"action":"block","reason":"Reflex Control: this command writes to `.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_rm_rf_root.in.json b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_rm_rf_root.in.json new file mode 100644 index 0000000..75e43e1 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_rm_rf_root.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "bash", + "args": { + "command": "rm -rf /" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_rm_rf_root.out b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_rm_rf_root.out new file mode 100644 index 0000000..59bc030 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__bash_rm_rf_root.out @@ -0,0 +1 @@ +{"action":"ask","reason":"Reflex Control: this command recursively deletes a broad target. Confirm before it runs."} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__edit_nested_env_local.in.json b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__edit_nested_env_local.in.json new file mode 100644 index 0000000..6eaa69d --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__edit_nested_env_local.in.json @@ -0,0 +1,10 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "edit", + "args": { + "filePath": "/work/proj/app/.env.local", + "oldString": "a", + "newString": "b" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__edit_nested_env_local.out b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__edit_nested_env_local.out new file mode 100644 index 0000000..8d850b7 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__edit_nested_env_local.out @@ -0,0 +1 @@ +{"action":"block","reason":"Reflex Control: this edit writes to `/work/proj/app/.env.local`, which is protected (matches `.env.*`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__malformed.in.json b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__malformed.in.json new file mode 100644 index 0000000..e8d8251 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__malformed.in.json @@ -0,0 +1 @@ +{ this is not json \ No newline at end of file diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__malformed.out b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__malformed.out new file mode 100644 index 0000000..e69de29 diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__malformed.stderr b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__malformed.stderr new file mode 100644 index 0000000..a1b0025 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__malformed.stderr @@ -0,0 +1 @@ +reflex: warning: diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__multiedit_workflow.in.json b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__multiedit_workflow.in.json new file mode 100644 index 0000000..89c5403 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__multiedit_workflow.in.json @@ -0,0 +1,9 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "multiedit", + "args": { + "filePath": "/work/proj/.github/workflows/ci.yml", + "edits": [] + } +} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__multiedit_workflow.out b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__multiedit_workflow.out new file mode 100644 index 0000000..fe9ac0d --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__multiedit_workflow.out @@ -0,0 +1 @@ +{"action":"block","reason":"Reflex Control: this edit writes to `/work/proj/.github/workflows/ci.yml`, which is protected (matches `.github/workflows/**`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__read_ignored.in.json b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__read_ignored.in.json new file mode 100644 index 0000000..1065dd2 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__read_ignored.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "read", + "args": { + "filePath": "/work/proj/.env" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__read_ignored.out b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__read_ignored.out new file mode 100644 index 0000000..8cfdb52 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__read_ignored.out @@ -0,0 +1 @@ +{"action":"allow"} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_env.in.json b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_env.in.json new file mode 100644 index 0000000..1880c07 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_env.in.json @@ -0,0 +1,9 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "write", + "args": { + "filePath": "/work/proj/.env", + "content": "KEY=1" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_env.out b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_env.out new file mode 100644 index 0000000..b2269d0 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_env.out @@ -0,0 +1 @@ +{"action":"block","reason":"Reflex Control: this edit writes to `/work/proj/.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_missing_args.in.json b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_missing_args.in.json new file mode 100644 index 0000000..77ce7b3 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_missing_args.in.json @@ -0,0 +1,6 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "write", + "args": {} +} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_missing_args.out b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_missing_args.out new file mode 100644 index 0000000..8cfdb52 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_missing_args.out @@ -0,0 +1 @@ +{"action":"allow"} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_normal.in.json b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_normal.in.json new file mode 100644 index 0000000..bfa61ac --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_normal.in.json @@ -0,0 +1,9 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "write", + "args": { + "filePath": "/work/proj/src/main.rs", + "content": "fn main() {}" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_normal.out b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_normal.out new file mode 100644 index 0000000..8cfdb52 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_normal.out @@ -0,0 +1 @@ +{"action":"allow"} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/turn-end__allow.in.json b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__allow.in.json new file mode 100644 index 0000000..988b987 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__allow.in.json @@ -0,0 +1,4 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1" +} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/turn-end__allow.out b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__allow.out new file mode 100644 index 0000000..8cfdb52 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__allow.out @@ -0,0 +1 @@ +{"action":"allow"} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/turn-end__allow.verdict b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__allow.verdict new file mode 100644 index 0000000..f5da834 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__allow.verdict @@ -0,0 +1 @@ +allow diff --git a/crates/reflex-hooks/tests/fixtures/opencode/turn-end__ask_human.in.json b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__ask_human.in.json new file mode 100644 index 0000000..988b987 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__ask_human.in.json @@ -0,0 +1,4 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1" +} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/turn-end__ask_human.out b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__ask_human.out new file mode 100644 index 0000000..d908d1f --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__ask_human.out @@ -0,0 +1 @@ +{"action":"notify","reason":"Reflex Control: tests are still failing (`cargo test`) after 2 automatic fix attempts, so please take a look."} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/turn-end__ask_human.verdict b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__ask_human.verdict new file mode 100644 index 0000000..d2f90e5 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__ask_human.verdict @@ -0,0 +1 @@ +ask-human: Reflex Control: tests are still failing (`cargo test`) after 2 automatic fix attempts, so please take a look. diff --git a/crates/reflex-hooks/tests/fixtures/opencode/turn-end__malformed.in.json b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__malformed.in.json new file mode 100644 index 0000000..e7c3e7f --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__malformed.in.json @@ -0,0 +1 @@ +not json at all \ No newline at end of file diff --git a/crates/reflex-hooks/tests/fixtures/opencode/turn-end__malformed.out b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__malformed.out new file mode 100644 index 0000000..e69de29 diff --git a/crates/reflex-hooks/tests/fixtures/opencode/turn-end__malformed.stderr b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__malformed.stderr new file mode 100644 index 0000000..a1b0025 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__malformed.stderr @@ -0,0 +1 @@ +reflex: warning: diff --git a/crates/reflex-hooks/tests/fixtures/opencode/turn-end__notify.in.json b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__notify.in.json new file mode 100644 index 0000000..988b987 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__notify.in.json @@ -0,0 +1,4 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1" +} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/turn-end__notify.out b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__notify.out new file mode 100644 index 0000000..52751ab --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__notify.out @@ -0,0 +1 @@ +{"action":"notify","reason":"Reflex Control: large change (1200 lines changed, limit 800), please review."} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/turn-end__notify.verdict b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__notify.verdict new file mode 100644 index 0000000..a53edb8 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__notify.verdict @@ -0,0 +1 @@ +notify: Reflex Control: large change (1200 lines changed, limit 800), please review. diff --git a/crates/reflex-hooks/tests/fixtures/opencode/turn-end__retry.in.json b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__retry.in.json new file mode 100644 index 0000000..988b987 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__retry.in.json @@ -0,0 +1,4 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1" +} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/turn-end__retry.out b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__retry.out new file mode 100644 index 0000000..392ee1f --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__retry.out @@ -0,0 +1 @@ +{"action":"retry","reason":"Reflex Control: the tests failed (`cargo test`). This is fix attempt 1 of 2. Fix the failures, then finish again. Last 40 lines of output:\n\nassertion failed"} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/turn-end__retry.verdict b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__retry.verdict new file mode 100644 index 0000000..349302e --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/turn-end__retry.verdict @@ -0,0 +1 @@ +retry: Reflex Control: the tests failed (`cargo test`). This is fix attempt 1 of 2. Fix the failures, then finish again. Last 40 lines of output:\n\nassertion failed diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_cargo_test.in.json b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_cargo_test.in.json new file mode 100644 index 0000000..2a7ea80 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_cargo_test.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "bash", + "args": { + "command": "cargo test" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_cargo_test.out b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_cargo_test.out new file mode 100644 index 0000000..8cfdb52 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_cargo_test.out @@ -0,0 +1 @@ +{"action":"allow"} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_force_push.in.json b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_force_push.in.json new file mode 100644 index 0000000..cd01889 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_force_push.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "bash", + "args": { + "command": "git push --force origin main" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_force_push.out b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_force_push.out new file mode 100644 index 0000000..5596fce --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_force_push.out @@ -0,0 +1 @@ +{"action":"ask","reason":"Reflex Control: this command force-pushes and can overwrite remote history. Confirm before it runs."} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_redirect_env.in.json b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_redirect_env.in.json new file mode 100644 index 0000000..fb566e1 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_redirect_env.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "bash", + "args": { + "command": "echo KEY=1 > .env" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_redirect_env.out b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_redirect_env.out new file mode 100644 index 0000000..a46b130 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_redirect_env.out @@ -0,0 +1 @@ +{"action":"block","reason":"Reflex Control: this command writes to `.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_rm_rf_root.in.json b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_rm_rf_root.in.json new file mode 100644 index 0000000..75e43e1 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_rm_rf_root.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "bash", + "args": { + "command": "rm -rf /" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_rm_rf_root.out b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_rm_rf_root.out new file mode 100644 index 0000000..59bc030 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__bash_rm_rf_root.out @@ -0,0 +1 @@ +{"action":"ask","reason":"Reflex Control: this command recursively deletes a broad target. Confirm before it runs."} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__edit_nested_env_local.in.json b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__edit_nested_env_local.in.json new file mode 100644 index 0000000..a3525a0 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__edit_nested_env_local.in.json @@ -0,0 +1,14 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "edit", + "args": { + "path": "/work/proj/app/.env.local", + "edits": [ + { + "oldText": "a", + "newText": "b" + } + ] + } +} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__edit_nested_env_local.out b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__edit_nested_env_local.out new file mode 100644 index 0000000..8d850b7 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__edit_nested_env_local.out @@ -0,0 +1 @@ +{"action":"block","reason":"Reflex Control: this edit writes to `/work/proj/app/.env.local`, which is protected (matches `.env.*`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__malformed.in.json b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__malformed.in.json new file mode 100644 index 0000000..e8d8251 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__malformed.in.json @@ -0,0 +1 @@ +{ this is not json \ No newline at end of file diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__malformed.out b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__malformed.out new file mode 100644 index 0000000..e69de29 diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__malformed.stderr b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__malformed.stderr new file mode 100644 index 0000000..a1b0025 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__malformed.stderr @@ -0,0 +1 @@ +reflex: warning: diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__powershell_redirect_env.in.json b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__powershell_redirect_env.in.json new file mode 100644 index 0000000..ff69e00 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__powershell_redirect_env.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "powershell", + "args": { + "command": "echo KEY=1 > .env" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__powershell_redirect_env.out b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__powershell_redirect_env.out new file mode 100644 index 0000000..a46b130 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__powershell_redirect_env.out @@ -0,0 +1 @@ +{"action":"block","reason":"Reflex Control: this command writes to `.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__read_ignored.in.json b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__read_ignored.in.json new file mode 100644 index 0000000..0a986d9 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__read_ignored.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "read", + "args": { + "path": "/work/proj/.env" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__read_ignored.out b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__read_ignored.out new file mode 100644 index 0000000..8cfdb52 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__read_ignored.out @@ -0,0 +1 @@ +{"action":"allow"} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_env.in.json b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_env.in.json new file mode 100644 index 0000000..3e3c6b4 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_env.in.json @@ -0,0 +1,9 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "write", + "args": { + "path": "/work/proj/.env", + "content": "KEY=1" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_env.out b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_env.out new file mode 100644 index 0000000..b2269d0 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_env.out @@ -0,0 +1 @@ +{"action":"block","reason":"Reflex Control: this edit writes to `/work/proj/.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_missing_args.in.json b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_missing_args.in.json new file mode 100644 index 0000000..77ce7b3 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_missing_args.in.json @@ -0,0 +1,6 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "write", + "args": {} +} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_missing_args.out b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_missing_args.out new file mode 100644 index 0000000..8cfdb52 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_missing_args.out @@ -0,0 +1 @@ +{"action":"allow"} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_normal.in.json b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_normal.in.json new file mode 100644 index 0000000..5ae49bb --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_normal.in.json @@ -0,0 +1,9 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1", + "tool": "write", + "args": { + "path": "/work/proj/src/main.rs", + "content": "fn main() {}" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_normal.out b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_normal.out new file mode 100644 index 0000000..8cfdb52 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_normal.out @@ -0,0 +1 @@ +{"action":"allow"} diff --git a/crates/reflex-hooks/tests/fixtures/pi/turn-end__allow.in.json b/crates/reflex-hooks/tests/fixtures/pi/turn-end__allow.in.json new file mode 100644 index 0000000..988b987 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/turn-end__allow.in.json @@ -0,0 +1,4 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1" +} diff --git a/crates/reflex-hooks/tests/fixtures/pi/turn-end__allow.out b/crates/reflex-hooks/tests/fixtures/pi/turn-end__allow.out new file mode 100644 index 0000000..8cfdb52 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/turn-end__allow.out @@ -0,0 +1 @@ +{"action":"allow"} diff --git a/crates/reflex-hooks/tests/fixtures/pi/turn-end__allow.verdict b/crates/reflex-hooks/tests/fixtures/pi/turn-end__allow.verdict new file mode 100644 index 0000000..f5da834 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/turn-end__allow.verdict @@ -0,0 +1 @@ +allow diff --git a/crates/reflex-hooks/tests/fixtures/pi/turn-end__ask_human.in.json b/crates/reflex-hooks/tests/fixtures/pi/turn-end__ask_human.in.json new file mode 100644 index 0000000..988b987 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/turn-end__ask_human.in.json @@ -0,0 +1,4 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1" +} diff --git a/crates/reflex-hooks/tests/fixtures/pi/turn-end__ask_human.out b/crates/reflex-hooks/tests/fixtures/pi/turn-end__ask_human.out new file mode 100644 index 0000000..d908d1f --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/turn-end__ask_human.out @@ -0,0 +1 @@ +{"action":"notify","reason":"Reflex Control: tests are still failing (`cargo test`) after 2 automatic fix attempts, so please take a look."} diff --git a/crates/reflex-hooks/tests/fixtures/pi/turn-end__ask_human.verdict b/crates/reflex-hooks/tests/fixtures/pi/turn-end__ask_human.verdict new file mode 100644 index 0000000..d2f90e5 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/turn-end__ask_human.verdict @@ -0,0 +1 @@ +ask-human: Reflex Control: tests are still failing (`cargo test`) after 2 automatic fix attempts, so please take a look. diff --git a/crates/reflex-hooks/tests/fixtures/pi/turn-end__malformed.in.json b/crates/reflex-hooks/tests/fixtures/pi/turn-end__malformed.in.json new file mode 100644 index 0000000..e7c3e7f --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/turn-end__malformed.in.json @@ -0,0 +1 @@ +not json at all \ No newline at end of file diff --git a/crates/reflex-hooks/tests/fixtures/pi/turn-end__malformed.out b/crates/reflex-hooks/tests/fixtures/pi/turn-end__malformed.out new file mode 100644 index 0000000..e69de29 diff --git a/crates/reflex-hooks/tests/fixtures/pi/turn-end__malformed.stderr b/crates/reflex-hooks/tests/fixtures/pi/turn-end__malformed.stderr new file mode 100644 index 0000000..a1b0025 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/turn-end__malformed.stderr @@ -0,0 +1 @@ +reflex: warning: diff --git a/crates/reflex-hooks/tests/fixtures/pi/turn-end__notify.in.json b/crates/reflex-hooks/tests/fixtures/pi/turn-end__notify.in.json new file mode 100644 index 0000000..988b987 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/turn-end__notify.in.json @@ -0,0 +1,4 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1" +} diff --git a/crates/reflex-hooks/tests/fixtures/pi/turn-end__notify.out b/crates/reflex-hooks/tests/fixtures/pi/turn-end__notify.out new file mode 100644 index 0000000..52751ab --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/turn-end__notify.out @@ -0,0 +1 @@ +{"action":"notify","reason":"Reflex Control: large change (1200 lines changed, limit 800), please review."} diff --git a/crates/reflex-hooks/tests/fixtures/pi/turn-end__notify.verdict b/crates/reflex-hooks/tests/fixtures/pi/turn-end__notify.verdict new file mode 100644 index 0000000..a53edb8 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/turn-end__notify.verdict @@ -0,0 +1 @@ +notify: Reflex Control: large change (1200 lines changed, limit 800), please review. diff --git a/crates/reflex-hooks/tests/fixtures/pi/turn-end__retry.in.json b/crates/reflex-hooks/tests/fixtures/pi/turn-end__retry.in.json new file mode 100644 index 0000000..988b987 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/turn-end__retry.in.json @@ -0,0 +1,4 @@ +{ + "cwd": "/work/proj", + "session_id": "ses_1" +} diff --git a/crates/reflex-hooks/tests/fixtures/pi/turn-end__retry.out b/crates/reflex-hooks/tests/fixtures/pi/turn-end__retry.out new file mode 100644 index 0000000..392ee1f --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/turn-end__retry.out @@ -0,0 +1 @@ +{"action":"retry","reason":"Reflex Control: the tests failed (`cargo test`). This is fix attempt 1 of 2. Fix the failures, then finish again. Last 40 lines of output:\n\nassertion failed"} diff --git a/crates/reflex-hooks/tests/fixtures/pi/turn-end__retry.verdict b/crates/reflex-hooks/tests/fixtures/pi/turn-end__retry.verdict new file mode 100644 index 0000000..349302e --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/turn-end__retry.verdict @@ -0,0 +1 @@ +retry: Reflex Control: the tests failed (`cargo test`). This is fix attempt 1 of 2. Fix the failures, then finish again. Last 40 lines of output:\n\nassertion failed diff --git a/crates/reflex-hooks/tests/js_agents_install.rs b/crates/reflex-hooks/tests/js_agents_install.rs new file mode 100644 index 0000000..d7f5fb8 --- /dev/null +++ b/crates/reflex-hooks/tests/js_agents_install.rs @@ -0,0 +1,313 @@ +//! Install and uninstall for OpenCode, Kilo Code, Cline and pi, on real directories: +//! what is written, that a file of the user's is never touched, that installing twice +//! changes nothing, and that the embedded scripts are valid. + +use reflex_hooks::adapters::{self, cline, opencode, pi}; +use reflex_hooks::config::{Config, Scope}; +use reflex_hooks::install::{ + apply, files_needed, files_needed_uninstall, plan, plan_uninstall, read_existing, Answers, Env, + Plan, PlanCtx, +}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn env(root: &Path) -> Env { + Env { + root: root.to_path_buf(), + home: Some(root.join("home")), + git_hooks_dir: Some(root.join(".git/hooks")), + hooks_dir_in_repo: false, + } +} + +fn answers(agents: &[&str], scope: Scope) -> Answers { + Answers { + agents: agents.iter().map(|s| s.to_string()).collect(), + scope, + config: Config::default(), + } +} + +fn install(a: &Answers, e: &Env) -> Plan { + let files = read_existing(&files_needed(a, e).unwrap()); + let p = plan(a, e, &files).unwrap(); + apply(&p.changes).unwrap(); + p +} + +fn uninstall(e: &Env) -> Plan { + let files = read_existing(&files_needed_uninstall(e)); + let p = plan_uninstall(e, &files).unwrap(); + apply(&p.changes).unwrap(); + p +} + +/// What `reflex install` writes for one agent, relative to the project root, and where +/// the file the agent loads is. +struct Case { + id: &'static str, + project_file: &'static str, + user_file: &'static str, + /// A sibling in the same directory that belongs to the user. + sibling: &'static str, + expected: fn() -> String, +} + +fn cases() -> Vec { + vec![ + Case { + id: "opencode", + project_file: ".opencode/plugins/reflex.js", + user_file: "home/.config/opencode/plugins/reflex.js", + sibling: "other.js", + expected: || opencode::plugin_source("opencode"), + }, + Case { + id: "kilo", + project_file: ".kilo/plugin/reflex.js", + user_file: "home/.config/kilo/plugin/reflex.js", + sibling: "other.js", + expected: || opencode::plugin_source("kilo"), + }, + Case { + id: "cline", + project_file: if cfg!(windows) { + ".clinerules/hooks/PreToolUse.ps1" + } else { + ".clinerules/hooks/PreToolUse" + }, + user_file: if cfg!(windows) { + "home/Documents/Cline/Hooks/PreToolUse.ps1" + } else { + "home/Documents/Cline/Hooks/PreToolUse" + }, + sibling: "PostToolUse", + expected: || { + if cfg!(windows) { + cline::SCRIPT_WINDOWS.to_string() + } else { + cline::SCRIPT_UNIX.to_string() + } + }, + }, + Case { + id: "pi", + project_file: ".pi/extensions/reflex.ts", + user_file: "home/.pi/agent/extensions/reflex.ts", + sibling: "other.ts", + expected: || pi::EXTENSION.to_string(), + }, + ] +} + +fn ctx_installed(id: &str, e: &Env, scope: Scope) -> bool { + let files = read_existing(&files_needed_uninstall(e)); + adapters::find(id) + .unwrap() + .is_installed(&PlanCtx { env: e, scope }, &files) +} + +#[test] +fn install_into_an_empty_project_writes_the_embedded_file() { + for case in cases() { + let tmp = tempfile::tempdir().unwrap(); + let e = env(tmp.path()); + let p = install(&answers(&[case.id], Scope::Project), &e); + + let path = tmp.path().join(case.project_file); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + (case.expected)(), + "{}", + case.id + ); + let written: Vec<&PathBuf> = p.changes.iter().map(|c| &c.path).collect(); + assert!(written.contains(&&path), "{}: {written:?}", case.id); + assert!(ctx_installed(case.id, &e, Scope::Project), "{}", case.id); + assert!(!ctx_installed(case.id, &e, Scope::User), "{}", case.id); + + // Only Cline needs a manual step, and it says so. + assert_eq!( + p.notes.len(), + usize::from(case.id == "cline"), + "{}", + case.id + ); + + #[cfg(unix)] + if case.id == "cline" { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o111, 0o111, "hook must be executable"); + } + } +} + +#[test] +fn user_scope_writes_below_the_home_directory() { + for case in cases() { + let tmp = tempfile::tempdir().unwrap(); + let e = env(tmp.path()); + install(&answers(&[case.id], Scope::User), &e); + assert_eq!( + std::fs::read_to_string(tmp.path().join(case.user_file)).unwrap(), + (case.expected)(), + "{}", + case.id + ); + assert!(ctx_installed(case.id, &e, Scope::User), "{}", case.id); + assert!(!tmp.path().join(case.project_file).exists(), "{}", case.id); + } +} + +#[test] +fn installing_twice_changes_nothing() { + for case in cases() { + let tmp = tempfile::tempdir().unwrap(); + let e = env(tmp.path()); + let a = answers(&[case.id], Scope::Project); + install(&a, &e); + let again = install(&a, &e); + assert!(again.is_empty(), "{}: {:?}", case.id, again.changes); + assert!(again.notes.is_empty(), "{}", case.id); + } +} + +#[test] +fn an_older_copy_of_ours_is_brought_up_to_date() { + for case in cases() { + let tmp = tempfile::tempdir().unwrap(); + let e = env(tmp.path()); + let path = tmp.path().join(case.project_file); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "// reflex-control managed hook\nold version\n").unwrap(); + + install(&answers(&[case.id], Scope::Project), &e); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + (case.expected)(), + "{}", + case.id + ); + } +} + +#[test] +fn a_file_of_the_users_is_kept_and_reported() { + for case in cases() { + let tmp = tempfile::tempdir().unwrap(); + let e = env(tmp.path()); + let path = tmp.path().join(case.project_file); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "my own hook\n").unwrap(); + + let p = install(&answers(&[case.id], Scope::Project), &e); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "my own hook\n"); + assert_eq!(p.notes.len(), 1, "{}: {:?}", case.id, p.notes); + assert!(p.notes[0].contains("not managed by Reflex Control")); + assert!(p.notes[0].contains("reflex hook"), "{}", p.notes[0]); + assert!(!ctx_installed(case.id, &e, Scope::Project), "{}", case.id); + + // The config was still written, and uninstall leaves the file alone. + assert!(tmp.path().join(".reflex.toml").is_file()); + let removed = uninstall(&e); + assert!(removed.is_empty(), "{}", case.id); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "my own hook\n"); + } +} + +#[test] +fn uninstall_removes_only_our_file() { + for case in cases() { + let tmp = tempfile::tempdir().unwrap(); + let e = env(tmp.path()); + let ours = tmp.path().join(case.project_file); + let sibling = ours.with_file_name(case.sibling); + std::fs::create_dir_all(ours.parent().unwrap()).unwrap(); + std::fs::write(&sibling, "not ours\n").unwrap(); + + install(&answers(&[case.id], Scope::Project), &e); + assert!(ours.is_file()); + + let p = uninstall(&e); + assert_eq!(p.changes.len(), 1, "{}", case.id); + assert!(!ours.exists(), "{}", case.id); + assert_eq!(std::fs::read_to_string(&sibling).unwrap(), "not ours\n"); + assert!(!ctx_installed(case.id, &e, Scope::Project), "{}", case.id); + assert!(uninstall(&e).is_empty(), "{}", case.id); + } +} + +#[test] +fn all_agents_together_do_not_share_files() { + let tmp = tempfile::tempdir().unwrap(); + let e = env(tmp.path()); + let ids: Vec<&str> = cases().iter().map(|c| c.id).collect(); + let a = answers(&ids, Scope::Project); + let p = install(&a, &e); + let mut paths: Vec<&PathBuf> = p.changes.iter().map(|c| &c.path).collect(); + let n = paths.len(); + paths.sort(); + paths.dedup(); + assert_eq!(paths.len(), n); + assert!(install(&a, &e).is_empty()); + assert_eq!(uninstall(&e).changes.len(), 4); +} + +#[test] +fn embedded_files_are_marked_and_the_plugins_have_the_agent_id() { + for text in [ + opencode::plugin_source("opencode"), + opencode::plugin_source("kilo"), + pi::EXTENSION.to_string(), + cline::SCRIPT_UNIX.to_string(), + cline::SCRIPT_WINDOWS.to_string(), + ] { + assert!(text.contains("reflex-control managed hook")); + assert!(!text.contains("__AGENT__")); + } + assert!(opencode::plugin_source("kilo").contains(r#"const AGENT = "kilo""#)); + assert!(pi::EXTENSION.contains(r#""hook", "pi""#)); + assert!(cline::SCRIPT_UNIX.contains("reflex hook cline pre-tool")); + assert!(cline::SCRIPT_WINDOWS.contains("reflex hook cline pre-tool")); +} + +/// Runs `program args`; `None` if the program is not installed. +fn try_run(program: &str, args: &[&Path]) -> Option { + Command::new(program).args(args).output().ok() +} + +#[test] +fn embedded_scripts_pass_a_syntax_check() { + let tmp = tempfile::tempdir().unwrap(); + + // The plugins are ES modules; node only treats a file as one if it ends in .mjs. + for (name, text) in [ + ("opencode.mjs", opencode::plugin_source("opencode")), + ("kilo.mjs", opencode::plugin_source("kilo")), + ("pi.mjs", pi::EXTENSION.to_string()), + ] { + let path = tmp.path().join(name); + std::fs::write(&path, text).unwrap(); + let Some(out) = try_run("node", &[Path::new("--check"), &path]) else { + eprintln!("node is not installed; skipping the JS syntax check"); + break; + }; + assert!( + out.status.success(), + "{name}: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + + let script = tmp.path().join("PreToolUse"); + std::fs::write(&script, cline::SCRIPT_UNIX).unwrap(); + match try_run("sh", &[Path::new("-n"), &script]) { + Some(out) => assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ), + None => eprintln!("sh is not installed; skipping the shell syntax check"), + } +} diff --git a/crates/reflex-hooks/tests/js_plugins_node.rs b/crates/reflex-hooks/tests/js_plugins_node.rs new file mode 100644 index 0000000..9fd67ff --- /dev/null +++ b/crates/reflex-hooks/tests/js_plugins_node.rs @@ -0,0 +1,311 @@ +//! Runs the OpenCode/Kilo plugin and the pi extension under `node` against a fake +//! `reflex` on `PATH`, to check the parts the Rust tests cannot see: what goes to +//! `reflex` on stdin, and that a verdict, garbage, a missing binary or a session that +//! errored is turned into the right call on the agent's API. Skipped without `node`, and +//! on Windows, where the fake `reflex` is a shell script. + +#![cfg(unix)] + +use reflex_hooks::adapters::{opencode, pi}; +use serde_json::{json, Value}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn node() -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|dir| dir.join("node")) + .find(|p| p.is_file()) +} + +/// A directory with a fake `reflex` in it. The fake appends its arguments and stdin to +/// `log` and prints the contents of `reply`. +struct Fake { + dir: tempfile::TempDir, +} + +impl Fake { + fn new(reply: &str) -> Fake { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("bin"); + std::fs::create_dir(&bin).unwrap(); + let script = bin.join("reflex"); + std::fs::write( + &script, + "#!/bin/sh\n{ echo \"ARGS $*\"; cat; echo; } >> \"$(dirname \"$0\")/../log\"\ncat \"$(dirname \"$0\")/../reply\"\n", + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(dir.path().join("reply"), reply).unwrap(); + Fake { dir } + } + + /// A `PATH` with the fake `reflex` and the system tools its script uses. + fn path(&self) -> std::ffi::OsString { + let dirs = [ + self.dir.path().join("bin"), + "/usr/bin".into(), + "/bin".into(), + ]; + std::env::join_paths(dirs).unwrap() + } + + /// Everything the fake was called with, one call per two lines. + fn calls(&self) -> Vec { + std::fs::read_to_string(self.dir.path().join("log")) + .unwrap_or_default() + .lines() + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect() + } +} + +/// Runs `driver` as an ES module next to the plugin file, with the given `PATH`. Returns what the driver printed. +fn run(node: &Path, plugin: &str, driver: &str, path: &std::ffi::OsStr) -> Value { + let work = tempfile::tempdir().unwrap(); + std::fs::write(work.path().join("plugin.mjs"), plugin).unwrap(); + std::fs::write(work.path().join("driver.mjs"), driver).unwrap(); + let out = Command::new(node) + .arg(work.path().join("driver.mjs")) + .env("PATH", path) + .output() + .unwrap(); + assert!( + out.status.success(), + "driver failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + serde_json::from_slice(&out.stdout).unwrap_or_else(|e| { + panic!( + "driver output {:?}: {e}", + String::from_utf8_lossy(&out.stdout) + ) + }) +} + +const OPENCODE_DRIVER: &str = r#" +import plugin from "./plugin.mjs" +const seen = [] +const client = { + session: { + get: async ({ path }) => ({ data: path.id === "child" ? { parentID: "root" } : {} }), + promptAsync: async (a) => seen.push(["prompt", a.path.id, a.body.parts[0].text]), + }, + app: { log: async (a) => seen.push(["log", a.body.message]) }, + tui: { showToast: async (a) => seen.push(["toast", a.body.message]) }, +} +const hooks = await plugin.server({ client, directory: "/work/proj" }) +const attempt = async (input, args) => { + try { await hooks["tool.execute.before"](input, { args }); return "ok" } catch (e) { return e.message } +} +const results = {} +results.first = await attempt({ tool: "bash", sessionID: "s1", callID: "c1" }, { command: "rm -rf /" }) +results.second = await attempt({ tool: "bash", sessionID: "s1", callID: "c2" }, { command: "rm -rf /" }) +results.other = await attempt({ tool: "write", sessionID: "s1", callID: "c3" }, { filePath: "a.txt" }) +const idle = (id) => hooks.event({ event: { type: "session.idle", properties: { sessionID: id } } }) +await idle("s1") +await idle("child") +await hooks.event({ event: { type: "session.error", properties: { sessionID: "s2" } } }) +await idle("s2") +console.log(JSON.stringify({ results, seen })) +"#; + +#[test] +fn opencode_plugin_turns_verdicts_into_agent_calls() { + let Some(node) = node() else { + eprintln!("node is not installed; skipping"); + return; + }; + let plugin = opencode::plugin_source("kilo"); + + // A block reply refuses every tool call with the reason; garbage lets everything through. + for (reply, expect_blocked) in [ + (r#"{"action":"block","reason":"protected"}"#, true), + ("nonsense", false), + ("", false), + (r#"{"action":"allow"}"#, false), + ] { + let fake = Fake::new(reply); + let out = run(&node, &plugin, OPENCODE_DRIVER, &fake.path()); + assert_eq!( + out["results"]["first"] == "protected", + expect_blocked, + "{reply}" + ); + assert_eq!( + out["results"]["other"] == "protected", + expect_blocked, + "{reply}" + ); + assert_eq!(out["seen"], json!([]), "{reply}"); + // The plugin used the id of its agent and sent the tool call as documented. + let calls = fake.calls(); + assert_eq!(calls[0], "ARGS hook kilo pre-tool"); + let request: Value = serde_json::from_str(&calls[1]).unwrap(); + assert_eq!( + request, + json!({ "cwd": "/work/proj", "session_id": "s1", "tool": "bash", "args": { "command": "rm -rf /" } }) + ); + // Two turn-end checks: the root session, and none for the subagent or the failed run. + assert_eq!( + calls + .iter() + .filter(|c| c.as_str() == "ARGS hook kilo turn-end") + .count(), + 1, + "{reply}: {calls:?}" + ); + } + + // No reflex on PATH: everything is allowed. + let empty = tempfile::tempdir().unwrap(); + let out = run(&node, &plugin, OPENCODE_DRIVER, empty.path().as_os_str()); + assert_eq!( + out["results"], + json!({ "first": "ok", "second": "ok", "other": "ok" }) + ); + assert_eq!(out["seen"], json!([])); +} + +#[test] +fn opencode_plugin_asks_once_then_lets_the_same_call_through() { + let Some(node) = node() else { + eprintln!("node is not installed; skipping"); + return; + }; + let fake = Fake::new(r#"{"action":"ask","reason":"Confirm rm."}"#); + let out = run( + &node, + &opencode::plugin_source("opencode"), + OPENCODE_DRIVER, + &fake.path(), + ); + assert_eq!( + out["results"]["first"], + "Confirm rm. Ask the user first; if they agree, repeat the same call." + ); + assert_eq!(out["results"]["second"], "ok"); + // A different call is asked about again. + assert!(out["results"]["other"] + .as_str() + .unwrap() + .starts_with("Confirm rm.")); +} + +#[test] +fn opencode_plugin_sends_a_retry_back_to_the_session() { + let Some(node) = node() else { + eprintln!("node is not installed; skipping"); + return; + }; + let fake = Fake::new(r#"{"action":"retry","reason":"tests failed"}"#); + let out = run( + &node, + &opencode::plugin_source("opencode"), + OPENCODE_DRIVER, + &fake.path(), + ); + assert_eq!(out["seen"], json!([["prompt", "s1", "tests failed"]])); + + let fake = Fake::new(r#"{"action":"notify","reason":"large change"}"#); + let out = run( + &node, + &opencode::plugin_source("opencode"), + OPENCODE_DRIVER, + &fake.path(), + ); + assert_eq!( + out["seen"], + json!([["log", "large change"], ["toast", "large change"]]) + ); +} + +const PI_DRIVER: &str = r#" +import register from "./plugin.mjs" +const handlers = {} +register({ on: (name, fn) => (handlers[name] = fn) }) +const ctx = (hasUI, confirm) => ({ + cwd: "/work/proj", + hasUI, + sessionManager: { getSessionId: () => "sess-1" }, + ui: { confirm: async () => confirm, notify: (m, level) => notes.push([m, level]) }, +}) +const notes = [] +const call = (c) => handlers.tool_call({ toolName: "bash", input: { command: "rm -rf /" } }, c) +const results = { + noUi: await call(ctx(false, true)), + confirmed: await call(ctx(true, true)), + declined: await call(ctx(true, false)), + broken: await call({}), +} +const settle = (outcome) => handlers.agent_before_settle({ outcome, entries: [{ type: "custom", customType: "x" }] }, ctx(true, true)) +results.settled = await settle("completed") +results.aborted = await settle("aborted") +console.log(JSON.stringify({ results, notes })) +"#; + +#[test] +fn pi_extension_turns_verdicts_into_pi_results() { + let Some(node) = node() else { + eprintln!("node is not installed; skipping"); + return; + }; + + let fake = Fake::new(r#"{"action":"block","reason":"protected"}"#); + let out = run(&node, pi::EXTENSION, PI_DRIVER, &fake.path()); + let blocked = json!({ "block": true, "reason": "protected" }); + assert_eq!(out["results"]["noUi"], blocked); + assert_eq!(out["results"]["confirmed"], blocked); + // A handler that fails must not throw: pi blocks the tool if it does. + assert_eq!(out["results"]["broken"], Value::Null); + let calls = fake.calls(); + assert_eq!(calls[0], "ARGS hook pi pre-tool"); + let request: Value = serde_json::from_str(&calls[1]).unwrap(); + assert_eq!( + request, + json!({ "cwd": "/work/proj", "session_id": "sess-1", "tool": "bash", "args": { "command": "rm -rf /" } }) + ); + + // Ask: pi's own dialog decides; without a UI the call is refused. + let fake = Fake::new(r#"{"action":"ask","reason":"Confirm."}"#); + let out = run(&node, pi::EXTENSION, PI_DRIVER, &fake.path()); + assert_eq!( + out["results"]["noUi"], + json!({ "block": true, "reason": "Confirm." }) + ); + assert_eq!(out["results"]["confirmed"], Value::Null); + assert_eq!( + out["results"]["declined"], + json!({ "block": true, "reason": "Confirm." }) + ); + + // Retry: one more model request, with the failure appended as a message. Not after an + // aborted run, which is not asked about at all. + let fake = Fake::new(r#"{"action":"retry","reason":"tests failed"}"#); + let out = run(&node, pi::EXTENSION, PI_DRIVER, &fake.path()); + assert_eq!( + out["results"]["settled"], + json!({ + "entries": [ + { "type": "custom", "customType": "x" }, + { "type": "custom_message", "customType": "reflex-control", "content": "tests failed", "display": true } + ], + "continue": true + }) + ); + assert_eq!(out["results"]["aborted"], Value::Null); + + // Notify shows a message and lets pi settle. + let fake = Fake::new(r#"{"action":"notify","reason":"large change"}"#); + let out = run(&node, pi::EXTENSION, PI_DRIVER, &fake.path()); + assert_eq!(out["results"]["settled"], Value::Null); + assert_eq!(out["notes"], json!([["large change", "warning"]])); + + // No reflex: everything is allowed. + let empty = tempfile::tempdir().unwrap(); + let out = run(&node, pi::EXTENSION, PI_DRIVER, empty.path().as_os_str()); + assert_eq!(out["results"]["confirmed"], Value::Null); + assert_eq!(out["results"]["settled"], Value::Null); +} diff --git a/crates/reflex-hooks/tests/kilo_golden.rs b/crates/reflex-hooks/tests/kilo_golden.rs new file mode 100644 index 0000000..8fb7182 --- /dev/null +++ b/crates/reflex-hooks/tests/kilo_golden.rs @@ -0,0 +1,8 @@ +//! Golden tests for the kilo adapter; see `common/mod.rs` for the fixture format. + +mod common; + +#[test] +fn kilo_fixtures() { + common::run_fixtures("kilo", "kilo", 16); +} diff --git a/crates/reflex-hooks/tests/opencode_golden.rs b/crates/reflex-hooks/tests/opencode_golden.rs new file mode 100644 index 0000000..b1de978 --- /dev/null +++ b/crates/reflex-hooks/tests/opencode_golden.rs @@ -0,0 +1,8 @@ +//! Golden tests for the opencode adapter; see `common/mod.rs` for the fixture format. + +mod common; + +#[test] +fn opencode_fixtures() { + common::run_fixtures("opencode", "opencode", 19); +} diff --git a/crates/reflex-hooks/tests/pi_golden.rs b/crates/reflex-hooks/tests/pi_golden.rs new file mode 100644 index 0000000..af1ccdf --- /dev/null +++ b/crates/reflex-hooks/tests/pi_golden.rs @@ -0,0 +1,8 @@ +//! Golden tests for the pi adapter; see `common/mod.rs` for the fixture format. + +mod common; + +#[test] +fn pi_fixtures() { + common::run_fixtures("pi", "pi", 16); +} diff --git a/integrations/.gitattributes b/integrations/.gitattributes new file mode 100644 index 0000000..fcadb2c --- /dev/null +++ b/integrations/.gitattributes @@ -0,0 +1 @@ +* text eol=lf diff --git a/integrations/cline/PreToolUse b/integrations/cline/PreToolUse new file mode 100644 index 0000000..b2730e1 --- /dev/null +++ b/integrations/cline/PreToolUse @@ -0,0 +1,9 @@ +#!/bin/sh +# reflex-control managed hook. Installed by `reflex install`, removed by `reflex uninstall`. +# Cline hands the tool call to this script as JSON on stdin; reflex answers with JSON on stdout. +# Without reflex on PATH the call is allowed. +if command -v reflex >/dev/null 2>&1; then + exec reflex hook cline pre-tool +fi +echo '{}' +exit 0 diff --git a/integrations/cline/PreToolUse.ps1 b/integrations/cline/PreToolUse.ps1 new file mode 100644 index 0000000..0c4e5a4 --- /dev/null +++ b/integrations/cline/PreToolUse.ps1 @@ -0,0 +1,9 @@ +# reflex-control managed hook. Installed by `reflex install`, removed by `reflex uninstall`. +# Cline hands the tool call to this script as JSON on stdin; reflex answers with JSON on stdout. +# Without reflex on PATH the call is allowed. +if (Get-Command reflex -ErrorAction SilentlyContinue) { + $input | reflex hook cline pre-tool +} else { + '{}' +} +exit 0 diff --git a/integrations/opencode/reflex.js b/integrations/opencode/reflex.js new file mode 100644 index 0000000..d11202f --- /dev/null +++ b/integrations/opencode/reflex.js @@ -0,0 +1,72 @@ +// reflex-control managed hook. Installed by `reflex install`, removed by `reflex uninstall`. +// Sends tool calls and finished turns to `reflex hook __AGENT__ ` and applies the verdict. +// Fails open: if reflex is missing, slow or answers with anything unexpected, everything is allowed. +import { execFile } from "node:child_process" + +const AGENT = "__AGENT__" +const allow = { action: "allow" } +const confirmed = new Set() // calls that were refused once with "ask the user first" +const failed = new Set() // sessions whose last run ended in an error or was stopped +const checking = new Set() // sessions with a turn-end check in flight + +// Runs `reflex hook AGENT event` with `request` on stdin. Never rejects; resolves to a verdict. +function verdict(event, request, timeoutMs) { + return new Promise((resolve) => { + try { + const child = execFile("reflex", ["hook", AGENT, event], { timeout: timeoutMs, windowsHide: true }, (_error, stdout) => { + try { + const v = JSON.parse(stdout) + resolve(v && typeof v.action === "string" ? v : allow) + } catch { + resolve(allow) + } + }) + child.stdin.on("error", () => {}) + child.stdin.end(JSON.stringify(request)) + } catch { + resolve(allow) + } + }) +} + +export default { + id: "reflex-control", + server: async ({ client, directory }) => ({ + // Throwing is how a plugin refuses a tool call; the message goes to the model. + "tool.execute.before": async (input, output) => { + const v = await verdict("pre-tool", { cwd: directory, session_id: input.sessionID, tool: input.tool, args: output.args }, 10000) + if (v.action === "block") throw new Error(v.reason || "Blocked by Reflex Control.") + if (v.action === "ask") { + // No confirmation dialog is available here: refuse once, then let the same call through. + const key = JSON.stringify([input.sessionID, input.tool, output.args]) + if (!confirmed.delete(key)) { + confirmed.add(key) + throw new Error(`${v.reason} Ask the user first; if they agree, repeat the same call.`) + } + } + }, + event: async ({ event }) => { + try { + const id = event.properties?.sessionID + if (!id) return + if (event.type === "session.error") failed.add(id) + else if (event.type === "session.status" && event.properties.status?.type === "busy") failed.delete(id) + if (event.type !== "session.idle" || failed.delete(id) || checking.has(id)) return + checking.add(id) + try { + const session = await client.session.get({ path: { id } }) + if (session.data?.parentID) return // a subagent; the parent session is checked instead + const v = await verdict("turn-end", { cwd: directory, session_id: id }, 600000) + if (v.action === "retry") { + await client.session.promptAsync({ path: { id }, body: { parts: [{ type: "text", text: v.reason }] } }) + } else if (v.action === "notify") { + await client.app.log({ body: { service: "reflex-control", level: "warn", message: v.reason } }) + await client.tui.showToast({ body: { message: v.reason, variant: "warning" } }) + } + } finally { + checking.delete(id) + } + } catch {} + }, + }), +} diff --git a/integrations/pi/reflex.ts b/integrations/pi/reflex.ts new file mode 100644 index 0000000..7da554c --- /dev/null +++ b/integrations/pi/reflex.ts @@ -0,0 +1,55 @@ +// reflex-control managed hook. Installed by `reflex install`, removed by `reflex uninstall`. +// Sends tool calls and finished runs to `reflex hook pi ` and applies the verdict. +// Fails open: if reflex is missing, slow or answers with anything unexpected, everything is allowed. +// Every handler catches its own errors, because pi blocks a tool whose tool_call handler throws. +import { execFile } from "node:child_process"; + +const allow = { action: "allow" }; + +// Runs `reflex hook pi event` with `request` on stdin. Never rejects; resolves to a verdict. +function verdict(event, request, timeoutMs) { + return new Promise((resolve) => { + try { + const child = execFile("reflex", ["hook", "pi", event], { timeout: timeoutMs, windowsHide: true }, (_error, stdout) => { + try { + const v = JSON.parse(stdout); + resolve(v && typeof v.action === "string" ? v : allow); + } catch { + resolve(allow); + } + }); + child.stdin.on("error", () => {}); + child.stdin.end(JSON.stringify(request)); + } catch { + resolve(allow); + } + }); +} + +export default function (pi) { + pi.on("tool_call", async (event, ctx) => { + try { + const v = await verdict("pre-tool", { cwd: ctx.cwd, session_id: ctx.sessionManager.getSessionId(), tool: event.toolName, args: event.input }, 10000); + if (v.action === "block") return { block: true, reason: v.reason }; + if (v.action === "ask") { + if (ctx.hasUI && (await ctx.ui.confirm("Reflex Control", v.reason))) return undefined; + return { block: true, reason: v.reason }; + } + } catch {} + return undefined; + }); + + pi.on("agent_before_settle", async (event, ctx) => { + try { + if (event.outcome !== "completed") return undefined; + const v = await verdict("turn-end", { cwd: ctx.cwd, session_id: ctx.sessionManager.getSessionId() }, 600000); + if (v.action === "retry") { + // One more model request, with the failure as a message the model sees. Reflex counts the retries. + const note = { type: "custom_message", customType: "reflex-control", content: v.reason, display: true }; + return { entries: [...event.entries, note], continue: true }; + } + if (v.action === "notify" && ctx.hasUI) ctx.ui.notify(v.reason, "warning"); + } catch {} + return undefined; + }); +} From 06d5cb93f6fe3aca7241071c4cdc53362fa53b3a Mon Sep 17 00:00:00 2001 From: rustfuture <121799572+rustfuture@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:53:31 +0000 Subject: [PATCH 2/5] feat(hooks): resolve relative paths against the agent's directory, document the new agents Relative paths in OpenCode, Kilo Code, Cline and pi tool calls are made absolute against the agent's working directory before they are checked, so a write from a subdirectory is matched the way the agent resolves it. The patch header parser and path resolver from the Codex adapter are shared. Adds docs/agent-hooks.md sections for the four agents (what each can and cannot do, what was verified from source and what was not), the installer file table, the --agent help text and a changelog entry. --- CHANGELOG.md | 1 + crates/reflex-cli/src/commands/install.rs | 2 +- crates/reflex-hooks/src/adapters/cline.rs | 6 +- crates/reflex-hooks/src/adapters/codex.rs | 7 +- crates/reflex-hooks/src/adapters/js_bridge.rs | 10 +- crates/reflex-hooks/src/adapters/opencode.rs | 3 +- crates/reflex-hooks/src/adapters/util.rs | 51 +++--- .../pre-tool__apply_patch_extension_shape.out | 2 +- .../pre-tool__apply_patch_object_input.out | 2 +- .../pre-tool__apply_patch_string_input.out | 2 +- ...elative_in_subdir_not_root_secrets.in.json | 26 +++ ...or_relative_in_subdir_not_root_secrets.out | 1 + ...re-tool__editor_relative_up_to_env.in.json | 26 +++ .../pre-tool__editor_relative_up_to_env.out | 1 + ...pre-tool__write_to_file_legacy_secrets.out | 2 +- .../kilo/pre-tool__apply_patch_env.out | 2 +- .../opencode/pre-tool__apply_patch_env.out | 2 +- ...re-tool__apply_patch_move_into_secrets.out | 2 +- ...ol__apply_patch_relative_in_subdir.in.json | 8 + ...e-tool__apply_patch_relative_in_subdir.out | 1 + ...elative_in_subdir_not_root_secrets.in.json | 9 + ...te_relative_in_subdir_not_root_secrets.out | 1 + ...pre-tool__write_relative_up_to_env.in.json | 9 + .../pre-tool__write_relative_up_to_env.out | 1 + ...elative_in_subdir_not_root_secrets.in.json | 9 + ...te_relative_in_subdir_not_root_secrets.out | 1 + ...pre-tool__write_relative_up_to_env.in.json | 9 + .../pi/pre-tool__write_relative_up_to_env.out | 1 + docs/agent-hooks.md | 163 ++++++++++++++++++ 29 files changed, 316 insertions(+), 44 deletions(-) create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_relative_in_subdir_not_root_secrets.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_relative_in_subdir_not_root_secrets.out create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_relative_up_to_env.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_relative_up_to_env.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_relative_in_subdir.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_relative_in_subdir.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_relative_in_subdir_not_root_secrets.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_relative_in_subdir_not_root_secrets.out create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_relative_up_to_env.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_relative_up_to_env.out create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_relative_in_subdir_not_root_secrets.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_relative_in_subdir_not_root_secrets.out create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_relative_up_to_env.in.json create mode 100644 crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_relative_up_to_env.out diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b22e95..c9a6b34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to Reflex Control are documented here. ## Unreleased +- Added agent hooks for OpenCode, Kilo Code, Cline and pi. OpenCode, Kilo Code and pi get a small plugin or extension file (embedded in `reflex`, source in `integrations/`) that relays tool calls and finished turns to `reflex hook`; Cline gets a `PreToolUse` script. Cline has no hook that can send the agent back, so it is guarded before tool calls only; OpenCode, Kilo Code and Cline cannot ask for confirmation, so a risky command is refused (OpenCode and Kilo let the same call through when the agent repeats it). See `docs/agent-hooks.md`. - Added prebuilt release binaries and one-line installers. A `v*` tag now builds `reflex` for Linux (x86_64, aarch64), macOS (Intel, Apple Silicon) and Windows (x86_64) and publishes archives with SHA-256 checksums; `install.sh` (macOS/Linux) and `install.ps1` (Windows) download, verify and install the right one without Rust. See `docs/releasing.md`. - Added agent hooks: `reflex install` (interactive setup wizard), `reflex uninstall` and `reflex doctor`, plus a `reflex hook ` entry point. Targets are Claude Code (`PreToolUse` and `Stop` hooks) and a generic git pre-commit hook. Hooks block writes to protected paths, ask before risky shell commands, run the project's tests when the agent stops and send failures back for a limited number of retries. Settings live in `.reflex.toml`. Hook mode uses deterministic evidence only; see `docs/agent-hooks.md`. New crate `reflex-hooks`; new dependencies `toml`, `globset`, `dirs` and `cliclack`. - `reflex run` now rejects an unrecognised `--risk` value instead of silently treating it as `low`, which would have skipped the mandatory High/Critical gate on a typo. diff --git a/crates/reflex-cli/src/commands/install.rs b/crates/reflex-cli/src/commands/install.rs index f879dd0..2390894 100644 --- a/crates/reflex-cli/src/commands/install.rs +++ b/crates/reflex-cli/src/commands/install.rs @@ -26,7 +26,7 @@ pub struct InstallArgs { #[arg(short, long)] pub yes: bool, - /// Agents to install into, comma separated (claude-code, git) + /// Agents to install into, comma separated (claude-code, cursor, codex, opencode, kilo, cline, pi, git) #[arg(long, value_delimiter = ',')] pub agent: Option>, diff --git a/crates/reflex-hooks/src/adapters/cline.rs b/crates/reflex-hooks/src/adapters/cline.rs index d04b5de..97bd0f8 100644 --- a/crates/reflex-hooks/src/adapters/cline.rs +++ b/crates/reflex-hooks/src/adapters/cline.rs @@ -44,7 +44,8 @@ //! ignores hooks in `--yolo` mode. A file that is not ours at the same path is left //! alone, because Cline runs one file per event. -use super::util::{self, ManagedFile}; +use super::codex::patch_paths; +use super::util::{self, resolve_paths, ManagedFile}; use super::{AgentAdapter, HookEvent, HookInput, ParseError, Probe, Rendered}; use crate::config::Scope; use crate::guard::Verdict; @@ -173,7 +174,7 @@ fn classify(tool: &str, input: &Value) -> HookEvent { } "apply_patch" => { let paths = patch_text(input) - .map(|patch| util::patch_paths(&patch)) + .map(|patch| patch_paths(&patch)) .unwrap_or_default(); if paths.is_empty() { HookEvent::Ignore @@ -241,6 +242,7 @@ impl AgentAdapter for Cline { Some((tool, input)) => classify(tool, input.unwrap_or(&Value::Null)), None => HookEvent::Ignore, }; + let event = resolve_paths(event, cwd.as_deref().and_then(|c| c.to_str())); Ok(HookInput { cwd, event }) } diff --git a/crates/reflex-hooks/src/adapters/codex.rs b/crates/reflex-hooks/src/adapters/codex.rs index 38f5d24..c79d02f 100644 --- a/crates/reflex-hooks/src/adapters/codex.rs +++ b/crates/reflex-hooks/src/adapters/codex.rs @@ -75,8 +75,9 @@ fn command_field(input: &Option) -> Option { .filter(|s| !s.trim().is_empty()) } -/// Files an `apply_patch` patch adds, updates, deletes or moves to. -fn patch_paths(patch: &str) -> Vec { +/// Files an `apply_patch` patch adds, updates, deletes or moves to. OpenCode, Kilo Code and +/// Cline take the same patch format, so their adapters use this too. +pub(super) fn patch_paths(patch: &str) -> Vec { const MARKERS: [&str; 4] = [ "*** Add File: ", "*** Update File: ", @@ -108,7 +109,7 @@ fn is_absolute(path: &str) -> bool { } /// Patch paths are relative to the session's directory; make them absolute when it is known. -fn resolve(cwd: Option<&str>, path: String) -> String { +pub(super) fn resolve(cwd: Option<&str>, path: String) -> String { match cwd.map(|c| c.trim_end_matches(['/', '\\'])) { Some(dir) if !dir.is_empty() && !is_absolute(&path) => format!("{dir}/{path}"), _ => path, diff --git a/crates/reflex-hooks/src/adapters/js_bridge.rs b/crates/reflex-hooks/src/adapters/js_bridge.rs index ebc9f47..22f6f88 100644 --- a/crates/reflex-hooks/src/adapters/js_bridge.rs +++ b/crates/reflex-hooks/src/adapters/js_bridge.rs @@ -26,6 +26,7 @@ //! shape (a warning on stderr with empty stdout is what a malformed request produces), //! the plugin allows. +use super::util::resolve_paths; use super::{HookEvent, HookInput, ParseError, Rendered}; use crate::guard::Verdict; use serde_json::{json, Value}; @@ -45,9 +46,12 @@ pub fn parse( let text = |key: &str| request.get(key).and_then(Value::as_str); let cwd = text("cwd").filter(|c| !c.is_empty()).map(PathBuf::from); let event = match event { - "pre-tool" => classify( - text("tool").unwrap_or(""), - request.get("args").unwrap_or(&Value::Null), + "pre-tool" => resolve_paths( + classify( + text("tool").unwrap_or(""), + request.get("args").unwrap_or(&Value::Null), + ), + text("cwd"), ), "turn-end" => HookEvent::TurnEnd { session_id: text("session_id").unwrap_or("").to_string(), diff --git a/crates/reflex-hooks/src/adapters/opencode.rs b/crates/reflex-hooks/src/adapters/opencode.rs index 27dcd16..290e4da 100644 --- a/crates/reflex-hooks/src/adapters/opencode.rs +++ b/crates/reflex-hooks/src/adapters/opencode.rs @@ -27,6 +27,7 @@ //! Not run for subagent sessions (checked through `parentID`), for sessions whose last //! run ended in an error, or while another check of the same session is running. +use super::codex::patch_paths; use super::js_bridge; use super::util::{self, ManagedFile}; use super::{AgentAdapter, HookEvent, HookInput, ParseError, Probe, Rendered}; @@ -101,7 +102,7 @@ fn classify(tool: &str, args: &Value) -> HookEvent { .map(|p| vec![p]) .unwrap_or_default(), "apply_patch" | "patch" => util::string_field(args, &["patchText"]) - .map(|patch| util::patch_paths(&patch)) + .map(|patch| patch_paths(&patch)) .unwrap_or_default(), "bash" => { return match util::string_field(args, &["command"]) { diff --git a/crates/reflex-hooks/src/adapters/util.rs b/crates/reflex-hooks/src/adapters/util.rs index d08ab9a..88508f7 100644 --- a/crates/reflex-hooks/src/adapters/util.rs +++ b/crates/reflex-hooks/src/adapters/util.rs @@ -9,6 +9,8 @@ //! touched, and the plan says what to add by hand instead //! * uninstall deletes the file only if it is ours +use super::codex::resolve; +use super::HookEvent; use crate::install::{FileChange, Files, Plan}; use serde_json::Value; use std::path::{Path, PathBuf}; @@ -76,27 +78,16 @@ pub fn string_field(input: &Value, keys: &[&str]) -> Option { }) } -/// The files a patch in the `*** Begin Patch` format touches: the `Add File`, `Update -/// File`, `Delete File` and `Move to` headers. Used by OpenCode, Kilo Code and Cline, -/// whose `apply_patch` tools take this format. -pub fn patch_paths(patch: &str) -> Vec { - const HEADERS: [&str; 4] = [ - "*** Add File:", - "*** Update File:", - "*** Delete File:", - "*** Move to:", - ]; - patch - .lines() - .filter_map(|line| { - let line = line.trim_end(); - HEADERS - .iter() - .find_map(|h| line.strip_prefix(h)) - .map(|p| p.trim().to_string()) - .filter(|p| !p.is_empty()) - }) - .collect() +/// Makes the paths of a file-writing event absolute against `cwd`, the way the agent +/// resolves them: `secrets/a` written from `app/` is `app/secrets/a`, which the +/// root-relative pattern `secrets/**` must not match. +pub fn resolve_paths(event: HookEvent, cwd: Option<&str>) -> HookEvent { + match event { + HookEvent::PreWrite { paths } => HookEvent::PreWrite { + paths: paths.into_iter().map(|p| resolve(cwd, p)).collect(), + }, + other => other, + } } #[cfg(test)] @@ -104,14 +95,20 @@ mod tests { use super::*; #[test] - fn patch_paths_reads_every_header() { - let patch = "*** Begin Patch\r\n*** Add File: a.txt\n+hi\n*** Update File: src/b.rs\n\ - *** Move to: src/c.rs\n@@\n-x\n+y\n*** Delete File: .env\n*** End Patch\n"; + fn relative_paths_are_resolved_against_the_agents_directory() { + let write = |p: &str| HookEvent::PreWrite { + paths: vec![p.to_string()], + }; assert_eq!( - patch_paths(patch), - ["a.txt", "src/b.rs", "src/c.rs", ".env"] + resolve_paths(write("secrets/a"), Some("/work/proj/app")), + write("/work/proj/app/secrets/a") ); - assert!(patch_paths("just text\n*** Update File:\n").is_empty()); + assert_eq!(resolve_paths(write("/etc/x"), Some("/w")), write("/etc/x")); + assert_eq!(resolve_paths(write("a"), None), write("a")); + let shell = HookEvent::PreShell { + command: "ls".into(), + }; + assert_eq!(resolve_paths(shell.clone(), Some("/w")), shell); } #[test] diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_extension_shape.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_extension_shape.out index fcdfdb9..ef0ffe8 100644 --- a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_extension_shape.out +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_extension_shape.out @@ -1 +1 @@ -{"cancel":true,"errorMessage":"Reflex Control: this edit writes to `secrets/a.rs`, which is protected (matches `secrets/**`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} +{"cancel":true,"errorMessage":"Reflex Control: this edit writes to `/work/proj/secrets/a.rs`, which is protected (matches `secrets/**`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_object_input.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_object_input.out index cfbbfcd..cc75ff9 100644 --- a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_object_input.out +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_object_input.out @@ -1 +1 @@ -{"cancel":true,"errorMessage":"Reflex Control: this edit writes to `config/.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} +{"cancel":true,"errorMessage":"Reflex Control: this edit writes to `/work/proj/config/.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_string_input.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_string_input.out index cfbbfcd..cc75ff9 100644 --- a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_string_input.out +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__apply_patch_string_input.out @@ -1 +1 @@ -{"cancel":true,"errorMessage":"Reflex Control: this edit writes to `config/.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} +{"cancel":true,"errorMessage":"Reflex Control: this edit writes to `/work/proj/config/.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_relative_in_subdir_not_root_secrets.in.json b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_relative_in_subdir_not_root_secrets.in.json new file mode 100644 index 0000000..1d3acd2 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_relative_in_subdir_not_root_secrets.in.json @@ -0,0 +1,26 @@ +{ + "hookName": "tool_call", + "clineVersion": "1.0.0", + "timestamp": "2026-01-15T10:30:00Z", + "taskId": "conv-123", + "workspaceRoots": [ + "/work/proj/app" + ], + "userId": "user", + "iteration": 1, + "tool_call": { + "id": "call-1", + "name": "editor", + "input": { + "path": "secrets/x.txt", + "new_text": "x" + } + }, + "preToolUse": { + "toolName": "editor", + "parameters": { + "path": "secrets/x.txt", + "new_text": "x" + } + } +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_relative_in_subdir_not_root_secrets.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_relative_in_subdir_not_root_secrets.out new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_relative_in_subdir_not_root_secrets.out @@ -0,0 +1 @@ +{} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_relative_up_to_env.in.json b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_relative_up_to_env.in.json new file mode 100644 index 0000000..788781d --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_relative_up_to_env.in.json @@ -0,0 +1,26 @@ +{ + "hookName": "tool_call", + "clineVersion": "1.0.0", + "timestamp": "2026-01-15T10:30:00Z", + "taskId": "conv-123", + "workspaceRoots": [ + "/work/proj/app" + ], + "userId": "user", + "iteration": 1, + "tool_call": { + "id": "call-1", + "name": "editor", + "input": { + "path": "../.env", + "new_text": "x" + } + }, + "preToolUse": { + "toolName": "editor", + "parameters": { + "path": "../.env", + "new_text": "x" + } + } +} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_relative_up_to_env.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_relative_up_to_env.out new file mode 100644 index 0000000..a76ac05 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__editor_relative_up_to_env.out @@ -0,0 +1 @@ +{"cancel":true,"errorMessage":"Reflex Control: this edit writes to `/work/proj/app/../.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__write_to_file_legacy_secrets.out b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__write_to_file_legacy_secrets.out index 1eb7d28..e553f42 100644 --- a/crates/reflex-hooks/tests/fixtures/cline/pre-tool__write_to_file_legacy_secrets.out +++ b/crates/reflex-hooks/tests/fixtures/cline/pre-tool__write_to_file_legacy_secrets.out @@ -1 +1 @@ -{"cancel":true,"errorMessage":"Reflex Control: this edit writes to `secrets/token.txt`, which is protected (matches `secrets/**`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} +{"cancel":true,"errorMessage":"Reflex Control: this edit writes to `/work/proj/secrets/token.txt`, which is protected (matches `secrets/**`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__apply_patch_env.out b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__apply_patch_env.out index 6cbc0ea..2d2dede 100644 --- a/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__apply_patch_env.out +++ b/crates/reflex-hooks/tests/fixtures/kilo/pre-tool__apply_patch_env.out @@ -1 +1 @@ -{"action":"block","reason":"Reflex Control: this edit writes to `config/.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} +{"action":"block","reason":"Reflex Control: this edit writes to `/work/proj/config/.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_env.out b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_env.out index 6cbc0ea..2d2dede 100644 --- a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_env.out +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_env.out @@ -1 +1 @@ -{"action":"block","reason":"Reflex Control: this edit writes to `config/.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} +{"action":"block","reason":"Reflex Control: this edit writes to `/work/proj/config/.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_move_into_secrets.out b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_move_into_secrets.out index 62f5f29..ccb4323 100644 --- a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_move_into_secrets.out +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_move_into_secrets.out @@ -1 +1 @@ -{"action":"block","reason":"Reflex Control: this edit writes to `secrets/a.rs`, which is protected (matches `secrets/**`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} +{"action":"block","reason":"Reflex Control: this edit writes to `/work/proj/secrets/a.rs`, which is protected (matches `secrets/**`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_relative_in_subdir.in.json b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_relative_in_subdir.in.json new file mode 100644 index 0000000..208c975 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_relative_in_subdir.in.json @@ -0,0 +1,8 @@ +{ + "cwd": "/work/proj/app", + "session_id": "ses_1", + "tool": "apply_patch", + "args": { + "patchText": "*** Begin Patch\n*** Update File: .env.local\n@@\n-a\n+b\n*** End Patch" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_relative_in_subdir.out b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_relative_in_subdir.out new file mode 100644 index 0000000..8d850b7 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__apply_patch_relative_in_subdir.out @@ -0,0 +1 @@ +{"action":"block","reason":"Reflex Control: this edit writes to `/work/proj/app/.env.local`, which is protected (matches `.env.*`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_relative_in_subdir_not_root_secrets.in.json b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_relative_in_subdir_not_root_secrets.in.json new file mode 100644 index 0000000..e934b89 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_relative_in_subdir_not_root_secrets.in.json @@ -0,0 +1,9 @@ +{ + "cwd": "/work/proj/app", + "session_id": "ses_1", + "tool": "write", + "args": { + "filePath": "secrets/x.txt", + "content": "x" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_relative_in_subdir_not_root_secrets.out b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_relative_in_subdir_not_root_secrets.out new file mode 100644 index 0000000..8cfdb52 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_relative_in_subdir_not_root_secrets.out @@ -0,0 +1 @@ +{"action":"allow"} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_relative_up_to_env.in.json b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_relative_up_to_env.in.json new file mode 100644 index 0000000..9d1f167 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_relative_up_to_env.in.json @@ -0,0 +1,9 @@ +{ + "cwd": "/work/proj/app", + "session_id": "ses_1", + "tool": "write", + "args": { + "filePath": "../.env", + "content": "x" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_relative_up_to_env.out b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_relative_up_to_env.out new file mode 100644 index 0000000..3f2239d --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/opencode/pre-tool__write_relative_up_to_env.out @@ -0,0 +1 @@ +{"action":"block","reason":"Reflex Control: this edit writes to `/work/proj/app/../.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_relative_in_subdir_not_root_secrets.in.json b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_relative_in_subdir_not_root_secrets.in.json new file mode 100644 index 0000000..33ccf60 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_relative_in_subdir_not_root_secrets.in.json @@ -0,0 +1,9 @@ +{ + "cwd": "/work/proj/app", + "session_id": "ses_1", + "tool": "write", + "args": { + "path": "secrets/x.txt", + "content": "x" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_relative_in_subdir_not_root_secrets.out b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_relative_in_subdir_not_root_secrets.out new file mode 100644 index 0000000..8cfdb52 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_relative_in_subdir_not_root_secrets.out @@ -0,0 +1 @@ +{"action":"allow"} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_relative_up_to_env.in.json b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_relative_up_to_env.in.json new file mode 100644 index 0000000..42e1b25 --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_relative_up_to_env.in.json @@ -0,0 +1,9 @@ +{ + "cwd": "/work/proj/app", + "session_id": "ses_1", + "tool": "write", + "args": { + "path": "../.env", + "content": "x" + } +} diff --git a/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_relative_up_to_env.out b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_relative_up_to_env.out new file mode 100644 index 0000000..3f2239d --- /dev/null +++ b/crates/reflex-hooks/tests/fixtures/pi/pre-tool__write_relative_up_to_env.out @@ -0,0 +1 @@ +{"action":"block","reason":"Reflex Control: this edit writes to `/work/proj/app/../.env`, which is protected (matches `.env`). Do not change it. To allow it, the user can edit `protect.paths` in .reflex.toml."} diff --git a/docs/agent-hooks.md b/docs/agent-hooks.md index bc76895..570bf21 100644 --- a/docs/agent-hooks.md +++ b/docs/agent-hooks.md @@ -58,6 +58,10 @@ Turn end means "the agent stopped and wants to hand control back". | `git commit` and `git.run_tests` is on and tests fail | Commit refused, test output shown | | Reflex itself breaks (bad JSON, unreadable config, no git) | Warning on stderr, everything allowed | +The table is what Reflex decides; each agent decides how much of it can be carried out. +Cline cannot run tests at turn end at all, and OpenCode, Kilo Code and Cline have no way to +ask you to confirm a command (see their sections below). + Details worth knowing: - Tests are skipped when the working tree is identical to the last check that did not @@ -224,6 +228,156 @@ enforcement is in the current source and has a test, but which released version not checked. Test with your version: ask Codex to edit `.env` and see the edit refused. Nothing was run against a real Codex either. +### OpenCode + +Installs one plugin file: `.opencode/plugins/reflex.js` (this project) or +`~/.config/opencode/plugins/reflex.js` (all your projects; `XDG_CONFIG_HOME` is not +consulted). OpenCode loads every file in those directories at startup, so restart it after +installing. Nothing else is changed, and `reflex uninstall` deletes only that file. + +The plugin is a short JS file that lives inside the `reflex` binary (source: +`integrations/opencode/reflex.js`; no npm dependencies). It runs in OpenCode's own process +and only relays: it sends each tool call to `reflex hook opencode pre-tool` and each idle +session to `reflex hook opencode turn-end`, and applies the JSON verdict that comes back. +The decisions are made by `reflex`. If `reflex` is missing, takes longer than 10 s (10 min +at turn end, where it runs the tests) or answers with anything unexpected, the plugin +allows. It starts `reflex` as a child process without waiting on it, so OpenCode stays +responsive while the tests run. + +- `write`, `edit`, `multiedit` and `apply_patch` (the files named in the patch): a write to + a protected path is refused by throwing, and the reason goes to the model. +- `bash`: a command that writes to a protected path is refused the same way. +- **Risky commands cannot be confirmed.** OpenCode has no confirmation prompt for plugins. + A command that would ask you (force push, `rm -rf /`) is refused once, with a message + telling the agent to ask you first; if the agent then repeats exactly the same call, it + goes through. That is weaker than Claude Code's prompt: the agent could repeat the call + without asking you. +- Turn end is the `session.idle` event. When the tests fail, the failure output is sent + to the session as a new message (`client.session.promptAsync`) and the agent keeps + working, up to `tests.max_retries` times. When it is out of tries, or for a large change, + the message goes to OpenCode's log and, in the terminal UI, a toast. +- Not checked at turn end: subagent sessions (the parent is checked instead) and a session + whose last run ended in an error. +- Cannot see reads, or what MCP and custom tools do. The shell check has the limits listed + under Claude Code. + +**Verified against the OpenCode source** (`sst/opencode`, `dev` as of 2026-09-30): the +plugin directories; that files there load as modules whose default export is `{ id, +server }`; the `tool.execute.before` hook and that a throw refuses the call; the tool ids +and arguments (`write` and `edit` with `filePath`, `apply_patch` with `patchText` in the +`*** Update File:` format, `bash` with `command`); the `session.idle`, `session.error` and +`session.status` events; and `client.session.get`, `client.session.promptAsync`, +`client.app.log` and `client.tui.showToast` in the SDK. The `permission.ask` hook is +declared there but nothing calls it, which is why there is no confirmation. + +**Not verified.** The plugin has been run only against a fake `reflex` and a stand-in for +OpenCode's plugin API (under Node and Bun), never inside a real OpenCode. Assumed: +- versions from before the `{ id, server }` module shape (about March 2026) may not load + the file; +- `multiedit` and `patch` are tool names from older versions and are not in the current + source; they are checked in case a version still uses them; +- that a `session.idle` after you press Esc is skipped because OpenCode also reports an + error for that stop; if it does not, the tests run after a stop too; +- that sending a message from inside the idle event starts a new run cleanly. + +### Kilo Code + +Kilo Code is a fork of OpenCode and uses the same plugin, installed for Kilo: +`.kilo/plugin/reflex.js` (this project) or `~/.config/kilo/plugin/reflex.js` (all your +projects). Kilo does not read `.opencode/`. Its VS Code extension runs the Kilo CLI, and +Kilo's documentation says plugins work in both. Restart Kilo after installing. Everything +under OpenCode applies, with `reflex hook kilo ...` as the command. + +**Verified** in the Kilo source (`Kilo-Org/kilocode`, as of 2026-09-30) and its plugin +documentation: the plugin directories (`plugin/` or `plugins/` inside `.kilo/`, the older +`.kilocode/` and `~/.config/kilo/`), the `{ id, server }` module shape that its +documentation asks for local files, the same hooks, tool ids and SDK calls as OpenCode. +**Not verified:** nothing was run in Kilo, and the JetBrains extension was not checked. + +### Cline + +Installs one hook script, `PreToolUse`: `.clinerules/hooks/PreToolUse` (this project) or +`~/Documents/Cline/Hooks/PreToolUse` (all your projects). On Windows the file is +`PreToolUse.ps1`, which Cline runs through PowerShell. It is a few lines that run +`reflex hook cline pre-tool`. The VS Code extension and the Cline CLI both read these +locations (the CLI also reads `.cline/hooks`, which is left alone so that the hook does not +run twice). + +**Turn on hooks.** Cline runs hooks only when they are enabled: in VS Code, tick "Enable +Hooks" in Cline's settings (Feature Settings). The CLI runs them unless started with +`--yolo` (as Cline's hook README says). `reflex install` prints this reminder; `reflex doctor` only shows that the file +is in place. + +- `editor` (and the older `write_to_file`, `replace_in_file`, `delete_file`) and + `apply_patch` (the files named in the patch): a write to a protected path is cancelled. +- `run_commands` (and the older `execute_command`): a command that writes to a protected + path is cancelled. Every command in the call is checked. +- **Cancelling ends the task run.** Cline stops the tool call and the current run, and shows + the reason; you have to send Cline another message to go on. +- **Risky commands cannot be confirmed.** Cline's reply format has a `review` field that is + supposed to ask you, but the CLI reads it and does nothing with it, and the extension's + format has no such field. A command that would ask you (force push, `rm -rf /`) is + therefore cancelled, with a message saying so; run it yourself if you want it. +- **No tests at the end of a task.** Cline's `TaskComplete` hook runs after the task has + finished and its reply is ignored, so a hook cannot send the agent back to fix failing + tests. `reflex install` therefore installs no turn-end hook for Cline. Add the Git + pre-commit hook: it runs the tests before any commit, whichever tool makes it. +- Cannot see reads, other tools or MCP. The `apply_patch` call of the VS Code extension + carries its patch only in the tool's input; if Cline passes that as a bare string the + extension's hook payload has no parameters, and the patch is not checked. The shell + check has the limits listed under Claude Code. +- Cline runs one file per event and directory. If a `PreToolUse` of yours is already + there, it is not changed; call `reflex hook cline pre-tool` from it instead (it reads + Cline's JSON on stdin and prints the reply). +- The extension stops a hook after 30 s, the CLI after 120 s; the check itself takes a + moment. + +**Verified against the Cline source** (`cline/cline`, `main` as of 2026-09-30): the hook +directories and file names for the CLI, the extension and both operating systems; that a +hook file is run with the event as JSON on stdin; both payload shapes (`tool_call.name` and +`tool_call.input` for the CLI, `preToolUse.toolName` and `preToolUse.parameters`, with +non-string values JSON-encoded, for the extension); the tool names and their input shapes; +`cancel` and `errorMessage` stopping the run; `review` being parsed but ignored; and +`TaskComplete` / `agent_end` being observe-only. + +**Not verified.** Nothing was run in Cline. The Windows script was not run at all, not even +under PowerShell. Reading the hook's stdin as text under Windows PowerShell 5.1 uses the +console code page, so non-ASCII paths may be misread there. + +### pi + +Installs one extension file: `.pi/extensions/reflex.ts` (this project; pi loads project +extensions only after you have trusted the project) or `~/.pi/agent/extensions/reflex.ts` +(all your projects; `PI_CODING_AGENT_DIR` is not consulted). Restart pi or run `/reload`. +The file is a short plain-JS extension inside the `reflex` binary (source: +`integrations/pi/reflex.ts`, no dependencies) that relays to `reflex hook pi pre-tool` and +`reflex hook pi turn-end`, with the same fail-open rules and child process as the OpenCode +plugin. Every handler catches its own errors, because pi blocks a tool whose `tool_call` +handler throws. + +- `write` and `edit`: a write to a protected path is blocked, with the reason. +- `bash` and `powershell`: a command that writes to a protected path is blocked; a risky + one opens pi's own confirmation dialog (and is blocked when pi has no UI, as in print + mode). +- Turn end is `agent_before_settle`, for runs that completed. When the tests fail, the + failure output is added as a message and pi makes one more model request, up to + `tests.max_retries` times in total. When it is out of tries, or for a large change, you get + a notification. +- Cannot see reads or other tools. The shell check has the limits listed under Claude Code. + +**Verified against the pi source** (`badlogic/pi-mono`, `main` as of 2026-09-30): the +extension directories and that a default-exported factory is loaded from a `.ts` file; the +`tool_call` event, `{ block, reason }` and the failure-blocks rule; `agent_before_settle`, +its `outcome`, and that a handler's `entries` replace the list (so the extension returns the +existing entries plus its own) and `continue: true` requests one more request; the +`custom_message` entry shape; the tool names and arguments (`path`, `command`); and +`ctx.cwd`, `ctx.hasUI`, `ctx.ui.confirm` and `ctx.ui.notify`. + +**Not verified.** The extension has only run against a fake `reflex` and a stand-in for pi's +API under Node, not inside pi. Assumed: that a `custom_message` entry reaches the model as +a user message on the continued request (the source says custom messages are sent to the +model, but this was not run), and that jiti loads the file as written. + ### Git pre-commit Installs `.git/hooks/pre-commit` (or the directory `core.hooksPath` points to). It @@ -258,6 +412,10 @@ is the git hook's deliberate refusal. | `.claude/settings.json` (or `~/.claude/`) | our hook entries merged in, everything else kept | | `.cursor/hooks.json` (or `~/.cursor/`) | our hook entries merged in, everything else kept | | `.codex/hooks.json` (or `~/.codex/`) | our hook entries merged in, everything else kept | +| `.opencode/plugins/reflex.js` (or `~/.config/opencode/plugins/`) | one plugin file, created or updated | +| `.kilo/plugin/reflex.js` (or `~/.config/kilo/plugin/`) | one plugin file, created or updated | +| `.clinerules/hooks/PreToolUse` (or `~/Documents/Cline/Hooks/`; `.ps1` on Windows) | one hook script, created or updated | +| `.pi/extensions/reflex.ts` (or `~/.pi/agent/extensions/`) | one extension file, created or updated | | `.git/hooks/pre-commit` | created, or an existing hook chained as above | | `.gitignore` | `.reflex/` and `*.reflex-bak` appended (project scope) | @@ -266,6 +424,11 @@ changes nothing the second time. `reflex uninstall` removes only our hook entrie files, and deletes a settings file that ends up empty; it leaves `.reflex.toml` and `.gitignore` alone. +The plugin, extension and script files of OpenCode, Kilo Code, Cline and pi are wholly +ours and carry a `reflex-control managed hook` line in their header. Install updates such a +file to the current version; a file of yours at the same path is never overwritten (the +install says what to add to it by hand); uninstall deletes only files with that line. + ## About the decision engine Hook mode uses the same `GuardedHybridComposer` as the rest of Reflex Control, fed with From bc118dc705c269a224c339ff8475f6c226365bac Mon Sep 17 00:00:00 2001 From: rustfuture <121799572+rustfuture@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:52:48 +0300 Subject: [PATCH 3/5] Resolve shell write targets against the agent's directory --- CHANGELOG.md | 2 + crates/reflex-hooks/src/guard.rs | 130 +++++++++++++++++++++++++++-- crates/reflex-hooks/src/runtime.rs | 113 +++++++++++++++++++++++-- docs/agent-hooks.md | 4 +- 4 files changed, 238 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c133e52..61cb1ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to Reflex Control are documented here. ## Unreleased +- Fixed shell protected-path checks to resolve relative write targets against the agent's working directory across all shell adapters, falling back to the project root when unknown. + - Added agent hooks for OpenCode, Kilo Code, Cline and pi. OpenCode, Kilo Code and pi get a small plugin or extension file (embedded in `reflex`, source in `integrations/`) that relays tool calls and finished turns to `reflex hook`; Cline gets a `PreToolUse` script. Cline has no hook that can send the agent back, so it is guarded before tool calls only; OpenCode, Kilo Code and Cline cannot ask for confirmation, so a risky command is refused (OpenCode and Kilo let the same call through when the agent repeats it). See `docs/agent-hooks.md`. ## 0.4.0 - 2026-10-02 diff --git a/crates/reflex-hooks/src/guard.rs b/crates/reflex-hooks/src/guard.rs index 438aa49..714821d 100644 --- a/crates/reflex-hooks/src/guard.rs +++ b/crates/reflex-hooks/src/guard.rs @@ -122,9 +122,19 @@ impl PathPolicy { if rel.is_empty() { return None; } + let normalized = collapse_dots(&normalize_separators(path.trim())); + let root = collapse_dots(&self.root); + let outside = (normalized.starts_with('/') || is_drive_path(&normalized)) + && strip_prefix_dir( + &normalized, + &root, + is_drive_path(&normalized) || is_drive_path(&root), + ) + .is_none(); self.set .matches(&rel) .into_iter() + .filter(|&i| !outside || !normalize_separators(self.patterns[i].trim()).contains('/')) .min() .map(|i| self.patterns[i].as_str()) } @@ -264,8 +274,21 @@ fn strip_prefix_dir<'a>(path: &'a str, root: &str, ignore_case: bool) -> Option< /// A protected-path write gives `Block`; a dangerous command gives `Ask`; the first /// `Block` wins over any `Ask`. pub fn check_command(command: &str, policy: &PathPolicy) -> Verdict { + check_command_in_dir(command, policy, None) +} + +/// Checks shell writes relative to the agent's directory, or the project root when +/// it is unknown. Directory changes inside the command are not tracked. +pub fn check_command_in_dir(command: &str, policy: &PathPolicy, cwd: Option<&Path>) -> Verdict { + let policy = ShellPolicy { + paths: policy, + cwd: cwd + .filter(|dir| !dir.as_os_str().is_empty()) + .map(|dir| normalize_separators(&dir.to_string_lossy())) + .unwrap_or_else(|| policy.root.clone()), + }; let mut ask: Option = None; - for finding in analyze(command, policy, 0) { + for finding in analyze(command, &policy, 0) { match finding { Finding::Protected { path, pattern } => { return Verdict::Block { @@ -341,7 +364,25 @@ struct Segment { out_targets: Vec, } -fn analyze(command: &str, policy: &PathPolicy, depth: usize) -> Vec { +/// Resolves only extracted write targets; command text and displayed paths stay intact. +struct ShellPolicy<'a> { + paths: &'a PathPolicy, + cwd: String, +} + +impl ShellPolicy<'_> { + fn matching_pattern(&self, target: &str) -> Option<&str> { + let target = normalize_separators(target); + let resolved = if target.starts_with('/') || is_drive_path(&target) { + target + } else { + format!("{}/{target}", self.cwd.trim_end_matches('/')) + }; + self.paths.matching_pattern(&resolved) + } +} + +fn analyze(command: &str, policy: &ShellPolicy, depth: usize) -> Vec { let mut findings = Vec::new(); for seg in split_segments(command) { for target in &seg.out_targets { @@ -354,7 +395,7 @@ fn analyze(command: &str, policy: &PathPolicy, depth: usize) -> Vec { /// Like [`check_target`], but `target` may be a directory: a protected directory is /// caught by probing a file below it. -fn check_target_or_dir(target: &str, policy: &PathPolicy, findings: &mut Vec) { +fn check_target_or_dir(target: &str, policy: &ShellPolicy, findings: &mut Vec) { check_target(target, policy, findings); let dir = target.trim_end_matches(['/', '\\']); if !dir.is_empty() { @@ -369,7 +410,7 @@ fn check_target_or_dir(target: &str, policy: &PathPolicy, findings: &mut Vec) { +fn check_target(target: &str, policy: &ShellPolicy, findings: &mut Vec) { if target.is_empty() || target.starts_with("/dev/") { return; } @@ -549,7 +590,7 @@ fn has_short_flag(word: &str, letters: &[char]) -> bool { && word[1..].chars().any(|c| letters.contains(&c)) } -fn analyze_segment(seg: &Segment, policy: &PathPolicy, depth: usize, out: &mut Vec) { +fn analyze_segment(seg: &Segment, policy: &ShellPolicy, depth: usize, out: &mut Vec) { let mut words: &[String] = &seg.words; // Skip `VAR=x`, `sudo`, `env -i`, ... in front of the real command. loop { @@ -913,6 +954,8 @@ mod tests { // absolute path outside the root: only name patterns apply (&default, "/home/me/keys/server.pem", true), (&default, "/home/me/secrets/a.txt", false), + (&default, "/secrets/a.txt", false), + (&default, "/secrets/../.env", true), // a sibling directory sharing the root as a string prefix is outside it (&default, "/work/proj-other/secrets/a.txt", false), // `.` and `..` are resolved @@ -1035,6 +1078,83 @@ mod tests { } } + #[test] + fn shell_write_targets_use_the_agents_directory() { + let policy = policy_with(&["secrets/**"], "/p"); + let cwd = Some(Path::new("/p/app")); + for (target, expected) in [ + ("../secrets/token.txt", "block"), + ("secrets/token.txt", "allow"), + ("/p/secrets/abs.txt", "block"), + ("./../secrets/token.txt", "block"), + ("../../secrets/token.txt", "allow"), + ("/secrets/token.txt", "allow"), + ("/p-other/secrets/token.txt", "allow"), + ] { + for command in [ + format!("echo x > '{target}'"), + format!("echo x >> '{target}'"), + format!("tee '{target}'"), + format!("cp source '{target}'"), + format!("mv source '{target}'"), + format!("rm '{target}'"), + format!("install source '{target}'"), + format!("sed -i 's/x/y/' '{target}'"), + format!("dd 'of={target}'"), + format!("bash -c \"echo x > '{target}'\""), + ] { + assert_eq!( + verdict_kind(&check_command_in_dir(&command, &policy, cwd)), + expected, + "command {command:?}" + ); + } + } + assert_eq!( + verdict_kind(&check_command_in_dir("cp source ../secrets", &policy, cwd)), + "block" + ); + assert_eq!( + check_command_in_dir("cp source secrets", &policy, cwd), + Verdict::Allow + ); + for cwd in [None, Some(Path::new(""))] { + assert_eq!( + verdict_kind(&check_command_in_dir( + "echo x > secrets/token.txt", + &policy, + cwd + )), + "block" + ); + assert_eq!( + check_command_in_dir("echo x > ../secrets/token.txt", &policy, cwd), + Verdict::Allow + ); + } + } + + #[test] + fn shell_write_targets_resolve_windows_paths() { + let policy = policy_with(&["secrets/**"], "C:/p"); + for (command, expected) in [ + ("echo x > '../secrets/token.txt'", "block"), + ("echo x > 'secrets/token.txt'", "allow"), + ("echo x > 'C:/p/secrets/abs.txt'", "block"), + ("echo x > 'D:/secrets/token.txt'", "allow"), + ] { + assert_eq!( + verdict_kind(&check_command_in_dir( + command, + &policy, + Some(Path::new("C:\\p\\app")) + )), + expected, + "command {command:?}" + ); + } + } + #[test] fn block_reason_names_the_file_and_how_to_allow_it() { let v = check_command("echo x > app/.env", &default_policy()); diff --git a/crates/reflex-hooks/src/runtime.rs b/crates/reflex-hooks/src/runtime.rs index a74ac84..ce91389 100644 --- a/crates/reflex-hooks/src/runtime.rs +++ b/crates/reflex-hooks/src/runtime.rs @@ -12,8 +12,8 @@ use crate::adapters::{self, HookEvent, Rendered}; use crate::config::{self, Config, ConfigSource}; use crate::guard::{ - self, check_command, check_paths, check_staged, decide_turn_end, PathPolicy, TestOutcome, - TurnEndInput, Verdict, + self, check_command_in_dir, check_paths, check_staged, decide_turn_end, PathPolicy, + TestOutcome, TurnEndInput, Verdict, }; use crate::install::Env; use crate::state::{self, SessionState, UntrackedFile}; @@ -98,17 +98,24 @@ fn try_handle(agent: &str, event: &str, rt: &dyn Runtime) -> Result Result { +fn evaluate( + event: &HookEvent, + project: &Project, + cwd: Option<&Path>, + rt: &dyn Runtime, +) -> Result { let policy = |p: &Project| PathPolicy::new(&p.config.protect.paths, &p.root).map_err(|e| e.to_string()); match event { HookEvent::Ignore => Ok(Verdict::Allow), HookEvent::PreWrite { paths } => Ok(check_paths(paths, &policy(project)?)), - HookEvent::PreShell { command } => Ok(check_command(command, &policy(project)?)), + HookEvent::PreShell { command } => { + Ok(check_command_in_dir(command, &policy(project)?, cwd)) + } HookEvent::TurnEnd { session_id } => rt.turn_end(project, session_id), HookEvent::PreCommit => { let staged = rt.staged_files(project)?; @@ -495,6 +502,102 @@ mod tests { } } + #[test] + fn shell_adapters_resolve_writes_from_a_project_subdirectory() { + use serde_json::json; + + for agent in [ + "claude-code", + "codex", + "cursor", + "opencode", + "kilo", + "cline", + "pi", + ] { + for cwd in [Some("/p/app"), None] { + for (command, blocked_in_app, blocked_at_root) in [ + ("echo x > ../secrets/token.txt", true, false), + ("echo x > secrets/token.txt", false, true), + ("echo x > /p/secrets/abs.txt", true, true), + ("echo x > ../../secrets/token.txt", false, false), + ] { + let mut payload = match agent { + "claude-code" | "codex" => json!({ + "tool_name": "Bash", "tool_input": { "command": command } + }), + "cursor" => json!({ "command": command }), + "cline" => json!({ + "tool_call": { "name": "run_commands", "input": { "commands": [command] } } + }), + _ => json!({ "tool": "bash", "args": { "command": command } }), + }; + if let Some(cwd) = cwd { + if agent == "cline" { + payload["workspaceRoots"] = json!([cwd]); + } else { + payload["cwd"] = json!(cwd); + } + } + let mut rt = Fake::new(&payload.to_string()); + rt.project.as_mut().unwrap().root = PathBuf::from("/p"); + let event = if agent == "cursor" { + "shell" + } else { + "pre-tool" + }; + let out = handle(agent, event, &rt); + let blocked = if cwd.is_some() { + blocked_in_app + } else { + blocked_at_root + }; + let context = format!("{agent}, cwd={cwd:?}, command={command:?}"); + if agent == "cursor" && blocked { + assert!( + out.stderr.contains("secrets/**"), + "{context}: {}", + out.stderr + ); + assert_eq!(out.exit_code, 2, "{context}"); + } else { + assert!(out.stderr.is_empty(), "{context}: {}", out.stderr); + assert_eq!(out.exit_code, 0, "{context}"); + } + if !blocked { + match agent { + "claude-code" | "codex" | "cursor" => { + assert!(out.stdout.is_empty(), "{context}: {}", out.stdout); + } + "cline" => assert_eq!(out.stdout, "{}\n", "{context}"), + _ => { + let reply: serde_json::Value = + serde_json::from_str(&out.stdout).unwrap(); + assert_eq!(reply["action"], "allow", "{context}"); + } + } + } else { + let reply: serde_json::Value = serde_json::from_str(&out.stdout).unwrap(); + match agent { + "claude-code" | "codex" => assert_eq!( + reply["hookSpecificOutput"]["permissionDecision"], "deny", + "{context}" + ), + "cursor" => assert_eq!(reply["permission"], "deny", "{context}"), + "cline" => assert_eq!(reply["cancel"], true, "{context}"), + _ => assert_eq!(reply["action"], "block", "{context}"), + } + assert!( + out.stdout.contains("secrets/**"), + "{context}: {}", + out.stdout + ); + } + } + } + } + } + #[test] fn malformed_input_allows_and_warns() { let out = handle("claude-code", "pre-tool", &Fake::new("{ not json")); diff --git a/docs/agent-hooks.md b/docs/agent-hooks.md index 570bf21..096d191 100644 --- a/docs/agent-hooks.md +++ b/docs/agent-hooks.md @@ -143,7 +143,9 @@ starts with `reflex hook`. - Cannot see what the agent reads, or what other tools (MCP servers, `WebFetch`, ...) do. - The shell check reads the command text. It does not run the command, expand variables or follow `$(...)`, and it cannot see a script or `python -c` writing to a protected - file. Treat it as a guard rail against mistakes, not a sandbox. `cat .env` is allowed. + file. Relative write targets use the agent's working directory when provided, otherwise + the project root. Directory changes from `cd` inside the command are not tracked. + Treat it as a guard rail against mistakes, not a sandbox. `cat .env` is allowed. - Claude Code stops sending the agent back after a fixed number of consecutive stop blocks of its own; the default retry budget (2) is well below it. - A committed `.claude/settings.json` reaches teammates who do not have `reflex` From c9793388b6a14c2e55d51f9be131e9c819439808 Mon Sep 17 00:00:00 2001 From: rustfuture <121799572+rustfuture@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:54:22 +0300 Subject: [PATCH 4/5] Fail open on failed reflex runs and never drop a turn-end check --- crates/reflex-hooks/tests/js_plugins_node.rs | 88 +++++++++++++++++++- integrations/opencode/reflex.js | 37 +++++--- integrations/pi/reflex.ts | 3 +- 3 files changed, 115 insertions(+), 13 deletions(-) diff --git a/crates/reflex-hooks/tests/js_plugins_node.rs b/crates/reflex-hooks/tests/js_plugins_node.rs index 9fd67ff..df7df44 100644 --- a/crates/reflex-hooks/tests/js_plugins_node.rs +++ b/crates/reflex-hooks/tests/js_plugins_node.rs @@ -27,13 +27,17 @@ struct Fake { impl Fake { fn new(reply: &str) -> Fake { + Self::with_exit(reply, 0) + } + + fn with_exit(reply: &str, exit_code: i32) -> Fake { let dir = tempfile::tempdir().unwrap(); let bin = dir.path().join("bin"); std::fs::create_dir(&bin).unwrap(); let script = bin.join("reflex"); std::fs::write( &script, - "#!/bin/sh\n{ echo \"ARGS $*\"; cat; echo; } >> \"$(dirname \"$0\")/../log\"\ncat \"$(dirname \"$0\")/../reply\"\n", + format!("#!/bin/sh\n{{ echo \"ARGS $*\"; cat; echo; }} >> \"$(dirname \"$0\")/../log\"\ncat \"$(dirname \"$0\")/../reply\"\nexit {exit_code}\n"), ) .unwrap(); std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); @@ -159,6 +163,11 @@ fn opencode_plugin_turns_verdicts_into_agent_calls() { ); } + // A deliberate-looking verdict is ignored when reflex exits unsuccessfully. + let failed = Fake::with_exit(r#"{"action":"block","reason":"protected"}"#, 1); + let out = run(&node, &plugin, OPENCODE_DRIVER, &failed.path()); + assert_eq!(out["results"]["first"], "ok"); + // No reflex on PATH: everything is allowed. let empty = tempfile::tempdir().unwrap(); let out = run(&node, &plugin, OPENCODE_DRIVER, empty.path().as_os_str()); @@ -169,6 +178,78 @@ fn opencode_plugin_turns_verdicts_into_agent_calls() { assert_eq!(out["seen"], json!([])); } +const OPENCODE_IDLE_RACE_DRIVER: &str = r#" +import plugin from "./plugin.mjs" +const releases = [] +let gets = 0 +const client = { + session: { + get: async () => { gets++; if (gets <= 2) { await new Promise((resolve) => { releases[gets - 1] = resolve; resolveEntered[gets - 1]() }) } return { data: {} } }, + promptAsync: async () => {}, + }, + app: { log: async () => {} }, tui: { showToast: async () => {} }, +} +const signal = [] +const resolveEntered = [] +for (let i = 0; i < 2; i++) signal[i] = new Promise((resolve) => (resolveEntered[i] = resolve)) +const hooks = await plugin.server({ client, directory: "/work/proj" }) +const idle = () => hooks.event({ event: { type: "session.idle", properties: { sessionID: "s1" } } }) +const first = idle() +await signal[0] +await idle() +releases[0]() +await signal[1] +await idle() +releases[1]() +await first +await new Promise((resolve) => setTimeout(resolve, 30)) +console.log(JSON.stringify({ gets })) +"#; + +#[test] +fn opencode_plugin_rechecks_an_idle_received_during_turn_end_check() { + let Some(node) = node() else { return }; + let fake = Fake::new(r#"{"action":"allow"}"#); + let out = run(&node, &opencode::plugin_source("opencode"), OPENCODE_IDLE_RACE_DRIVER, &fake.path()); + assert_eq!(out["gets"], 3); + assert_eq!(fake.calls().iter().filter(|c| c.as_str() == "ARGS hook opencode turn-end").count(), 3); +} + +const OPENCODE_RETRY_RACE_DRIVER: &str = r#" +import plugin from "./plugin.mjs" +let release +let entered +const inGet = new Promise((resolve) => (entered = resolve)) +let gets = 0 +let prompts = 0 +const client = { + session: { + get: async () => { gets++; if (gets === 1) { entered(); await new Promise((resolve) => (release = resolve)) } return { data: {} } }, + promptAsync: async () => { prompts++ }, + }, + app: { log: async () => {} }, tui: { showToast: async () => {} }, +} +const hooks = await plugin.server({ client, directory: "/work/proj" }) +const idle = () => hooks.event({ event: { type: "session.idle", properties: { sessionID: "s1" } } }) +const first = idle() +await inGet +await idle() +release() +await first +await new Promise((resolve) => setTimeout(resolve, 30)) +console.log(JSON.stringify({ gets, prompts })) +"#; + +#[test] +fn opencode_retry_discards_pending_idle_recheck() { + let Some(node) = node() else { return }; + let fake = Fake::new(r#"{"action":"retry","reason":"continue"}"#); + let out = run(&node, &opencode::plugin_source("opencode"), OPENCODE_RETRY_RACE_DRIVER, &fake.path()); + assert_eq!(out["gets"], 1); + assert_eq!(out["prompts"], 1); + assert_eq!(fake.calls().iter().filter(|c| c.as_str() == "ARGS hook opencode turn-end").count(), 1); +} + #[test] fn opencode_plugin_asks_once_then_lets_the_same_call_through() { let Some(node) = node() else { @@ -303,6 +384,11 @@ fn pi_extension_turns_verdicts_into_pi_results() { assert_eq!(out["results"]["settled"], Value::Null); assert_eq!(out["notes"], json!([["large change", "warning"]])); + // A valid block response from a failed process must fail open. + let failed = Fake::with_exit(r#"{"action":"block","reason":"protected"}"#, 1); + let out = run(&node, pi::EXTENSION, PI_DRIVER, &failed.path()); + assert_eq!(out["results"]["noUi"], Value::Null); + // No reflex: everything is allowed. let empty = tempfile::tempdir().unwrap(); let out = run(&node, pi::EXTENSION, PI_DRIVER, empty.path().as_os_str()); diff --git a/integrations/opencode/reflex.js b/integrations/opencode/reflex.js index d11202f..eac51a3 100644 --- a/integrations/opencode/reflex.js +++ b/integrations/opencode/reflex.js @@ -8,12 +8,14 @@ const allow = { action: "allow" } const confirmed = new Set() // calls that were refused once with "ask the user first" const failed = new Set() // sessions whose last run ended in an error or was stopped const checking = new Set() // sessions with a turn-end check in flight +const checkAgain = new Set() // sessions that became idle during a turn-end check // Runs `reflex hook AGENT event` with `request` on stdin. Never rejects; resolves to a verdict. function verdict(event, request, timeoutMs) { return new Promise((resolve) => { try { - const child = execFile("reflex", ["hook", AGENT, event], { timeout: timeoutMs, windowsHide: true }, (_error, stdout) => { + const child = execFile("reflex", ["hook", AGENT, event], { timeout: timeoutMs, windowsHide: true }, (error, stdout) => { + if (error) return resolve(allow) try { const v = JSON.parse(stdout) resolve(v && typeof v.action === "string" ? v : allow) @@ -51,20 +53,33 @@ export default { if (!id) return if (event.type === "session.error") failed.add(id) else if (event.type === "session.status" && event.properties.status?.type === "busy") failed.delete(id) - if (event.type !== "session.idle" || failed.delete(id) || checking.has(id)) return + if (event.type !== "session.idle" || failed.delete(id)) return + if (checking.has(id)) { + checkAgain.add(id) + return + } checking.add(id) try { - const session = await client.session.get({ path: { id } }) - if (session.data?.parentID) return // a subagent; the parent session is checked instead - const v = await verdict("turn-end", { cwd: directory, session_id: id }, 600000) - if (v.action === "retry") { - await client.session.promptAsync({ path: { id }, body: { parts: [{ type: "text", text: v.reason }] } }) - } else if (v.action === "notify") { - await client.app.log({ body: { service: "reflex-control", level: "warn", message: v.reason } }) - await client.tui.showToast({ body: { message: v.reason, variant: "warning" } }) - } + do { + const session = await client.session.get({ path: { id } }) + if (session.data?.parentID) { + checkAgain.delete(id) + return // a subagent; the parent session is checked instead + } + const v = await verdict("turn-end", { cwd: directory, session_id: id }, 600000) + if (v.action === "retry") { + await client.session.promptAsync({ path: { id }, body: { parts: [{ type: "text", text: v.reason }] } }) + // The retry starts another run; its eventual idle will trigger the next check. + checkAgain.delete(id) + break + } else if (v.action === "notify") { + await client.app.log({ body: { service: "reflex-control", level: "warn", message: v.reason } }) + await client.tui.showToast({ body: { message: v.reason, variant: "warning" } }) + } + } while (checkAgain.delete(id)) } finally { checking.delete(id) + checkAgain.delete(id) } } catch {} }, diff --git a/integrations/pi/reflex.ts b/integrations/pi/reflex.ts index 7da554c..b068770 100644 --- a/integrations/pi/reflex.ts +++ b/integrations/pi/reflex.ts @@ -10,7 +10,8 @@ const allow = { action: "allow" }; function verdict(event, request, timeoutMs) { return new Promise((resolve) => { try { - const child = execFile("reflex", ["hook", "pi", event], { timeout: timeoutMs, windowsHide: true }, (_error, stdout) => { + const child = execFile("reflex", ["hook", "pi", event], { timeout: timeoutMs, windowsHide: true }, (error, stdout) => { + if (error) return resolve(allow); try { const v = JSON.parse(stdout); resolve(v && typeof v.action === "string" ? v : allow); From dc646a23d655ea75779b1e7d9401d9a5b3bca029 Mon Sep 17 00:00:00 2001 From: rustfuture <121799572+rustfuture@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:54:32 +0300 Subject: [PATCH 5/5] Format the JS plugin tests --- crates/reflex-hooks/tests/js_plugins_node.rs | 30 +++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/reflex-hooks/tests/js_plugins_node.rs b/crates/reflex-hooks/tests/js_plugins_node.rs index df7df44..8db478f 100644 --- a/crates/reflex-hooks/tests/js_plugins_node.rs +++ b/crates/reflex-hooks/tests/js_plugins_node.rs @@ -210,9 +210,20 @@ console.log(JSON.stringify({ gets })) fn opencode_plugin_rechecks_an_idle_received_during_turn_end_check() { let Some(node) = node() else { return }; let fake = Fake::new(r#"{"action":"allow"}"#); - let out = run(&node, &opencode::plugin_source("opencode"), OPENCODE_IDLE_RACE_DRIVER, &fake.path()); + let out = run( + &node, + &opencode::plugin_source("opencode"), + OPENCODE_IDLE_RACE_DRIVER, + &fake.path(), + ); assert_eq!(out["gets"], 3); - assert_eq!(fake.calls().iter().filter(|c| c.as_str() == "ARGS hook opencode turn-end").count(), 3); + assert_eq!( + fake.calls() + .iter() + .filter(|c| c.as_str() == "ARGS hook opencode turn-end") + .count(), + 3 + ); } const OPENCODE_RETRY_RACE_DRIVER: &str = r#" @@ -244,10 +255,21 @@ console.log(JSON.stringify({ gets, prompts })) fn opencode_retry_discards_pending_idle_recheck() { let Some(node) = node() else { return }; let fake = Fake::new(r#"{"action":"retry","reason":"continue"}"#); - let out = run(&node, &opencode::plugin_source("opencode"), OPENCODE_RETRY_RACE_DRIVER, &fake.path()); + let out = run( + &node, + &opencode::plugin_source("opencode"), + OPENCODE_RETRY_RACE_DRIVER, + &fake.path(), + ); assert_eq!(out["gets"], 1); assert_eq!(out["prompts"], 1); - assert_eq!(fake.calls().iter().filter(|c| c.as_str() == "ARGS hook opencode turn-end").count(), 1); + assert_eq!( + fake.calls() + .iter() + .filter(|c| c.as_str() == "ARGS hook opencode turn-end") + .count(), + 1 + ); } #[test]