Skip to main content

harn_vm/orchestration/
hooks.rs

1//! Runtime lifecycle hooks — tool, agent-turn, and worker interception.
2
3use std::cell::RefCell;
4use std::rc::Rc;
5
6use regex::Regex;
7use serde::{Deserialize, Serialize};
8
9use crate::agent_events::WorkerEvent;
10use crate::value::{VmClosure, VmError, VmValue};
11
12/// Manifest / runtime hook event names.
13#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
14pub enum HookEvent {
15    #[serde(rename = "PreToolUse")]
16    PreToolUse,
17    #[serde(rename = "PostToolUse")]
18    PostToolUse,
19    #[serde(rename = "PreAgentTurn")]
20    PreAgentTurn,
21    #[serde(rename = "PostAgentTurn")]
22    PostAgentTurn,
23    #[serde(rename = "WorkerSpawned")]
24    WorkerSpawned,
25    #[serde(rename = "WorkerProgressed")]
26    WorkerProgressed,
27    #[serde(rename = "WorkerWaitingForInput")]
28    WorkerWaitingForInput,
29    #[serde(rename = "WorkerCompleted")]
30    WorkerCompleted,
31    #[serde(rename = "WorkerFailed")]
32    WorkerFailed,
33    #[serde(rename = "WorkerCancelled")]
34    WorkerCancelled,
35    #[serde(rename = "PreStep")]
36    PreStep,
37    #[serde(rename = "PostStep")]
38    PostStep,
39    #[serde(rename = "OnBudgetThreshold")]
40    OnBudgetThreshold,
41    #[serde(rename = "OnApprovalRequested")]
42    OnApprovalRequested,
43    #[serde(rename = "OnHandoffEmitted")]
44    OnHandoffEmitted,
45    #[serde(rename = "OnPersonaPaused")]
46    OnPersonaPaused,
47    #[serde(rename = "OnPersonaResumed")]
48    OnPersonaResumed,
49    #[serde(rename = "SessionStart")]
50    SessionStart,
51    #[serde(rename = "SessionEnd")]
52    SessionEnd,
53    #[serde(rename = "UserPromptSubmit")]
54    UserPromptSubmit,
55    #[serde(rename = "PreCompact")]
56    PreCompact,
57    #[serde(rename = "PostCompact")]
58    PostCompact,
59    #[serde(rename = "PostTurn")]
60    PostTurn,
61    #[serde(rename = "PermissionAsked")]
62    PermissionAsked,
63    #[serde(rename = "PermissionReplied")]
64    PermissionReplied,
65    #[serde(rename = "FileEdited")]
66    FileEdited,
67    #[serde(rename = "SessionError")]
68    SessionError,
69    #[serde(rename = "SessionIdle")]
70    SessionIdle,
71}
72
73impl HookEvent {
74    pub fn as_str(self) -> &'static str {
75        match self {
76            Self::PreToolUse => "PreToolUse",
77            Self::PostToolUse => "PostToolUse",
78            Self::PreAgentTurn => "PreAgentTurn",
79            Self::PostAgentTurn => "PostAgentTurn",
80            Self::WorkerSpawned => "WorkerSpawned",
81            Self::WorkerProgressed => "WorkerProgressed",
82            Self::WorkerWaitingForInput => "WorkerWaitingForInput",
83            Self::WorkerCompleted => "WorkerCompleted",
84            Self::WorkerFailed => "WorkerFailed",
85            Self::WorkerCancelled => "WorkerCancelled",
86            Self::PreStep => "PreStep",
87            Self::PostStep => "PostStep",
88            Self::OnBudgetThreshold => "OnBudgetThreshold",
89            Self::OnApprovalRequested => "OnApprovalRequested",
90            Self::OnHandoffEmitted => "OnHandoffEmitted",
91            Self::OnPersonaPaused => "OnPersonaPaused",
92            Self::OnPersonaResumed => "OnPersonaResumed",
93            Self::SessionStart => "SessionStart",
94            Self::SessionEnd => "SessionEnd",
95            Self::UserPromptSubmit => "UserPromptSubmit",
96            Self::PreCompact => "PreCompact",
97            Self::PostCompact => "PostCompact",
98            Self::PostTurn => "PostTurn",
99            Self::PermissionAsked => "PermissionAsked",
100            Self::PermissionReplied => "PermissionReplied",
101            Self::FileEdited => "FileEdited",
102            Self::SessionError => "SessionError",
103            Self::SessionIdle => "SessionIdle",
104        }
105    }
106
107    /// Parse a session-level hook event name. Returns `Err` for unknown
108    /// or non-session events; persona/tool events are intentionally
109    /// rejected so each registration surface owns its own event set.
110    pub fn parse_session_event(name: &str) -> Result<Self, String> {
111        match name.trim() {
112            "SessionStart" | "session_start" => Ok(Self::SessionStart),
113            "SessionEnd" | "session_end" => Ok(Self::SessionEnd),
114            "UserPromptSubmit" | "user_prompt_submit" => Ok(Self::UserPromptSubmit),
115            "PreCompact" | "pre_compact" => Ok(Self::PreCompact),
116            "PostCompact" | "post_compact" => Ok(Self::PostCompact),
117            "PostTurn" | "post_turn" => Ok(Self::PostTurn),
118            "PermissionAsked" | "permission_asked" => Ok(Self::PermissionAsked),
119            "PermissionReplied" | "permission_replied" => Ok(Self::PermissionReplied),
120            "FileEdited" | "file_edited" => Ok(Self::FileEdited),
121            "SessionError" | "session_error" | "error" => Ok(Self::SessionError),
122            "SessionIdle" | "session_idle" => Ok(Self::SessionIdle),
123            other => Err(format!("unknown session hook event `{other}`")),
124        }
125    }
126
127    pub fn from_worker_event(event: WorkerEvent) -> Self {
128        match event {
129            WorkerEvent::WorkerSpawned => Self::WorkerSpawned,
130            WorkerEvent::WorkerProgressed => Self::WorkerProgressed,
131            WorkerEvent::WorkerWaitingForInput => Self::WorkerWaitingForInput,
132            WorkerEvent::WorkerCompleted => Self::WorkerCompleted,
133            WorkerEvent::WorkerFailed => Self::WorkerFailed,
134            WorkerEvent::WorkerCancelled => Self::WorkerCancelled,
135        }
136    }
137}
138
139/// Control flow returned by a session-level lifecycle hook.
140///
141/// Most session events are advisory (`Allow`). The two veto-capable
142/// events — `UserPromptSubmit` and `PreCompact` — accept `Block`.
143/// `PermissionAsked` additionally accepts a `Decision` short-circuit so
144/// hooks can override the dynamic permission policy entirely.
145#[derive(Clone, Debug)]
146pub enum HookControl {
147    Allow,
148    Block {
149        reason: String,
150    },
151    Decision {
152        kind: String,
153        reason: Option<String>,
154    },
155}
156
157impl HookControl {
158    pub fn as_str(&self) -> &'static str {
159        match self {
160            Self::Allow => "allow",
161            Self::Block { .. } => "block",
162            Self::Decision { kind, .. } => match kind.as_str() {
163                "allow" => "decision_allow",
164                "deny" => "decision_deny",
165                "ask" => "decision_ask",
166                _ => "decision_unknown",
167            },
168        }
169    }
170}
171
172/// Action returned by a PreToolUse hook.
173#[derive(Clone, Debug)]
174pub enum PreToolAction {
175    /// Allow the tool call to proceed unchanged.
176    Allow,
177    /// Deny the tool call with an explanation.
178    Deny(String),
179    /// Allow but replace the arguments.
180    Modify(serde_json::Value),
181}
182
183/// Action returned by a PostToolUse hook.
184#[derive(Clone, Debug)]
185pub enum PostToolAction {
186    /// Pass the result through unchanged.
187    Pass,
188    /// Replace the result text.
189    Modify(String),
190}
191
192/// Callback types for legacy tool lifecycle hooks.
193pub type PreToolHookFn = Rc<dyn Fn(&str, &serde_json::Value) -> PreToolAction>;
194pub type PostToolHookFn = Rc<dyn Fn(&str, &str) -> PostToolAction>;
195
196/// A registered tool hook with a name pattern and callbacks.
197#[derive(Clone)]
198pub struct ToolHook {
199    /// Glob-style pattern matched against tool names (e.g. `"*"`, `"exec*"`, `"read_file"`).
200    pub pattern: String,
201    /// Called before tool execution. Return `Deny` to reject, `Modify` to rewrite args.
202    pub pre: Option<PreToolHookFn>,
203    /// Called after tool execution with the result text. Return `Modify` to rewrite.
204    pub post: Option<PostToolHookFn>,
205}
206
207impl std::fmt::Debug for ToolHook {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        f.debug_struct("ToolHook")
210            .field("pattern", &self.pattern)
211            .field("has_pre", &self.pre.is_some())
212            .field("has_post", &self.post.is_some())
213            .finish()
214    }
215}
216
217#[derive(Clone)]
218enum PatternMatcher {
219    ToolNameGlob(String),
220    EventExpression {
221        source: String,
222        expression: EventPatternExpression,
223    },
224}
225
226#[derive(Clone)]
227enum EventPatternExpression {
228    MatchAll,
229    NeverMatch,
230    Regex { path: String, regex: Regex },
231    Equals { path: String, value: String },
232    NotEquals { path: String, value: String },
233    PathTruthy(String),
234    ToolNameGlob(String),
235}
236
237impl std::fmt::Debug for PatternMatcher {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        match self {
240            Self::ToolNameGlob(pattern) => f.debug_tuple("ToolNameGlob").field(pattern).finish(),
241            Self::EventExpression { source, expression } => f
242                .debug_struct("EventExpression")
243                .field("source", source)
244                .field("expression", expression)
245                .finish(),
246        }
247    }
248}
249
250impl std::fmt::Debug for EventPatternExpression {
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        match self {
253            Self::MatchAll => f.write_str("MatchAll"),
254            Self::NeverMatch => f.write_str("NeverMatch"),
255            Self::Regex { path, regex } => f
256                .debug_struct("Regex")
257                .field("path", path)
258                .field("regex", &regex.as_str())
259                .finish(),
260            Self::Equals { path, value } => f
261                .debug_struct("Equals")
262                .field("path", path)
263                .field("value", value)
264                .finish(),
265            Self::NotEquals { path, value } => f
266                .debug_struct("NotEquals")
267                .field("path", path)
268                .field("value", value)
269                .finish(),
270            Self::PathTruthy(path) => f.debug_tuple("PathTruthy").field(path).finish(),
271            Self::ToolNameGlob(pattern) => f.debug_tuple("ToolNameGlob").field(pattern).finish(),
272        }
273    }
274}
275
276#[derive(Clone)]
277enum RuntimeHookHandler {
278    NativePreTool(PreToolHookFn),
279    NativePostTool(PostToolHookFn),
280    Vm {
281        handler_name: String,
282        closure: Rc<VmClosure>,
283    },
284}
285
286impl std::fmt::Debug for RuntimeHookHandler {
287    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288        match self {
289            Self::NativePreTool(_) => f.write_str("NativePreTool(..)"),
290            Self::NativePostTool(_) => f.write_str("NativePostTool(..)"),
291            Self::Vm { handler_name, .. } => f
292                .debug_struct("Vm")
293                .field("handler_name", handler_name)
294                .finish(),
295        }
296    }
297}
298
299#[derive(Clone, Debug)]
300struct RuntimeHook {
301    event: HookEvent,
302    matcher: PatternMatcher,
303    handler: RuntimeHookHandler,
304}
305
306#[derive(Clone, Debug)]
307pub struct VmLifecycleHookInvocation {
308    pub closure: Rc<VmClosure>,
309    pub handler_name: String,
310}
311
312#[derive(Clone, Debug)]
313struct VmLifecycleHookRegistration {
314    handler_name: String,
315    closure: Rc<VmClosure>,
316}
317
318thread_local! {
319    static RUNTIME_HOOKS: RefCell<Vec<RuntimeHook>> = const { RefCell::new(Vec::new()) };
320    /// Pending `FileEdited` notifications queued from sync builtins
321    /// (e.g. `write_file`). Drained at safe async boundaries — typically
322    /// at the start of each agent-loop turn — so VM closure handlers
323    /// can run inside an async builtin context.
324    static FILE_EDIT_QUEUE: RefCell<Vec<FileEditedNotification>> = const { RefCell::new(Vec::new()) };
325}
326
327#[derive(Clone, Debug)]
328pub struct FileEditedNotification {
329    pub path: String,
330    pub metadata: serde_json::Value,
331}
332
333/// Queue a file-edited notification. Safe to call from sync contexts.
334pub fn queue_file_edited(path: &str, metadata: serde_json::Value) {
335    FILE_EDIT_QUEUE.with(|queue| {
336        queue.borrow_mut().push(FileEditedNotification {
337            path: path.to_string(),
338            metadata,
339        });
340    });
341}
342
343/// Drain queued file-edited notifications. Returns them in the order
344/// they were queued; the caller is responsible for invoking matching
345/// `FileEdited` hooks (async context required).
346pub fn drain_file_edits() -> Vec<FileEditedNotification> {
347    FILE_EDIT_QUEUE.with(|queue| std::mem::take(&mut *queue.borrow_mut()))
348}
349
350pub fn clear_file_edit_queue() {
351    FILE_EDIT_QUEUE.with(|queue| queue.borrow_mut().clear());
352}
353
354pub(crate) fn glob_match(pattern: &str, name: &str) -> bool {
355    if pattern == "*" {
356        return true;
357    }
358    if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
359        if let Ok(glob) = globset::Glob::new(pattern) {
360            if glob.compile_matcher().is_match(name) {
361                return true;
362            }
363        }
364    }
365    if let Some(prefix) = pattern.strip_suffix('*') {
366        return name.starts_with(prefix);
367    }
368    if let Some(suffix) = pattern.strip_prefix('*') {
369        return name.ends_with(suffix);
370    }
371    pattern == name
372}
373
374pub fn register_tool_hook(hook: ToolHook) {
375    if let Some(pre) = hook.pre {
376        RUNTIME_HOOKS.with(|hooks| {
377            hooks.borrow_mut().push(RuntimeHook {
378                event: HookEvent::PreToolUse,
379                matcher: PatternMatcher::ToolNameGlob(hook.pattern.clone()),
380                handler: RuntimeHookHandler::NativePreTool(pre),
381            });
382        });
383    }
384    if let Some(post) = hook.post {
385        RUNTIME_HOOKS.with(|hooks| {
386            hooks.borrow_mut().push(RuntimeHook {
387                event: HookEvent::PostToolUse,
388                matcher: PatternMatcher::ToolNameGlob(hook.pattern),
389                handler: RuntimeHookHandler::NativePostTool(post),
390            });
391        });
392    }
393}
394
395pub fn register_vm_hook(
396    event: HookEvent,
397    pattern: impl Into<String>,
398    handler_name: impl Into<String>,
399    closure: Rc<VmClosure>,
400) {
401    RUNTIME_HOOKS.with(|hooks| {
402        hooks.borrow_mut().push(RuntimeHook {
403            event,
404            matcher: compile_event_pattern(pattern.into()),
405            handler: RuntimeHookHandler::Vm {
406                handler_name: handler_name.into(),
407                closure,
408            },
409        });
410    });
411}
412
413pub fn clear_tool_hooks() {
414    RUNTIME_HOOKS.with(|hooks| {
415        hooks
416            .borrow_mut()
417            .retain(|hook| !matches!(hook.event, HookEvent::PreToolUse | HookEvent::PostToolUse));
418    });
419}
420
421pub fn clear_runtime_hooks() {
422    RUNTIME_HOOKS.with(|hooks| hooks.borrow_mut().clear());
423    super::clear_command_policies();
424}
425
426/// Clear only session-level lifecycle hooks (session_start, session_end,
427/// user_prompt_submit, etc.). Leaves tool, persona, step, worker, and
428/// agent-turn hooks installed. Mirrors `clear_tool_hooks()` /
429/// `clear_persona_hooks()` for the new surface.
430pub fn clear_session_hooks() {
431    RUNTIME_HOOKS.with(|hooks| {
432        hooks.borrow_mut().retain(|hook| {
433            !matches!(
434                hook.event,
435                HookEvent::SessionStart
436                    | HookEvent::SessionEnd
437                    | HookEvent::UserPromptSubmit
438                    | HookEvent::PreCompact
439                    | HookEvent::PostCompact
440                    | HookEvent::PostTurn
441                    | HookEvent::PermissionAsked
442                    | HookEvent::PermissionReplied
443                    | HookEvent::FileEdited
444                    | HookEvent::SessionError
445                    | HookEvent::SessionIdle
446            )
447        });
448    });
449}
450
451fn value_at_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
452    let mut current = value;
453    for segment in path.split('.') {
454        let serde_json::Value::Object(map) = current else {
455            return None;
456        };
457        current = map.get(segment)?;
458    }
459    Some(current)
460}
461
462fn value_truthy(value: &serde_json::Value) -> bool {
463    match value {
464        serde_json::Value::Null => false,
465        serde_json::Value::Bool(value) => *value,
466        serde_json::Value::Number(value) => value
467            .as_i64()
468            .map(|number| number != 0)
469            .or_else(|| value.as_u64().map(|number| number != 0))
470            .or_else(|| value.as_f64().map(|number| number != 0.0))
471            .unwrap_or(false),
472        serde_json::Value::String(value) => !value.is_empty(),
473        serde_json::Value::Array(values) => !values.is_empty(),
474        serde_json::Value::Object(values) => !values.is_empty(),
475    }
476}
477
478fn value_to_pattern_string(value: Option<&serde_json::Value>) -> String {
479    match value {
480        Some(serde_json::Value::String(text)) => text.clone(),
481        Some(other) => other.to_string(),
482        None => String::new(),
483    }
484}
485
486fn strip_quoted(value: &str) -> &str {
487    value
488        .trim()
489        .strip_prefix('"')
490        .and_then(|text| text.strip_suffix('"'))
491        .or_else(|| {
492            value
493                .trim()
494                .strip_prefix('\'')
495                .and_then(|text| text.strip_suffix('\''))
496        })
497        .unwrap_or(value.trim())
498}
499
500fn compile_event_pattern(pattern: String) -> PatternMatcher {
501    let trimmed = pattern.trim();
502    let expression = if trimmed.is_empty() || trimmed == "*" {
503        EventPatternExpression::MatchAll
504    } else if let Some((lhs, rhs)) = trimmed.split_once("=~") {
505        match Regex::new(strip_quoted(rhs)) {
506            Ok(regex) => EventPatternExpression::Regex {
507                path: lhs.trim().to_string(),
508                regex,
509            },
510            Err(_) => EventPatternExpression::NeverMatch,
511        }
512    } else if let Some((lhs, rhs)) = trimmed.split_once("==") {
513        EventPatternExpression::Equals {
514            path: lhs.trim().to_string(),
515            value: strip_quoted(rhs).to_string(),
516        }
517    } else if let Some((lhs, rhs)) = trimmed.split_once("!=") {
518        EventPatternExpression::NotEquals {
519            path: lhs.trim().to_string(),
520            value: strip_quoted(rhs).to_string(),
521        }
522    } else if trimmed.contains('.') {
523        EventPatternExpression::PathTruthy(trimmed.to_string())
524    } else {
525        EventPatternExpression::ToolNameGlob(trimmed.to_string())
526    };
527    PatternMatcher::EventExpression {
528        source: pattern,
529        expression,
530    }
531}
532
533fn expression_matches(
534    source: &str,
535    expression: &EventPatternExpression,
536    payload: &serde_json::Value,
537) -> bool {
538    let pattern = source.trim();
539    if pattern.is_empty() || pattern == "*" {
540        return true;
541    }
542    if let Some(target) = value_at_path(payload, "target").and_then(serde_json::Value::as_str) {
543        if glob_match(pattern, target) {
544            return true;
545        }
546    }
547    match expression {
548        EventPatternExpression::MatchAll => true,
549        EventPatternExpression::NeverMatch => false,
550        EventPatternExpression::Regex { path, regex } => {
551            let value = value_to_pattern_string(value_at_path(payload, path));
552            regex.is_match(&value)
553        }
554        EventPatternExpression::Equals { path, value } => {
555            value_to_pattern_string(value_at_path(payload, path)) == *value
556        }
557        EventPatternExpression::NotEquals { path, value } => {
558            value_to_pattern_string(value_at_path(payload, path)) != *value
559        }
560        EventPatternExpression::PathTruthy(path) => {
561            value_at_path(payload, path).is_some_and(value_truthy)
562        }
563        EventPatternExpression::ToolNameGlob(pattern) => glob_match(
564            pattern,
565            &value_to_pattern_string(value_at_path(payload, "tool.name")),
566        ),
567    }
568}
569
570fn hook_matches(hook: &RuntimeHook, tool_name: Option<&str>, payload: &serde_json::Value) -> bool {
571    match &hook.matcher {
572        PatternMatcher::ToolNameGlob(pattern) => {
573            tool_name.is_some_and(|candidate| glob_match(pattern, candidate))
574        }
575        PatternMatcher::EventExpression { source, expression } => {
576            expression_matches(source, expression, payload)
577        }
578    }
579}
580
581fn runtime_hooks_for_event(event: HookEvent) -> Vec<RuntimeHook> {
582    RUNTIME_HOOKS.with(|hooks| {
583        hooks
584            .borrow()
585            .iter()
586            .filter(|hook| hook.event == event)
587            .cloned()
588            .collect()
589    })
590}
591
592async fn invoke_vm_hook(
593    closure: &Rc<VmClosure>,
594    payload: &serde_json::Value,
595) -> Result<VmValue, VmError> {
596    let Some(mut vm) = crate::vm::clone_async_builtin_child_vm() else {
597        return Err(VmError::Runtime(
598            "runtime hook requires an async builtin VM context".to_string(),
599        ));
600    };
601    let arg = crate::stdlib::json_to_vm_value(payload);
602    vm.call_closure_pub(closure, &[arg]).await
603}
604
605async fn invoke_vm_lifecycle_hooks(
606    event: HookEvent,
607    registrations: Vec<VmLifecycleHookRegistration>,
608    payload: &serde_json::Value,
609) -> Result<(), VmError> {
610    let Some(mut vm) = crate::vm::clone_async_builtin_child_vm() else {
611        return Err(VmError::Runtime(
612            "runtime hook requires an async builtin VM context".to_string(),
613        ));
614    };
615    let arg = crate::stdlib::json_to_vm_value(payload);
616    let session_id = payload
617        .get("session")
618        .and_then(|v| v.get("id"))
619        .and_then(|v| v.as_str())
620        .unwrap_or("")
621        .to_string();
622    for registration in registrations {
623        record_hook_call(&session_id, event, &registration.handler_name, payload);
624        let raw = vm
625            .call_closure_pub(&registration.closure, &[arg.clone()])
626            .await?;
627        record_hook_returned(
628            &session_id,
629            event,
630            &registration.handler_name,
631            &HookControl::Allow,
632            &raw,
633        );
634    }
635    Ok(())
636}
637
638fn parse_pre_tool_result(value: VmValue) -> Result<PreToolAction, VmError> {
639    match value {
640        VmValue::Nil => Ok(PreToolAction::Allow),
641        VmValue::Dict(map) => {
642            if let Some(reason) = map.get("deny") {
643                return Ok(PreToolAction::Deny(reason.display()));
644            }
645            if let Some(args) = map.get("args") {
646                return Ok(PreToolAction::Modify(crate::llm::vm_value_to_json(args)));
647            }
648            Ok(PreToolAction::Allow)
649        }
650        other => Err(VmError::Runtime(format!(
651            "PreToolUse hook must return nil or {{deny, args}}, got {}",
652            other.type_name()
653        ))),
654    }
655}
656
657fn parse_post_tool_result(value: VmValue) -> Result<PostToolAction, VmError> {
658    match value {
659        VmValue::Nil => Ok(PostToolAction::Pass),
660        VmValue::String(text) => Ok(PostToolAction::Modify(text.to_string())),
661        VmValue::Dict(map) => {
662            if let Some(result) = map.get("result") {
663                return Ok(PostToolAction::Modify(result.display()));
664            }
665            Ok(PostToolAction::Pass)
666        }
667        other => Err(VmError::Runtime(format!(
668            "PostToolUse hook must return nil, string, or {{result}}, got {}",
669            other.type_name()
670        ))),
671    }
672}
673
674/// Run all matching PreToolUse hooks. Returns the final action.
675pub async fn run_pre_tool_hooks(
676    tool_name: &str,
677    args: &serde_json::Value,
678) -> Result<PreToolAction, VmError> {
679    let hooks = runtime_hooks_for_event(HookEvent::PreToolUse);
680    let mut current_args = args.clone();
681    for hook in &hooks {
682        let payload = if matches!(hook.matcher, PatternMatcher::EventExpression { .. }) {
683            Some(serde_json::json!({
684                "event": HookEvent::PreToolUse.as_str(),
685                "tool": {
686                    "name": tool_name,
687                    "args": current_args.clone(),
688                },
689            }))
690        } else {
691            None
692        };
693        if !hook_matches(
694            hook,
695            Some(tool_name),
696            payload.as_ref().unwrap_or(&serde_json::Value::Null),
697        ) {
698            continue;
699        }
700        let action = match &hook.handler {
701            RuntimeHookHandler::NativePreTool(pre) => pre(tool_name, &current_args),
702            RuntimeHookHandler::Vm { closure, .. } => {
703                let payload = payload.as_ref().ok_or_else(|| {
704                    VmError::Runtime("VM PreToolUse hook requires an event payload".to_string())
705                })?;
706                parse_pre_tool_result(invoke_vm_hook(closure, payload).await?)?
707            }
708            RuntimeHookHandler::NativePostTool(_) => continue,
709        };
710        match action {
711            PreToolAction::Allow => {}
712            PreToolAction::Deny(reason) => return Ok(PreToolAction::Deny(reason)),
713            PreToolAction::Modify(new_args) => {
714                current_args = new_args;
715            }
716        }
717    }
718    if current_args != *args {
719        Ok(PreToolAction::Modify(current_args))
720    } else {
721        Ok(PreToolAction::Allow)
722    }
723}
724
725/// Run all matching PostToolUse hooks. Returns the (possibly modified) result.
726pub async fn run_post_tool_hooks(
727    tool_name: &str,
728    args: &serde_json::Value,
729    result: &str,
730) -> Result<String, VmError> {
731    let hooks = runtime_hooks_for_event(HookEvent::PostToolUse);
732    let mut current = result.to_string();
733    for hook in &hooks {
734        let payload = if matches!(hook.matcher, PatternMatcher::EventExpression { .. }) {
735            Some(serde_json::json!({
736                "event": HookEvent::PostToolUse.as_str(),
737                "tool": {
738                    "name": tool_name,
739                    "args": args,
740                },
741                "result": {
742                    "text": current.clone(),
743                },
744            }))
745        } else {
746            None
747        };
748        if !hook_matches(
749            hook,
750            Some(tool_name),
751            payload.as_ref().unwrap_or(&serde_json::Value::Null),
752        ) {
753            continue;
754        }
755        let action = match &hook.handler {
756            RuntimeHookHandler::NativePostTool(post) => post(tool_name, &current),
757            RuntimeHookHandler::Vm { closure, .. } => {
758                let payload = payload.as_ref().ok_or_else(|| {
759                    VmError::Runtime("VM PostToolUse hook requires an event payload".to_string())
760                })?;
761                parse_post_tool_result(invoke_vm_hook(closure, payload).await?)?
762            }
763            RuntimeHookHandler::NativePreTool(_) => continue,
764        };
765        match action {
766            PostToolAction::Pass => {}
767            PostToolAction::Modify(new_result) => {
768                current = new_result;
769            }
770        }
771    }
772    Ok(current)
773}
774
775pub async fn run_lifecycle_hooks(
776    event: HookEvent,
777    payload: &serde_json::Value,
778) -> Result<(), VmError> {
779    let registrations = matching_vm_lifecycle_registrations(event, payload);
780    if registrations.is_empty() {
781        return Ok(());
782    }
783    invoke_vm_lifecycle_hooks(event, registrations, payload).await
784}
785
786/// Run veto-capable session-level lifecycle hooks. Successive hooks see
787/// `Allow`; the first non-`Allow` return short-circuits and is returned
788/// to the caller. Hook invocations and decisions are captured on the
789/// active session's transcript under `hook_call`, `hook_returned`, and
790/// `hook_vetoed` so a replay reproduces the same control flow.
791pub async fn run_lifecycle_hooks_with_control(
792    event: HookEvent,
793    payload: &serde_json::Value,
794) -> Result<HookControl, VmError> {
795    let registrations = matching_vm_lifecycle_registrations(event, payload);
796    if registrations.is_empty() {
797        return Ok(HookControl::Allow);
798    }
799    let Some(mut vm) = crate::vm::clone_async_builtin_child_vm() else {
800        return Err(VmError::Runtime(
801            "session lifecycle hook requires an async builtin VM context".to_string(),
802        ));
803    };
804    let arg = crate::stdlib::json_to_vm_value(payload);
805    let session_id = payload
806        .get("session")
807        .and_then(|v| v.get("id"))
808        .and_then(|v| v.as_str())
809        .unwrap_or("")
810        .to_string();
811    for registration in registrations {
812        record_hook_call(&session_id, event, &registration.handler_name, payload);
813        let raw = vm
814            .call_closure_pub(&registration.closure, &[arg.clone()])
815            .await?;
816        let control = parse_hook_control(event, &raw)?;
817        record_hook_returned(
818            &session_id,
819            event,
820            &registration.handler_name,
821            &control,
822            &raw,
823        );
824        if !matches!(control, HookControl::Allow) {
825            record_hook_vetoed(&session_id, event, &registration.handler_name, &control);
826            return Ok(control);
827        }
828    }
829    Ok(HookControl::Allow)
830}
831
832fn parse_hook_control(event: HookEvent, value: &VmValue) -> Result<HookControl, VmError> {
833    match value {
834        VmValue::Nil | VmValue::Bool(true) => Ok(HookControl::Allow),
835        VmValue::Bool(false) => Ok(HookControl::Block {
836            reason: format!("{} hook returned false", event.as_str()),
837        }),
838        VmValue::Dict(map) => {
839            if let Some(decision) = map.get("decision") {
840                let kind = decision.display();
841                let kind_norm = kind.trim().to_ascii_lowercase();
842                if !matches!(kind_norm.as_str(), "allow" | "deny" | "ask") {
843                    return Err(VmError::Runtime(format!(
844                        "{} hook `decision` must be \"allow\", \"deny\", or \"ask\"; got \"{kind}\"",
845                        event.as_str()
846                    )));
847                }
848                let reason = map.get("reason").and_then(|v| match v {
849                    VmValue::Nil => None,
850                    other => Some(other.display()),
851                });
852                return Ok(HookControl::Decision {
853                    kind: kind_norm,
854                    reason,
855                });
856            }
857            let block = map.get("block").map(vm_value_truthy).unwrap_or(false);
858            if block {
859                let reason = map
860                    .get("reason")
861                    .map(|v| v.display())
862                    .unwrap_or_else(|| format!("{} hook blocked the operation", event.as_str()));
863                return Ok(HookControl::Block { reason });
864            }
865            Ok(HookControl::Allow)
866        }
867        other => Err(VmError::Runtime(format!(
868            "{} hook must return nil, bool, or a control dict; got {}",
869            event.as_str(),
870            other.type_name()
871        ))),
872    }
873}
874
875fn vm_value_truthy(value: &VmValue) -> bool {
876    match value {
877        VmValue::Nil => false,
878        VmValue::Bool(value) => *value,
879        VmValue::Int(value) => *value != 0,
880        VmValue::Float(value) => *value != 0.0,
881        VmValue::String(value) => !value.is_empty(),
882        VmValue::List(value) => !value.is_empty(),
883        VmValue::Dict(value) => !value.is_empty(),
884        _ => true,
885    }
886}
887
888fn record_hook_call(
889    session_id: &str,
890    event: HookEvent,
891    handler: &str,
892    payload: &serde_json::Value,
893) {
894    if session_id.is_empty() {
895        return;
896    }
897    let metadata = serde_json::json!({
898        "event": event.as_str(),
899        "handler": handler,
900        "payload": payload,
901    });
902    let entry = crate::llm::helpers::transcript_event(
903        "hook_call",
904        "system",
905        "internal",
906        &format!("hook {} invoked: {}", event.as_str(), handler),
907        Some(metadata),
908    );
909    let _ = crate::agent_sessions::append_event(session_id, entry);
910}
911
912fn record_hook_returned(
913    session_id: &str,
914    event: HookEvent,
915    handler: &str,
916    control: &HookControl,
917    raw: &VmValue,
918) {
919    if session_id.is_empty() {
920        return;
921    }
922    let metadata = serde_json::json!({
923        "event": event.as_str(),
924        "handler": handler,
925        "result": control.as_str(),
926        "raw": crate::llm::vm_value_to_json(raw),
927    });
928    let entry = crate::llm::helpers::transcript_event(
929        "hook_returned",
930        "system",
931        "internal",
932        &format!(
933            "hook {} returned {} from {}",
934            event.as_str(),
935            control.as_str(),
936            handler
937        ),
938        Some(metadata),
939    );
940    let _ = crate::agent_sessions::append_event(session_id, entry);
941}
942
943fn record_hook_vetoed(session_id: &str, event: HookEvent, handler: &str, control: &HookControl) {
944    if session_id.is_empty() {
945        return;
946    }
947    let (reason, decision) = match control {
948        HookControl::Allow => return,
949        HookControl::Block { reason } => (reason.clone(), None),
950        HookControl::Decision { kind, reason } => (
951            reason.clone().unwrap_or_else(|| format!("decision={kind}")),
952            Some(kind.clone()),
953        ),
954    };
955    let metadata = serde_json::json!({
956        "event": event.as_str(),
957        "handler": handler,
958        "reason": reason,
959        "decision": decision,
960    });
961    let entry = crate::llm::helpers::transcript_event(
962        "hook_vetoed",
963        "system",
964        "internal",
965        &format!("hook {} vetoed by {}: {reason}", event.as_str(), handler),
966        Some(metadata),
967    );
968    let _ = crate::agent_sessions::append_event(session_id, entry);
969}
970
971pub fn matching_vm_lifecycle_hooks(
972    event: HookEvent,
973    payload: &serde_json::Value,
974) -> Vec<VmLifecycleHookInvocation> {
975    matching_vm_lifecycle_registrations(event, payload)
976        .into_iter()
977        .map(|registration| VmLifecycleHookInvocation {
978            closure: registration.closure,
979            handler_name: registration.handler_name,
980        })
981        .collect()
982}
983
984fn matching_vm_lifecycle_registrations(
985    event: HookEvent,
986    payload: &serde_json::Value,
987) -> Vec<VmLifecycleHookRegistration> {
988    RUNTIME_HOOKS.with(|hooks| {
989        hooks
990            .borrow()
991            .iter()
992            .filter(|hook| hook.event == event)
993            .filter(|hook| hook_matches(hook, None, payload))
994            .filter_map(|hook| match &hook.handler {
995                RuntimeHookHandler::Vm {
996                    closure,
997                    handler_name,
998                } => Some(VmLifecycleHookRegistration {
999                    handler_name: handler_name.clone(),
1000                    closure: Rc::clone(closure),
1001                }),
1002                RuntimeHookHandler::NativePreTool(_) | RuntimeHookHandler::NativePostTool(_) => {
1003                    None
1004                }
1005            })
1006            .collect()
1007    })
1008}