Skip to main content

defect_agent/hooks/
builtin.rs

1//! Builtin hook handlers.
2//!
3//! In-process Rust handlers with zero external dependencies. During CLI assembly, they
4//! are looked up by name in [`BuiltinRegistry`], instantiated, and registered into
5//! [`super::HandlerTable`] of `DefaultHookEngine`.
6
7use std::collections::BTreeMap;
8use std::sync::Arc;
9
10use futures::future::BoxFuture;
11use serde_json::{Map, Value};
12
13use super::{HookCtx, HookError, StepHandler};
14use crate::tool::SkillEntry;
15
16/// Registry mapping builtin handler names to factory closures.
17///
18/// When the CLI assembles `DefaultHookEngine`, it feeds `HookHandlerSpec::Builtin { name
19/// }` to
20/// [`Self::lookup_step`]. Unknown names fail fast at config-load time, so users don't
21/// discover
22/// typos mid-turn.
23///
24/// The factory signature is `Fn() -> Arc<dyn HookHandler>`: handlers have no per-config
25/// parameters, and multiple `[[hooks.*]]` entries referencing the same builtin share a
26/// single
27/// `Arc`. If a builtin later needs configuration parameters, upgrade `name` to a
28/// structured
29/// enum and switch the registry to `match` dispatch.
30pub struct BuiltinRegistry {
31    /// A map from name to `Arc<dyn StepHandler>` factory.
32    step_factories: BTreeMap<String, Box<dyn Fn() -> Arc<dyn StepHandler> + Send + Sync>>,
33}
34
35impl BuiltinRegistry {
36    /// Default registry: `tracing-audit` + `redact-secrets`.
37    pub fn defaults() -> Self {
38        let mut reg = Self {
39            step_factories: BTreeMap::new(),
40        };
41        reg.register_step("tracing-audit", || Arc::new(TracingAuditHook));
42        reg.register_step("redact-secrets", || Arc::new(RedactSecretsHook));
43        reg
44    }
45
46    /// Register a builtin step handler factory. Duplicate names overwrite previous
47    /// entries, allowing tests to stub and replace default behavior.
48    pub fn register_step<F>(&mut self, name: &str, factory: F)
49    where
50        F: Fn() -> Arc<dyn StepHandler> + Send + Sync + 'static,
51    {
52        self.step_factories
53            .insert(name.to_string(), Box::new(factory));
54    }
55
56    /// Look up a step handler by name. `None` means the configuration layer should
57    /// fail-fast with an error.
58    pub fn lookup_step(&self, name: &str) -> Option<Arc<dyn StepHandler>> {
59        self.step_factories.get(name).map(|f| f())
60    }
61
62    /// Lists registered builtin names, used by the `defect hooks list` CLI.
63    pub fn names(&self) -> impl Iterator<Item = &str> {
64        self.step_factories.keys().map(String::as_str)
65    }
66}
67
68impl Default for BuiltinRegistry {
69    fn default() -> Self {
70        Self::defaults()
71    }
72}
73
74// tracing-audit
75
76/// Converts `Post*ToolUse` events into structured tracing records.
77///
78/// Intended to be attached to `[[hooks.post_tool_use]]` /
79/// `[[hooks.post_tool_use_failure]]` for an audit trail; attaching it to other events
80/// will cause [`StepHandler::handle_step`] to simply `Pass` through.
81pub struct TracingAuditHook;
82
83impl StepHandler for TracingAuditHook {
84    /// Step model: consumes an `after_tool_apply` envelope `{tool, is_error}`, writes a
85    /// structured audit log, and produces no verdict.
86    fn handle_step<'a>(
87        &'a self,
88        envelope: &'a Value,
89        _ctx: HookCtx<'a>,
90    ) -> BoxFuture<'a, Result<Option<Value>, HookError>> {
91        Box::pin(async move {
92            let tool = envelope.get("tool").and_then(Value::as_str).unwrap_or("?");
93            let is_error = envelope
94                .get("is_error")
95                .and_then(Value::as_bool)
96                .unwrap_or(false);
97            tracing::info!(
98                target: "defect_agent::hooks::audit",
99                tool = %tool,
100                outcome = if is_error { "error" } else { "ok" },
101                "tool call completed",
102            );
103            Ok(None)
104        })
105    }
106}
107
108// ---------------------------------------------------------------------------
109// redact-secrets
110// ---------------------------------------------------------------------------
111
112/// On `PreToolUse`, performs in-place replacement of likely sensitive fields in `args`.
113///
114/// Matches (case-insensitive substring): `password` / `secret` / `token` / `api_key`
115/// / `apikey` / `authorization`. When matched, the field value is replaced with `"***"`
116/// and patched into `args`.
117///
118/// Only operates when `args` is an `Object`; other shapes (arrays, strings) are left
119/// untouched — the shape of `args` is defined by the tool itself, and deep recursive
120/// rewriting could break tool semantics.
121///
122/// Does not handle `password=xxx` embedded inside a `bash` `command` string — that would
123/// require shell lexing, which is beyond the stability guarantees of this builtin.
124pub struct RedactSecretsHook;
125
126const SECRET_KEY_NEEDLES: &[&str] = &[
127    "password",
128    "secret",
129    "token",
130    "api_key",
131    "apikey",
132    "authorization",
133];
134
135impl StepHandler for RedactSecretsHook {
136    /// Step model: consumes the `before_tool_apply` envelope `{tool, args}`, redacts
137    /// potentially sensitive fields in `args` in place, and returns a `{args:
138    /// <redacted>}` verdict if any were found (the engine applies it back to the step,
139    /// modifying `args`).
140    fn handle_step<'a>(
141        &'a self,
142        envelope: &'a Value,
143        _ctx: HookCtx<'a>,
144    ) -> BoxFuture<'a, Result<Option<Value>, HookError>> {
145        let verdict = envelope
146            .get("args")
147            .and_then(Value::as_object)
148            .map(redact_object)
149            .filter(|r| r.changed)
150            .map(|r| serde_json::json!({ "args": Value::Object(r.value) }));
151        Box::pin(async move { Ok(verdict) })
152    }
153}
154
155struct Redacted {
156    value: Map<String, Value>,
157    changed: bool,
158}
159
160fn redact_object(obj: &Map<String, Value>) -> Redacted {
161    let mut out = Map::with_capacity(obj.len());
162    let mut changed = false;
163    for (key, value) in obj {
164        if key_is_secret(key) {
165            out.insert(key.clone(), Value::String("***".to_string()));
166            changed = true;
167        } else {
168            out.insert(key.clone(), value.clone());
169        }
170    }
171    Redacted {
172        value: out,
173        changed,
174    }
175}
176
177fn key_is_secret(key: &str) -> bool {
178    let lower = key.to_ascii_lowercase();
179    SECRET_KEY_NEEDLES
180        .iter()
181        .any(|needle| lower.contains(needle))
182}
183
184// ---------------------------------------------------------------------------
185// skill-manifest
186// ---------------------------------------------------------------------------
187
188/// On `SessionStart`, appends the L1 manifest of available skills (`name + description`)
189/// to the system prompt suffix, so the model is aware of which skills it can load on
190/// demand via the `skill` tool.
191///
192/// This is the L1 injection point for progressive disclosure. Note that the
193/// `skill` tool's own description already embeds the same catalog (see
194/// [`crate::tool::SkillTool`]), so this hook is an **optional enhancement**: when
195/// installed, it also places the manifest in the system prompt (more robust for clients
196/// that do not count tool descriptions toward the attention budget). Both paths originate
197/// from the same skill index, so they will not diverge.
198///
199/// Unlike other builtins, this handler holds a skill index and **cannot** be constructed
200/// via the parameterless factory [`BuiltinRegistry::defaults`]. Instead, it is registered
201/// during CLI assembly using a closure that captures the index (see `defect_cli::hooks`).
202pub struct SkillManifestHook {
203    skills: Arc<BTreeMap<String, SkillEntry>>,
204}
205
206impl SkillManifestHook {
207    /// Constructs from a loaded skill index. The caller **must not** register this hook
208    /// when `skills` is empty (the manifest would be an empty segment, wasting tokens).
209    pub fn new(skills: Arc<BTreeMap<String, SkillEntry>>) -> Self {
210        Self { skills }
211    }
212}
213
214/// Renders the session-start injection: a level-1 manifest (name + description for every
215/// skill) plus the full body of each `always` skill (always-on, injected directly into
216/// the system prompt). Returns `None` for an empty index (no empty segment injected).
217fn render_skill_manifest(skills: &BTreeMap<String, SkillEntry>) -> Option<String> {
218    if skills.is_empty() {
219        return None;
220    }
221    let mut out = String::from(
222        "## Available Skills\n\n\
223         Load a skill's full instructions with the `skill` tool (by name) when the task matches:\n",
224    );
225    for (name, entry) in skills {
226        out.push_str(&format!("- **{name}**: {}\n", entry.description));
227    }
228    // Always-on skills: inline the body of any skill marked `always: true` so the model
229    // has those instructions from the start, without needing to call the `skill` tool.
230    for (name, entry) in skills {
231        if entry.always {
232            out.push_str(&format!("\n## Skill: {name}\n\n{}\n", entry.body));
233        }
234    }
235    Some(out)
236}
237
238impl StepHandler for SkillManifestHook {
239    /// In the step model, inject the L1 skill manifest as `additional_context` during
240    /// `after_session_enter`
241    /// (the engine applies it back to the step, appending it to the system prompt
242    /// suffix).
243    fn handle_step<'a>(
244        &'a self,
245        _envelope: &'a Value,
246        _ctx: HookCtx<'a>,
247    ) -> BoxFuture<'a, Result<Option<Value>, HookError>> {
248        let verdict = render_skill_manifest(&self.skills)
249            .map(|manifest| serde_json::json!({ "additional_context": [manifest] }));
250        Box::pin(async move { Ok(verdict) })
251    }
252}
253
254// ---------------------------------------------------------------------------
255// skill-triggers
256// ---------------------------------------------------------------------------
257
258/// On `before_ingest`, automatically activate relevant skills based on the user prompt.
259/// When a match is found, insert a **L1 hint** (e.g. "Detected skill X relevance; use the
260/// `skill` tool if needed") before the prompt, rather than injecting the full skill body.
261/// This follows progressive disclosure: the model decides whether to actually load the
262/// skill.
263///
264/// Trigger conditions (any one triggers):
265/// - **keyword**: any of the skill's `triggers.keywords` is a case-insensitive substring
266///   of the prompt text.
267/// - **glob**: any "path-like token" extracted from the prompt text matches one of the
268///   skill's `triggers.globs`.
269///
270/// Skills with `always` trigger are already injected in full at session start, so they
271/// are skipped here to avoid duplicate hints.
272///
273/// Like [`SkillManifestHook`], this hook holds a skill index and is registered via a
274/// closure that captures the index (see `defect_cli::hooks`).
275pub struct SkillTriggersHook {
276    skills: Arc<BTreeMap<String, SkillEntry>>,
277}
278
279impl SkillTriggersHook {
280    /// Constructs from the already-loaded skill index. The caller **must not** register
281    /// this hook when `skills` is empty.
282    pub fn new(skills: Arc<BTreeMap<String, SkillEntry>>) -> Self {
283        Self { skills }
284    }
285}
286
287/// Extract path-like tokens from a prompt string (best-effort, no NLP).
288///
289/// Split on whitespace, strip surrounding quotes/backticks/brackets and trailing
290/// punctuation. A token is considered a path if it either:
291/// (1) contains `/` (e.g. `crates/agent/src/foo.rs`); or (2) ends with an extension
292/// `xxx.ext` (e.g. `Cargo.toml` / `main.rs`). Strip leading `./`. Bare words (no `/` and
293/// no extension) are not paths — they are left for keyword matching.
294fn extract_path_tokens(prompt: &str) -> Vec<String> {
295    prompt
296        .split_whitespace()
297        .filter_map(|raw| {
298            let trimmed = raw.trim_matches(|c: char| {
299                c == '`' || c == '"' || c == '\'' || c == '(' || c == ')' || c == '[' || c == ']'
300            });
301            let trimmed = trimmed.trim_end_matches([',', '.', ':', ';']);
302            let token = trimmed.strip_prefix("./").unwrap_or(trimmed);
303            if token.is_empty() {
304                return None;
305            }
306            if is_path_like(token) {
307                Some(token.to_string())
308            } else {
309                None
310            }
311        })
312        .collect()
313}
314
315/// Whether the token is "path-like": contains `/`, or matches `name.ext` (a dot followed
316/// by one or more alphanumeric characters at the end).
317fn is_path_like(token: &str) -> bool {
318    if token.contains('/') {
319        return true;
320    }
321    // Ending extension: at least one alphanumeric character after the last `.`, and the
322    // dot is not at the start.
323    match token.rsplit_once('.') {
324        Some((stem, ext)) => {
325            !stem.is_empty() && !ext.is_empty() && ext.chars().all(|c| c.is_ascii_alphanumeric())
326        }
327        None => false,
328    }
329}
330
331/// Returns whether a single skill is activated by the prompt: keyword substring OR glob
332/// matches a path token.
333fn skill_triggered(entry: &SkillEntry, prompt_lower: &str, path_tokens: &[String]) -> bool {
334    let keyword_hit = entry
335        .triggers
336        .keywords
337        .iter()
338        .any(|kw| !kw.is_empty() && prompt_lower.contains(&kw.to_ascii_lowercase()));
339    if keyword_hit {
340        return true;
341    }
342    match &entry.triggers.globs {
343        Some(set) => path_tokens.iter().any(|t| set.is_match(t)),
344        None => false,
345    }
346}
347
348impl StepHandler for SkillTriggersHook {
349    /// In the `before_ingest` step, read the prompt text and, for each matched skill,
350    /// prepend an L1 hint (a `prepend_input` verdict). Return `None` if no skill matches.
351    fn handle_step<'a>(
352        &'a self,
353        envelope: &'a Value,
354        _ctx: HookCtx<'a>,
355    ) -> BoxFuture<'a, Result<Option<Value>, HookError>> {
356        let prompt = envelope.get("input").and_then(Value::as_str).unwrap_or("");
357        let prompt_lower = prompt.to_ascii_lowercase();
358        let path_tokens = extract_path_tokens(prompt);
359
360        let hints: Vec<String> = self
361            .skills
362            .iter()
363            .filter(|(_, e)| !e.always)
364            .filter(|(_, e)| skill_triggered(e, &prompt_lower, &path_tokens))
365            .map(|(name, _)| {
366                format!(
367                    "Detected skill `{name}` is relevant to the current task; \
368                     load it with the `skill` tool when needed."
369                )
370            })
371            .collect();
372
373        let verdict = (!hints.is_empty()).then(|| serde_json::json!({ "prepend_input": hints }));
374        Box::pin(async move { Ok(verdict) })
375    }
376}
377
378// ---------------------------------------------------------------------------
379// goal-gate
380// ---------------------------------------------------------------------------
381
382/// The core hook for the `--goal` goal-driven loop, **subscribing to two events**
383/// (dispatched via the `hook_event` envelope):
384///
385/// - `after_session_enter`: Injects the goal description + `goal_done` usage contract as
386///   `additional_context` into the system prompt suffix — **effective from turn 1**. This
387///   lets the model know the goal and that it must actively call `goal_done` upon
388///   completion from the start, avoiding an extra wasted turn waiting for the first
389///   voluntary stop.
390/// - `before_turn_end`: On voluntary turn stop, reads
391///   [`GoalState::is_reached`](crate::session::GoalState::is_reached): if reached (model
392///   called `goal_done`) → `proceed` to end; otherwise → `continue` to extend the turn +
393///   inject an English prompt reminder.
394///
395/// The hard cap on extensions is enforced by the turn loop's
396/// [`crate::session::TurnConfig::max_hook_continues`] (mapped from `--max-turns`) — this
397/// hook only checks "is it done?", it does not count extensions itself.
398///
399/// Like [`SkillManifestHook`], this is a stateful builtin (holds `Arc<GoalState>`) and
400/// cannot be constructed via [`BuiltinRegistry::defaults`]'s parameterless factory —
401/// during CLI assembly, a closure capturing the state is registered for both events under
402/// `--goal` (see `defect_cli::hooks`).
403pub struct GoalGate {
404    goal: Arc<crate::session::GoalState>,
405}
406
407impl GoalGate {
408    pub fn new(goal: Arc<crate::session::GoalState>) -> Self {
409        Self { goal }
410    }
411
412    /// Injected into the system prompt from turn 1 onward: goal description + `goal_done`
413    /// contract.
414    fn briefing(&self) -> String {
415        format!(
416            "## Goal\n\n\
417             You are running in goal-driven mode. Your objective:\n\n{}\n\n\
418             Work autonomously across as many turns as needed to achieve this goal. \
419             When — and only when — the goal is genuinely and fully achieved, call the \
420             `goal_done` tool to finish the run. Do not call it prematurely. If you stop \
421             without calling `goal_done`, you will be prompted to keep working.",
422            self.goal.objective()
423        )
424    }
425}
426
427impl StepHandler for GoalGate {
428    /// Dispatches on the envelope's `hook_event`:
429    /// - `after_session_enter` → injects goal description and contract
430    ///   (`additional_context`)
431    /// - `before_turn_end` → if reached, proceed; otherwise continue with a prompt
432    fn handle_step<'a>(
433        &'a self,
434        envelope: &'a Value,
435        _ctx: HookCtx<'a>,
436    ) -> BoxFuture<'a, Result<Option<Value>, HookError>> {
437        let event = envelope
438            .get("hook_event")
439            .and_then(Value::as_str)
440            .unwrap_or("");
441        let verdict = match event {
442            "after_session_enter" => {
443                serde_json::json!({ "additional_context": [self.briefing()] })
444            }
445            // before_turn_end (and fallback): check if the goal is reached.
446            _ if self.goal.is_reached() => serde_json::json!({ "control": "proceed" }),
447            _ => serde_json::json!({
448                "control": "continue",
449                "additional_context": [format!(
450                    "The goal \"{}\" is not yet complete. Keep working toward it. \
451                     Once it is genuinely achieved, call the `goal_done` tool to finish.",
452                    self.goal.objective()
453                )],
454            }),
455        };
456        Box::pin(async move { Ok(Some(verdict)) })
457    }
458}
459
460#[cfg(test)]
461mod tests;