Skip to main content

atomcode_core/hook/
mod.rs

1pub mod config;
2pub mod executor;
3pub mod json_config;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8/// Events in the agent lifecycle that can trigger hooks.
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum HookEvent {
12    PreToolUse,
13    PostToolUse,
14    SessionStart,
15    SessionEnd,
16    Notification,
17    /// Fires when a user submits a new message but before it reaches the
18    /// LLM. Hooks bound to this event can inject additional context or
19    /// block the submission entirely (CC parity — used by workflow router
20    /// plugins like the ascend `workflow_planner_hook.py`).
21    UserPromptSubmit,
22}
23
24/// A single hook definition from the user's configuration.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct HookConfig {
27    pub event: HookEvent,
28    /// Optional glob/regex pattern to match against tool names.
29    /// Only meaningful for `PreToolUse` and `PostToolUse` events.
30    pub matcher: Option<String>,
31    /// Shell command to execute when the hook fires.
32    pub command: String,
33    /// Maximum time (in milliseconds) the hook command may run before being killed.
34    #[serde(default = "default_timeout_ms")]
35    pub timeout_ms: u64,
36    /// Plugin install dir — set when this hook came from an installed
37    /// plugin. The executor exports it as `CLAUDE_PLUGIN_ROOT` and
38    /// `ATOMCODE_PLUGIN_ROOT` so plugin authors can reference resources
39    /// alongside their manifest. This is the safe channel: doing string
40    /// substitution into the command line would break on paths containing
41    /// spaces / quotes / `$` and could open command injection.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub plugin_root: Option<std::path::PathBuf>,
44}
45
46fn default_timeout_ms() -> u64 {
47    10_000
48}
49
50/// CC-compatible payload sent to a `UserPromptSubmit` hook over stdin
51/// (serialized as JSON). Field names match Claude Code's spec verbatim so
52/// existing CC plugin scripts work unchanged.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct UserPromptSubmitPayload {
55    pub session_id: String,
56    pub hook_event_name: String,
57    pub prompt: String,
58    pub cwd: String,
59}
60
61/// Result of running all `UserPromptSubmit` hooks for a single user message.
62#[derive(Debug, Clone, PartialEq)]
63pub enum UserPromptHookResult {
64    /// All hooks passed without modifying or blocking the prompt.
65    Continue,
66    /// At least one hook contributed extra context (concatenated by the
67    /// agent into the user message before LLM processing).
68    Inject(String),
69    /// A hook explicitly blocked the prompt; `reason` is shown in the UI.
70    Block(String),
71}
72
73/// Internal: parsed shape of a single hook's stdout payload (CC spec).
74/// We only deserialize the fields we act on; CC adds more keys in newer
75/// versions and we ignore them by default.
76#[derive(Debug, Deserialize, Default)]
77#[serde(default)]
78pub(crate) struct UserPromptSubmitOutput {
79    /// Top-level decision. `"block"` blocks the prompt; absent / other
80    /// values are treated as continue.
81    pub decision: Option<String>,
82    /// Reason shown to the user when `decision == "block"`.
83    pub reason: Option<String>,
84    /// Newer CC layout: hook-specific output bag.
85    #[serde(rename = "hookSpecificOutput")]
86    pub hook_specific_output: Option<UserPromptHookSpecific>,
87}
88
89#[derive(Debug, Deserialize, Default)]
90#[serde(default)]
91pub(crate) struct UserPromptHookSpecific {
92    /// Plain text appended to the user prompt as additional context.
93    #[serde(rename = "additionalContext")]
94    pub additional_context: Option<String>,
95}
96
97/// Result returned by a pre-tool-use hook to control tool execution.
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99#[serde(tag = "action", rename_all = "snake_case")]
100pub enum PreHookResult {
101    /// Allow the tool call to proceed unchanged.
102    Allow,
103    /// Block the tool call with a reason shown to the user/agent.
104    Block { reason: String },
105    /// Allow the tool call but replace its arguments.
106    Modify { args: Value },
107}
108
109/// Context passed to a hook command via stdin as JSON.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct HookContext {
112    /// The event name that triggered this hook (e.g. `"pre_tool_use"`).
113    pub event: String,
114    /// Tool name, present for tool-related events.
115    pub tool_name: Option<String>,
116    /// Tool arguments, present for `pre_tool_use`.
117    pub tool_args: Option<Value>,
118    /// Tool output/result, present for `post_tool_use`.
119    pub tool_result: Option<String>,
120    /// Whether the tool succeeded, present for `post_tool_use`.
121    pub tool_success: Option<bool>,
122    /// Unique session identifier.
123    pub session_id: String,
124    /// Current working directory of the agent.
125    pub working_dir: String,
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use serde_json::json;
132
133    // ── HookEvent ────────────────────────────────────────────────
134
135    #[test]
136    fn hook_event_serializes_to_snake_case() {
137        assert_eq!(
138            serde_json::to_string(&HookEvent::PreToolUse).unwrap(),
139            r#""pre_tool_use""#
140        );
141        assert_eq!(
142            serde_json::to_string(&HookEvent::PostToolUse).unwrap(),
143            r#""post_tool_use""#
144        );
145        assert_eq!(
146            serde_json::to_string(&HookEvent::SessionStart).unwrap(),
147            r#""session_start""#
148        );
149        assert_eq!(
150            serde_json::to_string(&HookEvent::SessionEnd).unwrap(),
151            r#""session_end""#
152        );
153        assert_eq!(
154            serde_json::to_string(&HookEvent::Notification).unwrap(),
155            r#""notification""#
156        );
157    }
158
159    #[test]
160    fn hook_event_deserializes_from_snake_case() {
161        let event: HookEvent = serde_json::from_str(r#""pre_tool_use""#).unwrap();
162        assert_eq!(event, HookEvent::PreToolUse);
163
164        let event: HookEvent = serde_json::from_str(r#""post_tool_use""#).unwrap();
165        assert_eq!(event, HookEvent::PostToolUse);
166    }
167
168    // ── HookConfig ───────────────────────────────────────────────
169
170    #[test]
171    fn hook_config_roundtrip_json() {
172        let cfg = HookConfig {
173            event: HookEvent::PreToolUse,
174            matcher: Some("bash".into()),
175            command: "echo ok".into(),
176            timeout_ms: 5000,
177            plugin_root: None,
178        };
179        let json = serde_json::to_string(&cfg).unwrap();
180        let back: HookConfig = serde_json::from_str(&json).unwrap();
181        assert_eq!(back.event, HookEvent::PreToolUse);
182        assert_eq!(back.matcher.as_deref(), Some("bash"));
183        assert_eq!(back.command, "echo ok");
184        assert_eq!(back.timeout_ms, 5000);
185    }
186
187    #[test]
188    fn hook_config_timeout_defaults_to_10000() {
189        let json = r#"{
190            "event": "session_start",
191            "command": "notify-send hello"
192        }"#;
193        let cfg: HookConfig = serde_json::from_str(json).unwrap();
194        assert_eq!(cfg.timeout_ms, 10_000);
195        assert!(cfg.matcher.is_none());
196    }
197
198    #[test]
199    fn hook_config_roundtrip_toml() {
200        let toml_str = r#"
201event = "pre_tool_use"
202matcher = "write"
203command = "check-write.sh"
204timeout_ms = 3000
205"#;
206        let cfg: HookConfig = toml::from_str(toml_str).unwrap();
207        assert_eq!(cfg.event, HookEvent::PreToolUse);
208        assert_eq!(cfg.matcher.as_deref(), Some("write"));
209        assert_eq!(cfg.timeout_ms, 3000);
210    }
211
212    // ── PreHookResult ────────────────────────────────────────────
213
214    #[test]
215    fn pre_hook_result_allow_roundtrip() {
216        let r = PreHookResult::Allow;
217        let json = serde_json::to_value(&r).unwrap();
218        assert_eq!(json, json!({"action": "allow"}));
219
220        let back: PreHookResult = serde_json::from_value(json).unwrap();
221        assert_eq!(back, PreHookResult::Allow);
222    }
223
224    #[test]
225    fn pre_hook_result_block_roundtrip() {
226        let r = PreHookResult::Block {
227            reason: "unsafe".into(),
228        };
229        let json = serde_json::to_value(&r).unwrap();
230        assert_eq!(json, json!({"action": "block", "reason": "unsafe"}));
231
232        let back: PreHookResult = serde_json::from_value(json).unwrap();
233        assert_eq!(back, r);
234    }
235
236    #[test]
237    fn pre_hook_result_modify_roundtrip() {
238        let new_args = json!({"path": "/safe/dir", "content": "ok"});
239        let r = PreHookResult::Modify {
240            args: new_args.clone(),
241        };
242        let json = serde_json::to_value(&r).unwrap();
243        assert_eq!(
244            json,
245            json!({"action": "modify", "args": {"path": "/safe/dir", "content": "ok"}})
246        );
247
248        let back: PreHookResult = serde_json::from_value(json).unwrap();
249        assert_eq!(back, r);
250    }
251
252    // ── HookContext ──────────────────────────────────────────────
253
254    #[test]
255    fn hook_context_full_roundtrip() {
256        let ctx = HookContext {
257            event: "pre_tool_use".into(),
258            tool_name: Some("bash".into()),
259            tool_args: Some(json!({"command": "ls"})),
260            tool_result: None,
261            tool_success: None,
262            session_id: "abc-123".into(),
263            working_dir: "/home/user/project".into(),
264        };
265        let json = serde_json::to_string(&ctx).unwrap();
266        let back: HookContext = serde_json::from_str(&json).unwrap();
267        assert_eq!(back.event, "pre_tool_use");
268        assert_eq!(back.tool_name.as_deref(), Some("bash"));
269        assert!(back.tool_result.is_none());
270        assert!(back.tool_success.is_none());
271        assert_eq!(back.session_id, "abc-123");
272    }
273
274    #[test]
275    fn hook_context_post_tool_use() {
276        let ctx = HookContext {
277            event: "post_tool_use".into(),
278            tool_name: Some("write".into()),
279            tool_args: None,
280            tool_result: Some("file written".into()),
281            tool_success: Some(true),
282            session_id: "xyz-789".into(),
283            working_dir: "/tmp".into(),
284        };
285        let v = serde_json::to_value(&ctx).unwrap();
286        assert_eq!(v["tool_success"], json!(true));
287        assert_eq!(v["tool_result"], json!("file written"));
288    }
289
290    #[test]
291    fn hook_context_minimal_session_event() {
292        let json_str = r#"{
293            "event": "session_start",
294            "tool_name": null,
295            "tool_args": null,
296            "tool_result": null,
297            "tool_success": null,
298            "session_id": "s1",
299            "working_dir": "/home"
300        }"#;
301        let ctx: HookContext = serde_json::from_str(json_str).unwrap();
302        assert_eq!(ctx.event, "session_start");
303        assert!(ctx.tool_name.is_none());
304    }
305}