Skip to main content

recursive/hooks/
external.rs

1//! External process-based hooks.
2//!
3//! External hooks are executable scripts/programs placed in hook directories
4//! (`~/.recursive/hooks/` or `<workspace>/.recursive/hooks/`). They receive
5//! a JSON event on stdin and must reply with a JSON decision on stdout within
6//! 5 seconds. Timeout or non-parseable output is treated as "continue".
7//!
8//! # Protocol
9//!
10//! **Input** (stdin, single line JSON):
11//! ```json
12//! {
13//!   "event": "preToolCall",
14//!   "toolName": "run_shell",
15//!   "args": {"command": "rm -rf /"},
16//!   "mode": "ask"
17//! }
18//! ```
19//!
20//! **Output** (stdout, single line JSON):
21//! ```json
22//! {"action": "continue"}
23//! {"action": "skip", "message": "dangerous command"}
24//! {"action": "error", "message": "not allowed"}
25//! ```
26
27use std::collections::HashSet;
28use std::sync::{Arc, Mutex};
29
30use crate::error::{Error, Result};
31use crate::event::AgentEvent;
32use crate::hooks::config::{matches_hook, HookCommand, HookCommandType, HooksConfig};
33use crate::llm::LlmProvider;
34use crate::message::Message;
35use serde::{Deserialize, Serialize};
36use std::path::PathBuf;
37use std::time::Duration;
38use tokio::process::Command;
39use tokio::sync::mpsc;
40use tokio::time::timeout;
41use tokio_util::sync::CancellationToken;
42
43pub use crate::hooks::HookAction;
44
45/// Time limit for a single external hook to respond.
46const HOOK_TIMEOUT: Duration = Duration::from_secs(5);
47
48// ── JSON protocol types ────────────────────────────────────────────
49
50/// The kind of lifecycle event sent to the external hook.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase")]
53pub enum HookEvent {
54    // existing
55    PreToolCall,
56    PostToolCall,
57    PermissionRequest,
58    // new in Goal 204
59    PostToolCallFailure,
60    PermissionDenied,
61    SessionStart,
62    SessionEnd,
63    UserPromptSubmit,
64    Stop,
65    SubagentStart,
66    SubagentStop,
67    Notification,
68    Setup,
69}
70
71/// Input payload sent to the external hook on stdin.
72#[derive(Debug, Clone, Serialize)]
73#[serde(rename_all = "camelCase")]
74pub struct HookInput {
75    pub event: HookEvent,
76    /// Tool name — `None` for non-tool events.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub tool_name: Option<String>,
79    /// Tool arguments — `None` for non-tool events.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub args: Option<serde_json::Value>,
82    pub mode: String,
83    // optional context fields for specific events
84    /// User's input content (UserPromptSubmit).
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub content: Option<String>,
87    /// Notification message (Notification).
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub message: Option<String>,
90    /// Nesting depth (SubagentStart / SubagentStop).
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub depth: Option<usize>,
93    /// Denial reason (PermissionDenied).
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub reason: Option<String>,
96    /// Error message (PostToolCallFailure).
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub error: Option<String>,
99}
100
101/// Action returned by the external hook, as deserialized from JSON.
102#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
103#[serde(rename_all = "camelCase")]
104enum JsonAction {
105    Continue,
106    Skip,
107    Error,
108}
109
110/// Output payload expected from the external hook on stdout.
111#[derive(Debug, Clone, Deserialize, Default)]
112#[serde(rename_all = "camelCase")]
113struct HookOutput {
114    #[serde(default)]
115    action: Option<JsonAction>,
116    #[serde(default)]
117    message: Option<String>,
118
119    // ── Goal 205: extended output fields ──────────────────────────
120    /// Append to the next LLM system prompt.
121    #[serde(default)]
122    additional_context: Option<String>,
123    /// Override tool arguments (PreToolCall only).
124    #[serde(default)]
125    updated_input: Option<serde_json::Value>,
126    /// Warning message shown to the user (via AgentEvent).
127    #[serde(default)]
128    system_message: Option<String>,
129    /// When true, suppress writing hook stdout to the transcript.
130    #[serde(default)]
131    suppress_output: bool,
132    /// Permission decision: "allow" / "deny" / "passthrough".
133    #[serde(default)]
134    permission_decision: Option<String>,
135    /// Reason for the permission decision (audit log only).
136    #[serde(default)]
137    permission_decision_reason: Option<String>,
138}
139
140/// Permission decision returned by a hook.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum PermissionDecision {
143    Allow,
144    Deny,
145    Passthrough,
146}
147
148impl PermissionDecision {
149    fn from_str(s: &str) -> Option<Self> {
150        match s {
151            "allow" => Some(Self::Allow),
152            "deny" => Some(Self::Deny),
153            "passthrough" => Some(Self::Passthrough),
154            _ => None,
155        }
156    }
157}
158
159/// Rich result returned by a hook dispatch, containing the primary action
160/// as well as optional structured metadata for the caller to consume.
161#[derive(Debug, Clone)]
162pub struct HookResult {
163    /// Primary decision.
164    pub action: HookAction,
165    /// Extra context to inject into the next LLM system prompt.
166    pub additional_context: Option<String>,
167    /// Override tool arguments (meaningful only for PreToolCall).
168    pub updated_input: Option<serde_json::Value>,
169    /// User-facing warning message.
170    pub system_message: Option<String>,
171    /// When `true`, suppress the hook's raw stdout from the transcript.
172    pub suppress_output: bool,
173    /// Explicit permission decision (overrides normal permission check).
174    pub permission_decision: Option<PermissionDecision>,
175    /// Reason for the permission decision (for audit logging).
176    pub permission_decision_reason: Option<String>,
177}
178
179impl Default for HookResult {
180    fn default() -> Self {
181        Self {
182            action: HookAction::Continue,
183            additional_context: None,
184            updated_input: None,
185            system_message: None,
186            suppress_output: false,
187            permission_decision: None,
188            permission_decision_reason: None,
189        }
190    }
191}
192
193impl HookResult {
194    /// Shorthand for the default "do nothing" result.
195    pub fn continue_default() -> Self {
196        Self::default()
197    }
198}
199
200impl HookOutput {
201    /// Convert the external hook's JSON output into a `HookResult`.
202    fn into_hook_result(self) -> HookResult {
203        let action = match self.action.unwrap_or(JsonAction::Continue) {
204            JsonAction::Continue => HookAction::Continue,
205            JsonAction::Skip => HookAction::Skip,
206            JsonAction::Error => HookAction::Error(
207                self.message
208                    .clone()
209                    .unwrap_or_else(|| "external hook blocked".to_string()),
210            ),
211        };
212        let permission_decision = self
213            .permission_decision
214            .as_deref()
215            .and_then(PermissionDecision::from_str);
216        HookResult {
217            action,
218            additional_context: self.additional_context,
219            updated_input: self.updated_input,
220            system_message: self.system_message,
221            suppress_output: self.suppress_output,
222            permission_decision,
223            permission_decision_reason: self.permission_decision_reason,
224        }
225    }
226}
227
228// ── Resolved hook entry ────────────────────────────────────────────
229
230/// What kind of execution a resolved hook uses.
231#[derive(Clone, Debug)]
232enum ResolvedHookKind {
233    /// Local executable (shell script, binary).
234    Command(PathBuf),
235    /// HTTP POST to a remote endpoint.
236    Http {
237        url: String,
238        headers: Option<std::collections::HashMap<String, String>>,
239        allowed_env_vars: Option<Vec<String>>,
240    },
241    /// LLM prompt evaluation — the prompt template is evaluated by an LLM.
242    Prompt {
243        /// Template string; `$ARGUMENTS` is replaced with serialised `HookInput`.
244        prompt: String,
245    },
246}
247
248/// A single resolved hook entry (command path + metadata from config).
249#[derive(Clone, Debug)]
250struct ResolvedHook {
251    /// How to execute this hook.
252    kind: ResolvedHookKind,
253    /// Per-hook timeout override (None = use global HOOK_TIMEOUT).
254    timeout_secs: Option<u64>,
255    /// Event name this hook is registered for (None = all events).
256    event_name: Option<String>,
257    /// Tool/arg filter pattern (None = all tools).
258    matcher: Option<String>,
259    /// When `true`, run once and then mark as executed.
260    once: bool,
261    /// When `true`, run in background — Agent continues immediately.
262    r#async: bool,
263    /// When `true`, run in background and cancel Agent on exit code 2.
264    async_rewake: bool,
265}
266
267// ── Runner ─────────────────────────────────────────────────────────
268
269// ── Runner ─────────────────────────────────────────────────────────
270
271/// Discovers and runs external hook executables.
272///
273/// External hooks are scanned from one or more directories. Each
274/// executable file is treated as a hook. When `dispatch` is called,
275/// the runner sends the event to each hook in order and returns the
276/// first non-`Continue` decision. Hooks that timeout or return
277/// invalid output are treated as `Continue` (fail-open).
278#[derive(Clone)]
279pub struct ExternalHookRunner {
280    hooks: Vec<ResolvedHook>,
281    /// Optional LLM provider for prompt-type hooks.
282    llm: Option<Arc<dyn LlmProvider>>,
283    /// Tracks indices of `once: true` hooks that have already been executed.
284    executed_once: Arc<Mutex<HashSet<usize>>>,
285    /// Optional cancellation token for `asyncRewake` hooks.
286    pub cancel_token: Option<CancellationToken>,
287    /// Optional event channel for TUI progress events.
288    event_tx: Option<mpsc::UnboundedSender<AgentEvent>>,
289}
290
291impl ExternalHookRunner {
292    /// Scan the given directories and collect all executable files.
293    ///
294    /// This is the legacy discovery mode: every executable in the directory
295    /// is treated as a hook that fires for all events.
296    pub fn discover(dirs: &[PathBuf]) -> Self {
297        let mut hooks = Vec::new();
298        for dir in dirs {
299            if let Ok(entries) = std::fs::read_dir(dir) {
300                for entry in entries.flatten() {
301                    let path = entry.path();
302                    if is_executable(&path) {
303                        hooks.push(ResolvedHook {
304                            kind: ResolvedHookKind::Command(path),
305                            timeout_secs: None,
306                            event_name: None,
307                            matcher: None,
308                            once: false,
309                            r#async: false,
310                            async_rewake: false,
311                        });
312                    }
313                }
314            }
315        }
316        Self {
317            hooks,
318            llm: None,
319            executed_once: Arc::new(Mutex::new(HashSet::new())),
320            cancel_token: None,
321            event_tx: None,
322        }
323    }
324
325    /// Build a runner from a structured `HooksConfig`.
326    ///
327    /// Supports `command` and `http` types; `prompt` / `agent` require an LLM
328    /// provider and are handled by [`from_config_with_llm`].
329    pub fn from_config(config: HooksConfig) -> Self {
330        Self::from_config_with_llm(config, None)
331    }
332
333    /// Build a runner from a structured `HooksConfig`, with an optional LLM
334    /// provider for prompt/agent-type hooks.
335    pub fn from_config_with_llm(config: HooksConfig, llm: Option<Arc<dyn LlmProvider>>) -> Self {
336        let mut hooks = Vec::new();
337        for (event_name, matchers) in config.events {
338            for matcher_entry in matchers {
339                for cmd in &matcher_entry.hooks {
340                    if let Some(resolved) = Self::resolve_command(
341                        cmd,
342                        Some(event_name.clone()),
343                        matcher_entry.matcher.clone(),
344                    ) {
345                        hooks.push(resolved);
346                    }
347                }
348            }
349        }
350        Self {
351            hooks,
352            llm,
353            executed_once: Arc::new(Mutex::new(HashSet::new())),
354            cancel_token: None,
355            event_tx: None,
356        }
357    }
358
359    /// Attach a `CancellationToken` for `asyncRewake` hooks.
360    pub fn with_cancel_token(mut self, token: CancellationToken) -> Self {
361        self.cancel_token = Some(token);
362        self
363    }
364
365    /// Attach an `AgentEvent` channel for TUI progress events.
366    pub fn with_event_tx(mut self, tx: mpsc::UnboundedSender<AgentEvent>) -> Self {
367        self.event_tx = Some(tx);
368        self
369    }
370
371    /// Emit an `AgentEvent` if an event channel is registered.
372    fn emit(&self, event: AgentEvent) {
373        if let Some(tx) = &self.event_tx {
374            let _ = tx.send(event);
375        }
376    }
377
378    /// Convert a single `HookCommand` entry to a `ResolvedHook`.
379    ///
380    /// Returns `None` for types that need an LLM (prompt/agent) or missing fields.
381    fn resolve_command(
382        cmd: &HookCommand,
383        event_name: Option<String>,
384        matcher: Option<String>,
385    ) -> Option<ResolvedHook> {
386        let base = |kind: ResolvedHookKind| ResolvedHook {
387            kind,
388            timeout_secs: Some(cmd.timeout),
389            event_name,
390            matcher,
391            once: cmd.once,
392            r#async: cmd.r#async,
393            async_rewake: cmd.async_rewake,
394        };
395
396        match cmd.r#type {
397            HookCommandType::Command => {
398                let command_str = cmd.command.as_deref()?;
399                let path = expand_tilde(command_str);
400                Some(base(ResolvedHookKind::Command(path)))
401            }
402            HookCommandType::Http => {
403                let url = cmd.url.clone()?;
404                Some(base(ResolvedHookKind::Http {
405                    url,
406                    headers: None,
407                    allowed_env_vars: None,
408                }))
409            }
410            HookCommandType::Prompt | HookCommandType::Agent => {
411                let prompt = cmd.prompt.clone()?;
412                Some(base(ResolvedHookKind::Prompt { prompt }))
413            }
414        }
415    }
416
417    /// Number of registered hooks.
418    pub fn len(&self) -> usize {
419        self.hooks.len()
420    }
421
422    /// True when no hooks are registered.
423    pub fn is_empty(&self) -> bool {
424        self.hooks.is_empty()
425    }
426
427    /// Dispatch an event to all matching hooks.
428    ///
429    /// Returns the first non-`Continue` `HookResult`. Hooks that fail,
430    /// timeout, or return unparseable output are silently skipped (fail-open).
431    pub async fn dispatch(&self, input: &HookInput) -> HookResult {
432        let event_str = serde_json::to_string(&input.event)
433            .unwrap_or_default()
434            .trim_matches('"')
435            .to_string();
436        let tool_name = input.tool_name.as_deref().unwrap_or("");
437        let empty_args = serde_json::Value::Object(Default::default());
438        let args = input.args.as_ref().unwrap_or(&empty_args);
439
440        for (idx, hook) in self.hooks.iter().enumerate() {
441            // Filter by event name (if registered for a specific event).
442            if let Some(ref ev) = hook.event_name {
443                if !event_names_match(ev, &event_str) {
444                    continue;
445                }
446            }
447            // Filter by tool/arg matcher.
448            if !matches_hook(&hook.matcher, tool_name, args) {
449                continue;
450            }
451            // Skip once-hooks that already ran.
452            if hook.once {
453                let already_ran = {
454                    let guard = self.executed_once.lock().unwrap();
455                    guard.contains(&idx)
456                };
457                if already_ran {
458                    continue;
459                }
460            }
461
462            // Async fire-and-forget: spawn and return Continue immediately.
463            if hook.r#async && !hook.async_rewake {
464                let hook_clone = hook.clone();
465                let input_clone = input.clone();
466                let self_clone = self.clone();
467                tokio::spawn(async move {
468                    if let Err(e) = self_clone.run_hook(&hook_clone, &input_clone).await {
469                        tracing::warn!("async hook error: {e}");
470                    }
471                });
472                if hook.once {
473                    self.executed_once.lock().unwrap().insert(idx);
474                }
475                continue;
476            }
477
478            // asyncRewake: spawn, cancel Agent if hook exits with code 2.
479            if hook.async_rewake {
480                let hook_clone = hook.clone();
481                let input_clone = input.clone();
482                let self_clone = self.clone();
483                let cancel = self.cancel_token.clone();
484                tokio::spawn(async move {
485                    let exit_code = self_clone
486                        .run_command_exit_code(&hook_clone, &input_clone)
487                        .await
488                        .unwrap_or(None);
489                    if exit_code == Some(2) {
490                        tracing::warn!("asyncRewake hook exited with code 2 — cancelling agent");
491                        if let Some(token) = cancel {
492                            token.cancel();
493                        }
494                    }
495                });
496                if hook.once {
497                    self.executed_once.lock().unwrap().insert(idx);
498                }
499                continue;
500            }
501
502            // Synchronous execution.
503            let hook_display_name = match &hook.kind {
504                ResolvedHookKind::Command(path) => path
505                    .file_name()
506                    .map(|n| n.to_string_lossy().into_owned())
507                    .unwrap_or_else(|| "hook".to_string()),
508                ResolvedHookKind::Http { url, .. } => url.clone(),
509                ResolvedHookKind::Prompt { .. } => "prompt-hook".to_string(),
510            };
511            self.emit(AgentEvent::HookStarted {
512                hook_event: event_str.clone(),
513                hook_name: hook_display_name.clone(),
514                status_message: None,
515            });
516            let start = std::time::Instant::now();
517            if let Ok(result) = self.run_hook(hook, input).await {
518                if hook.once {
519                    self.executed_once.lock().unwrap().insert(idx);
520                }
521                let duration_ms = start.elapsed().as_millis() as u64;
522                let outcome = match &result.action {
523                    HookAction::Continue => "continue".to_string(),
524                    HookAction::Skip => "skip".to_string(),
525                    HookAction::Error(reason) => format!("error: {reason}"),
526                };
527                if let Some(msg) = &result.system_message {
528                    self.emit(AgentEvent::HookSystemMessage { text: msg.clone() });
529                }
530                self.emit(AgentEvent::HookFinished {
531                    hook_event: event_str.clone(),
532                    hook_name: hook_display_name.clone(),
533                    outcome,
534                    duration_ms,
535                });
536                if !matches!(result.action, HookAction::Continue) {
537                    return result;
538                }
539            }
540        }
541        HookResult::continue_default()
542    }
543
544    /// Run a single hook entry and return a `HookResult`.
545    async fn run_hook(&self, hook: &ResolvedHook, input: &HookInput) -> Result<HookResult> {
546        let hook_timeout = hook
547            .timeout_secs
548            .map(Duration::from_secs)
549            .unwrap_or(HOOK_TIMEOUT);
550
551        match &hook.kind {
552            ResolvedHookKind::Http {
553                url,
554                headers,
555                allowed_env_vars,
556            } => {
557                let result = run_http_hook(
558                    url,
559                    input,
560                    hook_timeout.as_secs(),
561                    headers.as_ref(),
562                    allowed_env_vars.as_deref(),
563                )
564                .await;
565                Ok(result)
566            }
567            ResolvedHookKind::Prompt { prompt } => {
568                if let Some(llm) = &self.llm {
569                    let result = run_prompt_hook(llm.as_ref(), prompt, input, hook_timeout).await;
570                    Ok(result)
571                } else {
572                    // No LLM configured — fail-open with a warning.
573                    tracing::warn!(
574                        "prompt hook configured but no LLM provider available; skipping"
575                    );
576                    Ok(HookResult::continue_default())
577                }
578            }
579            ResolvedHookKind::Command(path) => {
580                self.run_command_hook(path, input, hook_timeout).await
581            }
582        }
583    }
584
585    /// Run a command hook and return only the exit code (for asyncRewake).
586    async fn run_command_exit_code(
587        &self,
588        hook: &ResolvedHook,
589        input: &HookInput,
590    ) -> Result<Option<i32>> {
591        let ResolvedHookKind::Command(path) = &hook.kind else {
592            return Ok(None);
593        };
594        let hook_timeout = hook
595            .timeout_secs
596            .map(Duration::from_secs)
597            .unwrap_or(HOOK_TIMEOUT);
598        let input_json = serde_json::to_string(input).map_err(|e| Error::Config {
599            message: format!("hook input serialize: {e}"),
600        })?;
601        let mut child = Command::new(path)
602            .stdin(std::process::Stdio::piped())
603            .stdout(std::process::Stdio::null())
604            .stderr(std::process::Stdio::null())
605            .spawn()
606            .map_err(|e| Error::Config {
607                message: format!("hook spawn {}: {e}", path.display()),
608            })?;
609        let status = timeout(hook_timeout, async {
610            use tokio::io::AsyncWriteExt;
611            if let Some(stdin) = child.stdin.as_mut() {
612                let _ = stdin.write_all(input_json.as_bytes()).await;
613                let _ = stdin.shutdown().await;
614            }
615            child.wait().await
616        })
617        .await;
618        match status {
619            Ok(Ok(s)) => Ok(s.code()),
620            _ => Ok(None),
621        }
622    }
623
624    /// Run a single hook command executable and return a `HookResult`.
625    async fn run_command_hook(
626        &self,
627        path: &PathBuf,
628        input: &HookInput,
629        hook_timeout: Duration,
630    ) -> Result<HookResult> {
631        let input_json = serde_json::to_string(input).map_err(|e| Error::Config {
632            message: format!("hook input serialize: {e}"),
633        })?;
634
635        let mut child = Command::new(path)
636            .stdin(std::process::Stdio::piped())
637            .stdout(std::process::Stdio::piped())
638            .stderr(std::process::Stdio::null())
639            .spawn()
640            .map_err(|e| Error::Config {
641                message: format!("hook spawn {}: {e}", path.display()),
642            })?;
643
644        // Write stdin then wait for output, respecting per-hook timeout.
645        let output = timeout(hook_timeout, async {
646            use tokio::io::AsyncWriteExt;
647            if let Some(stdin) = child.stdin.as_mut() {
648                let _ = stdin.write_all(input_json.as_bytes()).await;
649                // Close stdin so the child knows input is done.
650                let _ = stdin.shutdown().await;
651            }
652            child.wait_with_output().await
653        })
654        .await
655        .map_err(|_| Error::Config {
656            message: format!("hook timeout: {}", path.display()),
657        })?
658        .map_err(|e| Error::Config {
659            message: format!("hook wait {}: {e}", path.display()),
660        })?;
661
662        let stdout = String::from_utf8_lossy(&output.stdout);
663        let parsed: HookOutput =
664            serde_json::from_str(stdout.trim()).map_err(|e| Error::Config {
665                message: format!("hook output parse {}: {e}", path.display()),
666            })?;
667
668        Ok(parsed.into_hook_result())
669    }
670}
671
672/// Expand a leading `~` to the home directory.
673fn expand_tilde(path: &str) -> PathBuf {
674    if let Some(rest) = path.strip_prefix("~/") {
675        if let Some(home) = std::env::var_os("HOME") {
676            return PathBuf::from(home).join(rest);
677        }
678    }
679    PathBuf::from(path)
680}
681
682/// Compare event names case-insensitively (config may use PascalCase, wire uses camelCase).
683fn event_names_match(config_name: &str, wire_name: &str) -> bool {
684    config_name.to_lowercase() == wire_name.to_lowercase()
685}
686
687// ── Prompt hook ────────────────────────────────────────────────────
688
689/// Evaluate a prompt template via `llm`, replacing `$ARGUMENTS` with
690/// the serialised `HookInput`. Fail-open on timeout or LLM error.
691async fn run_prompt_hook(
692    llm: &dyn LlmProvider,
693    prompt_template: &str,
694    input: &HookInput,
695    hook_timeout: Duration,
696) -> HookResult {
697    let args_json = serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string());
698    let prompt = prompt_template.replace("$ARGUMENTS", &args_json);
699
700    let messages = [Message::user(prompt)];
701    let completion_future = llm.complete(&messages, &[]);
702    let completion = match timeout(hook_timeout, completion_future).await {
703        Ok(Ok(c)) => c,
704        Ok(Err(e)) => {
705            tracing::warn!("prompt hook LLM error: {e}");
706            return HookResult::continue_default();
707        }
708        Err(_) => {
709            tracing::warn!("prompt hook timeout");
710            return HookResult::continue_default();
711        }
712    };
713
714    match serde_json::from_str::<HookOutput>(completion.content.trim()) {
715        Ok(output) => output.into_hook_result(),
716        Err(_) => {
717            // Non-JSON or empty response — fail-open.
718            HookResult::continue_default()
719        }
720    }
721}
722
723// ── HTTP hook ──────────────────────────────────────────────────────
724
725/// POST `input` as JSON to `url` and parse the response as `HookOutput`.
726///
727/// Fail-open: returns `HookResult::continue_default()` on timeout or error.
728pub(crate) async fn run_http_hook(
729    url: &str,
730    input: &HookInput,
731    timeout_secs: u64,
732    headers: Option<&std::collections::HashMap<String, String>>,
733    allowed_env_vars: Option<&[String]>,
734) -> HookResult {
735    let client_result = reqwest::Client::builder()
736        .timeout(Duration::from_secs(timeout_secs))
737        .connect_timeout(Duration::from_secs(timeout_secs))
738        .build();
739
740    let client = match client_result {
741        Ok(c) => c,
742        Err(_) => return HookResult::continue_default(),
743    };
744
745    let mut builder = client.post(url).json(input);
746
747    if let Some(headers_map) = headers {
748        for (k, v) in headers_map {
749            let interpolated = interpolate_env_vars(v, allowed_env_vars);
750            builder = builder.header(k.as_str(), interpolated);
751        }
752    }
753
754    let response = match builder.send().await {
755        Ok(r) => r,
756        Err(e) => {
757            tracing::warn!("http hook request failed: {e}");
758            return HookResult::continue_default();
759        }
760    };
761
762    let body = match response.text().await {
763        Ok(b) => b,
764        Err(e) => {
765            tracing::warn!("http hook response read failed: {e}");
766            return HookResult::continue_default();
767        }
768    };
769
770    match serde_json::from_str::<HookOutput>(body.trim()) {
771        Ok(output) => output.into_hook_result(),
772        Err(e) => {
773            tracing::warn!("http hook response parse failed: {e}");
774            HookResult::continue_default()
775        }
776    }
777}
778
779/// Interpolate `$VAR` and `${VAR}` patterns in a string, replacing only
780/// variables in `allowed_env_vars`. Non-allowed variables are replaced with
781/// empty string.
782fn interpolate_env_vars(value: &str, allowed: Option<&[String]>) -> String {
783    let mut result = value.to_string();
784
785    // Replace ${VAR} form first, then $VAR form.
786    for (pattern, var_name) in find_env_var_refs(&result) {
787        let replacement = if is_allowed(&var_name, allowed) {
788            std::env::var(&var_name).unwrap_or_default()
789        } else {
790            String::new()
791        };
792        result = result.replacen(&pattern, &replacement, 1);
793    }
794    result
795}
796
797/// Find all `${VAR}` and `$VAR` patterns in `s`, returning `(full_pattern, var_name)` pairs.
798fn find_env_var_refs(s: &str) -> Vec<(String, String)> {
799    let mut refs = Vec::new();
800    let bytes = s.as_bytes();
801    let mut i = 0;
802    while i < bytes.len() {
803        if bytes[i] == b'$' {
804            if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
805                // ${VAR} form
806                if let Some(end) = s[i + 2..].find('}') {
807                    let var_name = &s[i + 2..i + 2 + end];
808                    refs.push((format!("${{{var_name}}}"), var_name.to_string()));
809                    i += 3 + end;
810                    continue;
811                }
812            } else {
813                // $VAR form — collect alphanumeric + underscore
814                let start = i + 1;
815                let mut end = start;
816                while end < bytes.len()
817                    && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
818                {
819                    end += 1;
820                }
821                if end > start {
822                    let var_name = &s[start..end];
823                    refs.push((format!("${var_name}"), var_name.to_string()));
824                    i = end;
825                    continue;
826                }
827            }
828        }
829        i += 1;
830    }
831    refs
832}
833
834fn is_allowed(var: &str, allowed: Option<&[String]>) -> bool {
835    let Some(list) = allowed else { return true };
836    list.iter().any(|a| a == var)
837}
838
839// ── helpers ────────────────────────────────────────────────────────
840
841/// Check whether `path` is a regular file with an executable bit set.
842///
843/// On Unix/macOS this checks the owner/group/world execute permission
844/// bits. On Windows this function always returns `false` because the
845/// concept of an executable bit doesn't exist on that platform.
846fn is_executable(path: &std::path::Path) -> bool {
847    #[cfg(unix)]
848    {
849        use std::os::unix::fs::PermissionsExt;
850        path.is_file()
851            && std::fs::metadata(path)
852                .map(|m| m.permissions().mode() & 0o111 != 0)
853                .unwrap_or(false)
854    }
855    #[cfg(not(unix))]
856    {
857        // On non-Unix platforms we fall back to checking the extension.
858        let _ = path;
859        false
860    }
861}
862
863// ── tests ──────────────────────────────────────────────────────────
864
865#[cfg(test)]
866mod tests {
867    use super::*;
868
869    /// Convenience helper: build a tool-call `HookInput` for tests.
870    fn make_tool_input(event: HookEvent, tool: &str, args: serde_json::Value) -> HookInput {
871        HookInput {
872            event,
873            tool_name: Some(tool.to_string()),
874            args: Some(args),
875            mode: "ask".to_string(),
876            content: None,
877            message: None,
878            depth: None,
879            reason: None,
880            error: None,
881        }
882    }
883
884    // ── JSON parsing ────────────────────────────────────────────
885
886    #[test]
887    fn hook_output_parse_continue() {
888        let json = r#"{"action":"continue"}"#;
889        let out: HookOutput = serde_json::from_str(json).unwrap();
890        assert!(matches!(out.action, Some(JsonAction::Continue)));
891        assert!(out.message.is_none());
892    }
893
894    #[test]
895    fn hook_output_parse_skip() {
896        let json = r#"{"action":"skip","message":"blocked"}"#;
897        let out: HookOutput = serde_json::from_str(json).unwrap();
898        assert!(matches!(out.action, Some(JsonAction::Skip)));
899        assert_eq!(out.message.as_deref(), Some("blocked"));
900    }
901
902    #[test]
903    fn hook_output_parse_error() {
904        let json = r#"{"action":"error","message":"not allowed"}"#;
905        let out: HookOutput = serde_json::from_str(json).unwrap();
906        assert!(matches!(out.action, Some(JsonAction::Error)));
907        assert_eq!(out.message.as_deref(), Some("not allowed"));
908    }
909
910    #[test]
911    fn hook_output_parse_camel_case() {
912        let json = r#"{"action":"continue"}"#;
913        let out: HookOutput = serde_json::from_str(json).unwrap();
914        assert!(matches!(out.action, Some(JsonAction::Continue)));
915
916        let json = r#"{"action":"skip","message":"nope"}"#;
917        let out: HookOutput = serde_json::from_str(json).unwrap();
918        assert!(matches!(out.action, Some(JsonAction::Skip)));
919    }
920
921    #[test]
922    fn hook_output_parse_missing_message() {
923        // message is optional
924        let json = r#"{"action":"error"}"#;
925        let out: HookOutput = serde_json::from_str(json).unwrap();
926        assert!(matches!(out.action, Some(JsonAction::Error)));
927        assert!(out.message.is_none());
928    }
929
930    #[test]
931    fn hook_output_into_hook_action_continue() {
932        let json = r#"{"action":"continue"}"#;
933        let out: HookOutput = serde_json::from_str(json).unwrap();
934        let result = out.into_hook_result();
935        assert!(matches!(result.action, HookAction::Continue));
936    }
937
938    #[test]
939    fn hook_output_into_hook_action_skip() {
940        let json = r#"{"action":"skip","message":"blocked"}"#;
941        let out: HookOutput = serde_json::from_str(json).unwrap();
942        let result = out.into_hook_result();
943        assert!(matches!(result.action, HookAction::Skip));
944    }
945
946    #[test]
947    fn hook_output_into_hook_action_error_with_message() {
948        let json = r#"{"action":"error","message":"not allowed"}"#;
949        let out: HookOutput = serde_json::from_str(json).unwrap();
950        let result = out.into_hook_result();
951        assert!(matches!(result.action, HookAction::Error(ref msg) if msg == "not allowed"));
952    }
953
954    #[test]
955    fn hook_output_into_hook_action_error_without_message() {
956        let json = r#"{"action":"error"}"#;
957        let out: HookOutput = serde_json::from_str(json).unwrap();
958        let result = out.into_hook_result();
959        assert!(
960            matches!(result.action, HookAction::Error(ref msg) if msg == "external hook blocked")
961        );
962    }
963
964    // ── Runner semantics ────────────────────────────────────────
965
966    #[test]
967    fn empty_runner_returns_continue() {
968        let runner = ExternalHookRunner::discover(&[]);
969        assert!(runner.is_empty());
970        assert_eq!(runner.len(), 0);
971    }
972
973    #[test]
974    fn discover_skips_non_executable() {
975        // Create a temp dir with a non-executable file.
976        let tmp = tempfile::tempdir().unwrap();
977        let non_exec = tmp.path().join("script.sh");
978        std::fs::write(&non_exec, "echo hello").unwrap();
979        // Ensure it's not executable.
980        #[cfg(unix)]
981        {
982            use std::os::unix::fs::PermissionsExt;
983            let mut perms = std::fs::metadata(&non_exec).unwrap().permissions();
984            perms.set_mode(0o644);
985            std::fs::set_permissions(&non_exec, perms).unwrap();
986        }
987        let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
988        assert!(runner.is_empty());
989    }
990
991    #[test]
992    #[cfg(unix)]
993    fn discover_collects_executable() {
994        use std::os::unix::fs::PermissionsExt;
995        let tmp = tempfile::tempdir().unwrap();
996        let exec = tmp.path().join("hook.sh");
997        std::fs::write(&exec, "#!/bin/sh\necho '{\"action\":\"continue\"}'").unwrap();
998        let mut perms = std::fs::metadata(&exec).unwrap().permissions();
999        perms.set_mode(0o755);
1000        std::fs::set_permissions(&exec, perms).unwrap();
1001        let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
1002        assert_eq!(runner.len(), 1);
1003    }
1004
1005    #[test]
1006    fn is_executable_rejects_directory() {
1007        let tmp = tempfile::tempdir().unwrap();
1008        let dir = tmp.path().join("subdir");
1009        std::fs::create_dir(&dir).unwrap();
1010        // Even if the directory has the x bit, is_executable requires is_file().
1011        assert!(!is_executable(&dir));
1012    }
1013
1014    #[test]
1015    fn is_executable_rejects_nonexistent() {
1016        assert!(!is_executable(std::path::Path::new(
1017            "/nonexistent/path/script"
1018        )));
1019    }
1020
1021    // ── Integration test: run a real shell script ───────────────
1022
1023    #[tokio::test]
1024    #[cfg(unix)]
1025    async fn dispatch_runs_executable_hook_and_returns_decision() {
1026        use std::os::unix::fs::PermissionsExt;
1027        let tmp = tempfile::tempdir().unwrap();
1028        let hook_path = tmp.path().join("my-hook.sh");
1029
1030        // A hook script that reads stdin, echoes back a skip decision.
1031        let script = r#"#!/bin/sh
1032read -r line
1033echo '{"action":"skip","message":"blocked by test hook"}'
1034"#;
1035        std::fs::write(&hook_path, script).unwrap();
1036        let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1037        perms.set_mode(0o755);
1038        std::fs::set_permissions(&hook_path, perms).unwrap();
1039
1040        let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
1041        assert_eq!(runner.len(), 1);
1042        let input = make_tool_input(
1043            HookEvent::PreToolCall,
1044            "run_shell",
1045            serde_json::json!({"command": "ls"}),
1046        );
1047        let result = runner.dispatch(&input).await;
1048        assert!(matches!(result.action, HookAction::Skip));
1049    }
1050
1051    #[tokio::test]
1052    async fn dispatch_returns_continue_when_no_hooks() {
1053        let runner = ExternalHookRunner::discover(&[]);
1054        let input = make_tool_input(
1055            HookEvent::PreToolCall,
1056            "read_file",
1057            serde_json::json!({"path": "foo.txt"}),
1058        );
1059        let result = runner.dispatch(&input).await;
1060        assert!(matches!(result.action, HookAction::Continue));
1061    }
1062
1063    #[tokio::test]
1064    #[cfg(unix)]
1065    async fn dispatch_treats_timeout_as_continue() {
1066        use std::os::unix::fs::PermissionsExt;
1067        let tmp = tempfile::tempdir().unwrap();
1068        let hook_path = tmp.path().join("hang.sh");
1069
1070        // A hook that hangs (sleeps 30s — longer than the 5s timeout).
1071        let script = "#!/bin/sh\nsleep 30\n";
1072        std::fs::write(&hook_path, script).unwrap();
1073        let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1074        perms.set_mode(0o755);
1075        std::fs::set_permissions(&hook_path, perms).unwrap();
1076
1077        let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
1078        assert_eq!(runner.len(), 1);
1079        let input = make_tool_input(
1080            HookEvent::PreToolCall,
1081            "run_shell",
1082            serde_json::json!({"command": "ls"}),
1083        );
1084        let result = runner.dispatch(&input).await;
1085        // Timeout → fail-open → Continue.
1086        assert!(matches!(result.action, HookAction::Continue));
1087    }
1088
1089    #[tokio::test]
1090    #[cfg(unix)]
1091    async fn dispatch_treats_bad_output_as_continue() {
1092        use std::os::unix::fs::PermissionsExt;
1093        let tmp = tempfile::tempdir().unwrap();
1094        let hook_path = tmp.path().join("bad.sh");
1095
1096        // A hook that outputs invalid JSON.
1097        let script = "#!/bin/sh\necho 'not json'\n";
1098        std::fs::write(&hook_path, script).unwrap();
1099        let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1100        perms.set_mode(0o755);
1101        std::fs::set_permissions(&hook_path, perms).unwrap();
1102
1103        let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
1104        assert_eq!(runner.len(), 1);
1105        let input = make_tool_input(
1106            HookEvent::PreToolCall,
1107            "run_shell",
1108            serde_json::json!({"command": "ls"}),
1109        );
1110        let result = runner.dispatch(&input).await;
1111        // Bad output → fail-open → Continue.
1112        assert!(matches!(result.action, HookAction::Continue));
1113    }
1114
1115    #[tokio::test]
1116    #[cfg(unix)]
1117    async fn dispatch_short_circuits_on_first_non_continue() {
1118        use std::os::unix::fs::PermissionsExt;
1119        let tmp = tempfile::tempdir().unwrap();
1120
1121        // Hook 1: returns skip.
1122        let h1 = tmp.path().join("h1.sh");
1123        let s1 = "#!/bin/sh\nread -r line\necho '{\"action\":\"skip\",\"message\":\"first\"}'\n";
1124        std::fs::write(&h1, s1).unwrap();
1125
1126        // Hook 2: returns continue (should NOT be called).
1127        let h2 = tmp.path().join("h2.sh");
1128        let s2 = "#!/bin/sh\nread -r line\necho '{\"action\":\"continue\"}'\n";
1129        std::fs::write(&h2, s2).unwrap();
1130
1131        for p in [&h1, &h2] {
1132            let mut perms = std::fs::metadata(p).unwrap().permissions();
1133            perms.set_mode(0o755);
1134            std::fs::set_permissions(p, perms).unwrap();
1135        }
1136
1137        let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
1138        assert_eq!(runner.len(), 2);
1139        let input = make_tool_input(
1140            HookEvent::PreToolCall,
1141            "write_file",
1142            serde_json::json!({"path": "test.txt"}),
1143        );
1144        let result = runner.dispatch(&input).await;
1145        assert!(matches!(result.action, HookAction::Skip));
1146    }
1147
1148    // ── Goal 204 new tests ──────────────────────────────────────
1149
1150    #[test]
1151    fn new_hook_events_serialize_camel_case() {
1152        let cases: &[(&str, HookEvent)] = &[
1153            ("postToolCallFailure", HookEvent::PostToolCallFailure),
1154            ("permissionDenied", HookEvent::PermissionDenied),
1155            ("sessionStart", HookEvent::SessionStart),
1156            ("sessionEnd", HookEvent::SessionEnd),
1157            ("userPromptSubmit", HookEvent::UserPromptSubmit),
1158            ("stop", HookEvent::Stop),
1159            ("subagentStart", HookEvent::SubagentStart),
1160            ("subagentStop", HookEvent::SubagentStop),
1161            ("notification", HookEvent::Notification),
1162            ("setup", HookEvent::Setup),
1163        ];
1164        for (expected, event) in cases {
1165            let json = serde_json::to_string(event).unwrap();
1166            assert_eq!(
1167                json,
1168                format!("\"{expected}\""),
1169                "wrong camelCase for {expected}"
1170            );
1171        }
1172    }
1173
1174    #[test]
1175    fn hook_input_optional_fields_absent_when_none() {
1176        let input = HookInput {
1177            event: HookEvent::UserPromptSubmit,
1178            tool_name: None,
1179            args: None,
1180            mode: "ask".to_string(),
1181            content: Some("hello".to_string()),
1182            message: None,
1183            depth: None,
1184            reason: None,
1185            error: None,
1186        };
1187        let json = serde_json::to_string(&input).unwrap();
1188        // tool_name and args should be absent
1189        assert!(
1190            !json.contains("tool_name") && !json.contains("toolName"),
1191            "toolName should be absent"
1192        );
1193        assert!(!json.contains("\"args\""), "args should be absent");
1194        assert!(json.contains("\"hello\""), "content should be present");
1195    }
1196
1197    #[test]
1198    fn hook_input_tool_name_present_for_tool_events() {
1199        let input = make_tool_input(
1200            HookEvent::PreToolCall,
1201            "run_shell",
1202            serde_json::json!({"command": "ls"}),
1203        );
1204        let json = serde_json::to_string(&input).unwrap();
1205        assert!(json.contains("run_shell"));
1206        assert!(json.contains("command"));
1207    }
1208
1209    // ── Goal 205 tests ─────────────────────────────────────────────
1210
1211    #[test]
1212    fn hook_output_parses_additional_context() {
1213        let json = r#"{"action":"continue","additionalContext":"extra info"}"#;
1214        let output: HookOutput = serde_json::from_str(json).unwrap();
1215        let result = output.into_hook_result();
1216        assert!(matches!(result.action, HookAction::Continue));
1217        assert_eq!(result.additional_context.as_deref(), Some("extra info"));
1218    }
1219
1220    #[test]
1221    fn hook_output_parses_updated_input() {
1222        let json = r#"{"action":"continue","updatedInput":{"command":"ls -la"}}"#;
1223        let output: HookOutput = serde_json::from_str(json).unwrap();
1224        let result = output.into_hook_result();
1225        assert_eq!(
1226            result.updated_input,
1227            Some(serde_json::json!({"command": "ls -la"}))
1228        );
1229    }
1230
1231    #[test]
1232    fn hook_output_parses_permission_decision_allow() {
1233        let json = r#"{"action":"continue","permissionDecision":"allow","permissionDecisionReason":"safe"}"#;
1234        let output: HookOutput = serde_json::from_str(json).unwrap();
1235        let result = output.into_hook_result();
1236        assert_eq!(result.permission_decision, Some(PermissionDecision::Allow));
1237        assert_eq!(result.permission_decision_reason.as_deref(), Some("safe"));
1238    }
1239
1240    #[test]
1241    fn hook_output_parses_permission_decision_deny() {
1242        let json = r#"{"action":"skip","permissionDecision":"deny"}"#;
1243        let output: HookOutput = serde_json::from_str(json).unwrap();
1244        let result = output.into_hook_result();
1245        assert_eq!(result.permission_decision, Some(PermissionDecision::Deny));
1246        assert!(matches!(result.action, HookAction::Skip));
1247    }
1248
1249    #[test]
1250    fn hook_output_parses_permission_decision_passthrough() {
1251        let json = r#"{"action":"continue","permissionDecision":"passthrough"}"#;
1252        let output: HookOutput = serde_json::from_str(json).unwrap();
1253        let result = output.into_hook_result();
1254        assert_eq!(
1255            result.permission_decision,
1256            Some(PermissionDecision::Passthrough)
1257        );
1258    }
1259
1260    #[test]
1261    fn hook_result_default_is_continue_with_no_extras() {
1262        let result = HookResult::default();
1263        assert!(matches!(result.action, HookAction::Continue));
1264        assert!(result.additional_context.is_none());
1265        assert!(result.updated_input.is_none());
1266        assert!(result.system_message.is_none());
1267        assert!(!result.suppress_output);
1268        assert!(result.permission_decision.is_none());
1269        assert!(result.permission_decision_reason.is_none());
1270    }
1271
1272    #[test]
1273    fn hook_output_system_message_included() {
1274        let json = r#"{"action":"continue","systemMessage":"Please review before proceeding"}"#;
1275        let output: HookOutput = serde_json::from_str(json).unwrap();
1276        let result = output.into_hook_result();
1277        assert_eq!(
1278            result.system_message.as_deref(),
1279            Some("Please review before proceeding")
1280        );
1281    }
1282
1283    #[test]
1284    fn hook_output_suppress_output_default_false() {
1285        let json = r#"{"action":"continue"}"#;
1286        let output: HookOutput = serde_json::from_str(json).unwrap();
1287        let result = output.into_hook_result();
1288        assert!(!result.suppress_output);
1289    }
1290
1291    #[test]
1292    fn hook_output_suppress_output_true() {
1293        let json = r#"{"action":"continue","suppressOutput":true}"#;
1294        let output: HookOutput = serde_json::from_str(json).unwrap();
1295        let result = output.into_hook_result();
1296        assert!(result.suppress_output);
1297    }
1298
1299    // ── Goal 207 HTTP hook tests ───────────────────────────────────
1300
1301    fn make_non_tool_input() -> HookInput {
1302        HookInput {
1303            event: HookEvent::UserPromptSubmit,
1304            tool_name: None,
1305            args: None,
1306            mode: "ask".to_string(),
1307            content: Some("hello".to_string()),
1308            message: None,
1309            depth: None,
1310            reason: None,
1311            error: None,
1312        }
1313    }
1314
1315    #[tokio::test]
1316    async fn http_hook_posts_json_input_and_parses_response() {
1317        let mut server = mockito::Server::new_async().await;
1318        let mock = server
1319            .mock("POST", "/hook")
1320            .with_status(200)
1321            .with_header("content-type", "application/json")
1322            .with_body(r#"{"action":"skip","message":"blocked by http hook"}"#)
1323            .create_async()
1324            .await;
1325
1326        let input = make_non_tool_input();
1327        let url = format!("{}/hook", server.url());
1328        let result = run_http_hook(&url, &input, 10, None, None).await;
1329        assert!(matches!(result.action, HookAction::Skip));
1330        mock.assert_async().await;
1331    }
1332
1333    #[tokio::test]
1334    async fn http_hook_parses_continue_response() {
1335        let mut server = mockito::Server::new_async().await;
1336        server
1337            .mock("POST", "/hook")
1338            .with_status(200)
1339            .with_header("content-type", "application/json")
1340            .with_body(r#"{"action":"continue"}"#)
1341            .create_async()
1342            .await;
1343
1344        let input = make_non_tool_input();
1345        let url = format!("{}/hook", server.url());
1346        let result = run_http_hook(&url, &input, 10, None, None).await;
1347        assert!(matches!(result.action, HookAction::Continue));
1348    }
1349
1350    #[tokio::test]
1351    async fn http_hook_connection_error_returns_continue() {
1352        // Connect to a port that doesn't exist — should fail-open.
1353        let input = make_non_tool_input();
1354        let result = run_http_hook("http://127.0.0.1:19999/hook", &input, 2, None, None).await;
1355        assert!(matches!(result.action, HookAction::Continue));
1356    }
1357
1358    #[tokio::test]
1359    async fn http_hook_bad_json_response_returns_continue() {
1360        let mut server = mockito::Server::new_async().await;
1361        server
1362            .mock("POST", "/hook")
1363            .with_status(200)
1364            .with_body("not json at all")
1365            .create_async()
1366            .await;
1367
1368        let input = make_non_tool_input();
1369        let url = format!("{}/hook", server.url());
1370        let result = run_http_hook(&url, &input, 10, None, None).await;
1371        assert!(matches!(result.action, HookAction::Continue));
1372    }
1373
1374    #[test]
1375    fn env_var_interpolation_respects_allowlist() {
1376        std::env::set_var("MY_TOKEN", "secret123");
1377        let allowed = vec!["MY_TOKEN".to_string()];
1378        let result = interpolate_env_vars("Bearer $MY_TOKEN", Some(&allowed));
1379        assert_eq!(result, "Bearer secret123");
1380    }
1381
1382    #[test]
1383    fn env_var_interpolation_empty_for_non_allowed() {
1384        std::env::set_var("BLOCKED_VAR", "should-not-appear");
1385        let allowed = vec!["OTHER_VAR".to_string()];
1386        let result = interpolate_env_vars("Bearer $BLOCKED_VAR", Some(&allowed));
1387        assert_eq!(result, "Bearer ");
1388    }
1389
1390    #[test]
1391    fn env_var_interpolation_curly_brace_form() {
1392        std::env::set_var("API_KEY", "mykey");
1393        let allowed = vec!["API_KEY".to_string()];
1394        let result = interpolate_env_vars("key=${API_KEY}", Some(&allowed));
1395        assert_eq!(result, "key=mykey");
1396    }
1397
1398    #[test]
1399    fn env_var_interpolation_no_allowlist_replaces_all() {
1400        std::env::set_var("UNGUARDED", "value");
1401        let result = interpolate_env_vars("$UNGUARDED", None);
1402        assert_eq!(result, "value");
1403    }
1404
1405    // ── Goal 208 Prompt hook tests ─────────────────────────────────
1406
1407    #[tokio::test]
1408    async fn prompt_hook_replaces_arguments_placeholder() {
1409        use crate::llm::{Completion, MockProvider};
1410        // The mock provider will capture what prompt it received.
1411        let captured = Arc::new(std::sync::Mutex::new(String::new()));
1412        let c = captured.clone();
1413
1414        // We can't easily intercept the prompt with MockProvider, so just
1415        // verify the integration: mock returns skip JSON.
1416        let provider = MockProvider::new(vec![Completion {
1417            content: r#"{"action":"skip","message":"prompt blocked"}"#.to_string(),
1418            ..Default::default()
1419        }]);
1420        drop(c); // suppress unused warning
1421
1422        let input = make_non_tool_input();
1423        let result = run_prompt_hook(
1424            &provider,
1425            "Is this safe? $ARGUMENTS",
1426            &input,
1427            Duration::from_secs(10),
1428        )
1429        .await;
1430        assert!(matches!(result.action, HookAction::Skip));
1431    }
1432
1433    #[tokio::test]
1434    async fn prompt_hook_uses_llm_response_continue() {
1435        use crate::llm::{Completion, MockProvider};
1436        let provider = MockProvider::new(vec![Completion {
1437            content: r#"{"action":"continue"}"#.to_string(),
1438            ..Default::default()
1439        }]);
1440        let input = make_non_tool_input();
1441        let result = run_prompt_hook(
1442            &provider,
1443            "Check: $ARGUMENTS",
1444            &input,
1445            Duration::from_secs(10),
1446        )
1447        .await;
1448        assert!(matches!(result.action, HookAction::Continue));
1449    }
1450
1451    #[tokio::test]
1452    async fn prompt_hook_falls_back_on_non_json_response() {
1453        use crate::llm::{Completion, MockProvider};
1454        let provider = MockProvider::new(vec![Completion {
1455            content: "Sorry, I cannot evaluate this.".to_string(),
1456            ..Default::default()
1457        }]);
1458        let input = make_non_tool_input();
1459        let result = run_prompt_hook(
1460            &provider,
1461            "Is this safe? $ARGUMENTS",
1462            &input,
1463            Duration::from_secs(10),
1464        )
1465        .await;
1466        // Non-JSON → fail-open → Continue.
1467        assert!(matches!(result.action, HookAction::Continue));
1468    }
1469
1470    #[tokio::test]
1471    async fn no_llm_prompt_hook_returns_continue() {
1472        use crate::hooks::config::HooksConfig;
1473        let json = r#"{"PreToolCall":[{"hooks":[{"type":"prompt","prompt":"Is this safe? $ARGUMENTS"}]}]}"#;
1474        let cfg: HooksConfig = serde_json::from_str(json).unwrap();
1475        // No LLM provided.
1476        let runner = ExternalHookRunner::from_config_with_llm(cfg, None);
1477        assert_eq!(runner.len(), 1); // hook is registered
1478
1479        let input = make_tool_input(HookEvent::PreToolCall, "run_shell", serde_json::json!({}));
1480        let result = runner.dispatch(&input).await;
1481        // Should fail-open because no LLM is configured.
1482        assert!(matches!(result.action, HookAction::Continue));
1483    }
1484
1485    // ── Goal 206 tests ─────────────────────────────────────────────
1486
1487    #[test]
1488    fn from_config_creates_hooks_from_json() {
1489        use crate::hooks::config::HooksConfig;
1490        let json = r#"{"PreToolCall":[{"matcher":"run_shell","hooks":[{"type":"command","command":"/usr/bin/true"}]}]}"#;
1491        let cfg: HooksConfig = serde_json::from_str(json).unwrap();
1492        let runner = ExternalHookRunner::from_config(cfg);
1493        assert_eq!(runner.len(), 1);
1494    }
1495
1496    #[test]
1497    fn from_config_http_type_is_registered() {
1498        use crate::hooks::config::HooksConfig;
1499        // http type is now supported and should be registered
1500        let json = r#"{"PreToolCall":[{"hooks":[{"type":"http","url":"https://example.com"}]}]}"#;
1501        let cfg: HooksConfig = serde_json::from_str(json).unwrap();
1502        let runner = ExternalHookRunner::from_config(cfg);
1503        assert_eq!(runner.len(), 1);
1504    }
1505
1506    #[test]
1507    fn from_config_prompt_without_prompt_field_is_skipped() {
1508        use crate::hooks::config::HooksConfig;
1509        // prompt type without a prompt field → None from resolve_command → skipped
1510        let json = r#"{"PreToolCall":[{"hooks":[{"type":"prompt"}]}]}"#;
1511        let cfg: HooksConfig = serde_json::from_str(json).unwrap();
1512        let runner = ExternalHookRunner::from_config(cfg);
1513        assert_eq!(runner.len(), 0);
1514    }
1515
1516    #[test]
1517    fn from_config_http_without_url_is_skipped() {
1518        use crate::hooks::config::HooksConfig;
1519        // http type without a url field → None from resolve_command → skipped
1520        let json = r#"{"PreToolCall":[{"hooks":[{"type":"http"}]}]}"#;
1521        let cfg: HooksConfig = serde_json::from_str(json).unwrap();
1522        let runner = ExternalHookRunner::from_config(cfg);
1523        assert_eq!(runner.len(), 0);
1524    }
1525
1526    #[test]
1527    fn from_config_empty_config_gives_empty_runner() {
1528        use crate::hooks::config::HooksConfig;
1529        let runner = ExternalHookRunner::from_config(HooksConfig::default());
1530        assert!(runner.is_empty());
1531    }
1532
1533    #[tokio::test]
1534    #[cfg(unix)]
1535    async fn from_config_respects_matcher_event_filter() {
1536        use crate::hooks::config::HooksConfig;
1537        let tmp = tempfile::tempdir().unwrap();
1538        let hook_path = tmp.path().join("hook.sh");
1539        std::fs::write(
1540            &hook_path,
1541            "#!/bin/sh\nread -r line\necho '{\"action\":\"skip\"}'\n",
1542        )
1543        .unwrap();
1544        {
1545            use std::os::unix::fs::PermissionsExt;
1546            let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1547            perms.set_mode(0o755);
1548            std::fs::set_permissions(&hook_path, perms).unwrap();
1549        }
1550        // Register hook only for "PostToolCall" event
1551        let json = format!(
1552            r#"{{"PostToolCall":[{{"matcher":"run_shell","hooks":[{{"type":"command","command":"{}"}}]}}]}}"#,
1553            hook_path.display()
1554        );
1555        let cfg: HooksConfig = serde_json::from_str(&json).unwrap();
1556        let runner = ExternalHookRunner::from_config(cfg);
1557
1558        // Dispatch PreToolCall — should NOT trigger the hook
1559        let pre_input = make_tool_input(HookEvent::PreToolCall, "run_shell", serde_json::json!({}));
1560        let result = runner.dispatch(&pre_input).await;
1561        assert!(
1562            matches!(result.action, HookAction::Continue),
1563            "PreToolCall should not trigger PostToolCall hook"
1564        );
1565
1566        // Dispatch PostToolCall — SHOULD trigger the hook
1567        let post_input =
1568            make_tool_input(HookEvent::PostToolCall, "run_shell", serde_json::json!({}));
1569        let result = runner.dispatch(&post_input).await;
1570        assert!(
1571            matches!(result.action, HookAction::Skip),
1572            "PostToolCall should trigger hook"
1573        );
1574    }
1575
1576    // ── Goal 209 async / once tests ───────────────────────────────
1577
1578    #[tokio::test]
1579    #[cfg(unix)]
1580    async fn once_hook_runs_only_first_time() {
1581        use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1582        let tmp = tempfile::tempdir().unwrap();
1583        let counter_file = tmp.path().join("counter");
1584        let hook_path = tmp.path().join("once_hook.sh");
1585        std::fs::write(&counter_file, "0").unwrap();
1586        let script = format!(
1587            "#!/bin/sh\nread -r _\necho $(( $(cat {0}) + 1 )) > {0}\necho '{{\"action\":\"continue\"}}'\n",
1588            counter_file.display()
1589        );
1590        std::fs::write(&hook_path, &script).unwrap();
1591        {
1592            use std::os::unix::fs::PermissionsExt;
1593            let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1594            perms.set_mode(0o755);
1595            std::fs::set_permissions(&hook_path, perms).unwrap();
1596        }
1597        let cfg = HooksConfig {
1598            events: std::collections::HashMap::from([(
1599                "PreToolCall".to_string(),
1600                vec![HookMatcher {
1601                    matcher: None,
1602                    hooks: vec![HookCommand {
1603                        r#type: HookCommandType::Command,
1604                        command: Some(hook_path.to_string_lossy().to_string()),
1605                        once: true,
1606                        timeout: 5,
1607                        ..Default::default()
1608                    }],
1609                }],
1610            )]),
1611        };
1612        let runner = ExternalHookRunner::from_config(cfg);
1613        let input = make_tool_input(HookEvent::PreToolCall, "run_shell", serde_json::json!({}));
1614        runner.dispatch(&input).await;
1615        runner.dispatch(&input).await;
1616        // Counter should be 1, not 2.
1617        let count = std::fs::read_to_string(&counter_file)
1618            .unwrap()
1619            .trim()
1620            .to_string();
1621        assert_eq!(count, "1", "once hook should have run exactly once");
1622    }
1623
1624    #[tokio::test]
1625    #[cfg(unix)]
1626    async fn async_hook_returns_continue_immediately() {
1627        use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1628        let tmp = tempfile::tempdir().unwrap();
1629        let hook_path = tmp.path().join("slow_hook.sh");
1630        // Hook that sleeps 10s — if async works, dispatch should return immediately.
1631        std::fs::write(
1632            &hook_path,
1633            "#!/bin/sh\nread -r _\nsleep 10\necho '{\"action\":\"skip\"}'\n",
1634        )
1635        .unwrap();
1636        {
1637            use std::os::unix::fs::PermissionsExt;
1638            let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1639            perms.set_mode(0o755);
1640            std::fs::set_permissions(&hook_path, perms).unwrap();
1641        }
1642        let cfg = HooksConfig {
1643            events: std::collections::HashMap::from([(
1644                "PreToolCall".to_string(),
1645                vec![HookMatcher {
1646                    matcher: None,
1647                    hooks: vec![HookCommand {
1648                        r#type: HookCommandType::Command,
1649                        command: Some(hook_path.to_string_lossy().to_string()),
1650                        r#async: true,
1651                        timeout: 5,
1652                        ..Default::default()
1653                    }],
1654                }],
1655            )]),
1656        };
1657        let runner = ExternalHookRunner::from_config(cfg);
1658        let input = make_tool_input(HookEvent::PreToolCall, "run_shell", serde_json::json!({}));
1659        let start = std::time::Instant::now();
1660        let result = runner.dispatch(&input).await;
1661        let elapsed = start.elapsed();
1662        // Should return immediately (< 1s) even though hook sleeps 10s.
1663        assert!(
1664            elapsed.as_secs() < 1,
1665            "async hook blocked dispatch: {elapsed:?}"
1666        );
1667        // Async hooks always return Continue immediately.
1668        assert!(matches!(result.action, HookAction::Continue));
1669    }
1670
1671    #[tokio::test]
1672    #[cfg(unix)]
1673    async fn async_rewake_exit2_triggers_cancel() {
1674        use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1675        let tmp = tempfile::tempdir().unwrap();
1676        let hook_path = tmp.path().join("exit2.sh");
1677        // Hook that exits with code 2.
1678        std::fs::write(&hook_path, "#!/bin/sh\nread -r _\nexit 2\n").unwrap();
1679        {
1680            use std::os::unix::fs::PermissionsExt;
1681            let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1682            perms.set_mode(0o755);
1683            std::fs::set_permissions(&hook_path, perms).unwrap();
1684        }
1685        let token = CancellationToken::new();
1686        let child_token = token.clone();
1687        let cfg = HooksConfig {
1688            events: std::collections::HashMap::from([(
1689                "PreToolCall".to_string(),
1690                vec![HookMatcher {
1691                    matcher: None,
1692                    hooks: vec![HookCommand {
1693                        r#type: HookCommandType::Command,
1694                        command: Some(hook_path.to_string_lossy().to_string()),
1695                        async_rewake: true,
1696                        timeout: 5,
1697                        ..Default::default()
1698                    }],
1699                }],
1700            )]),
1701        };
1702        let runner = ExternalHookRunner::from_config(cfg).with_cancel_token(child_token);
1703        let input = make_tool_input(HookEvent::PreToolCall, "run_shell", serde_json::json!({}));
1704        runner.dispatch(&input).await;
1705        // Wait for the background task to complete and cancel the token.
1706        // Use a longer timeout to avoid flakiness under parallel test load.
1707        let deadline = std::time::Instant::now() + Duration::from_secs(5);
1708        while !token.is_cancelled() {
1709            if std::time::Instant::now() >= deadline {
1710                panic!("timed out waiting for asyncRewake cancellation (exit 2 hook)");
1711            }
1712            tokio::time::sleep(Duration::from_millis(50)).await;
1713        }
1714        assert!(
1715            token.is_cancelled(),
1716            "exit code 2 should have triggered cancellation"
1717        );
1718    }
1719
1720    #[tokio::test]
1721    #[cfg(unix)]
1722    async fn async_rewake_exit0_no_cancel() {
1723        use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1724        let tmp = tempfile::tempdir().unwrap();
1725        let hook_path = tmp.path().join("exit0.sh");
1726        // Hook that exits with code 0 (success).
1727        std::fs::write(&hook_path, "#!/bin/sh\nread -r _\nexit 0\n").unwrap();
1728        {
1729            use std::os::unix::fs::PermissionsExt;
1730            let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1731            perms.set_mode(0o755);
1732            std::fs::set_permissions(&hook_path, perms).unwrap();
1733        }
1734        let token = CancellationToken::new();
1735        let child_token = token.clone();
1736        let cfg = HooksConfig {
1737            events: std::collections::HashMap::from([(
1738                "PreToolCall".to_string(),
1739                vec![HookMatcher {
1740                    matcher: None,
1741                    hooks: vec![HookCommand {
1742                        r#type: HookCommandType::Command,
1743                        command: Some(hook_path.to_string_lossy().to_string()),
1744                        async_rewake: true,
1745                        timeout: 5,
1746                        ..Default::default()
1747                    }],
1748                }],
1749            )]),
1750        };
1751        let runner = ExternalHookRunner::from_config(cfg).with_cancel_token(child_token);
1752        let input = make_tool_input(HookEvent::PreToolCall, "run_shell", serde_json::json!({}));
1753        runner.dispatch(&input).await;
1754        tokio::time::sleep(Duration::from_millis(300)).await;
1755        assert!(
1756            !token.is_cancelled(),
1757            "exit code 0 should NOT trigger cancellation"
1758        );
1759    }
1760}