Skip to main content

codex_runtime/plugin/
mod.rs

1use std::future::Future;
2use std::pin::Pin;
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7pub type HookFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
10pub struct PluginContractVersion {
11    pub major: u16,
12    pub minor: u16,
13}
14
15impl PluginContractVersion {
16    pub const CURRENT: Self = Self { major: 1, minor: 0 };
17
18    pub const fn new(major: u16, minor: u16) -> Self {
19        Self { major, minor }
20    }
21
22    pub const fn is_compatible_with(self, other: Self) -> bool {
23        self.major == other.major
24    }
25}
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
28pub enum HookPhase {
29    PreRun,
30    PostRun,
31    PreSessionStart,
32    PostSessionStart,
33    PreTurn,
34    PostTurn,
35    /// Called before a tool (command/file-change) executes, via the approval loop.
36    PreToolUse,
37    /// Reserved for post-execution tool events (not yet wired).
38    PostToolUse,
39}
40
41#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
42pub struct HookContext {
43    pub phase: HookPhase,
44    pub thread_id: Option<String>,
45    pub turn_id: Option<String>,
46    pub cwd: Option<String>,
47    pub model: Option<String>,
48    pub main_status: Option<String>,
49    pub correlation_id: String,
50    pub ts_ms: i64,
51    pub metadata: Value,
52    /// Tool or command name, set for PreToolUse/PostToolUse phases.
53    pub tool_name: Option<String>,
54    /// Raw tool input params, set for PreToolUse/PostToolUse phases.
55    pub tool_input: Option<Value>,
56}
57
58#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub enum HookAttachment {
60    AtPath {
61        path: String,
62        placeholder: Option<String>,
63    },
64    ImageUrl {
65        url: String,
66    },
67    LocalImage {
68        path: String,
69    },
70    Skill {
71        name: String,
72        path: String,
73    },
74}
75
76#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
77pub struct HookPatch {
78    pub prompt_override: Option<String>,
79    pub model_override: Option<String>,
80    pub add_attachments: Vec<HookAttachment>,
81    pub metadata_delta: Value,
82}
83
84impl Default for HookPatch {
85    fn default() -> Self {
86        Self {
87            prompt_override: None,
88            model_override: None,
89            add_attachments: Vec::new(),
90            metadata_delta: Value::Null,
91        }
92    }
93}
94
95/// The reason a [`PreHook`] decided to block execution.
96/// Allocation: two Strings. Complexity: O(1) to construct.
97#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
98pub struct BlockReason {
99    pub hook_name: String,
100    pub phase: HookPhase,
101    pub message: String,
102}
103
104#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
105pub enum HookAction {
106    Noop,
107    Mutate(HookPatch),
108    /// Stop execution immediately. No subsequent hooks run. No state is mutated.
109    Block(BlockReason),
110}
111
112#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
113pub enum HookIssueClass {
114    Validation,
115    Execution,
116    Timeout,
117    Internal,
118}
119
120#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
121pub struct HookIssue {
122    pub hook_name: String,
123    pub phase: HookPhase,
124    pub class: HookIssueClass,
125    pub message: String,
126}
127
128#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
129pub struct HookReport {
130    pub issues: Vec<HookIssue>,
131}
132
133impl HookReport {
134    pub fn push(&mut self, issue: HookIssue) {
135        self.issues.push(issue);
136    }
137
138    pub fn is_clean(&self) -> bool {
139        self.issues.is_empty()
140    }
141}
142
143pub trait PreHook: Send + Sync + 'static {
144    fn name(&self) -> &'static str;
145    fn call<'a>(&'a self, ctx: &'a HookContext) -> HookFuture<'a, Result<HookAction, HookIssue>>;
146}
147
148pub trait PostHook: Send + Sync + 'static {
149    fn name(&self) -> &'static str;
150    fn call<'a>(&'a self, ctx: &'a HookContext) -> HookFuture<'a, Result<(), HookIssue>>;
151}
152
153/// Pure filter that gates a hook on phase, tool name, and/or cwd prefix.
154/// `phases` empty = all phases match. `tool_name` / `cwd_prefix` None = no constraint.
155/// Allocation: O(phases count + name/prefix length) at construction.
156#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
157pub struct HookMatcher {
158    /// Phases this hook applies to. Empty = all phases.
159    pub phases: Vec<HookPhase>,
160    /// Exact tool name filter (for PreToolUse). None = any tool.
161    pub tool_name: Option<String>,
162    /// Working-directory prefix filter. None = any cwd.
163    pub cwd_prefix: Option<String>,
164}
165
166impl HookMatcher {
167    /// Match only specific phases.
168    /// Allocation: one Vec. Complexity: O(phases count).
169    pub fn phases(phases: impl Into<Vec<HookPhase>>) -> Self {
170        Self {
171            phases: phases.into(),
172            ..Self::default()
173        }
174    }
175
176    /// Add exact tool_name constraint (meaningful for PreToolUse).
177    /// Allocation: one String. Complexity: O(name length).
178    pub fn with_tool_name(mut self, name: impl Into<String>) -> Self {
179        self.tool_name = Some(name.into());
180        self
181    }
182
183    /// Add cwd_prefix constraint. Uses `str::starts_with` matching.
184    /// Allocation: one String. Complexity: O(prefix length).
185    pub fn with_cwd_prefix(mut self, prefix: impl Into<String>) -> Self {
186        self.cwd_prefix = Some(prefix.into());
187        self
188    }
189
190    /// True when `ctx` satisfies all non-empty constraints.
191    /// `cwd_prefix` matches `cwd == prefix` or `cwd` starts with `prefix + "/"`.
192    /// This avoids treating `/project` as a prefix of `/project2`.
193    /// Pure function; no heap allocation. Complexity: O(phases count + prefix length).
194    pub fn matches(&self, ctx: &HookContext) -> bool {
195        let phase_ok = self.phases.is_empty() || self.phases.contains(&ctx.phase);
196        let tool_ok = self
197            .tool_name
198            .as_deref()
199            .is_none_or(|name| ctx.tool_name.as_deref() == Some(name));
200        let cwd_ok = self.cwd_prefix.as_deref().is_none_or(|prefix| {
201            ctx.cwd.as_deref().is_some_and(|cwd| {
202                // Exact match or child path. `starts_with(prefix)` guarantees
203                // `prefix.len()` is a char boundary in `cwd`, making the slice safe.
204                cwd == prefix || (cwd.starts_with(prefix) && cwd[prefix.len()..].starts_with('/'))
205            })
206        });
207        phase_ok && tool_ok && cwd_ok
208    }
209}
210
211/// A [`PreHook`] wrapper that runs the inner hook only when `matcher` passes.
212/// On mismatch, returns `HookAction::Noop` without invoking the inner hook.
213/// Allocation: none per call when matcher fails. Complexity: O(matcher check).
214pub struct FilteredPreHook<H: PreHook> {
215    inner: H,
216    matcher: HookMatcher,
217}
218
219impl<H: PreHook> FilteredPreHook<H> {
220    /// Wrap `hook` so it only fires when `matcher` passes.
221    /// Allocation: one HookMatcher clone. Complexity: O(1).
222    pub fn new(hook: H, matcher: HookMatcher) -> Self {
223        Self {
224            inner: hook,
225            matcher,
226        }
227    }
228}
229
230impl<H: PreHook> PreHook for FilteredPreHook<H> {
231    fn name(&self) -> &'static str {
232        self.inner.name()
233    }
234
235    fn call<'a>(&'a self, ctx: &'a HookContext) -> HookFuture<'a, Result<HookAction, HookIssue>> {
236        Box::pin(async move {
237            if self.matcher.matches(ctx) {
238                self.inner.call(ctx).await
239            } else {
240                Ok(HookAction::Noop)
241            }
242        })
243    }
244}
245
246/// A [`PostHook`] wrapper that runs the inner hook only when `matcher` passes.
247/// On mismatch, returns `Ok(())` without invoking the inner hook.
248/// Allocation: none per call when matcher fails. Complexity: O(matcher check).
249pub struct FilteredPostHook<H: PostHook> {
250    inner: H,
251    matcher: HookMatcher,
252}
253
254impl<H: PostHook> FilteredPostHook<H> {
255    /// Wrap `hook` so it only fires when `matcher` passes.
256    /// Allocation: one HookMatcher clone. Complexity: O(1).
257    pub fn new(hook: H, matcher: HookMatcher) -> Self {
258        Self {
259            inner: hook,
260            matcher,
261        }
262    }
263}
264
265impl<H: PostHook> PostHook for FilteredPostHook<H> {
266    fn name(&self) -> &'static str {
267        self.inner.name()
268    }
269
270    fn call<'a>(&'a self, ctx: &'a HookContext) -> HookFuture<'a, Result<(), HookIssue>> {
271        Box::pin(async move {
272            if self.matcher.matches(ctx) {
273                self.inner.call(ctx).await
274            } else {
275                Ok(())
276            }
277        })
278    }
279}
280
281#[cfg(test)]
282mod tests;