Skip to main content

forge_sdk/hooks/
callback.rs

1//! Hook callback trait + decision type + type-erasure plumbing.
2
3use std::marker::PhantomData;
4
5use serde_json::Value;
6
7use forge_primitives::HookContext;
8
9/// A hook decision.
10///
11/// The shape (allow / deny / replace-input / passthrough) mirrors
12/// the CLI's `SyncHookJSONOutput.decision` field. See the hook-callback
13/// contract docs for the full wire story.
14#[derive(Debug, Clone)]
15pub struct HookDecision {
16    inner: HookDecisionKind,
17}
18
19#[derive(Debug, Clone)]
20enum HookDecisionKind {
21    Allow {
22        updated_input: Option<Value>,
23    },
24    Deny {
25        reason: String,
26    },
27    /// No-op - purely observational; continue unchanged.
28    Passthrough,
29    /// Deferred-execution ACK - ships the CLI's `AsyncHookJSONOutput`
30    /// shape (`{"async": true, "asyncTimeout": <ms>}`) so the CLI
31    /// knows not to wait on the hook's in-line response. The caller
32    /// is expected to deliver the real result out-of-band. Wiring
33    /// the out-of-band return path is follow-up work; forge-sdk
34    /// currently only emits the ACK.
35    Defer {
36        timeout_ms: Option<u64>,
37    },
38}
39
40impl HookDecision {
41    fn with_inner(inner: HookDecisionKind) -> Self {
42        Self { inner }
43    }
44
45    /// Allow the action unchanged.
46    pub fn allow() -> Self {
47        Self::with_inner(HookDecisionKind::Allow { updated_input: None })
48    }
49
50    /// Allow but substitute a new input payload (`PreToolUse` /
51    /// `UserPromptSubmit`).
52    pub fn replace_input(new_input: Value) -> Self {
53        Self::with_inner(HookDecisionKind::Allow { updated_input: Some(new_input) })
54    }
55
56    /// Deny the action with a reason string.
57    pub fn deny(reason: impl Into<String>) -> Self {
58        Self::with_inner(HookDecisionKind::Deny { reason: reason.into() })
59    }
60
61    /// Observational only - continue unchanged (typical `PostToolUse` /
62    /// `Stop`).
63    pub fn passthrough() -> Self {
64        Self::with_inner(HookDecisionKind::Passthrough)
65    }
66
67    /// Defer the hook response. Emits the CLI's `AsyncHookJSONOutput`
68    /// shape: `{"async": true, "asyncTimeout": <ms>?}`. Pass `None` for
69    /// no explicit timeout. The CLI will proceed without waiting for the
70    /// hook's final verdict; the caller is expected to deliver the real
71    /// decision out-of-band (wiring that channel is follow-up work -
72    /// forge-sdk currently emits only the ACK shape).
73    pub fn defer(timeout_ms: Option<u64>) -> Self {
74        Self::with_inner(HookDecisionKind::Defer { timeout_ms })
75    }
76
77    /// True iff the decision was constructed via [`defer`](Self::defer).
78    pub fn is_deferred(&self) -> bool {
79        matches!(self.inner, HookDecisionKind::Defer { .. })
80    }
81
82    /// Optional timeout in milliseconds attached to a
83    /// [`defer`](Self::defer) decision.
84    pub fn defer_timeout_ms(&self) -> Option<u64> {
85        match &self.inner {
86            HookDecisionKind::Defer { timeout_ms } => *timeout_ms,
87            _ => None,
88        }
89    }
90
91    /// True if the decision allows the action.
92    pub fn is_allow(&self) -> bool {
93        !matches!(self.inner, HookDecisionKind::Deny { .. })
94    }
95
96    /// Optional modified input.
97    pub fn updated_input(&self) -> Option<&Value> {
98        match &self.inner {
99            HookDecisionKind::Allow { updated_input } => updated_input.as_ref(),
100            _ => None,
101        }
102    }
103
104    /// Optional deny reason.
105    pub fn reason(&self) -> Option<&str> {
106        match &self.inner {
107            HookDecisionKind::Deny { reason } => Some(reason),
108            _ => None,
109        }
110    }
111}
112
113/// Trait for hook callbacks. Each hook kind has its own concrete callback;
114/// the trait is parameterised over the input type.
115pub trait HookCallback<I>: Send + Sync
116where
117    I: Send + 'static,
118{
119    /// Called when the matching hook fires.
120    fn call<'a>(
121        &'a self,
122        input: I,
123        context: HookContext,
124    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = HookDecision> + Send + 'a>>;
125}
126
127impl<F, Fut, I> HookCallback<I> for F
128where
129    F: Fn(I, HookContext) -> Fut + Send + Sync,
130    Fut: std::future::Future<Output = HookDecision> + Send + 'static,
131    I: Send + 'static,
132{
133    fn call<'a>(
134        &'a self,
135        input: I,
136        context: HookContext,
137    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = HookDecision> + Send + 'a>> {
138        Box::pin(self(input, context))
139    }
140}
141
142/// Type-erased hook callback. The concrete input type is rebuilt at
143/// dispatch time from `input.hook_event_name`.
144#[async_trait::async_trait]
145pub trait ErasedHookCallback: Send + Sync {
146    /// Deserialise `input` into the concrete input type and invoke the
147    /// wrapped callback.
148    async fn call_erased(&self, input: Value, context: HookContext) -> HookDecision;
149}
150
151/// Adapter so any typed [`HookCallback<I>`] implements
152/// [`ErasedHookCallback`] when `I: DeserializeOwned`.
153pub(crate) struct ErasedCallback<I, C>
154where
155    I: serde::de::DeserializeOwned + Send + 'static,
156    C: HookCallback<I>,
157{
158    pub(crate) inner: C,
159    pub(crate) _marker: PhantomData<fn() -> I>,
160}
161
162#[async_trait::async_trait]
163impl<I, C> ErasedHookCallback for ErasedCallback<I, C>
164where
165    I: serde::de::DeserializeOwned + Send + 'static,
166    C: HookCallback<I>,
167{
168    async fn call_erased(&self, input: Value, context: HookContext) -> HookDecision {
169        match serde_json::from_value::<I>(input) {
170            Ok(typed) => self.inner.call(typed, context).await,
171            Err(e) => {
172                // Security-permissive passthrough would silently skip the
173                // caller's hook logic. Log prominently so a CLI schema drift
174                // doesn't invisibly bypass the user's policy.
175                tracing::warn!(
176                    error = %e,
177                    hook_kind = ?context.kind,
178                    "hook input deserialise failed; passthrough (hook not consulted). CLI schema drift?"
179                );
180                HookDecision::passthrough()
181            }
182        }
183    }
184}