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

@@ -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);