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>
161 lines
5.0 KiB
Rust
161 lines
5.0 KiB
Rust
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 > {} <<EOF
|
|
MY_VAR=hello
|
|
ANOTHER_VAR=world
|
|
EOF
|
|
exit 0
|
|
"#,
|
|
env_file.display()
|
|
);
|
|
let hook_path = hooks_dir.join("SessionStart");
|
|
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 SessionStart hook
|
|
let event = HookEvent::SessionStart {
|
|
session_id: "test-123".to_string(),
|
|
};
|
|
let result = manager.execute(&event, Some(5000)).await.unwrap();
|
|
assert_eq!(result, HookResult::Allow);
|
|
|
|
// Verify env file was created
|
|
assert!(env_file.exists());
|
|
let content = fs::read_to_string(&env_file).unwrap();
|
|
assert!(content.contains("MY_VAR=hello"));
|
|
assert!(content.contains("ANOTHER_VAR=world"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hook_timeout_works() {
|
|
let dir = tempdir().unwrap();
|
|
let hooks_dir = dir.path().join(".owlen/hooks");
|
|
fs::create_dir_all(&hooks_dir).unwrap();
|
|
|
|
// Create a hook that sleeps longer than the timeout
|
|
let hook_script = r#"#!/bin/bash
|
|
sleep 10
|
|
exit 0
|
|
"#;
|
|
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());
|
|
|
|
let event = HookEvent::PreToolUse {
|
|
tool: "Read".to_string(),
|
|
args: serde_json::json!({"path": "/tmp/test.txt"}),
|
|
};
|
|
|
|
// Should timeout after 1000ms
|
|
let result = manager.execute(&event, Some(1000)).await;
|
|
assert!(result.is_err());
|
|
let err_msg = result.unwrap_err().to_string();
|
|
assert!(err_msg.contains("timeout") || err_msg.contains("timed out"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hook_not_found_is_ok() {
|
|
let dir = tempdir().unwrap();
|
|
let manager = HookManager::new(dir.path().to_str().unwrap());
|
|
|
|
// No hooks directory exists, should just return Allow
|
|
let event = HookEvent::PreToolUse {
|
|
tool: "Read".to_string(),
|
|
args: serde_json::json!({"path": "/tmp/test.txt"}),
|
|
};
|
|
let result = manager.execute(&event, Some(5000)).await.unwrap();
|
|
assert_eq!(result, HookResult::Allow);
|
|
}
|