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