1use 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#[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 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#[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#[derive(Clone, Debug)]
174pub enum PreToolAction {
175 Allow,
177 Deny(String),
179 Modify(serde_json::Value),
181}
182
183#[derive(Clone, Debug)]
185pub enum PostToolAction {
186 Pass,
188 Modify(String),
190}
191
192pub type PreToolHookFn = Rc<dyn Fn(&str, &serde_json::Value) -> PreToolAction>;
194pub type PostToolHookFn = Rc<dyn Fn(&str, &str) -> PostToolAction>;
195
196#[derive(Clone)]
198pub struct ToolHook {
199 pub pattern: String,
201 pub pre: Option<PreToolHookFn>,
203 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", ®ex.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 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
333pub 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
343pub 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
426pub 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, ®istration.handler_name, payload);
624 let raw = vm
625 .call_closure_pub(®istration.closure, &[arg.clone()])
626 .await?;
627 record_hook_returned(
628 &session_id,
629 event,
630 ®istration.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
674pub 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, ¤t_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
725pub 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, ¤t),
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
786pub 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, ®istration.handler_name, payload);
813 let raw = vm
814 .call_closure_pub(®istration.closure, &[arg.clone()])
815 .await?;
816 let control = parse_hook_control(event, &raw)?;
817 record_hook_returned(
818 &session_id,
819 event,
820 ®istration.handler_name,
821 &control,
822 &raw,
823 );
824 if !matches!(control, HookControl::Allow) {
825 record_hook_vetoed(&session_id, event, ®istration.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}