Skip to main content

arcan_core/
hooks.rs

1//! User-configurable hook system for the Arcan agent runtime.
2//!
3//! Hooks are shell commands that fire on specific agent lifecycle events.
4//! They enable users to extend Arcan with custom automation — conversation
5//! logging, safety gates, webhook notifications, and more — without modifying
6//! the runtime itself.
7//!
8//! Modeled after Claude Code's 20-event hook system, providing feature parity
9//! for the Arcan runtime.
10
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::io::Read as _;
14use std::process::Command;
15use std::time::Duration;
16use wait_timeout::ChildExt;
17
18/// Events that can trigger hooks.
19#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum HookEvent {
22    /// Fires when a new session begins.
23    SessionStart,
24    /// Fires when a session ends (REPL exit, daemon shutdown).
25    SessionEnd,
26    /// Fires before a tool is executed. Blocking hooks can deny the operation.
27    PreToolUse,
28    /// Fires after a tool executes successfully.
29    PostToolUse,
30    /// Fires after a tool execution fails.
31    PostToolUseFailure,
32    /// Fires at the start of an agent loop run.
33    RunStart,
34    /// Fires when an agent loop run completes.
35    RunEnd,
36    /// Fires before context compaction.
37    PreCompact,
38    /// Fires after context compaction.
39    PostCompact,
40    /// Fires when the user submits a prompt.
41    UserPromptSubmit,
42    /// Fires when configuration changes.
43    ConfigChange,
44}
45
46impl std::fmt::Display for HookEvent {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            Self::SessionStart => write!(f, "session_start"),
50            Self::SessionEnd => write!(f, "session_end"),
51            Self::PreToolUse => write!(f, "pre_tool_use"),
52            Self::PostToolUse => write!(f, "post_tool_use"),
53            Self::PostToolUseFailure => write!(f, "post_tool_use_failure"),
54            Self::RunStart => write!(f, "run_start"),
55            Self::RunEnd => write!(f, "run_end"),
56            Self::PreCompact => write!(f, "pre_compact"),
57            Self::PostCompact => write!(f, "post_compact"),
58            Self::UserPromptSubmit => write!(f, "user_prompt_submit"),
59            Self::ConfigChange => write!(f, "config_change"),
60        }
61    }
62}
63
64/// A configured hook — a shell command to run on a specific event.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct HookConfig {
67    /// The event that triggers this hook.
68    pub event: HookEvent,
69    /// Optional matcher — only fire for specific tools (e.g., "bash", "file_edit").
70    /// Only relevant for tool-related events (PreToolUse, PostToolUse, PostToolUseFailure).
71    pub matcher: Option<String>,
72    /// Shell command to execute. Supports `{tool_name}`, `{session_id}`, `{workspace}` placeholders.
73    pub command: String,
74    /// Timeout in seconds (default 10).
75    #[serde(default = "default_timeout")]
76    pub timeout_secs: u32,
77    /// If true, a non-zero exit code blocks the operation (for Pre* events).
78    #[serde(default)]
79    pub blocking: bool,
80}
81
82fn default_timeout() -> u32 {
83    10
84}
85
86/// Context passed to hook execution.
87#[derive(Debug, Clone, Default)]
88pub struct HookContext {
89    /// Current session identifier.
90    pub session_id: String,
91    /// Name of the tool being invoked (for tool-related events).
92    pub tool_name: Option<String>,
93    /// Input passed to the tool (for tool-related events).
94    pub tool_input: Option<serde_json::Value>,
95    /// Workspace root path.
96    pub workspace: String,
97}
98
99/// Result of a single hook execution.
100#[derive(Debug)]
101pub struct HookResult {
102    /// Process exit code (or -1 if the process could not be started).
103    pub exit_code: i32,
104    /// Captured stdout.
105    pub stdout: String,
106    /// Captured stderr.
107    pub stderr: String,
108    /// Whether the hook was killed due to timeout.
109    pub timed_out: bool,
110}
111
112/// Error returned when a blocking hook denies an operation.
113#[derive(Debug, thiserror::Error)]
114#[error("hook blocked operation: {message}")]
115pub struct HookDenied {
116    /// The stderr or summary from the blocking hook.
117    pub message: String,
118}
119
120/// Registry that holds configured hooks and fires them.
121#[derive(Debug, Clone)]
122pub struct HookRegistry {
123    hooks: Vec<HookConfig>,
124}
125
126impl HookRegistry {
127    /// Create an empty registry with no hooks.
128    pub fn new() -> Self {
129        Self { hooks: Vec::new() }
130    }
131
132    /// Create a registry from a list of hook configurations.
133    pub fn from_configs(configs: Vec<HookConfig>) -> Self {
134        Self { hooks: configs }
135    }
136
137    /// Return the number of configured hooks.
138    pub fn len(&self) -> usize {
139        self.hooks.len()
140    }
141
142    /// Return whether the registry has no hooks.
143    pub fn is_empty(&self) -> bool {
144        self.hooks.is_empty()
145    }
146
147    /// Fire all hooks matching the event and context. Returns results in order.
148    ///
149    /// For each matching hook: expand placeholders, execute the shell command,
150    /// and collect the result. Hooks run sequentially (synchronous execution).
151    pub fn fire(&self, event: &HookEvent, ctx: &HookContext) -> Vec<HookResult> {
152        self.matching_hooks(event, ctx)
153            .into_iter()
154            .map(|hook| execute_hook(hook, ctx))
155            .collect()
156    }
157
158    /// Check if any blocking hook denies the operation.
159    ///
160    /// Fires all matching hooks for the event. If any hook with `blocking = true`
161    /// returns a non-zero exit code, returns `Err(HookDenied)` with the stderr.
162    pub fn check_blocking(&self, event: &HookEvent, ctx: &HookContext) -> Result<(), HookDenied> {
163        for hook in self.matching_hooks(event, ctx) {
164            if !hook.blocking {
165                continue;
166            }
167            let result = execute_hook(hook, ctx);
168            if result.exit_code != 0 {
169                let message = if result.timed_out {
170                    format!(
171                        "hook timed out after {}s: {}",
172                        hook.timeout_secs, hook.command
173                    )
174                } else if result.stderr.is_empty() {
175                    format!(
176                        "hook exited with code {}: {}",
177                        result.exit_code, hook.command
178                    )
179                } else {
180                    result.stderr.trim().to_string()
181                };
182                return Err(HookDenied { message });
183            }
184        }
185        Ok(())
186    }
187
188    /// Return hooks that match the given event and context.
189    fn matching_hooks<'a>(&'a self, event: &HookEvent, ctx: &HookContext) -> Vec<&'a HookConfig> {
190        self.hooks
191            .iter()
192            .filter(|hook| {
193                if &hook.event != event {
194                    return false;
195                }
196                // If the hook has a matcher, check it against the tool name
197                if let Some(ref matcher) = hook.matcher {
198                    if let Some(ref tool_name) = ctx.tool_name {
199                        tool_name == matcher
200                    } else {
201                        // Hook has a matcher but context has no tool name — skip
202                        false
203                    }
204                } else {
205                    true
206                }
207            })
208            .collect()
209    }
210}
211
212impl Default for HookRegistry {
213    fn default() -> Self {
214        Self::new()
215    }
216}
217
218/// Expand placeholders in a command string.
219fn expand_placeholders(command: &str, ctx: &HookContext) -> String {
220    let mut expanded = command.to_string();
221    expanded = expanded.replace("{session_id}", &ctx.session_id);
222    expanded = expanded.replace("{workspace}", &ctx.workspace);
223    if let Some(ref tool_name) = ctx.tool_name {
224        expanded = expanded.replace("{tool_name}", tool_name);
225    } else {
226        expanded = expanded.replace("{tool_name}", "");
227    }
228    if let Some(ref tool_input) = ctx.tool_input {
229        expanded = expanded.replace("{tool_input}", &tool_input.to_string());
230    } else {
231        expanded = expanded.replace("{tool_input}", "");
232    }
233    expanded
234}
235
236/// Execute a single hook command and return the result.
237fn execute_hook(hook: &HookConfig, ctx: &HookContext) -> HookResult {
238    let command = expand_placeholders(&hook.command, ctx);
239    let timeout = Duration::from_secs(u64::from(hook.timeout_secs));
240
241    // Set up environment variables for the hook
242    let mut env: HashMap<String, String> = std::env::vars().collect();
243    env.insert("ARCAN_SESSION_ID".to_string(), ctx.session_id.clone());
244    env.insert("ARCAN_WORKSPACE".to_string(), ctx.workspace.clone());
245    env.insert("ARCAN_HOOK_EVENT".to_string(), hook.event.to_string());
246    if let Some(ref tool_name) = ctx.tool_name {
247        env.insert("ARCAN_TOOL_NAME".to_string(), tool_name.clone());
248    }
249    if let Some(ref tool_input) = ctx.tool_input {
250        env.insert("ARCAN_TOOL_INPUT".to_string(), tool_input.to_string());
251    }
252
253    let child_result = Command::new("sh")
254        .arg("-c")
255        .arg(&command)
256        .envs(&env)
257        .stdout(std::process::Stdio::piped())
258        .stderr(std::process::Stdio::piped())
259        .spawn();
260
261    let mut child = match child_result {
262        Ok(c) => c,
263        Err(e) => {
264            return HookResult {
265                exit_code: -1,
266                stdout: String::new(),
267                stderr: format!("failed to spawn hook: {e}"),
268                timed_out: false,
269            };
270        }
271    };
272
273    // Wait with timeout
274    match child.wait_timeout(timeout) {
275        Ok(Some(status)) => {
276            let mut stdout = String::new();
277            let mut stderr = String::new();
278            if let Some(ref mut out) = child.stdout {
279                let _ = out.read_to_string(&mut stdout);
280            }
281            if let Some(ref mut err) = child.stderr {
282                let _ = err.read_to_string(&mut stderr);
283            }
284            HookResult {
285                exit_code: status.code().unwrap_or(-1),
286                stdout,
287                stderr,
288                timed_out: false,
289            }
290        }
291        Ok(None) => {
292            // Timed out — kill the child
293            let _ = child.kill();
294            let _ = child.wait();
295            let mut stdout = String::new();
296            let mut stderr = String::new();
297            if let Some(ref mut out) = child.stdout {
298                let _ = out.read_to_string(&mut stdout);
299            }
300            if let Some(ref mut err) = child.stderr {
301                let _ = err.read_to_string(&mut stderr);
302            }
303            HookResult {
304                exit_code: -1,
305                stdout,
306                stderr,
307                timed_out: true,
308            }
309        }
310        Err(e) => HookResult {
311            exit_code: -1,
312            stdout: String::new(),
313            stderr: format!("failed to wait on hook: {e}"),
314            timed_out: false,
315        },
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    fn make_ctx() -> HookContext {
324        HookContext {
325            session_id: "test-session-42".to_string(),
326            tool_name: Some("bash".to_string()),
327            tool_input: Some(serde_json::json!({"command": "ls"})),
328            workspace: "/tmp/test-workspace".to_string(),
329        }
330    }
331
332    #[test]
333    fn test_fire_matching_hooks() {
334        let registry = HookRegistry::from_configs(vec![
335            HookConfig {
336                event: HookEvent::SessionStart,
337                matcher: None,
338                command: "echo session_start".to_string(),
339                timeout_secs: 5,
340                blocking: false,
341            },
342            HookConfig {
343                event: HookEvent::RunEnd,
344                matcher: None,
345                command: "echo run_end".to_string(),
346                timeout_secs: 5,
347                blocking: false,
348            },
349            HookConfig {
350                event: HookEvent::SessionStart,
351                matcher: None,
352                command: "echo session_start_2".to_string(),
353                timeout_secs: 5,
354                blocking: false,
355            },
356        ]);
357
358        let ctx = make_ctx();
359
360        // SessionStart should fire 2 hooks
361        let results = registry.fire(&HookEvent::SessionStart, &ctx);
362        assert_eq!(results.len(), 2);
363        assert!(results[0].stdout.contains("session_start"));
364        assert!(results[1].stdout.contains("session_start_2"));
365
366        // RunEnd should fire 1 hook
367        let results = registry.fire(&HookEvent::RunEnd, &ctx);
368        assert_eq!(results.len(), 1);
369        assert!(results[0].stdout.contains("run_end"));
370
371        // PreToolUse should fire 0 hooks
372        let results = registry.fire(&HookEvent::PreToolUse, &ctx);
373        assert_eq!(results.len(), 0);
374    }
375
376    #[test]
377    fn test_matcher_filters() {
378        let registry = HookRegistry::from_configs(vec![
379            HookConfig {
380                event: HookEvent::PreToolUse,
381                matcher: Some("bash".to_string()),
382                command: "echo matched_bash".to_string(),
383                timeout_secs: 5,
384                blocking: false,
385            },
386            HookConfig {
387                event: HookEvent::PreToolUse,
388                matcher: Some("file_edit".to_string()),
389                command: "echo matched_file_edit".to_string(),
390                timeout_secs: 5,
391                blocking: false,
392            },
393            HookConfig {
394                event: HookEvent::PreToolUse,
395                matcher: None,
396                command: "echo matched_all".to_string(),
397                timeout_secs: 5,
398                blocking: false,
399            },
400        ]);
401
402        // With tool_name = "bash", should match "bash" matcher + no-matcher
403        let ctx = HookContext {
404            tool_name: Some("bash".to_string()),
405            ..make_ctx()
406        };
407        let results = registry.fire(&HookEvent::PreToolUse, &ctx);
408        assert_eq!(results.len(), 2);
409        assert!(results[0].stdout.contains("matched_bash"));
410        assert!(results[1].stdout.contains("matched_all"));
411
412        // With tool_name = "file_edit", should match "file_edit" matcher + no-matcher
413        let ctx = HookContext {
414            tool_name: Some("file_edit".to_string()),
415            ..make_ctx()
416        };
417        let results = registry.fire(&HookEvent::PreToolUse, &ctx);
418        assert_eq!(results.len(), 2);
419        assert!(results[0].stdout.contains("matched_file_edit"));
420        assert!(results[1].stdout.contains("matched_all"));
421
422        // With no tool_name, should only match no-matcher
423        let ctx = HookContext {
424            tool_name: None,
425            ..make_ctx()
426        };
427        let results = registry.fire(&HookEvent::PreToolUse, &ctx);
428        assert_eq!(results.len(), 1);
429        assert!(results[0].stdout.contains("matched_all"));
430    }
431
432    #[test]
433    fn test_blocking_hook_denies() {
434        let registry = HookRegistry::from_configs(vec![
435            HookConfig {
436                event: HookEvent::PreToolUse,
437                matcher: None,
438                command: "echo 'allowed' && exit 0".to_string(),
439                timeout_secs: 5,
440                blocking: true,
441            },
442            HookConfig {
443                event: HookEvent::PreToolUse,
444                matcher: None,
445                command: "echo 'denied' >&2 && exit 1".to_string(),
446                timeout_secs: 5,
447                blocking: true,
448            },
449        ]);
450
451        let ctx = make_ctx();
452        let result = registry.check_blocking(&HookEvent::PreToolUse, &ctx);
453        assert!(result.is_err());
454        let err = result.unwrap_err();
455        assert!(
456            err.message.contains("denied"),
457            "expected 'denied' in error: {}",
458            err.message
459        );
460    }
461
462    #[test]
463    fn test_blocking_hook_allows() {
464        let registry = HookRegistry::from_configs(vec![HookConfig {
465            event: HookEvent::PreToolUse,
466            matcher: None,
467            command: "exit 0".to_string(),
468            timeout_secs: 5,
469            blocking: true,
470        }]);
471
472        let ctx = make_ctx();
473        let result = registry.check_blocking(&HookEvent::PreToolUse, &ctx);
474        assert!(result.is_ok());
475    }
476
477    #[test]
478    fn test_non_blocking_hooks_ignored_in_check() {
479        // Non-blocking hooks with non-zero exit should NOT cause denial
480        let registry = HookRegistry::from_configs(vec![HookConfig {
481            event: HookEvent::PreToolUse,
482            matcher: None,
483            command: "exit 1".to_string(),
484            timeout_secs: 5,
485            blocking: false, // not blocking
486        }]);
487
488        let ctx = make_ctx();
489        let result = registry.check_blocking(&HookEvent::PreToolUse, &ctx);
490        assert!(result.is_ok());
491    }
492
493    #[test]
494    fn test_timeout_handling() {
495        let registry = HookRegistry::from_configs(vec![HookConfig {
496            event: HookEvent::RunEnd,
497            matcher: None,
498            command: "sleep 30".to_string(),
499            timeout_secs: 1, // 1 second timeout
500            blocking: false,
501        }]);
502
503        let ctx = make_ctx();
504        let results = registry.fire(&HookEvent::RunEnd, &ctx);
505        assert_eq!(results.len(), 1);
506        assert!(results[0].timed_out);
507        assert_eq!(results[0].exit_code, -1);
508    }
509
510    #[test]
511    fn test_placeholder_expansion() {
512        let registry = HookRegistry::from_configs(vec![HookConfig {
513            event: HookEvent::PreToolUse,
514            matcher: None,
515            command: "echo 'tool={tool_name} session={session_id} ws={workspace}'".to_string(),
516            timeout_secs: 5,
517            blocking: false,
518        }]);
519
520        let ctx = make_ctx();
521        let results = registry.fire(&HookEvent::PreToolUse, &ctx);
522        assert_eq!(results.len(), 1);
523        assert_eq!(results[0].exit_code, 0);
524        let stdout = &results[0].stdout;
525        assert!(
526            stdout.contains("tool=bash"),
527            "expected tool=bash in stdout: {stdout}"
528        );
529        assert!(
530            stdout.contains("session=test-session-42"),
531            "expected session=test-session-42 in stdout: {stdout}"
532        );
533        assert!(
534            stdout.contains("ws=/tmp/test-workspace"),
535            "expected ws=/tmp/test-workspace in stdout: {stdout}"
536        );
537    }
538
539    #[test]
540    fn test_environment_variables_set() {
541        let registry = HookRegistry::from_configs(vec![HookConfig {
542            event: HookEvent::PreToolUse,
543            matcher: None,
544            command:
545                "echo \"$ARCAN_SESSION_ID|$ARCAN_WORKSPACE|$ARCAN_TOOL_NAME|$ARCAN_HOOK_EVENT\""
546                    .to_string(),
547            timeout_secs: 5,
548            blocking: false,
549        }]);
550
551        let ctx = make_ctx();
552        let results = registry.fire(&HookEvent::PreToolUse, &ctx);
553        assert_eq!(results.len(), 1);
554        let stdout = results[0].stdout.trim();
555        assert_eq!(
556            stdout,
557            "test-session-42|/tmp/test-workspace|bash|pre_tool_use"
558        );
559    }
560
561    #[test]
562    fn test_empty_registry() {
563        let registry = HookRegistry::new();
564        assert!(registry.is_empty());
565        assert_eq!(registry.len(), 0);
566
567        let ctx = make_ctx();
568        let results = registry.fire(&HookEvent::SessionStart, &ctx);
569        assert!(results.is_empty());
570
571        // check_blocking on empty registry should always succeed
572        assert!(
573            registry
574                .check_blocking(&HookEvent::PreToolUse, &ctx)
575                .is_ok()
576        );
577    }
578
579    #[test]
580    fn test_hook_event_serde_roundtrip() {
581        let events = vec![
582            HookEvent::SessionStart,
583            HookEvent::SessionEnd,
584            HookEvent::PreToolUse,
585            HookEvent::PostToolUse,
586            HookEvent::PostToolUseFailure,
587            HookEvent::RunStart,
588            HookEvent::RunEnd,
589            HookEvent::PreCompact,
590            HookEvent::PostCompact,
591            HookEvent::UserPromptSubmit,
592            HookEvent::ConfigChange,
593        ];
594
595        for event in events {
596            let json = serde_json::to_string(&event).unwrap();
597            let deserialized: HookEvent = serde_json::from_str(&json).unwrap();
598            assert_eq!(event, deserialized);
599        }
600    }
601
602    #[test]
603    fn test_hook_config_serde_defaults() {
604        let json = r#"{"event":"run_end","command":"echo done"}"#;
605        let config: HookConfig = serde_json::from_str(json).unwrap();
606        assert_eq!(config.event, HookEvent::RunEnd);
607        assert_eq!(config.command, "echo done");
608        assert_eq!(config.timeout_secs, 10); // default
609        assert!(!config.blocking); // default
610        assert!(config.matcher.is_none());
611    }
612}