Skip to main content

defect_agent/
hooks.rs

1//! Hook system: extension points for the agent main loop.
2//!
3//! ## Abstraction layers
4//!
5//! - [`HookStep`](step::HookStep): interception points called by the main loop at step
6//!   boundaries (bucketed by event name)
7//! - [`StepHandler`]: a single executor (implemented in submodules as Builtin / Command /
8//!   Prompt)
9//! - [`HookMatcher`]: matching conditions for a single hook (filtering by tool / glob /
10//!   safety)
11//! - [`HookEngine`]: the dispatcher the main loop interacts with; holds a
12//!   [`HandlerTable`], executes the pipeline, and merges verdicts
13//!
14//! ## Default implementations
15//!
16//! [`NoopHookEngine`]: all `fire` calls return `Pass` directly, `observe` calls are
17//! discarded; used when no explicit hook engine is provided during session/turn assembly,
18//! preserving "no hook configured = main loop behavior unchanged".
19//!
20//! [`DefaultHookEngine`]: holds the handler table via [`arc_swap::ArcSwap`], dispatches
21//! serially according to the pipeline semantics; matcher, timeout, and panic
22//! capture are handled per the degradation table.
23
24use std::panic::AssertUnwindSafe;
25use std::path::Path;
26use std::sync::Arc;
27use std::time::Duration;
28
29use agent_client_protocol_schema::SessionId;
30use arc_swap::ArcSwap;
31use futures::FutureExt;
32use futures::future::BoxFuture;
33use serde_json::Value;
34use tokio_util::sync::CancellationToken;
35
36use crate::error::BoxError;
37use crate::tool::SafetyClass;
38
39pub mod builtin;
40pub mod command;
41pub mod prompt;
42pub mod step;
43
44/// Default per-handler timeout for `DefaultHookEngine`.
45const DEFAULT_HANDLER_TIMEOUT: Duration = Duration::from_secs(5);
46
47/// Matching conditions for a single hook.
48///
49/// Shape is identical to `defect-config`'s `HookMatcher`; the agent crate does not depend
50/// on config,
51/// so this is defined independently and the CLI translates the config shape into the
52/// agent shape at assembly time.
53/// See hooks design for trust model.
54///
55/// All fields empty = match all triggers under that event.
56#[non_exhaustive]
57#[derive(Debug, Clone, Default)]
58pub struct HookMatcher {
59    /// Match by exact tool name (only for `*ToolUse*` events).
60    pub tool: Option<String>,
61    /// Glob match by tool name (only for `*ToolUse*` events).
62    pub tool_glob: Option<String>,
63    /// Filter by [`SafetyClass`] (only `PreToolUse`); any match triggers. Empty vec = no
64    /// filter.
65    pub safety: Vec<SafetyClass>,
66}
67
68impl HookMatcher {
69    /// Matches a step model by tool name and safety (both taken from the step envelope;
70    /// non-tool steps pass `None`).
71    ///
72    /// All fields empty = matches everything. `tool` is exact, `tool_glob` is a glob
73    /// pattern, `safety` matches any (empty vec = no filter).
74    pub fn matches_step(&self, tool: Option<&str>, safety: Option<SafetyClass>) -> bool {
75        if let Some(expected) = &self.tool
76            && tool.is_none_or(|n| n != expected)
77        {
78            return false;
79        }
80        if let Some(pat) = &self.tool_glob
81            && tool.is_none_or(|n| !tool_name_matches(pat, n))
82        {
83            return false;
84        }
85        if !self.safety.is_empty() && safety.is_none_or(|s| !self.safety.contains(&s)) {
86            return false;
87        }
88        true
89    }
90}
91
92/// Tool name glob matching, using [`globset`] (same as skill triggers / search).
93///
94/// Tool names are dot-separated (e.g. `mcp.fs.read`), not file paths — `globset` treats
95/// `*` as "does not cross `/`" by default, but tool names contain no `/`, so `mcp.*`
96/// matches the whole string correctly. Patterns are compiled on each match (tool name
97/// matches are infrequent and patterns are short, so compilation overhead is negligible).
98/// Invalid patterns do not panic: a warn is logged and the match is treated as no-match
99/// (matcher mismatch = the hook is not triggered, safe side).
100fn tool_name_matches(pattern: &str, name: &str) -> bool {
101    match globset::Glob::new(pattern) {
102        Ok(glob) => glob.compile_matcher().is_match(name),
103        Err(err) => {
104            tracing::warn!(%pattern, %err, "invalid tool_glob pattern; treating as no-match");
105            false
106        }
107    }
108}
109
110/// A lightweight context shared with the handler.
111#[non_exhaustive]
112pub struct HookCtx<'a> {
113    pub session_id: &'a SessionId,
114    pub cwd: &'a Path,
115    pub cancel: CancellationToken,
116}
117
118impl<'a> HookCtx<'a> {
119    pub fn new(session_id: &'a SessionId, cwd: &'a Path, cancel: CancellationToken) -> Self {
120        Self {
121            session_id,
122            cwd,
123            cancel,
124        }
125    }
126}
127
128/// Reasons for handler failure.
129#[non_exhaustive]
130#[derive(Debug, thiserror::Error)]
131pub enum HookError {
132    #[error("hook handler timed out")]
133    Timeout,
134
135    #[error("hook handler failed: {0}")]
136    HandlerFailed(#[source] BoxError),
137
138    /// Handler trust not established, unregistered, or other configuration-layer errors.
139    #[error("hook configuration error: {0}")]
140    Configuration(String),
141}
142
143/// **Step model handler** (migration target). The engine gives it an input envelope for a
144/// mount point (produced by [`step::HookStep::to_envelope`]), and it produces a verdict
145/// JSON — the engine then applies the verdict back to the step via
146/// [`step::HookStep::apply_verdict`]. Both hook types implement this: internal Rust hooks
147/// compute the verdict directly; command/prompt hooks feed the envelope to a
148/// subprocess/LLM and parse the output into a verdict.
149///
150/// Returns `Ok(None)` = no intervention (equivalent to an empty verdict);
151/// `Ok(Some(verdict))` = apply that verdict; `Err` = failure, handled by the engine
152/// according to the degradation table.
153pub trait StepHandler: Send + Sync {
154    /// Process a mount point: input envelope → verdict JSON.
155    fn handle_step<'a>(
156        &'a self,
157        envelope: &'a Value,
158        ctx: HookCtx<'a>,
159    ) -> BoxFuture<'a, Result<Option<Value>, HookError>>;
160}
161
162// ---------------------------------------------------------------------------
163// HookEngine
164// ---------------------------------------------------------------------------
165
166/// Dispatcher for the main loop (step model).
167///
168/// The sole entry point is [`Self::dispatch`]: given a [`step::HookStep`] for a mount
169/// point, the engine finds matching handlers by `event_name`, feeds each handler the step
170/// envelope, applies the verdict back to the step (accumulating on the data axis), and
171/// merges the final [`step::HookControl`] (early exit on the control axis). Field
172/// mutations on the step (injection, argument changes, output filling, etc.) take effect
173/// in place. Summary: what the caller should read + control indication.
174///
175/// Default implementation is [`DefaultHookEngine`]; tests and default session setup use
176/// [`NoopHookEngine`].
177pub trait HookEngine: Send + Sync {
178    /// **Default implementation returns [`step::HookControl::Proceed`]** (no
179    /// intervention); [`NoopHookEngine`] uses this directly. [`DefaultHookEngine`]
180    /// overrides it for real dispatch.
181    fn dispatch<'a>(
182        &'a self,
183        _step: &'a mut dyn step::HookStep,
184        _ctx: HookCtx<'a>,
185    ) -> BoxFuture<'a, step::HookControl> {
186        Box::pin(async { step::HookControl::Proceed })
187    }
188}
189
190// ---------------------------------------------------------------------------
191// NoopHookEngine
192// ---------------------------------------------------------------------------
193
194/// Default hook engine: `dispatch` uses the trait's default implementation (`Proceed`,
195/// i.e., no-op).
196///
197/// When assembling a session/turn without an explicitly injected hook engine, this is
198/// used — ensuring that "no hook configured = main loop behavior is completely
199/// unchanged", analogous to [`crate::http::NoopHttpClient`].
200#[derive(Debug, Default)]
201pub struct NoopHookEngine;
202
203impl HookEngine for NoopHookEngine {}
204
205// ---------------------------------------------------------------------------
206// DefaultHookEngine
207// ---------------------------------------------------------------------------
208
209/// A handler table bucketed by step `event_name`.
210///
211/// It is mounted inside [`DefaultHookEngine`] and replaced atomically via
212/// [`DefaultHookEngine::reload`] — `ArcSwap` makes runtime hot-reloading nearly
213/// zero-cost.
214#[derive(Default)]
215pub struct HandlerTable {
216    /// Handler list indexed by step `event_name` (snake_case). Declaration order
217    /// determines pipeline execution order.
218    pub step_buckets: std::collections::HashMap<&'static str, Vec<StepHandlerEntry>>,
219}
220
221/// A fully assembled step handler: name, matcher, handler, and per-entry timeout.
222pub struct StepHandlerEntry {
223    /// Display name, used only in tracing / observability to identify this hook. Defaults
224    /// to an anonymous label (see [`Self::new`]); assemblers can override it with
225    /// [`Self::with_name`].
226    pub name: String,
227    pub matcher: HookMatcher,
228    pub handler: Arc<dyn StepHandler>,
229    pub timeout: Option<Duration>,
230}
231
232/// Placeholder name used in tracing for unnamed hooks.
233pub const ANONYMOUS_HOOK_NAME: &str = "anonymous";
234
235impl StepHandlerEntry {
236    pub fn new(matcher: HookMatcher, handler: Arc<dyn StepHandler>) -> Self {
237        Self {
238            name: ANONYMOUS_HOOK_NAME.to_string(),
239            matcher,
240            handler,
241            timeout: None,
242        }
243    }
244
245    /// Sets the display name. `None` keeps the anonymous placeholder
246    /// ([`ANONYMOUS_HOOK_NAME`]).
247    pub fn with_name(mut self, name: Option<String>) -> Self {
248        if let Some(name) = name {
249            self.name = name;
250        }
251        self
252    }
253
254    pub fn with_timeout(mut self, timeout: Duration) -> Self {
255        self.timeout = Some(timeout);
256        self
257    }
258}
259
260impl HandlerTable {
261    pub fn empty() -> Self {
262        Self::default()
263    }
264
265    /// Step handlers assembled under the step `event_name`.
266    pub fn step_handlers(&self, event_name: &str) -> &[StepHandlerEntry] {
267        self.step_buckets
268            .get(event_name)
269            .map(Vec::as_slice)
270            .unwrap_or(&[])
271    }
272
273    /// Appends a step handler under the given step `event_name`.
274    pub fn push_step(&mut self, event_name: &'static str, entry: StepHandlerEntry) {
275        self.step_buckets.entry(event_name).or_default().push(entry);
276    }
277}
278
279/// Default hook engine: serial dispatch following the pipeline semantics.
280///
281/// - Uses [`ArcSwap`] to hold a [`HandlerTable`]; [`Self::reload`] enables full hot-swap
282/// - `fire` internally filters by matcher → serial await, each handler sees the event
283///   after
284///   all prior patches have been applied
285/// - Timeout, panic, or error in a single handler is downgraded per the degradation table
286pub struct DefaultHookEngine {
287    table: ArcSwap<HandlerTable>,
288}
289
290impl DefaultHookEngine {
291    pub fn new() -> Self {
292        Self {
293            table: ArcSwap::from_pointee(HandlerTable::empty()),
294        }
295    }
296
297    /// Atomically replace the entire handler table with a new one; used for runtime
298    /// hot-reloading.
299    ///
300    /// The old table is automatically reclaimed by `Arc` once all in-flight
301    /// `fire`/`observe` calls finish.
302    pub fn reload(&self, table: HandlerTable) {
303        self.table.store(Arc::new(table));
304    }
305
306    /// A snapshot reference to the current handler table. Intended for
307    /// testing/diagnostics only.
308    #[doc(hidden)]
309    pub fn snapshot(&self) -> Arc<HandlerTable> {
310        self.table.load_full()
311    }
312}
313
314impl Default for DefaultHookEngine {
315    fn default() -> Self {
316        Self::new()
317    }
318}
319
320impl HookEngine for DefaultHookEngine {
321    fn dispatch<'a>(
322        &'a self,
323        step: &'a mut dyn step::HookStep,
324        ctx: HookCtx<'a>,
325    ) -> BoxFuture<'a, step::HookControl> {
326        let table = self.table.load_full();
327        Box::pin(async move {
328            let entries = table.step_handlers(step.event_name());
329            if entries.is_empty() {
330                return step::HookControl::Proceed;
331            }
332
333            // The matcher filters by tool name and safety, which are extracted from the
334            // step envelope (only *ToolApply* steps carry these fields).
335            let envelope_json = with_common_header(step.to_envelope(), step.event_name(), &ctx);
336            let tool = envelope_json.get("tool").and_then(Value::as_str);
337            let safety = envelope_json
338                .get("safety")
339                .and_then(Value::as_str)
340                .and_then(parse_safety);
341
342            for entry in entries {
343                if !entry.matcher.matches_step(tool, safety) {
344                    continue;
345                }
346                // Each handler sees the envelope as modified by the previous handler,
347                // plus the common headers.
348                let envelope = with_common_header(step.to_envelope(), step.event_name(), &ctx);
349                let timeout = entry.timeout.unwrap_or(DEFAULT_HANDLER_TIMEOUT);
350                let handler_ctx = HookCtx::new(ctx.session_id, ctx.cwd, ctx.cancel.clone());
351                let fut = AssertUnwindSafe(entry.handler.handle_step(&envelope, handler_ctx))
352                    .catch_unwind();
353                let verdict = match tokio::time::timeout(timeout, fut).await {
354                    Ok(Ok(Ok(v))) => v,
355                    Ok(Ok(Err(err))) => {
356                        tracing::warn!(event = %step.event_name(), hook = %entry.name, error = %err, "step hook handler error; skipped");
357                        continue;
358                    }
359                    Ok(Err(panic)) => {
360                        tracing::warn!(event = %step.event_name(), hook = %entry.name, panic = %panic_message(&panic), "step hook handler panicked; skipped");
361                        continue;
362                    }
363                    Err(_elapsed) => {
364                        tracing::warn!(event = %step.event_name(), hook = %entry.name, "step hook handler timed out; skipped");
365                        continue;
366                    }
367                };
368                let Some(verdict) = verdict else { continue };
369                match step.apply_verdict(&verdict) {
370                    // Early exit on control: anything other than Proceed stops the
371                    // pipeline.
372                    Ok(step::HookControl::Proceed) => {}
373                    Ok(control) => return control,
374                    Err(err) => {
375                        tracing::warn!(event = %step.event_name(), hook = %entry.name, error = %err, "step verdict malformed; skipped");
376                    }
377                }
378            }
379            step::HookControl::Proceed
380        })
381    }
382}
383
384/// Merge common headers into the step-specific envelope. Common headers: `session_id` /
385/// `cwd` / `hook_event`.
386///
387/// The step itself does not hold a `HookCtx` (zero-borrow, `Send`), so the engine fills
388/// in the common context at dispatch time — this ensures every user hook envelope
389/// contains session, cwd, and event name. Step-specific fields take precedence (they are
390/// not overwritten).
391fn with_common_header(envelope: Value, event_name: &str, ctx: &HookCtx<'_>) -> Value {
392    let Value::Object(mut map) = envelope else {
393        return envelope;
394    };
395    map.entry("session_id")
396        .or_insert_with(|| Value::String(ctx.session_id.0.to_string()));
397    map.entry("cwd")
398        .or_insert_with(|| Value::String(ctx.cwd.to_string_lossy().into_owned()));
399    map.entry("hook_event")
400        .or_insert_with(|| Value::String(event_name.to_string()));
401    Value::Object(map)
402}
403
404/// The `safety` field (snake_case) from the envelope maps to [`SafetyClass`]; unknown or
405/// missing values yield `None`.
406fn parse_safety(s: &str) -> Option<SafetyClass> {
407    match s {
408        "read_only" => Some(SafetyClass::ReadOnly),
409        "mutating" => Some(SafetyClass::Mutating),
410        "destructive" => Some(SafetyClass::Destructive),
411        "network" => Some(SafetyClass::Network),
412        _ => None,
413    }
414}
415
416// Extract a text representation from a `catch_unwind` payload without depending on the
417// concrete panic type.
418fn panic_message(payload: &Box<dyn std::any::Any + Send>) -> String {
419    if let Some(s) = payload.downcast_ref::<&'static str>() {
420        (*s).to_string()
421    } else if let Some(s) = payload.downcast_ref::<String>() {
422        s.clone()
423    } else {
424        "<non-string panic payload>".to_string()
425    }
426}
427
428#[cfg(test)]
429mod tests;