Skip to main content

remem/cursor_hook/
input.rs

1//! Bounded Cursor stdin reading and fail-closed payload parsing
2//! (B-002, B-003, B-007, B-009, B-014, B-015).
3//!
4//! Parsing copies only whitelisted fields out of the outer JSON object and
5//! then drops it: `user_email` and every other non-canonical field never
6//! reach a canonical event, log line, error message, or preview (B-014).
7
8use 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/// Sanitized, validated Cursor `sessionStart` event for `remem context`.
20#[derive(Debug, Clone)]
21pub struct CursorSessionStart {
22    pub session_id: String,
23    /// Normalized sole workspace root; becomes the invocation cwd/project.
24    pub workspace_root: String,
25    /// Null-tolerant base field; `None` on new sessions.
26    pub transcript_path: Option<String>,
27}
28
29/// Sanitized, validated Cursor tool event for `remem observe`.
30#[derive(Debug, Clone)]
31pub struct CursorToolEvent {
32    pub session_id: String,
33    pub workspace_root: String,
34    pub tool_name: String,
35    /// Canonical per-call event/upsert identity (SP823-T2 item 5).
36    pub tool_use_id: String,
37    /// Observed generic `tool_input` object (canonical form is bounded).
38    pub tool_input: serde_json::Map<String, Value>,
39    pub outcome: CursorToolOutcome,
40    pub transcript_path: Option<String>,
41}
42
43/// Canonical tool outcome preserved through capture, spill, and replay.
44#[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
63/// Generic-success tool names whose post-tool variants remain unobserved on
64/// Cursor 3.12.17 and are therefore fail-closed by the SP823-T2 approval
65/// (item 7): Task completion uses the subagent lifecycle, and Write/Edit/
66/// Delete were never exercised by the read-only probe. They are not treated
67/// as unknown names; they are explicitly disabled.
68const FAIL_CLOSED_SUCCESS_TOOL_NAMES: [&str; 4] = ["Task", "Write", "Edit", "Delete"];
69
70/// The only observed `postToolUseFailure` shape is a failed `Read`
71/// (PR #914); no other failure event is accepted by analogy.
72const ACCEPTED_FAILURE_TOOL_NAMES: [&str; 1] = ["Read"];
73
74/// Reads at most `CURSOR_HOOK_STDIN_MAX_BYTES + 1` bytes and rejects the
75/// one-byte-over sentinel before any UTF-8 conversion, `String` allocation,
76/// serde parse, or payload preview (B-009). The error records only the
77/// configured bound and a correlation id.
78pub 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
101/// Parses and validates a Cursor `sessionStart` payload for `remem context`.
102/// Any other event name is an event/command mismatch and fails closed.
103pub 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
116/// Parses and validates a Cursor observe payload. Only exact `postToolUse`
117/// and `postToolUseFailure` are accepted (B-016 generic ownership keeps
118/// `beforeMCPExecution`/`afterMCPExecution` unregistered and unsupported).
119pub 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
152/// Validates that a summarize payload is an exact Cursor `stop` event.
153/// The transcript path and every other field are dropped at this boundary:
154/// Cursor summarize stays fail-closed until GH-825's verified transcript
155/// reader lands (SP823-T5), so nothing may flow toward the Claude/Codex
156/// reader, enqueue, spill, or LLM paths.
157pub 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}