1use anyhow::{anyhow, Result};
9use serde_json::Value;
10use std::io::Read;
11
12use super::identity::{
13 field_error, required_non_empty_string, validate_identity,
14 validate_identity_with_required_conversation, validate_transcript_path,
15 validate_workspace_root,
16};
17use super::{correlation_id, CURSOR_HOOK_STDIN_MAX_BYTES, CURSOR_TOOL_FIELD_MAX_BYTES};
18
19#[derive(Debug, Clone)]
21pub struct CursorSessionStart {
22 pub session_id: String,
23 pub workspace_root: String,
25 pub transcript_path: Option<String>,
27}
28
29#[derive(Debug, Clone)]
31pub struct CursorToolEvent {
32 pub session_id: String,
33 pub workspace_root: String,
34 pub tool_name: String,
35 pub tool_use_id: String,
37 pub tool_input: serde_json::Map<String, Value>,
39 pub outcome: CursorToolOutcome,
40 pub transcript_path: Option<String>,
41}
42
43#[derive(Debug, Clone)]
45pub enum CursorToolOutcome {
46 Success {
47 tool_output: String,
48 },
49 Failure {
50 error_message: String,
51 failure_type: String,
52 duration: f64,
53 is_interrupt: bool,
54 },
55}
56
57impl CursorToolOutcome {
58 pub fn is_failure(&self) -> bool {
59 matches!(self, CursorToolOutcome::Failure { .. })
60 }
61}
62
63const FAIL_CLOSED_SUCCESS_TOOL_NAMES: [&str; 4] = ["Task", "Write", "Edit", "Delete"];
69
70const ACCEPTED_FAILURE_TOOL_NAMES: [&str; 1] = ["Read"];
73
74pub fn read_bounded_hook_stdin(reader: &mut dyn Read) -> Result<Vec<u8>> {
79 read_bounded_hook_input(reader, CURSOR_HOOK_STDIN_MAX_BYTES)
80}
81
82pub fn read_bounded_hook_input(reader: &mut dyn Read, max_bytes: usize) -> Result<Vec<u8>> {
83 let mut limited = reader.take(max_bytes as u64 + 1);
84 let mut buffer = Vec::new();
85 limited
86 .read_to_end(&mut buffer)
87 .map_err(|_| size_only_error(max_bytes, "stdin read failed"))?;
88 if buffer.len() > max_bytes {
89 return Err(size_only_error(max_bytes, "stdin exceeds configured bound"));
90 }
91 Ok(buffer)
92}
93
94fn size_only_error(max_bytes: usize, problem: &str) -> anyhow::Error {
95 anyhow!(
96 "cursor hook stdin rejected: {problem} (limit={max_bytes} bytes) [correlation_id={}]",
97 correlation_id()
98 )
99}
100
101pub fn parse_session_start(bytes: &[u8]) -> Result<CursorSessionStart> {
104 let object = parse_outer_object(bytes)?;
105 require_event_name(&object, "sessionStart")?;
106 let session_id = validate_identity(&object)?;
107 let workspace_root = validate_workspace_root(&object)?;
108 let transcript_path = validate_transcript_path(&object)?;
109 Ok(CursorSessionStart {
110 session_id,
111 workspace_root,
112 transcript_path,
113 })
114}
115
116pub fn parse_observe_event(bytes: &[u8]) -> Result<CursorToolEvent> {
120 let object = parse_outer_object(bytes)?;
121 let event_name = required_non_empty_string(&object, "hook_event_name")?;
122 let outcome = match event_name.as_str() {
123 "postToolUse" => parse_success_outcome(&object)?,
124 "postToolUseFailure" => parse_failure_outcome(&object)?,
125 _ => {
126 return Err(anyhow!(
127 "cursor observe rejects hook_event_name '{event_name}': only exact \
128 postToolUse and postToolUseFailure are supported (MCP-specific events \
129 stay unregistered under generic ownership) [correlation_id={}]",
130 correlation_id()
131 ))
132 }
133 };
134 let session_id = validate_identity_with_required_conversation(&object)?;
135 let workspace_root = validate_workspace_root(&object)?;
136 let transcript_path = validate_transcript_path(&object)?;
137 let tool_name = required_non_empty_string(&object, "tool_name")?;
138 validate_tool_name_support(&tool_name, &outcome)?;
139 let tool_use_id = required_non_empty_string(&object, "tool_use_id")?;
140 let tool_input = validate_tool_input(&object)?;
141 Ok(CursorToolEvent {
142 session_id,
143 workspace_root,
144 tool_name,
145 tool_use_id,
146 tool_input,
147 outcome,
148 transcript_path,
149 })
150}
151
152pub fn require_stop_event(bytes: &[u8]) -> Result<()> {
158 let object = parse_outer_object(bytes)?;
159 require_event_name(&object, "stop")?;
160 Ok(())
161}
162
163pub(super) fn parse_outer_object(bytes: &[u8]) -> Result<serde_json::Map<String, Value>> {
164 let text = std::str::from_utf8(bytes).map_err(|_| {
165 anyhow!(
166 "cursor hook payload is not valid UTF-8 [correlation_id={}]",
167 correlation_id()
168 )
169 })?;
170 let value: Value = serde_json::from_str(text).map_err(|error| {
171 anyhow!(
172 "cursor hook payload is not valid JSON (line {}, column {}) [correlation_id={}]",
173 error.line(),
174 error.column(),
175 correlation_id()
176 )
177 })?;
178 match value {
179 Value::Object(object) => Ok(object),
180 _ => Err(anyhow!(
181 "cursor hook payload is not a JSON object [correlation_id={}]",
182 correlation_id()
183 )),
184 }
185}
186
187pub(super) fn require_event_name(
188 object: &serde_json::Map<String, Value>,
189 expected: &str,
190) -> Result<()> {
191 let event_name = required_non_empty_string(object, "hook_event_name")?;
192 if event_name != expected {
193 return Err(anyhow!(
194 "cursor hook event/command mismatch: got hook_event_name '{event_name}', \
195 this command supports only exact '{expected}' [correlation_id={}]",
196 correlation_id()
197 ));
198 }
199 Ok(())
200}
201
202fn parse_success_outcome(object: &serde_json::Map<String, Value>) -> Result<CursorToolOutcome> {
203 let Some(value) = object.get("tool_output") else {
204 return Err(field_error("tool_output", "missing"));
205 };
206 let Value::String(tool_output) = value else {
207 return Err(field_error("tool_output", "wrong type"));
208 };
209 check_field_bytes("tool_output", tool_output.len())?;
210 Ok(CursorToolOutcome::Success {
211 tool_output: tool_output.clone(),
212 })
213}
214
215fn parse_failure_outcome(object: &serde_json::Map<String, Value>) -> Result<CursorToolOutcome> {
216 let error_message = required_non_empty_string(object, "error_message")?;
217 check_field_bytes("error_message", error_message.len())?;
218 let failure_type = required_non_empty_string(object, "failure_type")?;
219 if failure_type != "error" {
220 return Err(field_error("failure_type", "unobserved value"));
221 }
222 let duration = match object.get("duration") {
223 Some(Value::Number(number)) => number
224 .as_f64()
225 .ok_or_else(|| field_error("duration", "non-finite number"))?,
226 Some(_) => return Err(field_error("duration", "wrong type")),
227 None => return Err(field_error("duration", "missing")),
228 };
229 let is_interrupt = match object.get("is_interrupt") {
230 Some(Value::Bool(flag)) => *flag,
231 Some(_) => return Err(field_error("is_interrupt", "wrong type")),
232 None => return Err(field_error("is_interrupt", "missing")),
233 };
234 Ok(CursorToolOutcome::Failure {
235 error_message,
236 failure_type,
237 duration,
238 is_interrupt,
239 })
240}
241
242fn validate_tool_name_support(tool_name: &str, outcome: &CursorToolOutcome) -> Result<()> {
243 if outcome.is_failure() {
244 if !ACCEPTED_FAILURE_TOOL_NAMES.contains(&tool_name) {
245 return Err(anyhow!(
246 "cursor postToolUseFailure for tool '{tool_name}' is unobserved and \
247 stays fail-closed (only the PR #914 failed Read shape is accepted) \
248 [correlation_id={}]",
249 correlation_id()
250 ));
251 }
252 return Ok(());
253 }
254 if FAIL_CLOSED_SUCCESS_TOOL_NAMES.contains(&tool_name) {
255 return Err(anyhow!(
256 "cursor postToolUse for tool '{tool_name}' is an unobserved variant and \
257 stays fail-closed per the SP823-T2 approval [correlation_id={}]",
258 correlation_id()
259 ));
260 }
261 Ok(())
262}
263
264fn validate_tool_input(
265 object: &serde_json::Map<String, Value>,
266) -> Result<serde_json::Map<String, Value>> {
267 let Some(value) = object.get("tool_input") else {
268 return Err(field_error("tool_input", "missing"));
269 };
270 let Value::Object(tool_input) = value else {
271 return Err(field_error("tool_input", "wrong type"));
272 };
273 let canonical = serde_json::to_string(tool_input)
274 .map_err(|_| field_error("tool_input", "not canonically serializable"))?;
275 check_field_bytes("tool_input", canonical.len())?;
276 Ok(tool_input.clone())
277}
278
279fn check_field_bytes(field: &str, len: usize) -> Result<()> {
280 if len > CURSOR_TOOL_FIELD_MAX_BYTES {
281 return Err(anyhow!(
282 "cursor hook payload field '{field}' exceeds the configured bound \
283 (limit={CURSOR_TOOL_FIELD_MAX_BYTES} bytes) [correlation_id={}]",
284 correlation_id()
285 ));
286 }
287 Ok(())
288}