diff --git a/CHANGELOG.md b/CHANGELOG.md index fb27316..61cb1ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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 - 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`. 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 new file mode 100644 index 0000000..97bd0f8 --- /dev/null +++ b/crates/reflex-hooks/src/adapters/cline.rs @@ -0,0 +1,364 @@ +//! 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::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; +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| 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, + }; + let event = resolve_paths(event, cwd.as_deref().and_then(|c| c.to_str())); + 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/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 new file mode 100644 index 0000000..22f6f88 --- /dev/null +++ b/crates/reflex-hooks/src/adapters/js_bridge.rs @@ -0,0 +1,88 @@ +//! 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::util::resolve_paths; +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" => 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(), + }, + 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 742a8e5..bc06046 100644 --- a/crates/reflex-hooks/src/adapters/mod.rs +++ b/crates/reflex-hooks/src/adapters/mod.rs @@ -15,9 +15,14 @@ use crate::install::{Files, InstallError, Plan, PlanCtx}; use std::path::{Path, PathBuf}; pub mod claude_code; +pub mod cline; pub mod codex; pub mod cursor; 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)] @@ -99,10 +104,14 @@ pub trait AgentAdapter: Sync { fn is_installed(&self, ctx: &PlanCtx, existing: &Files) -> bool; } -static ADAPTERS: [&dyn AgentAdapter; 4] = [ +static ADAPTERS: [&dyn AgentAdapter; 8] = [ &claude_code::ClaudeCode, &cursor::Cursor, &codex::Codex, + &opencode::OPENCODE, + &opencode::KILO, + &cline::Cline, + &pi::Pi, &git::GitHook, ]; @@ -122,7 +131,19 @@ 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", "cursor", "codex", "git"]); + assert_eq!( + ids, + [ + "claude-code", + "cursor", + "codex", + "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..290e4da --- /dev/null +++ b/crates/reflex-hooks/src/adapters/opencode.rs @@ -0,0 +1,276 @@ +//! 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::codex::patch_paths; +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| 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..88508f7 --- /dev/null +++ b/crates/reflex-hooks/src/adapters/util.rs @@ -0,0 +1,160 @@ +//! 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 super::codex::resolve; +use super::HookEvent; +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) + }) +} + +/// 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)] +mod tests { + use super::*; + + #[test] + fn relative_paths_are_resolved_against_the_agents_directory() { + let write = |p: &str| HookEvent::PreWrite { + paths: vec![p.to_string()], + }; + assert_eq!( + resolve_paths(write("secrets/a"), Some("/work/proj/app")), + write("/work/proj/app/secrets/a") + ); + 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] + 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/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/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..ef0ffe8 --- /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 `/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_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..cc75ff9 --- /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 `/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.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..cc75ff9 --- /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 `/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_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__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__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..e553f42 --- /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 `/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/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..2d2dede --- /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 `/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/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..2d2dede --- /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 `/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.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..ccb4323 --- /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 `/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_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__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__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/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/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/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/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..8db478f --- /dev/null +++ b/crates/reflex-hooks/tests/js_plugins_node.rs @@ -0,0 +1,419 @@ +//! 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 { + 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, + 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(); + 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:?}" + ); + } + + // 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()); + assert_eq!( + out["results"], + json!({ "first": "ok", "second": "ok", "other": "ok" }) + ); + 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 { + 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"]])); + + // 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()); + 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/docs/agent-hooks.md b/docs/agent-hooks.md index bc76895..096d191 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 @@ -139,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` @@ -224,6 +230,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 +414,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 +426,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 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..eac51a3 --- /dev/null +++ b/integrations/opencode/reflex.js @@ -0,0 +1,87 @@ +// 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 +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) => { + if (error) return resolve(allow) + 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)) return + if (checking.has(id)) { + checkAgain.add(id) + return + } + checking.add(id) + try { + 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 new file mode 100644 index 0000000..b068770 --- /dev/null +++ b/integrations/pi/reflex.ts @@ -0,0 +1,56 @@ +// 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) => { + if (error) return resolve(allow); + 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; + }); +}