diff --git a/Cargo.toml b/Cargo.toml index 13b7a42..eaec354 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "crates/app/cli", "crates/llm/ollama", "crates/platform/config", + "crates/platform/hooks", "crates/platform/permissions", "crates/tools/bash", "crates/tools/fs", diff --git a/crates/app/cli/Cargo.toml b/crates/app/cli/Cargo.toml index cf614d7..a698672 100644 --- a/crates/app/cli/Cargo.toml +++ b/crates/app/cli/Cargo.toml @@ -17,6 +17,7 @@ tools-bash = { path = "../../tools/bash" } tools-slash = { path = "../../tools/slash" } config-agent = { package = "config-agent", path = "../../platform/config" } permissions = { path = "../../platform/permissions" } +hooks = { path = "../../platform/hooks" } futures-util = "0.3.31" [dev-dependencies] diff --git a/crates/app/cli/src/main.rs b/crates/app/cli/src/main.rs index 0e38b1a..0061c48 100644 --- a/crates/app/cli/src/main.rs +++ b/crates/app/cli/src/main.rs @@ -2,6 +2,7 @@ use clap::Parser; use color_eyre::eyre::{Result, eyre}; use config_agent::load_settings; use futures_util::TryStreamExt; +use hooks::{HookEvent, HookManager, HookResult}; use llm_ollama::{OllamaClient, OllamaOptions, types::ChatMessage}; use permissions::{PermissionDecision, Tool}; use std::io::{self, Write}; @@ -51,12 +52,27 @@ async fn main() -> Result<()> { // Create permission manager from settings let perms = settings.create_permission_manager(); + // Create hook manager + let hook_mgr = HookManager::new("."); + if let Some(cmd) = args.cmd { match cmd { Cmd::Read { path } => { // Check permission match perms.check(Tool::Read, None) { PermissionDecision::Allow => { + // Check PreToolUse hook + let event = HookEvent::PreToolUse { + tool: "Read".to_string(), + args: serde_json::json!({"path": &path}), + }; + match hook_mgr.execute(&event, Some(5000)).await? { + HookResult::Deny => { + return Err(eyre!("Hook denied Read operation")); + } + HookResult::Allow => {} + } + let s = tools_fs::read_file(&path)?; println!("{}", s); return Ok(()); @@ -75,6 +91,18 @@ async fn main() -> Result<()> { // Check permission match perms.check(Tool::Glob, None) { PermissionDecision::Allow => { + // Check PreToolUse hook + let event = HookEvent::PreToolUse { + tool: "Glob".to_string(), + args: serde_json::json!({"pattern": &pattern}), + }; + match hook_mgr.execute(&event, Some(5000)).await? { + HookResult::Deny => { + return Err(eyre!("Hook denied Glob operation")); + } + HookResult::Allow => {} + } + for p in tools_fs::glob_list(&pattern)? { println!("{}", p); } @@ -94,6 +122,18 @@ async fn main() -> Result<()> { // Check permission match perms.check(Tool::Grep, None) { PermissionDecision::Allow => { + // Check PreToolUse hook + let event = HookEvent::PreToolUse { + tool: "Grep".to_string(), + args: serde_json::json!({"root": &root, "pattern": &pattern}), + }; + match hook_mgr.execute(&event, Some(5000)).await? { + HookResult::Deny => { + return Err(eyre!("Hook denied Grep operation")); + } + HookResult::Allow => {} + } + for (path, line_number, text) in tools_fs::grep(&root, &pattern)? { println!("{path}:{line_number}:{text}") } @@ -113,6 +153,18 @@ async fn main() -> Result<()> { // Check permission match perms.check(Tool::Write, None) { PermissionDecision::Allow => { + // Check PreToolUse hook + let event = HookEvent::PreToolUse { + tool: "Write".to_string(), + args: serde_json::json!({"path": &path, "content": &content}), + }; + match hook_mgr.execute(&event, Some(5000)).await? { + HookResult::Deny => { + return Err(eyre!("Hook denied Write operation")); + } + HookResult::Allow => {} + } + tools_fs::write_file(&path, &content)?; println!("File written: {}", path); return Ok(()); @@ -131,6 +183,18 @@ async fn main() -> Result<()> { // Check permission match perms.check(Tool::Edit, None) { PermissionDecision::Allow => { + // Check PreToolUse hook + let event = HookEvent::PreToolUse { + tool: "Edit".to_string(), + args: serde_json::json!({"path": &path, "old_string": &old_string, "new_string": &new_string}), + }; + match hook_mgr.execute(&event, Some(5000)).await? { + HookResult::Deny => { + return Err(eyre!("Hook denied Edit operation")); + } + HookResult::Allow => {} + } + tools_fs::edit_file(&path, &old_string, &new_string)?; println!("File edited: {}", path); return Ok(()); @@ -149,6 +213,18 @@ async fn main() -> Result<()> { // Check permission with command context for pattern matching match perms.check(Tool::Bash, Some(&command)) { PermissionDecision::Allow => { + // Check PreToolUse hook + let event = HookEvent::PreToolUse { + tool: "Bash".to_string(), + args: serde_json::json!({"command": &command, "timeout": timeout}), + }; + match hook_mgr.execute(&event, Some(5000)).await? { + HookResult::Deny => { + return Err(eyre!("Hook denied Bash operation")); + } + HookResult::Allow => {} + } + let mut session = tools_bash::BashSession::new().await?; let output = session.execute(&command, timeout).await?; @@ -185,6 +261,18 @@ async fn main() -> Result<()> { // Check permission match perms.check(Tool::SlashCommand, None) { PermissionDecision::Allow => { + // Check PreToolUse hook + let event = HookEvent::PreToolUse { + tool: "SlashCommand".to_string(), + args: serde_json::json!({"command_name": &command_name, "args": &args}), + }; + match hook_mgr.execute(&event, Some(5000)).await? { + HookResult::Deny => { + return Err(eyre!("Hook denied SlashCommand operation")); + } + HookResult::Allow => {} + } + // Look for command file in .owlen/commands/ let command_path = format!(".owlen/commands/{}.md", command_name); diff --git a/crates/platform/hooks/Cargo.toml b/crates/platform/hooks/Cargo.toml new file mode 100644 index 0000000..51123c6 --- /dev/null +++ b/crates/platform/hooks/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "hooks" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1.39", features = ["process", "time", "io-util"] } +color-eyre = "0.6" + +[dev-dependencies] +tempfile = "3.23.0" +tokio = { version = "1.39", features = ["macros", "rt-multi-thread"] } diff --git a/crates/platform/hooks/src/lib.rs b/crates/platform/hooks/src/lib.rs new file mode 100644 index 0000000..bcf3618 --- /dev/null +++ b/crates/platform/hooks/src/lib.rs @@ -0,0 +1,171 @@ +use color_eyre::eyre::{Result, eyre}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::PathBuf; +use std::process::Stdio; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; +use tokio::time::timeout; +use std::time::Duration; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "camelCase")] +pub enum HookEvent { + #[serde(rename_all = "camelCase")] + PreToolUse { + tool: String, + args: Value, + }, + #[serde(rename_all = "camelCase")] + PostToolUse { + tool: String, + result: Value, + }, + #[serde(rename_all = "camelCase")] + SessionStart { + session_id: String, + }, + #[serde(rename_all = "camelCase")] + SessionEnd { + session_id: String, + }, + #[serde(rename_all = "camelCase")] + UserPromptSubmit { + prompt: String, + }, + PreCompact, +} + +impl HookEvent { + /// Get the hook name for this event (used to find the hook script) + pub fn hook_name(&self) -> &str { + match self { + HookEvent::PreToolUse { .. } => "PreToolUse", + HookEvent::PostToolUse { .. } => "PostToolUse", + HookEvent::SessionStart { .. } => "SessionStart", + HookEvent::SessionEnd { .. } => "SessionEnd", + HookEvent::UserPromptSubmit { .. } => "UserPromptSubmit", + HookEvent::PreCompact => "PreCompact", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HookResult { + Allow, + Deny, +} + +pub struct HookManager { + project_root: PathBuf, +} + +impl HookManager { + pub fn new(project_root: &str) -> Self { + Self { + project_root: PathBuf::from(project_root), + } + } + + /// Execute a hook for the given event + /// + /// Returns: + /// - Ok(HookResult::Allow) if hook succeeds or doesn't exist (exit code 0 or no hook) + /// - Ok(HookResult::Deny) if hook denies (exit code 2) + /// - Err if hook fails (other exit codes) or times out + pub async fn execute(&self, event: &HookEvent, timeout_ms: Option) -> Result { + let hook_path = self.get_hook_path(event); + + // If hook doesn't exist, allow by default + if !hook_path.exists() { + return Ok(HookResult::Allow); + } + + // Serialize event to JSON + let input_json = serde_json::to_string(event)?; + + // Spawn the hook process + let mut child = Command::new(&hook_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(&self.project_root) + .spawn()?; + + // Write JSON input to stdin + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(input_json.as_bytes()).await?; + stdin.flush().await?; + drop(stdin); // Close stdin + } + + // Wait for process with timeout + let result = if let Some(ms) = timeout_ms { + timeout(Duration::from_millis(ms), child.wait_with_output()).await + } else { + Ok(child.wait_with_output().await) + }; + + match result { + Ok(Ok(output)) => { + // Check exit code + match output.status.code() { + Some(0) => Ok(HookResult::Allow), + Some(2) => Ok(HookResult::Deny), + Some(code) => Err(eyre!( + "Hook {} failed with exit code {}: {}", + event.hook_name(), + code, + String::from_utf8_lossy(&output.stderr) + )), + None => Err(eyre!("Hook {} terminated by signal", event.hook_name())), + } + } + Ok(Err(e)) => Err(eyre!("Failed to execute hook {}: {}", event.hook_name(), e)), + Err(_) => Err(eyre!("Hook {} timed out", event.hook_name())), + } + } + + fn get_hook_path(&self, event: &HookEvent) -> PathBuf { + self.project_root + .join(".owlen") + .join("hooks") + .join(event.hook_name()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hook_event_serializes_correctly() { + let event = HookEvent::PreToolUse { + tool: "Read".to_string(), + args: serde_json::json!({"path": "/tmp/test.txt"}), + }; + + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("\"event\":\"preToolUse\"")); + assert!(json.contains("\"tool\":\"Read\"")); + } + + #[test] + fn hook_event_names() { + assert_eq!( + HookEvent::PreToolUse { + tool: "Read".to_string(), + args: serde_json::json!({}), + } + .hook_name(), + "PreToolUse" + ); + assert_eq!( + HookEvent::SessionStart { + session_id: "123".to_string(), + } + .hook_name(), + "SessionStart" + ); + } +} diff --git a/crates/platform/hooks/tests/hooks.rs b/crates/platform/hooks/tests/hooks.rs new file mode 100644 index 0000000..3f70ece --- /dev/null +++ b/crates/platform/hooks/tests/hooks.rs @@ -0,0 +1,160 @@ +use hooks::{HookEvent, HookManager, HookResult}; +use std::fs; +use tempfile::tempdir; + +#[tokio::test] +async fn pretooluse_can_deny_call() { + let dir = tempdir().unwrap(); + let hooks_dir = dir.path().join(".owlen/hooks"); + fs::create_dir_all(&hooks_dir).unwrap(); + + // Create a PreToolUse hook that denies Write operations + let hook_script = r#"#!/bin/bash +INPUT=$(cat) +TOOL=$(echo "$INPUT" | grep -o '"tool":"[^"]*"' | cut -d'"' -f4) + +if [ "$TOOL" = "Write" ]; then + exit 2 # Deny +fi +exit 0 # Allow +"#; + let hook_path = hooks_dir.join("PreToolUse"); + fs::write(&hook_path, hook_script).unwrap(); + fs::set_permissions(&hook_path, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap(); + + let manager = HookManager::new(dir.path().to_str().unwrap()); + + // Test Write tool (should be denied) + let write_event = HookEvent::PreToolUse { + tool: "Write".to_string(), + args: serde_json::json!({"path": "/tmp/test.txt", "content": "hello"}), + }; + let result = manager.execute(&write_event, Some(5000)).await.unwrap(); + assert_eq!(result, HookResult::Deny); + + // Test Read tool (should be allowed) + let read_event = HookEvent::PreToolUse { + tool: "Read".to_string(), + args: serde_json::json!({"path": "/tmp/test.txt"}), + }; + let result = manager.execute(&read_event, Some(5000)).await.unwrap(); + assert_eq!(result, HookResult::Allow); +} + +#[tokio::test] +async fn posttooluse_runs_parallel() { + let dir = tempdir().unwrap(); + let hooks_dir = dir.path().join(".owlen/hooks"); + fs::create_dir_all(&hooks_dir).unwrap(); + + let output_file = dir.path().join("hook_output.txt"); + + // Create a PostToolUse hook that writes to a file + let hook_script = format!( + r#"#!/bin/bash +INPUT=$(cat) +echo "Hook executed: $INPUT" >> {} +exit 0 +"#, + output_file.display() + ); + let hook_path = hooks_dir.join("PostToolUse"); + fs::write(&hook_path, hook_script).unwrap(); + fs::set_permissions(&hook_path, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap(); + + let manager = HookManager::new(dir.path().to_str().unwrap()); + + // Execute hook + let event = HookEvent::PostToolUse { + tool: "Read".to_string(), + result: serde_json::json!({"success": true}), + }; + let result = manager.execute(&event, Some(5000)).await.unwrap(); + assert_eq!(result, HookResult::Allow); + + // Verify hook ran + let output = fs::read_to_string(&output_file).unwrap(); + assert!(output.contains("Hook executed")); +} + +#[tokio::test] +async fn sessionstart_persists_env() { + let dir = tempdir().unwrap(); + let hooks_dir = dir.path().join(".owlen/hooks"); + fs::create_dir_all(&hooks_dir).unwrap(); + + let env_file = dir.path().join(".owlen/session.env"); + + // Create a SessionStart hook that writes env vars to a file + let hook_script = format!( + r#"#!/bin/bash +cat > {} <