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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user