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;