Skip to main content

coding_agent_hooks/
output.rs

1//! Hook output types — structured responses sent back to agents via stdout.
2//!
3//! [`HookOutput`] is the complete response type. It is used directly by Claude
4//! Code (serialized as JSON). Other agents convert it to their protocol format
5//! via the [`HookProtocol`](crate::protocol::HookProtocol) trait methods.
6
7use std::io::Write;
8
9use serde::{Deserialize, Serialize};
10use tracing::{Level, instrument};
11
12/// The effect of a policy decision on a tool invocation.
13///
14/// This is the agent-agnostic representation of a permission decision.
15/// Individual agents may use different vocabulary (e.g. "proceed"/"block",
16/// "approve"/"deny"), but they all map to these three effects.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum Effect {
20    /// Permission is granted — tool execution proceeds.
21    Allow,
22    /// Permission requires user confirmation via the agent's native UI.
23    Ask,
24    /// Permission is denied — tool execution is blocked.
25    Deny,
26}
27
28/// Hook-specific output for PreToolUse.
29#[derive(Debug, Clone, Serialize, PartialEq)]
30#[serde(rename_all = "camelCase")]
31pub struct PreToolUseOutput {
32    pub hook_event_name: &'static str,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub permission_decision: Option<Effect>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub permission_decision_reason: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub updated_input: Option<serde_json::Value>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub additional_context: Option<String>,
41}
42
43/// Decision behavior for PermissionRequest responses.
44#[derive(Debug, Clone, Serialize, PartialEq)]
45#[serde(rename_all = "lowercase")]
46pub enum PermissionBehavior {
47    Allow,
48    Deny,
49}
50
51/// Decision structure for PermissionRequest responses.
52#[derive(Debug, Clone, Serialize, PartialEq)]
53#[serde(rename_all = "camelCase")]
54pub struct PermissionDecision {
55    pub behavior: PermissionBehavior,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub updated_input: Option<serde_json::Value>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub message: Option<String>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub interrupt: Option<bool>,
62}
63
64/// Hook-specific output for PermissionRequest.
65#[derive(Debug, Clone, Serialize, PartialEq)]
66#[serde(rename_all = "camelCase")]
67pub struct PermissionRequestOutput {
68    pub hook_event_name: &'static str,
69    pub decision: PermissionDecision,
70}
71
72/// Hook-specific output for SessionStart.
73#[derive(Debug, Clone, Serialize, PartialEq)]
74#[serde(rename_all = "camelCase")]
75pub struct SessionStartOutput {
76    pub hook_event_name: &'static str,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub additional_context: Option<String>,
79}
80
81/// Output for PostToolUse hooks — provides advisory context back to the agent.
82#[derive(Debug, Clone, Serialize, PartialEq)]
83#[serde(rename_all = "camelCase")]
84pub struct PostToolUseOutput {
85    pub hook_event_name: &'static str,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub additional_context: Option<String>,
88}
89
90/// Hook-specific output variants.
91#[derive(Debug, Clone, Serialize, PartialEq)]
92#[serde(untagged)]
93pub enum HookSpecificOutput {
94    PreToolUse(PreToolUseOutput),
95    PostToolUse(PostToolUseOutput),
96    PermissionRequest(PermissionRequestOutput),
97    SessionStart(SessionStartOutput),
98}
99
100/// The complete hook output sent to an agent via stdout.
101#[derive(Debug, Clone, Serialize, PartialEq)]
102#[serde(rename_all = "camelCase")]
103pub struct HookOutput {
104    #[serde(rename = "continue")]
105    pub should_continue: bool,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub hook_specific_output: Option<HookSpecificOutput>,
108}
109
110impl HookOutput {
111    /// Private helper to construct a PreToolUse response with the given decision.
112    fn pretooluse_output(
113        decision: Effect,
114        reason: Option<String>,
115        context: Option<String>,
116        updated_input: Option<serde_json::Value>,
117    ) -> Self {
118        Self {
119            should_continue: true,
120            hook_specific_output: Some(HookSpecificOutput::PreToolUse(PreToolUseOutput {
121                hook_event_name: "PreToolUse",
122                permission_decision: Some(decision),
123                permission_decision_reason: reason,
124                updated_input,
125                additional_context: context,
126            })),
127        }
128    }
129
130    /// Create an "allow" response for PreToolUse - bypasses permission system.
131    #[instrument(level = Level::TRACE)]
132    pub fn allow(reason: Option<String>, context: Option<String>) -> Self {
133        Self::pretooluse_output(Effect::Allow, reason, context, None)
134    }
135
136    /// Create a "deny" response for PreToolUse - prevents tool execution.
137    #[instrument(level = Level::TRACE)]
138    pub fn deny(reason: String, context: Option<String>) -> Self {
139        Self::pretooluse_output(Effect::Deny, Some(reason), context, None)
140    }
141
142    /// Create an "ask" response for PreToolUse - prompts user for confirmation.
143    #[instrument(level = Level::TRACE)]
144    pub fn ask(reason: Option<String>, context: Option<String>) -> Self {
145        Self::pretooluse_output(Effect::Ask, reason, context, None)
146    }
147
148    /// Approve a permission request on behalf of the user.
149    #[instrument(level = Level::TRACE)]
150    pub fn approve_permission(updated_input: Option<serde_json::Value>) -> Self {
151        Self {
152            should_continue: true,
153            hook_specific_output: Some(HookSpecificOutput::PermissionRequest(
154                PermissionRequestOutput {
155                    hook_event_name: "PermissionRequest",
156                    decision: PermissionDecision {
157                        behavior: PermissionBehavior::Allow,
158                        updated_input,
159                        message: None,
160                        interrupt: None,
161                    },
162                },
163            )),
164        }
165    }
166
167    /// Deny a permission request on behalf of the user.
168    #[instrument(level = Level::TRACE)]
169    pub fn deny_permission(message: String, interrupt: bool) -> Self {
170        Self {
171            should_continue: true,
172            hook_specific_output: Some(HookSpecificOutput::PermissionRequest(
173                PermissionRequestOutput {
174                    hook_event_name: "PermissionRequest",
175                    decision: PermissionDecision {
176                        behavior: PermissionBehavior::Deny,
177                        updated_input: None,
178                        message: Some(message),
179                        interrupt: Some(interrupt),
180                    },
181                },
182            )),
183        }
184    }
185
186    /// Set the updated_input field on a PreToolUse response.
187    /// This rewrites the tool input before the agent executes it.
188    #[instrument(level = Level::TRACE, skip(self))]
189    pub fn set_updated_input(&mut self, updated_input: serde_json::Value) {
190        if let Some(HookSpecificOutput::PreToolUse(ref mut pre)) = self.hook_specific_output {
191            pre.updated_input = Some(updated_input);
192        }
193    }
194
195    /// Create a SessionStart response with optional context about the session setup.
196    #[instrument(level = Level::TRACE)]
197    pub fn session_start(additional_context: Option<String>) -> Self {
198        Self {
199            should_continue: true,
200            hook_specific_output: Some(HookSpecificOutput::SessionStart(SessionStartOutput {
201                hook_event_name: "SessionStart",
202                additional_context,
203            })),
204        }
205    }
206
207    /// Create a PostToolUse response with optional advisory context.
208    #[instrument(level = Level::TRACE)]
209    pub fn post_tool_use(additional_context: Option<String>) -> Self {
210        match additional_context {
211            Some(ctx) => Self {
212                should_continue: true,
213                hook_specific_output: Some(HookSpecificOutput::PostToolUse(PostToolUseOutput {
214                    hook_event_name: "PostToolUse",
215                    additional_context: Some(ctx),
216                })),
217            },
218            None => Self::continue_execution(),
219        }
220    }
221
222    /// Continue execution without making a decision (for informational hooks).
223    #[instrument(level = Level::TRACE)]
224    pub fn continue_execution() -> Self {
225        Self {
226            should_continue: true,
227            hook_specific_output: None,
228        }
229    }
230
231    /// Write response to any writer (for testability).
232    #[instrument(level = Level::TRACE, skip(self, writer))]
233    pub fn write_to(&self, mut writer: impl Write) -> anyhow::Result<()> {
234        serde_json::to_writer(&mut writer, self)?;
235        writeln!(writer)?;
236        Ok(())
237    }
238
239    /// Write response to stdout (convenience wrapper for production).
240    #[instrument(level = Level::TRACE, skip(self))]
241    pub fn write_stdout(&self) -> anyhow::Result<()> {
242        self.write_to(std::io::stdout().lock())
243    }
244
245    /// Extract the [`Effect`] from this output, if it contains a PreToolUse decision.
246    pub fn effect(&self) -> Option<Effect> {
247        match &self.hook_specific_output {
248            Some(HookSpecificOutput::PreToolUse(pre)) => pre.permission_decision,
249            _ => None,
250        }
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_output_allow() {
260        let output = HookOutput::allow(Some("Safe command".into()), None);
261        let mut buf = Vec::new();
262        output.write_to(&mut buf).unwrap();
263
264        let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
265        assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "allow");
266        assert_eq!(
267            json["hookSpecificOutput"]["permissionDecisionReason"],
268            "Safe command"
269        );
270    }
271
272    #[test]
273    fn test_output_deny() {
274        let output = HookOutput::deny("Dangerous command".into(), None);
275        let mut buf = Vec::new();
276        output.write_to(&mut buf).unwrap();
277
278        let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
279        assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "deny");
280        assert_eq!(
281            json["hookSpecificOutput"]["permissionDecisionReason"],
282            "Dangerous command"
283        );
284    }
285
286    #[test]
287    fn test_output_ask() {
288        let output = HookOutput::ask(None, None);
289        let mut buf = Vec::new();
290        output.write_to(&mut buf).unwrap();
291
292        let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
293        assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "ask");
294        assert!(json["hookSpecificOutput"]["permissionDecisionReason"].is_null());
295    }
296
297    #[test]
298    fn test_approve_permission() {
299        let output = HookOutput::approve_permission(None);
300        let mut buf = Vec::new();
301        output.write_to(&mut buf).unwrap();
302
303        let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
304        assert_eq!(
305            json["hookSpecificOutput"]["hookEventName"],
306            "PermissionRequest"
307        );
308        assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "allow");
309        assert!(json["hookSpecificOutput"]["decision"]["updatedInput"].is_null());
310    }
311
312    #[test]
313    fn test_approve_permission_with_updated_input() {
314        let updated = serde_json::json!({"command": "ls -la"});
315        let output = HookOutput::approve_permission(Some(updated.clone()));
316        let mut buf = Vec::new();
317        output.write_to(&mut buf).unwrap();
318
319        let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
320        assert_eq!(
321            json["hookSpecificOutput"]["hookEventName"],
322            "PermissionRequest"
323        );
324        assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "allow");
325        assert_eq!(
326            json["hookSpecificOutput"]["decision"]["updatedInput"],
327            updated
328        );
329    }
330
331    #[test]
332    fn test_deny_permission() {
333        let output = HookOutput::deny_permission("Not allowed".into(), true);
334        let mut buf = Vec::new();
335        output.write_to(&mut buf).unwrap();
336
337        let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
338        assert_eq!(
339            json["hookSpecificOutput"]["hookEventName"],
340            "PermissionRequest"
341        );
342        assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "deny");
343        assert_eq!(
344            json["hookSpecificOutput"]["decision"]["message"],
345            "Not allowed"
346        );
347        assert_eq!(json["hookSpecificOutput"]["decision"]["interrupt"], true);
348    }
349
350    #[test]
351    fn test_deny_permission_no_interrupt() {
352        let output = HookOutput::deny_permission("Try again".into(), false);
353        let mut buf = Vec::new();
354        output.write_to(&mut buf).unwrap();
355
356        let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
357        assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "deny");
358        assert_eq!(json["hookSpecificOutput"]["decision"]["interrupt"], false);
359    }
360
361    #[test]
362    fn test_effect_extraction() {
363        assert_eq!(HookOutput::allow(None, None).effect(), Some(Effect::Allow));
364        assert_eq!(
365            HookOutput::deny("x".into(), None).effect(),
366            Some(Effect::Deny)
367        );
368        assert_eq!(HookOutput::ask(None, None).effect(), Some(Effect::Ask));
369        assert_eq!(HookOutput::continue_execution().effect(), None);
370    }
371}