Skip to main content

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}