hotl_engine/hooks.rs
1//! Extension hooks (M5). The engine consults a `Hooks` impl at two
2//! points in the tool phase — before a call runs and after it returns —
3//! scoped to the events actually used, not a 35-event schema (Delivery #5).
4//!
5//! Vocabulary follows 0006's M5 pin #2: a **wrap-style** `PreToolUse` hook
6//! *intercepts* a call (allow / block / transiently rewrite its input); a
7//! **node-style** `PostToolUse` hook returns a *proposal* to replace the
8//! result. Hook-visible payloads are byte-capped (pin #1) so a hook can't be
9//! used to amplify a huge tool result into the process.
10
11use std::sync::atomic::{AtomicU8, Ordering};
12use std::sync::{Arc, Mutex, PoisonError};
13use std::time::Duration;
14
15use futures_util::future::BoxFuture;
16use serde_json::Value;
17use tokio::task::JoinHandle;
18use tokio_util::sync::CancellationToken;
19
20/// Default cap on the bytes of a tool result a hook is shown.
21pub const HOOK_PAYLOAD_CAP: usize = 2048;
22
23/// Appended to a field [`cap_tool_input`] clipped, so a hook can tell a capped
24/// view from a genuinely short value. Fixed text, never interpolated: a hook
25/// that echoes the view back must produce bytes [`restore_capped`] recognizes.
26pub const CAP_MARK: &str = "\n<capped — the tool receives the full value>";
27
28/// Wall-clock budget for a *blocking* hook call — the engine awaits these
29/// because they return context or decisions it needs. A hook that exceeds it is
30/// treated as a no-op (never a grant, never a block), so a crashed or hung hook
31/// can't stall a turn indefinitely. Shell hooks bound each subprocess more
32/// tightly (`shell_hooks::HOOK_TIMEOUT_SECS`); this is the outer net.
33///
34/// INVARIANT: every `Hooks` event the engine awaits is bounded by this AND
35/// raced against the turn's cancellation token — including `pre_tool` and
36/// `post_tool`, which reach the engine only through [`call_pre_tool`] /
37/// [`call_post_tool`]. Enforced by
38/// `a_hung_pre_tool_hook_times_out_instead_of_wedging_the_turn`,
39/// `a_cancelled_turn_does_not_wait_out_a_hook_timeout`, and
40/// `an_interrupt_lands_while_a_pre_tool_hook_hangs`.
41pub const HOOK_CALL_TIMEOUT: Duration = Duration::from_secs(15);
42
43/// `PreToolUse` under the timeout and the turn's cancel token. Both fall back
44/// to `Continue`: a no-op is not a grant — the call still faces the full
45/// permission gate (deny rules, protected paths, the human).
46pub async fn call_pre_tool(
47 hooks: &Arc<dyn Hooks>,
48 name: &str,
49 input: &Value,
50 cancel: &CancellationToken,
51) -> PreToolDecision {
52 tokio::select! {
53 biased;
54 _ = cancel.cancelled() => PreToolDecision::Continue,
55 decision = tokio::time::timeout(HOOK_CALL_TIMEOUT, hooks.pre_tool(name, input)) => {
56 decision.unwrap_or(PreToolDecision::Continue)
57 }
58 }
59}
60
61/// `PostToolUse` under the timeout and the cancel token, with the payload cap
62/// applied HERE (pin #1) rather than trusted to each impl. Both fallbacks are
63/// `None`: on a timeout or an interrupt the model sees the tool's real output,
64/// never a half-applied proposal.
65pub async fn call_post_tool(
66 hooks: &Arc<dyn Hooks>,
67 name: &str,
68 result: &str,
69 cancel: &CancellationToken,
70) -> Option<String> {
71 let capped = cap_payload(result);
72 tokio::select! {
73 biased;
74 _ = cancel.cancelled() => None,
75 replacement = tokio::time::timeout(HOOK_CALL_TIMEOUT, hooks.post_tool(name, capped)) => {
76 replacement.ok().flatten()
77 }
78 }
79}
80
81/// The capped rendering of one field, or `None` when it already fits.
82fn capped_view(s: &str) -> Option<String> {
83 (s.len() > HOOK_PAYLOAD_CAP).then(|| format!("{}{CAP_MARK}", cap_payload(s)))
84}
85
86/// The capped hook *view* of a tool input: every top-level string field over
87/// [`HOOK_PAYLOAD_CAP`] is clipped and marked. Nested values are left alone —
88/// tool inputs are flat by convention, and a hook that needs more should read
89/// the file itself.
90pub fn cap_tool_input(input: &Value) -> Value {
91 let Some(fields) = input.as_object() else {
92 return input.clone();
93 };
94 let mut out = serde_json::Map::with_capacity(fields.len());
95 for (key, value) in fields {
96 let capped = value.as_str().and_then(capped_view);
97 out.insert(
98 key.clone(),
99 capped.map_or_else(|| value.clone(), Value::String),
100 );
101 }
102 Value::Object(out)
103}
104
105/// Undo [`cap_tool_input`] for every field the hook handed back byte-identical
106/// to the capped form, so capping can never truncate what actually executes. A
107/// field the hook genuinely rewrote does not match the capped form, so it is
108/// left exactly as the hook wrote it.
109/// INVARIANT: the executed input differs from the model's only where a hook
110/// deliberately rewrote it. Enforced by
111/// `capping_a_tool_input_is_reversible_when_the_hook_echoes_it_back`,
112/// `a_deliberate_rewrite_of_a_capped_field_still_wins`, and
113/// `the_hook_sees_a_capped_input_but_the_tool_gets_the_full_one`.
114pub fn restore_capped(original: &Value, mut rewritten: Value) -> Value {
115 if let (Some(fields), Some(out)) = (original.as_object(), rewritten.as_object_mut()) {
116 for (key, value) in fields {
117 let Some(capped) = value.as_str().and_then(capped_view) else {
118 continue;
119 };
120 if out.get(key).and_then(Value::as_str) == Some(capped.as_str()) {
121 out.insert(key.clone(), value.clone());
122 }
123 }
124 }
125 rewritten
126}
127
128/// Await [`Hooks::on_user_prompt`] under [`HOOK_CALL_TIMEOUT`]; a timeout
129/// behaves exactly like `None` — no context, never a crash.
130pub async fn call_user_prompt(hooks: &Arc<dyn Hooks>, prompt: &str) -> Option<String> {
131 tokio::time::timeout(HOOK_CALL_TIMEOUT, hooks.on_user_prompt(prompt))
132 .await
133 .ok()
134 .flatten()
135}
136
137/// Budget for a background (`on_notification`/`on_session_end`) hook call —
138/// generous (it never blocks anything), but bounded, so a "phone home" hook
139/// can't leak a task forever.
140pub const NOTIFICATION_TIMEOUT: Duration = Duration::from_secs(10);
141
142/// A session-scoped registry of in-flight detached `notify` task handles
143/// (Finding 1 fix, the Critical review caught in the real CLI): `notify`
144/// pushes the `JoinHandle` it spawns here instead of discarding it, so a
145/// caller that's about to tear down its runtime — the one-shot CLI's
146/// `current_thread` `block_on`, which drops the instant its driving future
147/// resolves — can `drain` them first, under a short bounded grace period.
148/// Without this, a detached `tokio::spawn` awaiting a real subprocess (a
149/// `notification` shell hook) is silently killed mid-flight the moment
150/// `block_on` returns: nothing else is left polling it, unlike the
151/// long-lived TUI/interactive path (or a `#[tokio::test]` that keeps polling
152/// `events.recv()` in a loop) where the runtime stays alive on its own.
153/// Cloning shares the same underlying registry (an `Arc`), so `notify`'s
154/// caller (the actor/turn) and the CLI's exit-time drain call see the same
155/// set of handles.
156#[derive(Clone, Default)]
157pub struct NotificationDrain(Arc<Mutex<Vec<JoinHandle<()>>>>);
158
159impl NotificationDrain {
160 pub fn new() -> Self {
161 Self::default()
162 }
163
164 fn track(&self, handle: JoinHandle<()>) {
165 let mut guard = self.0.lock().unwrap_or_else(PoisonError::into_inner);
166 // Bound the registry's steady-state size across a long session:
167 // drop handles that already finished rather than let it grow
168 // unbounded (a poisoned lock has no invariants worth protecting —
169 // recover and keep going, same stance `SharedDeps`/`current_turn`
170 // take elsewhere).
171 guard.retain(|h| !h.is_finished());
172 guard.push(handle);
173 }
174
175 /// Await every still-pending handle, bounded by `grace` — never longer.
176 /// A handle that hasn't finished when `grace` elapses is abandoned
177 /// (best-effort, exactly like the per-hook timeout itself): this never
178 /// blocks the caller past `grace`, so a hung notifier can't wedge the
179 /// process's exit either.
180 pub async fn drain(&self, grace: Duration) {
181 let handles: Vec<_> = {
182 let mut guard = self.0.lock().unwrap_or_else(PoisonError::into_inner);
183 std::mem::take(&mut *guard)
184 };
185 if handles.is_empty() {
186 return;
187 }
188 let _ = tokio::time::timeout(grace, futures_util::future::join_all(handles)).await;
189 }
190}
191
192/// `Notification` (tier-1 gap #7, the `hotl watch`/desktop seam): spawn
193/// `on_notification` **detached**, under its own timeout, and return
194/// immediately — the caller MUST NOT `.await` this. A 2s (or hung) notifier
195/// must never stall the turn or the actor loop that calls it (Concurrency &
196/// parallelism §"Background (fire-and-forget) hooks"). The spawned handle is
197/// tracked in `drain` (Finding 1) so a caller about to tear down its runtime
198/// can wait for it first instead of silently killing it mid-flight.
199pub fn notify(
200 hooks: &Arc<dyn Hooks>,
201 drain: &NotificationDrain,
202 kind: NotificationKind,
203 detail: impl Into<String>,
204) {
205 let hooks = Arc::clone(hooks);
206 let detail = detail.into();
207 let handle = tokio::spawn(async move {
208 let _ =
209 tokio::time::timeout(NOTIFICATION_TIMEOUT, hooks.on_notification(kind, &detail)).await;
210 });
211 drain.track(handle);
212}
213
214/// Await [`Hooks::on_stop`] under [`HOOK_CALL_TIMEOUT`]; a timeout behaves
215/// exactly like `Allow` — a hung hook can never wedge a turn (it's a no-op,
216/// not a block).
217pub async fn call_stop(hooks: &Arc<dyn Hooks>, outcome: &str) -> StopDecision {
218 tokio::time::timeout(HOOK_CALL_TIMEOUT, hooks.on_stop(outcome))
219 .await
220 .unwrap_or(StopDecision::Allow)
221}
222
223/// `SessionEnd`: run AWAITED, under its own bounded timeout, at actor
224/// shutdown — NOT detached (Finding 1 fix). By definition nothing needs the
225/// actor responsive once it's already shutting down for good, so blocking
226/// here is correct, and it's the only way to *guarantee* the hook fires: a
227/// detached task here would race the same runtime-drop that silently killed
228/// `notify`'s old detached shape in the one-shot CLI path. The caller
229/// (`actor::run`) is itself a spawned task the CLI now awaits to completion
230/// before returning (`SessionHandle::finish`), so awaiting here — rather
231/// than spawning — is what makes that wait actually cover the hook call.
232pub async fn call_session_end(hooks: &Arc<dyn Hooks>) {
233 let _ = tokio::time::timeout(NOTIFICATION_TIMEOUT, hooks.on_session_end()).await;
234}
235
236/// A `PreToolUse` decision (wrap-style intercept). A `Rewrite` re-enters the
237/// normal permission gate with the new input — a hook cannot launder a call
238/// past the y/N ask (SECURITY.md §M5 routing rows).
239#[derive(Debug, Clone, PartialEq)]
240pub enum PreToolDecision {
241 Continue,
242 Deny { message: String },
243 Rewrite { input: Value },
244}
245
246/// Per-tool matcher (12 §"tool events match tool name; regex when
247/// non-alphanumeric" — hotl deliberately adopts only the exact-name half:
248/// full regex is YAGNI for a personal harness). `All` fires for every tool
249/// (the pre-matcher default); `Names` fires only for an exact (case-sensitive)
250/// name match.
251#[derive(Debug, Clone, PartialEq)]
252pub enum Matcher {
253 All,
254 Names(Vec<String>),
255}
256
257impl Matcher {
258 pub fn matches(&self, tool: &str) -> bool {
259 match self {
260 Matcher::All => true,
261 Matcher::Names(names) => names.iter().any(|n| n == tool),
262 }
263 }
264}
265
266/// One bit per `Hooks` event kind — the union gate every dispatch call site
267/// (`hook_gate!`) checks before doing ANY per-event work: payload
268/// construction, the ≤2KB cap copy, deadline registration, cancel-race
269/// scaffolding. A session whose `Hooks` impl doesn't register a given event
270/// pays exactly one branch on a cached `u8` for it instead (§S1 HookRouter
271/// diet).
272///
273/// This is the union half of the design doc's `{ union: EventMask,
274/// per_event: [SmallVec<[HookId;2]>; 6] }` table (design-docs §S1). hotl has
275/// exactly one `Hooks` object per session — there is no multi-hook registry
276/// to index a per-event dispatch table against — so only the union mask and
277/// its gates land here; the per-event table is deliberately left out. A
278/// future multi-hook registry would extend `EventMask` to that per-event
279/// table; until then, a registered event still runs every matching hook
280/// internally (lane 1 merges in registration order, lane 2 runs matching
281/// shell hooks concurrently) — the mask only decides whether the wrapper
282/// runs at all.
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
284pub struct EventMask(u8);
285
286impl EventMask {
287 pub const PRE_TOOL: EventMask = EventMask(1 << 0);
288 pub const POST_TOOL: EventMask = EventMask(1 << 1);
289 pub const USER_PROMPT: EventMask = EventMask(1 << 2);
290 pub const NOTIFICATION: EventMask = EventMask(1 << 3);
291 pub const STOP: EventMask = EventMask(1 << 4);
292 pub const SESSION_END: EventMask = EventMask(1 << 5);
293
294 /// No events registered — a zero-hook session's cached mask.
295 pub const NONE: EventMask = EventMask(0);
296 /// Every event registered — the trait default, so an impl that hasn't
297 /// opted into narrower dispatch (an external/unknown lane) stays correct.
298 pub const ALL: EventMask = EventMask(
299 Self::PRE_TOOL.0
300 | Self::POST_TOOL.0
301 | Self::USER_PROMPT.0
302 | Self::NOTIFICATION.0
303 | Self::STOP.0
304 | Self::SESSION_END.0,
305 );
306
307 pub const fn contains(self, bit: EventMask) -> bool {
308 self.0 & bit.0 == bit.0
309 }
310 pub const fn union(self, other: EventMask) -> EventMask {
311 EventMask(self.0 | other.0)
312 }
313 /// Clear `other`'s bits from `self` — used to narrow the mask when a
314 /// three-strike eviction silences the last live hook for an event.
315 pub const fn difference(self, other: EventMask) -> EventMask {
316 EventMask(self.0 & !other.0)
317 }
318 pub const fn bits(self) -> u8 {
319 self.0
320 }
321 /// Bits outside the six defined here are masked off — forward-compat
322 /// against a stray byte rather than a panic.
323 pub const fn from_bits(bits: u8) -> EventMask {
324 EventMask(bits & Self::ALL.0)
325 }
326}
327
328pub trait Hooks: Send + Sync {
329 /// Before a tool runs. The hook sees the tool name and full input.
330 fn pre_tool<'a>(&'a self, name: &'a str, input: &'a Value) -> BoxFuture<'a, PreToolDecision>;
331 /// After a tool succeeds. `result` is byte-capped to `HOOK_PAYLOAD_CAP`.
332 /// `Some(replacement)` swaps the result the model sees; `None` leaves it.
333 fn post_tool<'a>(&'a self, name: &'a str, result: &'a str) -> BoxFuture<'a, Option<String>>;
334
335 /// `UserPromptSubmit`: runs when a prompt is admitted, before the turn it
336 /// starts samples. `Some(context)` becomes one `SyntheticReason::SystemReminder`
337 /// user item committed right after the prompt — a tagged user item, never
338 /// a system-prompt edit (prefix-cache stability). Default: no-op (`None`),
339 /// so a lane that hasn't wired this event compiles and behaves inertly.
340 fn on_user_prompt<'a>(&'a self, _prompt: &'a str) -> BoxFuture<'a, Option<String>> {
341 Box::pin(std::future::ready(None))
342 }
343
344 /// `Notification`: fire-and-forget — the engine tells hooks the agent
345 /// blocked on a human (`Blocked`), went idle (`Idle`), or finished
346 /// (`Done`). Callers MUST NOT await this on the hot path (see
347 /// `spawn_notification`); the default is a no-op.
348 fn on_notification<'a>(
349 &'a self,
350 _kind: NotificationKind,
351 _detail: &'a str,
352 ) -> BoxFuture<'a, ()> {
353 Box::pin(std::future::ready(()))
354 }
355
356 /// `Stop`: a bounded veto at the turn's Done branch (tech-debt #10,
357 /// node-style: it returns a decision, it doesn't wrap the branch).
358 /// Default: `Allow` — a hook-less build never delays turn-end.
359 fn on_stop<'a>(&'a self, _outcome: &'a str) -> BoxFuture<'a, StopDecision> {
360 Box::pin(std::future::ready(StopDecision::Allow))
361 }
362
363 /// `SessionEnd`: fire-and-forget, called once at actor shutdown. Default:
364 /// no-op.
365 fn on_session_end<'a>(&'a self) -> BoxFuture<'a, ()> {
366 Box::pin(std::future::ready(()))
367 }
368
369 /// The union of event kinds this impl actually wants dispatched (§S1
370 /// HookRouter gate, Task 5). Default: [`EventMask::ALL`] — an
371 /// external/unknown impl (a third-party lane, a test double) that hasn't
372 /// overridden this stays correct: every event still reaches it exactly
373 /// as it did before this method existed. `ShellHooks` overrides it from
374 /// its loaded specs, narrowing to the events actually configured. Used
375 /// only as the one-time fallback when [`Hooks::mask_handle`] is `None` —
376 /// an impl that provides a live handle is read through that instead.
377 fn event_mask(&self) -> EventMask {
378 EventMask::ALL
379 }
380
381 /// A live, shareable cell backing this impl's mask, if it has one to
382 /// offer — the "Arc-swapped on registry change" mechanism (§S1)
383 /// resolved without a per-event hook table: the SAME `AtomicU8` the
384 /// impl narrows on eviction (`ShellHooks::refresh_mask_bit`) is what the
385 /// engine reads at every `hook_gate!` site, so a mid-session narrowing
386 /// is visible immediately — no second, disconnected snapshot to go
387 /// stale. Default: `None` — an impl with no live state to expose (an
388 /// external/unknown lane, anything built from [`InProcessHooks`]) keeps
389 /// working exactly as before: the engine falls back to a single
390 /// `event_mask()` snapshot taken once at session start.
391 fn mask_handle(&self) -> Option<Arc<AtomicU8>> {
392 None
393 }
394}
395
396/// Read the live §S1 mask through a handle — the one read discipline every
397/// holder of a mask atomic (`SharedDeps::hook_mask`, `question_sink`'s local
398/// copy, `ShellHooks::event_mask`) shares, so there is exactly one place
399/// that picks the `Ordering`.
400pub fn mask_of(handle: &AtomicU8) -> EventMask {
401 EventMask::from_bits(handle.load(Ordering::Relaxed))
402}
403
404/// §S1 HookRouter gate: the ONE macro every one of the six `Hooks` dispatch
405/// call sites expands from (`turn.rs`'s `gate`/`execute`/`consult_stop`,
406/// `actor.rs`'s `admit_prompt`/`end_turn`/shutdown, `lib.rs`'s
407/// `question_sink`), so a hand-copied gate that forgets a new event kind — a
408/// silent hook drop — can't happen: every call site is this macro, never a
409/// bespoke `if let Some(hooks) = ...`.
410///
411/// `$hooks_opt` is the `Option<Arc<dyn Hooks>>` to gate; `$mask` is the
412/// already-computed [`EventMask`] to test (an atomic read through
413/// [`mask_of`], never a fresh `Hooks::event_mask` dyn call at this call
414/// site — see `SharedDeps::hook_mask` and `question_sink`'s own handle for
415/// where the mask actually lives, and [`Hooks::mask_handle`] for how a
416/// mid-session narrowing reaches that same cell live); `$bit` is this call
417/// site's event kind. When hooks are present AND the bit is set, `$hooks` is
418/// bound to the live `&Arc<dyn Hooks>` and `$body` runs — payload
419/// construction, the byte cap, deadline registration, cancel-race
420/// scaffolding, all inside it. Otherwise `$off` runs, doing none of that: a
421/// `None` session and a masked-off event are observationally identical,
422/// both taking this same skip branch.
423macro_rules! hook_gate {
424 ($hooks_opt:expr, $mask:expr, $bit:expr, |$hooks:ident| $body:expr, else $off:expr) => {
425 match &$hooks_opt {
426 Some($hooks) if $mask.contains($bit) => $body,
427 _ => $off,
428 }
429 };
430}
431pub(crate) use hook_gate;
432
433/// The `Notification` hook's kind (tier-1 gap #7, the `hotl watch`/desktop
434/// seam): the agent blocked on a human, went idle awaiting a prompt, or
435/// completed a turn.
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437pub enum NotificationKind {
438 Blocked,
439 Idle,
440 Done,
441}
442
443/// A `Stop` hook's veto decision (tech-debt #10's node-vs-wrap pin: `Stop` is
444/// node-style — it returns a decision, never wraps the branch itself).
445#[derive(Debug, Clone, PartialEq)]
446pub enum StopDecision {
447 Allow,
448 Block { reason: String },
449}
450
451/// Clip a payload to the hook cap on a char boundary (never mid-UTF-8).
452pub fn cap_payload(s: &str) -> &str {
453 if s.len() <= HOOK_PAYLOAD_CAP {
454 return s;
455 }
456 let mut end = HOOK_PAYLOAD_CAP;
457 while !s.is_char_boundary(end) {
458 end -= 1;
459 }
460 &s[..end]
461}
462
463/// Lane 1 — in-process Rust hooks: the compiled-in registry that the
464/// shell adapter (lane 2) and any future third-party lane register against.
465#[derive(Default)]
466pub struct InProcessHooks {
467 #[allow(clippy::type_complexity)]
468 pre: Vec<(
469 Matcher,
470 Box<dyn Fn(&str, &Value) -> PreToolDecision + Send + Sync>,
471 )>,
472 #[allow(clippy::type_complexity)]
473 post: Vec<(
474 Matcher,
475 Box<dyn Fn(&str, &str) -> Option<String> + Send + Sync>,
476 )>,
477 #[allow(clippy::type_complexity)]
478 prompt: Vec<Box<dyn Fn(&str) -> Option<String> + Send + Sync>>,
479 #[allow(clippy::type_complexity)]
480 notification: Vec<Box<dyn Fn(NotificationKind, &str) + Send + Sync>>,
481 #[allow(clippy::type_complexity)]
482 stop: Vec<Box<dyn Fn(&str) -> StopDecision + Send + Sync>>,
483 #[allow(clippy::type_complexity)]
484 session_end: Vec<Box<dyn Fn() + Send + Sync>>,
485}
486
487impl InProcessHooks {
488 pub fn new() -> Self {
489 Self::default()
490 }
491 /// `Matcher::All` sugar — fires on every tool (back-compat shape).
492 pub fn on_pre_tool(
493 self,
494 f: impl Fn(&str, &Value) -> PreToolDecision + Send + Sync + 'static,
495 ) -> Self {
496 self.on_pre_tool_matching(Matcher::All, f)
497 }
498 pub fn on_pre_tool_matching(
499 mut self,
500 matcher: Matcher,
501 f: impl Fn(&str, &Value) -> PreToolDecision + Send + Sync + 'static,
502 ) -> Self {
503 self.pre.push((matcher, Box::new(f)));
504 self
505 }
506 /// `Matcher::All` sugar — fires on every tool (back-compat shape).
507 pub fn on_post_tool(
508 self,
509 f: impl Fn(&str, &str) -> Option<String> + Send + Sync + 'static,
510 ) -> Self {
511 self.on_post_tool_matching(Matcher::All, f)
512 }
513 pub fn on_post_tool_matching(
514 mut self,
515 matcher: Matcher,
516 f: impl Fn(&str, &str) -> Option<String> + Send + Sync + 'static,
517 ) -> Self {
518 self.post.push((matcher, Box::new(f)));
519 self
520 }
521 pub fn on_user_prompt(
522 mut self,
523 f: impl Fn(&str) -> Option<String> + Send + Sync + 'static,
524 ) -> Self {
525 self.prompt.push(Box::new(f));
526 self
527 }
528 pub fn on_notification(
529 mut self,
530 f: impl Fn(NotificationKind, &str) + Send + Sync + 'static,
531 ) -> Self {
532 self.notification.push(Box::new(f));
533 self
534 }
535 pub fn on_stop(mut self, f: impl Fn(&str) -> StopDecision + Send + Sync + 'static) -> Self {
536 self.stop.push(Box::new(f));
537 self
538 }
539 pub fn on_session_end(mut self, f: impl Fn() + Send + Sync + 'static) -> Self {
540 self.session_end.push(Box::new(f));
541 self
542 }
543 pub fn is_empty(&self) -> bool {
544 self.pre.is_empty()
545 && self.post.is_empty()
546 && self.prompt.is_empty()
547 && self.notification.is_empty()
548 && self.stop.is_empty()
549 && self.session_end.is_empty()
550 }
551}
552
553/// Deterministic most-restrictive merge over `pre_tool` results collected
554/// from every matching hook (Innovation #1): `Deny` beats `Rewrite` beats
555/// `Continue`. `results` is in **registration order** — the caller collects
556/// matching hooks in a plain loop (T3-10 removed the `join_all` that implied a
557/// concurrency these synchronous handlers never had), so a tie among
558/// same-severity decisions always resolves to the first-registered hook, never
559/// a race. Exposed (not just used internally by `InProcessHooks`) so lane 2
560/// (the shell adapter) shares the exact same merge discipline instead of
561/// re-implementing it.
562/// INVARIANT: ties resolve by registration order, never by completion order.
563/// Enforced by `ties_among_same_severity_decisions_resolve_by_registration_order`
564/// and `in_process_hooks_run_in_registration_order`.
565pub fn merge_pre_tool(results: Vec<PreToolDecision>) -> PreToolDecision {
566 if let Some(deny) = results
567 .iter()
568 .find(|d| matches!(d, PreToolDecision::Deny { .. }))
569 {
570 return deny.clone();
571 }
572 if let Some(rewrite) = results
573 .iter()
574 .find(|d| matches!(d, PreToolDecision::Rewrite { .. }))
575 {
576 return rewrite.clone();
577 }
578 PreToolDecision::Continue
579}
580
581/// Deterministic most-restrictive merge over `Stop` results: any `Block`
582/// wins, first-registered among ties — the same discipline as
583/// [`merge_pre_tool`], exposed for the shell adapter to share.
584pub fn merge_stop(results: Vec<StopDecision>) -> StopDecision {
585 results
586 .into_iter()
587 .find(|d| matches!(d, StopDecision::Block { .. }))
588 .unwrap_or(StopDecision::Allow)
589}
590
591impl Hooks for InProcessHooks {
592 fn pre_tool<'a>(&'a self, name: &'a str, input: &'a Value) -> BoxFuture<'a, PreToolDecision> {
593 // These handlers are synchronous `Fn`s: `join_all` over futures that are
594 // ready on first poll bought concurrency-shaped syntax and no
595 // concurrency (T3-10). Registration order is the tiebreak
596 // `merge_pre_tool` documents, and a loop states that plainly.
597 // INVARIANT: an in-process hook must not block — it runs inline on the
598 // caller's task, bounded only by `call_pre_tool`'s timeout.
599 Box::pin(async move {
600 let mut results = Vec::new();
601 for (matcher, hook) in &self.pre {
602 if matcher.matches(name) {
603 results.push(hook(name, input));
604 }
605 }
606 merge_pre_tool(results)
607 })
608 }
609 fn post_tool<'a>(&'a self, name: &'a str, result: &'a str) -> BoxFuture<'a, Option<String>> {
610 Box::pin(async move {
611 let capped = cap_payload(result);
612 let mut current: Option<String> = None;
613 // Node-style proposal chain: each matching hook sees the
614 // previous one's replacement (not a race — a later hook is
615 // meant to refine an earlier one's output), so this stays
616 // sequential rather than joined.
617 for (matcher, hook) in &self.post {
618 if !matcher.matches(name) {
619 continue;
620 }
621 let view = current.as_deref().unwrap_or(capped);
622 if let Some(replacement) = hook(name, view) {
623 current = Some(replacement);
624 }
625 }
626 current
627 })
628 }
629 fn on_user_prompt<'a>(&'a self, prompt: &'a str) -> BoxFuture<'a, Option<String>> {
630 Box::pin(async move {
631 let results: Vec<_> = self.prompt.iter().filter_map(|hook| hook(prompt)).collect();
632 join_additional_context(results.into_iter())
633 })
634 }
635 fn on_notification<'a>(&'a self, kind: NotificationKind, detail: &'a str) -> BoxFuture<'a, ()> {
636 Box::pin(async move {
637 for hook in &self.notification {
638 hook(kind, detail);
639 }
640 })
641 }
642 fn on_stop<'a>(&'a self, outcome: &'a str) -> BoxFuture<'a, StopDecision> {
643 Box::pin(async move {
644 let results: Vec<_> = self.stop.iter().map(|hook| hook(outcome)).collect();
645 merge_stop(results)
646 })
647 }
648 fn on_session_end<'a>(&'a self) -> BoxFuture<'a, ()> {
649 Box::pin(async move {
650 for hook in &self.session_end {
651 hook();
652 }
653 })
654 }
655}
656
657/// One `additionalContext` item per commit point (Innovation #7): concatenate
658/// every non-empty hook result into a single string, capped to the Claude
659/// schema's ~10K-char shape, or `None` if nothing was returned.
660pub const ADDITIONAL_CONTEXT_CAP: usize = 10_000;
661
662pub fn join_additional_context(parts: impl Iterator<Item = String>) -> Option<String> {
663 let mut combined = String::new();
664 for part in parts {
665 if part.is_empty() {
666 continue;
667 }
668 if !combined.is_empty() {
669 combined.push('\n');
670 }
671 combined.push_str(&part);
672 }
673 if combined.is_empty() {
674 None
675 } else {
676 Some(cap_str(&combined, ADDITIONAL_CONTEXT_CAP).to_string())
677 }
678}
679
680/// Clip a string to `max` bytes on a char boundary (never mid-UTF-8) — the
681/// same discipline as [`cap_payload`], parameterized for a different cap.
682fn cap_str(s: &str, max: usize) -> &str {
683 if s.len() <= max {
684 return s;
685 }
686 let mut end = max;
687 while !s.is_char_boundary(end) {
688 end -= 1;
689 }
690 &s[..end]
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696 use serde_json::json;
697 use tokio_util::sync::CancellationToken;
698
699 #[test]
700 fn event_mask_bit_math() {
701 assert!(EventMask::ALL.contains(EventMask::PRE_TOOL));
702 assert!(EventMask::ALL.contains(EventMask::POST_TOOL));
703 assert!(EventMask::ALL.contains(EventMask::USER_PROMPT));
704 assert!(EventMask::ALL.contains(EventMask::NOTIFICATION));
705 assert!(EventMask::ALL.contains(EventMask::STOP));
706 assert!(EventMask::ALL.contains(EventMask::SESSION_END));
707 assert!(!EventMask::NONE.contains(EventMask::PRE_TOOL));
708
709 let union = EventMask::PRE_TOOL.union(EventMask::STOP);
710 assert!(union.contains(EventMask::PRE_TOOL));
711 assert!(union.contains(EventMask::STOP));
712 assert!(!union.contains(EventMask::POST_TOOL));
713
714 // Six distinct bits, no overlap.
715 let all_bits = [
716 EventMask::PRE_TOOL,
717 EventMask::POST_TOOL,
718 EventMask::USER_PROMPT,
719 EventMask::NOTIFICATION,
720 EventMask::STOP,
721 EventMask::SESSION_END,
722 ];
723 for (i, a) in all_bits.iter().enumerate() {
724 for (j, b) in all_bits.iter().enumerate() {
725 if i != j {
726 assert!(!a.contains(*b), "bit {i} must not contain bit {j}");
727 }
728 }
729 }
730
731 // Clearing a bit that was never set is a no-op; clearing a set bit
732 // removes exactly that bit.
733 let cleared = union.difference(EventMask::PRE_TOOL);
734 assert!(!cleared.contains(EventMask::PRE_TOOL));
735 assert!(cleared.contains(EventMask::STOP));
736 }
737
738 #[test]
739 fn event_mask_default_hooks_impl_is_all() {
740 struct DefaultMaskHooks;
741 impl Hooks for DefaultMaskHooks {
742 fn pre_tool<'a>(
743 &'a self,
744 _n: &'a str,
745 _i: &'a Value,
746 ) -> BoxFuture<'a, PreToolDecision> {
747 Box::pin(std::future::ready(PreToolDecision::Continue))
748 }
749 fn post_tool<'a>(&'a self, _n: &'a str, _r: &'a str) -> BoxFuture<'a, Option<String>> {
750 Box::pin(std::future::ready(None))
751 }
752 }
753 // An impl that doesn't override `event_mask` (an external/unknown
754 // lane, a plain test double) stays correct: every event still
755 // dispatches to it exactly as before this task.
756 assert_eq!(DefaultMaskHooks.event_mask(), EventMask::ALL);
757 }
758
759 struct HangingHooks;
760
761 impl Hooks for HangingHooks {
762 fn pre_tool<'a>(&'a self, _n: &'a str, _i: &'a Value) -> BoxFuture<'a, PreToolDecision> {
763 Box::pin(std::future::pending())
764 }
765 fn post_tool<'a>(&'a self, _n: &'a str, _r: &'a str) -> BoxFuture<'a, Option<String>> {
766 Box::pin(std::future::pending())
767 }
768 }
769
770 /// T2-12. The paused clock is legal here: these drive the call wrappers
771 /// alone, with no actor and therefore no writer-thread ack for the clock to
772 /// auto-advance past (0011's standing constraint).
773 #[tokio::test(start_paused = true)]
774 async fn a_hung_pre_tool_hook_times_out_instead_of_wedging_the_turn() {
775 let hooks: Arc<dyn Hooks> = Arc::new(HangingHooks);
776 let cancel = CancellationToken::new();
777 assert_eq!(
778 call_pre_tool(&hooks, "bash", &json!({}), &cancel).await,
779 PreToolDecision::Continue,
780 "a hung pre_tool degrades to a no-op — never a grant, never a block"
781 );
782 assert_eq!(
783 call_post_tool(&hooks, "bash", "the real output", &cancel).await,
784 None,
785 "a hung post_tool leaves the tool's real output in place"
786 );
787 }
788
789 #[tokio::test(start_paused = true)]
790 async fn a_cancelled_turn_does_not_wait_out_a_hook_timeout() {
791 let hooks: Arc<dyn Hooks> = Arc::new(HangingHooks);
792 let cancel = CancellationToken::new();
793 cancel.cancel();
794 let start = tokio::time::Instant::now();
795 assert_eq!(
796 call_pre_tool(&hooks, "bash", &json!({}), &cancel).await,
797 PreToolDecision::Continue
798 );
799 assert_eq!(call_post_tool(&hooks, "bash", "out", &cancel).await, None);
800 assert_eq!(
801 start.elapsed(),
802 Duration::ZERO,
803 "an interrupt must not wait out HOOK_CALL_TIMEOUT"
804 );
805 }
806
807 /// T2-12b, the subtle half: capping is a *view*. A hook that hands the view
808 /// straight back must not silently truncate what actually executes.
809 #[test]
810 fn capping_a_tool_input_is_reversible_when_the_hook_echoes_it_back() {
811 let body = "z".repeat(HOOK_PAYLOAD_CAP * 2);
812 let input = json!({"path": "notes.txt", "content": body});
813 let view = cap_tool_input(&input);
814 let seen = view["content"].as_str().expect("string field");
815 assert!(
816 seen.len() <= HOOK_PAYLOAD_CAP + CAP_MARK.len(),
817 "the hook saw {} bytes",
818 seen.len()
819 );
820 assert_eq!(
821 view["path"],
822 json!("notes.txt"),
823 "fields under the cap pass through untouched"
824 );
825 let restored = restore_capped(&input, view);
826 assert_eq!(restored, input, "capping must be observationally inert");
827 }
828
829 #[test]
830 fn a_deliberate_rewrite_of_a_capped_field_still_wins() {
831 let body = "z".repeat(HOOK_PAYLOAD_CAP * 2);
832 let input = json!({"path": "notes.txt", "content": body});
833 let mut view = cap_tool_input(&input);
834 view["content"] = json!("redacted by policy");
835 let restored = restore_capped(&input, view);
836 assert_eq!(
837 restored["content"],
838 json!("redacted by policy"),
839 "restoration must never undo a hook's real rewrite"
840 );
841 assert_eq!(restored["path"], json!("notes.txt"));
842 }
843
844 #[test]
845 fn capping_leaves_non_object_inputs_and_nested_values_alone() {
846 let nested = json!({"outer": {"inner": "z".repeat(HOOK_PAYLOAD_CAP * 2)}});
847 assert_eq!(
848 cap_tool_input(&nested),
849 nested,
850 "nested values are not capped"
851 );
852 let scalar = json!("just a string");
853 assert_eq!(cap_tool_input(&scalar), scalar);
854 assert_eq!(
855 restore_capped(&scalar, json!("rewritten")),
856 json!("rewritten")
857 );
858 }
859
860 #[tokio::test]
861 async fn pre_hook_denies_rewrites_or_continues() {
862 let hooks = InProcessHooks::new().on_pre_tool(|name, input| {
863 if name == "bash" && input.get("command").and_then(Value::as_str) == Some("rm -rf /") {
864 PreToolDecision::Deny {
865 message: "no destructive commands".into(),
866 }
867 } else if name == "write" {
868 PreToolDecision::Rewrite {
869 input: json!({"path": "safe.txt", "content": "x"}),
870 }
871 } else {
872 PreToolDecision::Continue
873 }
874 });
875 assert_eq!(
876 hooks
877 .pre_tool("bash", &json!({"command": "rm -rf /"}))
878 .await,
879 PreToolDecision::Deny {
880 message: "no destructive commands".into()
881 }
882 );
883 assert!(matches!(
884 hooks
885 .pre_tool("write", &json!({"path": "x", "content": "y"}))
886 .await,
887 PreToolDecision::Rewrite { .. }
888 ));
889 assert_eq!(
890 hooks.pre_tool("read", &json!({})).await,
891 PreToolDecision::Continue
892 );
893 }
894
895 #[tokio::test]
896 async fn post_hook_caps_and_replaces() {
897 let hooks = InProcessHooks::new()
898 .on_post_tool(|_n, result| Some(format!("[annotated] {} chars", result.len())));
899 let big = "z".repeat(HOOK_PAYLOAD_CAP * 2);
900 let out = hooks.post_tool("read", &big).await.unwrap();
901 // The hook only ever saw the capped payload.
902 assert!(out.contains(&format!("{} chars", HOOK_PAYLOAD_CAP)));
903 }
904
905 #[tokio::test]
906 async fn matcher_scopes_pre_hook_to_named_tools() {
907 let hooks = InProcessHooks::new().on_pre_tool_matching(
908 Matcher::Names(vec!["bash".into()]),
909 |_n, _i| PreToolDecision::Deny {
910 message: "no bash".into(),
911 },
912 );
913 // fires on bash
914 assert!(matches!(
915 hooks.pre_tool("bash", &json!({})).await,
916 PreToolDecision::Deny { .. }
917 ));
918 // does not fire on read
919 assert_eq!(
920 hooks.pre_tool("read", &json!({})).await,
921 PreToolDecision::Continue
922 );
923 }
924
925 /// T3-10's merge criterion. In-process handlers are synchronous `Fn`s, so
926 /// this pinned current observable behavior *before* `join_all` was replaced
927 /// with a plain loop, and must keep passing after: registration order is
928 /// the tiebreak `merge_stop`/`merge_pre_tool` document, and it is now
929 /// stated plainly rather than implied by `join_all`'s input ordering.
930 #[tokio::test]
931 async fn in_process_hooks_run_in_registration_order() {
932 let order: Arc<Mutex<Vec<&'static str>>> = Arc::default();
933 let push = |order: &Arc<Mutex<Vec<&'static str>>>, tag: &'static str| {
934 let order = Arc::clone(order);
935 move |_outcome: &str| {
936 order.lock().expect("lock").push(tag);
937 StopDecision::Allow
938 }
939 };
940 let hooks = InProcessHooks::new()
941 .on_stop(push(&order, "a"))
942 .on_stop(push(&order, "b"))
943 .on_stop(push(&order, "c"));
944 // Disambiguated: the builder's `on_stop` shadows the trait's.
945 assert_eq!(Hooks::on_stop(&hooks, "done").await, StopDecision::Allow);
946 assert_eq!(*order.lock().expect("lock"), vec!["a", "b", "c"]);
947 }
948
949 #[test]
950 fn matcher_all_and_names() {
951 assert!(Matcher::All.matches("anything"));
952 assert!(Matcher::Names(vec!["bash".into(), "write".into()]).matches("write"));
953 assert!(!Matcher::Names(vec!["bash".into()]).matches("read"));
954 }
955
956 #[tokio::test]
957 async fn multiple_matching_pre_hooks_merge_most_restrictive_first() {
958 // Registration order: Continue, Rewrite, Deny. Deny must win even
959 // though it's registered last — most-restrictive-first, not
960 // first-registered-wins.
961 let hooks = InProcessHooks::new()
962 .on_pre_tool(|_n, _i| PreToolDecision::Continue)
963 .on_pre_tool(|_n, _i| PreToolDecision::Rewrite {
964 input: json!({"rewritten": true}),
965 })
966 .on_pre_tool(|_n, _i| PreToolDecision::Deny {
967 message: "blocked".into(),
968 });
969 assert_eq!(
970 hooks.pre_tool("bash", &json!({})).await,
971 PreToolDecision::Deny {
972 message: "blocked".into()
973 }
974 );
975 }
976
977 #[tokio::test]
978 async fn ties_among_same_severity_decisions_resolve_by_registration_order() {
979 // Two Deny hooks: the FIRST registered must win, regardless of which
980 // future would complete first — the fold is over registration
981 // order, never completion order (no fast-hook-wins race).
982 let hooks = InProcessHooks::new()
983 .on_pre_tool(|_n, _i| PreToolDecision::Deny {
984 message: "first".into(),
985 })
986 .on_pre_tool(|_n, _i| PreToolDecision::Deny {
987 message: "second".into(),
988 });
989 assert_eq!(
990 hooks.pre_tool("bash", &json!({})).await,
991 PreToolDecision::Deny {
992 message: "first".into()
993 }
994 );
995 }
996
997 #[tokio::test]
998 async fn a_non_matching_hook_never_contributes_to_the_merge() {
999 let hooks = InProcessHooks::new()
1000 .on_pre_tool_matching(Matcher::Names(vec!["write".into()]), |_n, _i| {
1001 PreToolDecision::Deny {
1002 message: "no write".into(),
1003 }
1004 })
1005 .on_pre_tool(|_n, _i| PreToolDecision::Continue);
1006 // `bash` only matches the `All` hook (Continue) — the `write`-only
1007 // Deny must not leak into an unrelated tool's decision.
1008 assert_eq!(
1009 hooks.pre_tool("bash", &json!({})).await,
1010 PreToolDecision::Continue
1011 );
1012 }
1013}