feat: add lifecycle hooks system

Adds a hooks module that lets users extend goose at key lifecycle points
via shell commands or HTTP endpoints, configured in config.yaml.

Hook events:
  - before_tool_call: fires before tool execution, can block (exit 2)
  - after_tool_call: fires after tool dispatch
  - before_reply: fires when user message arrives
  - after_reply: available via emit_hook() for callers
  - on_session_start / on_session_end: available via emit_hook()

Configuration (in ~/.config/goose/config.yaml):
  hooks:
    before_tool_call:
      - matcher: "bash|write_file"
        hooks:
          - command: "~/.config/goose/hooks/safety-check.sh"
            timeout: 5
    after_tool_call:
      - hooks:
          - url: "http://localhost:9000/hook"

Wire protocol:
  - Shell: JSON on stdin, optional JSON on stdout
  - HTTP: JSON POST body, optional JSON response
  - Exit code 2 = block (Claude Code convention)
  - Supports Claude Code (decision/reason), Hermes (action/message),
    and direct (block/reason) response formats

Design principles:
  - Fail-open: broken hooks log warnings, never crash the agent
  - Matcher filtering: regex against tool name for tool events
  - Short-circuit: first block wins for before_tool_call
  - Zero overhead: has_hooks() fast-path skips when no hooks configured

Signed-off-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
Michael Neale
2026-04-24 22:55:37 -07:00
parent df302d7434
commit 83c0dcfebf
3 changed files with 550 additions and 10 deletions
+105 -10
View File
@@ -4,9 +4,9 @@ use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use anyhow::{Context, Result, anyhow};
use futures::stream::BoxStream;
use futures::{stream, FutureExt, Stream, StreamExt, TryStreamExt};
use futures::{FutureExt, Stream, StreamExt, TryStreamExt, stream};
use tracing_futures::Instrument;
use uuid::Uuid;
@@ -14,33 +14,33 @@ use super::container::Container;
use super::final_output_tool::FinalOutputTool;
use super::platform_tools;
use super::tool_confirmation_router::ToolConfirmationRouter;
use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE};
use super::tool_execution::{CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE, ToolCallResult};
use crate::action_required_manager::ActionRequiredManager;
use crate::agents::extension::{ExtensionConfig, ExtensionResult, ToolInfo};
use crate::agents::extension_manager::{
get_parameter_names, ExtensionManager, ExtensionManagerCapabilities,
ExtensionManager, ExtensionManagerCapabilities, get_parameter_names,
};
use crate::agents::final_output_tool::{FINAL_OUTPUT_CONTINUATION_MESSAGE, FINAL_OUTPUT_TOOL_NAME};
use crate::agents::platform_extensions::summon::discover_filesystem_sources;
use crate::agents::platform_extensions::MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE;
use crate::agents::platform_extensions::summon::discover_filesystem_sources;
use crate::agents::platform_tools::PLATFORM_MANAGE_SCHEDULE_TOOL_NAME;
use crate::agents::prompt_manager::PromptManager;
use crate::agents::retry::{RetryManager, RetryResult};
use crate::agents::types::{FrontendTool, SessionConfig, SharedProvider, ToolResultReceiver};
use crate::config::permission::PermissionManager;
use crate::config::{get_enabled_extensions, Config, GooseMode};
use crate::config::{Config, GooseMode, get_enabled_extensions};
use crate::context_mgmt::{
check_if_compaction_needed, compact_messages, DEFAULT_COMPACTION_THRESHOLD,
DEFAULT_COMPACTION_THRESHOLD, check_if_compaction_needed, compact_messages,
};
use crate::conversation::message::{
ActionRequiredData, Message, MessageContent, ProviderMetadata, SystemNotificationType,
ToolRequest,
};
use crate::conversation::{debug_conversation_fix, fix_conversation, Conversation};
use crate::conversation::{Conversation, debug_conversation_fix, fix_conversation};
use crate::mcp_utils::ToolResult;
use crate::permission::PermissionConfirmation;
use crate::permission::permission_inspector::PermissionInspector;
use crate::permission::permission_judge::PermissionCheckResult;
use crate::permission::PermissionConfirmation;
use crate::providers::base::{PermissionRouting, Provider};
use crate::providers::errors::ProviderError;
use crate::recipe::{Author, Recipe, Response, Settings};
@@ -59,7 +59,7 @@ use rmcp::model::{
ServerNotification, Tool,
};
use serde_json::Value;
use tokio::sync::{mpsc, Mutex};
use tokio::sync::{Mutex, mpsc};
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, instrument, warn};
@@ -153,6 +153,7 @@ pub struct Agent {
pub(super) retry_manager: RetryManager,
pub(super) tool_inspection_manager: ToolInspectionManager,
pub(super) hook_manager: crate::hooks::HookManager,
container: Mutex<Option<Container>>,
}
@@ -251,6 +252,7 @@ impl Agent {
permission_manager,
provider.clone(),
),
hook_manager: crate::hooks::HookManager::from_config(),
container: Mutex::new(None),
}
}
@@ -281,6 +283,27 @@ impl Agent {
tool_inspection_manager
}
/// Emit a lifecycle hook event (for use by CLI/server for session events)
pub async fn emit_hook(
&self,
event: crate::hooks::HookEvent,
session_id: &str,
working_dir: Option<&str>,
) {
if self.hook_manager.has_hooks(event) {
let ctx = crate::hooks::HookContext {
event: event.to_string(),
session_id: session_id.to_string(),
tool_name: None,
tool_input: None,
tool_result: None,
message: None,
working_dir: working_dir.map(|s| s.to_string()),
};
self.hook_manager.emit(event, ctx).await;
}
}
/// Reset the retry attempts counter to 0
pub async fn reset_retry_attempts(&self) {
self.retry_manager.reset_attempts().await;
@@ -568,6 +591,40 @@ impl Agent {
.await
.record_tool_arguments(&tool_call.arguments, &session.working_dir);
// Emit before_tool_call hook
if self
.hook_manager
.has_hooks(crate::hooks::HookEvent::BeforeToolCall)
{
let hook_ctx = crate::hooks::HookContext {
event: "before_tool_call".to_string(),
session_id: session.id.clone(),
tool_name: Some(tool_call.name.to_string()),
tool_input: tool_call.arguments.as_ref().map(|a| serde_json::json!(a)),
tool_result: None,
message: None,
working_dir: Some(session.working_dir.to_string_lossy().to_string()),
};
let decision = self
.hook_manager
.emit(crate::hooks::HookEvent::BeforeToolCall, hook_ctx)
.await;
if decision.block {
let reason = decision
.reason
.unwrap_or_else(|| "Blocked by hook".to_string());
tracing::info!(tool = %tool_call.name, reason = %reason, "Tool call blocked by before_tool_call hook");
return (
request_id,
Err(ErrorData::new(
ErrorCode::INVALID_REQUEST,
format!("Tool call blocked by hook: {}", reason),
None,
)),
);
}
}
if tool_call.name == PLATFORM_MANAGE_SCHEDULE_TOOL_NAME {
let arguments = tool_call
.arguments
@@ -633,6 +690,25 @@ impl Agent {
debug!("WAITING_TOOL_END: {}", tool_call.name);
// Emit after_tool_call hook (fire-and-forget, no blocking)
if self
.hook_manager
.has_hooks(crate::hooks::HookEvent::AfterToolCall)
{
let hook_ctx = crate::hooks::HookContext {
event: "after_tool_call".to_string(),
session_id: session.id.clone(),
tool_name: Some(tool_call.name.to_string()),
tool_input: tool_call.arguments.as_ref().map(|a| serde_json::json!(a)),
tool_result: None,
message: None,
working_dir: Some(session.working_dir.to_string_lossy().to_string()),
};
self.hook_manager
.emit(crate::hooks::HookEvent::AfterToolCall, hook_ctx)
.await;
}
(
request_id,
Ok(ToolCallResult {
@@ -1040,6 +1116,25 @@ impl Agent {
let message_text = user_message.as_concat_text();
// Emit before_reply hook
if self
.hook_manager
.has_hooks(crate::hooks::HookEvent::BeforeReply)
{
let hook_ctx = crate::hooks::HookContext {
event: "before_reply".to_string(),
session_id: session_config.id.clone(),
tool_name: None,
tool_input: None,
tool_result: None,
message: Some(message_text.clone()),
working_dir: None,
};
self.hook_manager
.emit(crate::hooks::HookEvent::BeforeReply, hook_ctx)
.await;
}
// Track custom slash command usage (don't track command name for privacy)
if message_text.trim().starts_with('/') {
let command = message_text.split_whitespace().next();
+444
View File
@@ -0,0 +1,444 @@
use anyhow::Result;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use crate::config::Config;
/// Lifecycle hook events that can be intercepted
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookEvent {
BeforeToolCall,
AfterToolCall,
OnSessionStart,
OnSessionEnd,
BeforeReply,
AfterReply,
}
impl std::fmt::Display for HookEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HookEvent::BeforeToolCall => write!(f, "before_tool_call"),
HookEvent::AfterToolCall => write!(f, "after_tool_call"),
HookEvent::OnSessionStart => write!(f, "on_session_start"),
HookEvent::OnSessionEnd => write!(f, "on_session_end"),
HookEvent::BeforeReply => write!(f, "before_reply"),
HookEvent::AfterReply => write!(f, "after_reply"),
}
}
}
/// A single hook handler (command or HTTP)
#[derive(Debug, Clone, Deserialize)]
pub struct HookHandler {
/// Shell command to execute (receives JSON on stdin)
pub command: Option<String>,
/// HTTP URL to POST to (receives JSON body)
pub url: Option<String>,
/// Timeout in seconds (default: 10)
pub timeout: Option<u64>,
}
/// A hook entry that pairs a matcher with one or more handlers
#[derive(Debug, Clone, Deserialize)]
pub struct HookEntry {
/// Regex pattern to match tool names (only for tool events, optional)
pub matcher: Option<String>,
/// Handlers to execute when this hook fires
pub hooks: Vec<HookHandler>,
}
/// Context passed to hooks as JSON on stdin / HTTP body
#[derive(Debug, Clone, Serialize)]
pub struct HookContext {
pub event: String,
pub session_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_input: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
}
/// Decision returned by a hook
#[derive(Debug, Clone, Default)]
pub struct HookDecision {
/// Block the action (for before_tool_call: deny the tool call)
pub block: bool,
/// Reason for blocking
pub reason: Option<String>,
}
/// The hooks manager - loads config and dispatches events
pub struct HookManager {
hooks: HashMap<HookEvent, Vec<HookEntry>>,
}
impl HookManager {
pub fn new() -> Self {
Self {
hooks: HashMap::new(),
}
}
/// Load hooks from goose config.yaml
pub fn from_config() -> Self {
let mut manager = Self::new();
if let Err(e) = manager.load_from_config() {
tracing::warn!(error = %e, "Failed to load hooks from config");
}
manager
}
fn load_from_config(&mut self) -> Result<()> {
let config = Config::global();
let hooks_value: Value = match config.get_param("hooks") {
Ok(v) => v,
Err(_) => return Ok(()), // No hooks configured
};
let hooks_map: HashMap<HookEvent, Vec<HookEntry>> = serde_json::from_value(hooks_value)?;
self.hooks = hooks_map;
let total: usize = self.hooks.values().map(|v| v.len()).sum();
if total > 0 {
tracing::info!(
hook_count = total,
events = ?self.hooks.keys().collect::<Vec<_>>(),
"Loaded lifecycle hooks"
);
}
Ok(())
}
/// Check if any hooks are registered for an event
pub fn has_hooks(&self, event: HookEvent) -> bool {
self.hooks
.get(&event)
.is_some_and(|entries| !entries.is_empty())
}
/// Emit a hook event and collect decisions
pub async fn emit(&self, event: HookEvent, ctx: HookContext) -> HookDecision {
let entries = match self.hooks.get(&event) {
Some(entries) => entries,
None => return HookDecision::default(),
};
let ctx_json = match serde_json::to_string(&ctx) {
Ok(j) => j,
Err(e) => {
tracing::warn!(error = %e, "Failed to serialize hook context");
return HookDecision::default();
}
};
let mut decision = HookDecision::default();
for entry in entries {
// Check matcher against tool name
if let Some(ref matcher) = entry.matcher {
if let Some(ref tool_name) = ctx.tool_name {
match Regex::new(matcher) {
Ok(re) => {
if !re.is_match(tool_name) {
continue;
}
}
Err(e) => {
tracing::warn!(
matcher = matcher,
error = %e,
"Invalid hook matcher regex, skipping"
);
continue;
}
}
}
}
for handler in &entry.hooks {
let timeout_secs = handler.timeout.unwrap_or(10);
if let Some(ref cmd) = handler.command {
match execute_command_hook(cmd, &ctx_json, timeout_secs).await {
Ok(Some(d)) => {
if d.block {
decision = d;
return decision; // Short-circuit on first block
}
}
Ok(None) => {}
Err(e) => {
tracing::warn!(
command = cmd,
event = %event,
error = %e,
"Hook command failed"
);
}
}
}
if let Some(ref url) = handler.url {
match execute_http_hook(url, &ctx_json, timeout_secs).await {
Ok(Some(d)) => {
if d.block {
decision = d;
return decision;
}
}
Ok(None) => {}
Err(e) => {
tracing::warn!(
url = url,
event = %event,
error = %e,
"Hook HTTP request failed"
);
}
}
}
}
}
decision
}
}
impl Default for HookManager {
fn default() -> Self {
Self::new()
}
}
/// Execute a shell command hook. Returns a decision if the command produces JSON output.
/// Exit code 2 = block (Claude Code convention).
async fn execute_command_hook(
cmd: &str,
ctx_json: &str,
timeout_secs: u64,
) -> Result<Option<HookDecision>> {
let expanded = shellexpand::tilde(cmd);
let parts: Vec<&str> = expanded.split_whitespace().collect();
if parts.is_empty() {
return Ok(None);
}
let mut child = Command::new(parts[0])
.args(&parts[1..])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(ctx_json.as_bytes()).await;
let _ = stdin.shutdown().await;
}
let output = tokio::time::timeout(Duration::from_secs(timeout_secs), child.wait_with_output())
.await
.map_err(|_| anyhow::anyhow!("Hook command timed out after {}s", timeout_secs))??;
// Exit code 2 = block (Claude Code convention)
if output.status.code() == Some(2) {
let reason = String::from_utf8_lossy(&output.stdout).trim().to_string();
return Ok(Some(HookDecision {
block: true,
reason: if reason.is_empty() {
None
} else {
Some(reason)
},
}));
}
// Try to parse stdout as JSON decision
if !output.stdout.is_empty() {
if let Ok(json) = serde_json::from_slice::<Value>(&output.stdout) {
return Ok(Some(parse_decision(&json)));
}
}
Ok(None)
}
/// Execute an HTTP POST hook
async fn execute_http_hook(
url: &str,
ctx_json: &str,
timeout_secs: u64,
) -> Result<Option<HookDecision>> {
let client = reqwest::Client::new();
let resp = client
.post(url)
.header("Content-Type", "application/json")
.body(ctx_json.to_string())
.timeout(Duration::from_secs(timeout_secs))
.send()
.await?;
if resp.status().is_success() {
let body = resp.text().await?;
if !body.is_empty() {
if let Ok(json) = serde_json::from_str::<Value>(&body) {
return Ok(Some(parse_decision(&json)));
}
}
}
Ok(None)
}
/// Parse a JSON response into a HookDecision.
/// Supports both Claude Code style (decision/reason) and Hermes style (action/message).
fn parse_decision(json: &Value) -> HookDecision {
// Claude Code style: {"decision": "block", "reason": "..."}
if let Some(decision) = json.get("decision").and_then(|v| v.as_str()) {
if decision == "block" || decision == "deny" {
return HookDecision {
block: true,
reason: json
.get("reason")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
};
}
}
// Hermes style: {"action": "block", "message": "..."}
if let Some(action) = json.get("action").and_then(|v| v.as_str()) {
if action == "block" || action == "deny" {
return HookDecision {
block: true,
reason: json
.get("message")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
};
}
}
// Direct: {"block": true, "reason": "..."}
if json.get("block").and_then(|v| v.as_bool()).unwrap_or(false) {
return HookDecision {
block: true,
reason: json
.get("reason")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
};
}
HookDecision::default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_decision_claude_style() {
let json: Value =
serde_json::from_str(r#"{"decision": "block", "reason": "dangerous"}"#).unwrap();
let d = parse_decision(&json);
assert!(d.block);
assert_eq!(d.reason.as_deref(), Some("dangerous"));
}
#[test]
fn test_parse_decision_hermes_style() {
let json: Value =
serde_json::from_str(r#"{"action": "deny", "message": "not allowed"}"#).unwrap();
let d = parse_decision(&json);
assert!(d.block);
assert_eq!(d.reason.as_deref(), Some("not allowed"));
}
#[test]
fn test_parse_decision_allow() {
let json: Value = serde_json::from_str(r#"{"decision": "allow"}"#).unwrap();
let d = parse_decision(&json);
assert!(!d.block);
}
#[test]
fn test_parse_decision_direct() {
let json: Value = serde_json::from_str(r#"{"block": true, "reason": "nope"}"#).unwrap();
let d = parse_decision(&json);
assert!(d.block);
assert_eq!(d.reason.as_deref(), Some("nope"));
}
#[test]
fn test_hook_event_display() {
assert_eq!(HookEvent::BeforeToolCall.to_string(), "before_tool_call");
assert_eq!(HookEvent::AfterToolCall.to_string(), "after_tool_call");
assert_eq!(HookEvent::OnSessionStart.to_string(), "on_session_start");
}
#[test]
fn test_hook_manager_empty() {
let manager = HookManager::new();
assert!(!manager.has_hooks(HookEvent::BeforeToolCall));
}
#[tokio::test]
async fn test_emit_no_hooks() {
let manager = HookManager::new();
let ctx = HookContext {
event: "before_tool_call".to_string(),
session_id: "test".to_string(),
tool_name: Some("bash".to_string()),
tool_input: None,
tool_result: None,
message: None,
working_dir: None,
};
let decision = manager.emit(HookEvent::BeforeToolCall, ctx).await;
assert!(!decision.block);
}
#[test]
fn test_deserialize_hook_config() {
let yaml = r#"
before_tool_call:
- matcher: "bash|write_file"
hooks:
- command: "~/.config/goose/hooks/check-safety.sh"
timeout: 5
after_tool_call:
- hooks:
- url: "http://localhost:9000/hook"
"#;
let hooks: HashMap<HookEvent, Vec<HookEntry>> = serde_yaml::from_str(yaml).unwrap();
assert_eq!(hooks.len(), 2);
assert_eq!(hooks[&HookEvent::BeforeToolCall].len(), 1);
assert_eq!(
hooks[&HookEvent::BeforeToolCall][0].matcher.as_deref(),
Some("bash|write_file")
);
assert_eq!(hooks[&HookEvent::BeforeToolCall][0].hooks.len(), 1);
assert_eq!(
hooks[&HookEvent::BeforeToolCall][0].hooks[0]
.command
.as_deref(),
Some("~/.config/goose/hooks/check-safety.sh")
);
}
}
+1
View File
@@ -19,6 +19,7 @@ pub mod execution;
pub mod gateway;
pub mod goose_apps;
pub mod hints;
pub mod hooks;
pub mod instance_id;
pub mod logging;
pub mod mcp_utils;