Skip to main content

polyhook_core/
response.rs

1use crate::types::{CallerKind, HookEventEvent, HookResponse};
2use serde_json::{json, Value};
3
4/// Serialize a [`HookResponse`] into the JSON format expected by the detected caller.
5///
6/// Does not use event-type context. For PreToolUse blocking on Claude Code, prefer
7/// [`serialize_response_with_event`] so the correct `hookSpecificOutput` format is used.
8pub fn serialize_response(resp: &HookResponse, caller: &CallerKind) -> Value {
9    serialize_response_with_event(resp, *caller, None)
10}
11
12/// Like [`serialize_response`] but uses the event type to pick the correct block format.
13/// For Claude Code + `tool:before`, emits `hookSpecificOutput.permissionDecision: "deny"`
14/// instead of `decision: "block"` (which would terminate the whole session).
15pub(crate) fn serialize_response_with_event(
16    resp: &HookResponse,
17    caller: CallerKind,
18    event: Option<HookEventEvent>,
19) -> Value {
20    if let HookResponse::ContextResponse(c) = resp {
21        if let Some(value) = serialize_context(&c.context, caller) {
22            return value;
23        }
24    }
25    match caller {
26        CallerKind::ClaudeCode | CallerKind::Pi | CallerKind::Codex | CallerKind::Unknown => {
27            serialize_claude_code(resp, event)
28        }
29        CallerKind::Cursor => serialize_cursor(resp),
30        CallerKind::Windsurf => serialize_windsurf(resp),
31        CallerKind::Cline => serialize_cline(resp),
32        CallerKind::Amp => serialize_amp(resp),
33        CallerKind::GeminiCli => serialize_gemini_cli(resp),
34        CallerKind::Hermes => serialize_hermes(resp),
35    }
36}
37
38/// Context injection for a prompt:submit event, or `None` when the caller has
39/// no prompt hook that can add context (Cursor, Windsurf, Amp); those callers
40/// then fall through to their approve format.
41fn serialize_context(context: &str, caller: CallerKind) -> Option<Value> {
42    match caller {
43        CallerKind::ClaudeCode | CallerKind::Pi | CallerKind::Codex | CallerKind::Unknown => {
44            Some(json!({
45                "hookSpecificOutput": {
46                    "hookEventName": "UserPromptSubmit",
47                    "additionalContext": context
48                }
49            }))
50        }
51        CallerKind::GeminiCli => Some(json!({
52            "hookSpecificOutput": {
53                "hookEventName": "BeforeAgent",
54                "additionalContext": context
55            }
56        })),
57        CallerKind::Hermes => Some(json!({ "context": context })),
58        CallerKind::Cline => Some(json!({ "cancel": false, "contextModification": context })),
59        CallerKind::Cursor | CallerKind::Windsurf | CallerKind::Amp => None,
60    }
61}
62
63// ---------------------------------------------------------------------------
64// Per-caller serializers
65// ---------------------------------------------------------------------------
66
67fn serialize_claude_code(resp: &HookResponse, event: Option<HookEventEvent>) -> Value {
68    let is_pre_tool_use = matches!(event, Some(HookEventEvent::ToolBefore));
69    match resp {
70        HookResponse::ApproveResponse(_) | HookResponse::ContextResponse(_) => {
71            if is_pre_tool_use {
72                // PreToolUse only reads hookSpecificOutput.permissionDecision; an empty
73                // `{}` is a passive no-op (falls through to the normal permission flow)
74                // rather than an explicit allow that bypasses the confirmation prompt.
75                json!({
76                    "hookSpecificOutput": {
77                        "hookEventName": "PreToolUse",
78                        "permissionDecision": "allow"
79                    }
80                })
81            } else {
82                json!({})
83            }
84        }
85        HookResponse::BlockResponse(b) => {
86            if is_pre_tool_use {
87                json!({
88                    "hookSpecificOutput": {
89                        "hookEventName": "PreToolUse",
90                        "permissionDecision": "deny",
91                        "permissionDecisionReason": b.message
92                    }
93                })
94            } else {
95                json!({ "decision": "block", "reason": b.message })
96            }
97        }
98        HookResponse::ModifyResponse(m) => {
99            if is_pre_tool_use {
100                // Same reasoning as BlockResponse above: PreToolUse does not read the
101                // top-level `decision`/`tool_input` fields, so the replacement input
102                // must go under hookSpecificOutput.updatedInput to actually apply.
103                json!({
104                    "hookSpecificOutput": {
105                        "hookEventName": "PreToolUse",
106                        "permissionDecision": "allow",
107                        "updatedInput": m.input
108                    }
109                })
110            } else {
111                json!({ "decision": "approve", "tool_input": m.input })
112            }
113        }
114    }
115}
116
117fn serialize_cursor(resp: &HookResponse) -> Value {
118    match resp {
119        HookResponse::ApproveResponse(_) | HookResponse::ContextResponse(_) => {
120            json!({ "action": "allow" })
121        }
122        HookResponse::BlockResponse(b) => {
123            json!({ "action": "deny", "message": b.message })
124        }
125        HookResponse::ModifyResponse(m) => {
126            json!({ "action": "modify", "args": m.input })
127        }
128    }
129}
130
131fn serialize_windsurf(resp: &HookResponse) -> Value {
132    match resp {
133        HookResponse::ApproveResponse(_) | HookResponse::ContextResponse(_) => {
134            json!({ "allow": true })
135        }
136        HookResponse::BlockResponse(b) => {
137            json!({ "allow": false, "reason": b.message })
138        }
139        HookResponse::ModifyResponse(m) => {
140            json!({ "allow": true, "modified_parameters": m.input })
141        }
142    }
143}
144
145fn serialize_cline(resp: &HookResponse) -> Value {
146    match resp {
147        HookResponse::ApproveResponse(_) | HookResponse::ContextResponse(_) => {
148            json!({ "approved": true })
149        }
150        HookResponse::BlockResponse(b) => {
151            json!({ "approved": false, "reason": b.message })
152        }
153        HookResponse::ModifyResponse(m) => {
154            json!({ "approved": true, "modifiedInput": m.input })
155        }
156    }
157}
158
159fn serialize_amp(resp: &HookResponse) -> Value {
160    match resp {
161        HookResponse::ApproveResponse(_) | HookResponse::ContextResponse(_) => {
162            json!({ "result": "allow" })
163        }
164        HookResponse::BlockResponse(b) => {
165            json!({ "result": "deny", "reason": b.message })
166        }
167        HookResponse::ModifyResponse(m) => {
168            json!({ "result": "allow", "modified": m.input })
169        }
170    }
171}
172
173fn serialize_gemini_cli(resp: &HookResponse) -> Value {
174    match resp {
175        HookResponse::ApproveResponse(_) | HookResponse::ContextResponse(_) => {
176            json!({ "decision": "allow" })
177        }
178        HookResponse::BlockResponse(b) => {
179            json!({ "decision": "deny", "reason": b.message })
180        }
181        HookResponse::ModifyResponse(m) => {
182            json!({ "decision": "allow", "tool_input": m.input })
183        }
184    }
185}
186
187fn serialize_hermes(resp: &HookResponse) -> Value {
188    match resp {
189        HookResponse::ApproveResponse(_) | HookResponse::ContextResponse(_) => json!({}),
190        HookResponse::BlockResponse(b) => {
191            json!({ "action": "block", "message": b.message })
192        }
193        HookResponse::ModifyResponse(m) => {
194            json!({ "action": "modify", "tool_input": m.input })
195        }
196    }
197}
198
199// ---------------------------------------------------------------------------
200// Tests
201// ---------------------------------------------------------------------------
202
203#[cfg(test)]
204#[path = "response_tests.rs"]
205mod tests;