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/// MCP tool names are `__`-separated (e.g. `mcp__fs__read`), not file paths — `globset`
95/// treats `*` as "does not cross `/`" by default, but tool names contain no `/`, so
96/// `mcp__*` 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;