Skip to main content

remem/cursor_hook/
stop.rs

1//! Full Cursor `stop` event validation (GH-823 SP823-T5, consumed by GH-825).
2//!
3//! Validates the normalized Cursor Stop invocation before any database open,
4//! transcript read, enqueue, spill, or LLM call:
5//! - exact `hook_event_name == "stop"`;
6//! - canonical session identity (`session_id` with required, equal
7//!   `conversation_id`) and single normalized workspace root;
8//! - `status` restricted to the observed set `{completed, aborted}`
9//!   (PR #914 evidence); `error` stays unobserved and fails closed;
10//! - non-empty string `generation_id`;
11//! - `loop_count` normalized from an exact non-negative integer JSON number
12//!   (observed value: `0`); missing, `null`, negative, fractional, or
13//!   non-number forms fail closed instead of being guessed as `0`.
14//!
15//! The canonical Stop key is `(session_id, generation_id, loop_count)`.
16
17use anyhow::{anyhow, Result};
18use serde_json::Value;
19
20use super::correlation_id;
21use super::identity::{
22    field_error, required_non_empty_string, validate_identity_with_required_conversation,
23    validate_workspace_root,
24};
25use super::input::{parse_outer_object, require_event_name};
26
27/// Human-approved accepted Stop status set (PR #914 real-host evidence).
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum CursorStopStatus {
30    Completed,
31    Aborted,
32}
33
34impl CursorStopStatus {
35    pub fn as_str(&self) -> &'static str {
36        match self {
37            CursorStopStatus::Completed => "completed",
38            CursorStopStatus::Aborted => "aborted",
39        }
40    }
41}
42
43/// Sanitized, fully validated Cursor `stop` event.
44#[derive(Debug, Clone)]
45pub struct CursorStopEvent {
46    pub session_id: String,
47    /// Normalized sole workspace root; becomes the invocation cwd/project.
48    pub workspace_root: String,
49    pub status: CursorStopStatus,
50    pub generation_id: String,
51    /// Normalized non-negative integer loop count.
52    pub loop_count: u64,
53    /// Null-tolerant base field. `None` for missing/`null`; a string is kept
54    /// verbatim (including whitespace-only strings, which downstream maps to
55    /// an explicit `path_blank` degradation instead of dropping the Stop).
56    pub transcript_path: Option<String>,
57}
58
59impl CursorStopEvent {
60    /// Canonical idempotency key `(session_id, generation_id, loop_count)`.
61    pub fn canonical_stop_key(&self) -> String {
62        format!(
63            "{}:{}:{}",
64            self.session_id, self.generation_id, self.loop_count
65        )
66    }
67}
68
69/// Parses and fully validates a Cursor `stop` payload.
70pub fn parse_stop_event(bytes: &[u8]) -> Result<CursorStopEvent> {
71    let object = parse_outer_object(bytes)?;
72    require_event_name(&object, "stop")?;
73    let session_id = validate_identity_with_required_conversation(&object)?;
74    let workspace_root = validate_workspace_root(&object)?;
75    let status = validate_stop_status(&object)?;
76    let generation_id = required_non_empty_string(&object, "generation_id")?;
77    let loop_count = validate_loop_count(&object)?;
78    let transcript_path = stop_transcript_path_field(&object)?;
79    Ok(CursorStopEvent {
80        session_id,
81        workspace_root,
82        status,
83        generation_id,
84        loop_count,
85        transcript_path,
86    })
87}
88
89fn validate_stop_status(object: &serde_json::Map<String, Value>) -> Result<CursorStopStatus> {
90    let status = required_non_empty_string(object, "status")?;
91    match status.as_str() {
92        "completed" => Ok(CursorStopStatus::Completed),
93        "aborted" => Ok(CursorStopStatus::Aborted),
94        other => Err(anyhow!(
95            "cursor stop status '{other}' is outside the approved set \
96             (completed, aborted); unobserved statuses fail closed with zero \
97             writes [correlation_id={}]",
98            correlation_id()
99        )),
100    }
101}
102
103fn validate_loop_count(object: &serde_json::Map<String, Value>) -> Result<u64> {
104    match object.get("loop_count") {
105        None => Err(field_error("loop_count", "missing")),
106        Some(Value::Null) => Err(field_error("loop_count", "null")),
107        Some(Value::Number(number)) => number
108            .as_u64()
109            .ok_or_else(|| field_error("loop_count", "not a non-negative integer")),
110        Some(_) => Err(field_error("loop_count", "wrong type")),
111    }
112}
113
114/// Stop-specific `transcript_path` extraction. Unlike the sessionStart /
115/// observe boundary, a whitespace-only string is preserved here so the Stop
116/// itself is never lost: the transcript layer degrades it to an explicit
117/// `path_blank` reason (GH-825 transcript failure matrix).
118fn stop_transcript_path_field(object: &serde_json::Map<String, Value>) -> Result<Option<String>> {
119    match object.get("transcript_path") {
120        None | Some(Value::Null) => Ok(None),
121        Some(Value::String(path)) => Ok(Some(path.clone())),
122        Some(_) => Err(field_error("transcript_path", "wrong type")),
123    }
124}