feat(M6): implement hooks system with PreToolUse, PostToolUse, and SessionStart events

Milestone M6 implementation adds a comprehensive hook system that allows
users to run custom scripts at various lifecycle events.

New crate: crates/platform/hooks
- HookEvent enum with multiple event types:
  * PreToolUse: fires before tool execution, can deny operations (exit code 2)
  * PostToolUse: fires after tool execution
  * SessionStart: fires at session start, can persist env vars
  * SessionEnd, UserPromptSubmit, PreCompact (defined for future use)
- HookManager for executing hooks with timeout support
- JSON I/O: hooks receive event data via stdin, can output to stdout
- Hooks located in .owlen/hooks/{EventName}

CLI integration:
- All tool commands (Read, Write, Edit, Glob, Grep, Bash, SlashCommand)
  now fire PreToolUse hooks before execution
- Hooks can deny operations by exiting with code 2
- Hooks timeout after 5 seconds by default

Tests added:
- pretooluse_can_deny_call: verifies hooks can block tool execution
- posttooluse_runs_parallel: verifies PostToolUse hooks execute
- sessionstart_persists_env: verifies SessionStart can create env files
- hook_timeout_works: verifies timeout mechanism
- hook_not_found_is_ok: verifies missing hooks don't cause errors

All 63 tests passing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-01 19:57:38 +01:00
parent 686526bbd4
commit a024a764d6
6 changed files with 437 additions and 0 deletions

View File

@@ -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<u64>) -> Result<HookResult> {
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"
);
}
}