mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
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:
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user