Skip to main content

kranz_cli/
hook_status.rs

1//! `kranz hook-status` — the command cursor CLI lifecycle hooks invoke
2//! inside agent sessions (ticket
3//! `.kranz/tickets/agent-hooks-status-signals`; the engine side is
4//! [`kranz_engine::hook_status`], which also records the verified cursor
5//! hook surface this relays).
6//!
7//! This is an INTERNAL plumbing command, never an operator surface: the
8//! backend installs it into the session-private `~/.cursor/hooks.json` as a
9//! command hook on the mapped lifecycle events (`sessionStart`, `stop`,
10//! `sessionEnd`, `postToolUseFailure`). The cursor CLI pipes the hook
11//! payload JSON to stdin; the relay bounds the read, maps the payload to
12//! a coarse [`kranz_engine::hook_status::HookSignal`], and POSTs
13//! `{token, missionId, runId, signal, detail}` to the loopback endpoint
14//! named in the engine-written spec file.
15//!
16//! Exit posture differs from `kranz hook-guard` ON PURPOSE: the guard is a
17//! gate (exit 2 blocks), this relay is pure observability — EVERY failure
18//! (unreadable spec, oversized/unparseable payload, unreachable endpoint,
19//! rejected POST) exits **0** with a stderr note. Cursor hook semantics:
20//! exit 2 would block the session's action, and any other non-zero code
21//! lands a hook-error notice in the transcript; neither is worth it for a
22//! signal that is never mission state. The relay also runs with the
23//! session's already-cleared environment and reads nothing but the spec
24//! file and stdin — no new credential or env channel (the spec's per-run
25//! capability token is the whole authority, and it can only write that
26//! one run's projection entry).
27
28use kranz_engine::hook_status::{HookStatusSpec, SignalPost, STDIN_PAYLOAD_MAX_BYTES};
29use std::io::Read;
30use std::path::Path;
31use std::time::Duration;
32
33/// Bounds the endpoint round-trip: a wedged or absent server must never
34/// hold the session's hook process (and through it, the session) open.
35const POST_TIMEOUT: Duration = Duration::from_secs(5);
36
37/// Run the relay: read the hook payload from `stdin`, map it, POST the
38/// signal to the spec's endpoint. Always returns 0 (see module docs);
39/// `post_fn` is the test seam scripting endpoint outcomes without a
40/// network.
41pub async fn run_hook_status(
42    config: &Path,
43    stdin: &mut impl Read,
44    post_fn: &impl AsyncFn(&str, &SignalPost) -> Result<(), String>,
45) -> i32 {
46    let spec = match HookStatusSpec::load(config) {
47        Ok(spec) => spec,
48        Err(e) => {
49            eprintln!(
50                "kranz hook-status: failed to load the hook spec {}: {e} \
51                 (ignoring; the lane is observational)",
52                config.display()
53            );
54            return 0;
55        }
56    };
57
58    // Bounded read: the payload is CLI-produced but the channel is
59    // session-adjacent — a boundless read_to_string would let a broken or
60    // hostile producer exhaust memory in the relay.
61    let mut payload_bytes = Vec::new();
62    if let Err(e) = stdin
63        .take((STDIN_PAYLOAD_MAX_BYTES + 1) as u64)
64        .read_to_end(&mut payload_bytes)
65    {
66        eprintln!("kranz hook-status: failed to read the hook payload on stdin: {e} (ignoring)");
67        return 0;
68    }
69    if payload_bytes.len() > STDIN_PAYLOAD_MAX_BYTES {
70        eprintln!(
71            "kranz hook-status: hook payload exceeds {} bytes (ignoring)",
72            STDIN_PAYLOAD_MAX_BYTES
73        );
74        return 0;
75    }
76    let payload: serde_json::Value = match serde_json::from_slice(&payload_bytes) {
77        Ok(payload) => payload,
78        Err(e) => {
79            eprintln!("kranz hook-status: hook payload was not JSON: {e} (ignoring)");
80            return 0;
81        }
82    };
83
84    // Unmapped payloads (unmapped event, malformed shape) are ordinary —
85    // the hooks.json installs exactly the events the mapping consumes, but
86    // a payload that maps to nothing is ignored, never an error.
87    let Some(post) = kranz_engine::hook_status::signal_post_for(&spec, &payload) else {
88        return 0;
89    };
90
91    if let Err(e) = post_fn(&spec.endpoint, &post).await {
92        eprintln!("kranz hook-status: signal POST failed: {e} (ignoring)");
93    }
94    0
95}
96
97/// The real POST: JSON body to the spec's loopback endpoint with a hard
98/// timeout. A non-2xx is an Err naming the status (the relay still exits
99/// 0 — the note is for the transcript/stderr, never for retry).
100pub async fn post_signal(endpoint: &str, post: &SignalPost) -> Result<(), String> {
101    let client = reqwest::Client::builder()
102        .timeout(POST_TIMEOUT)
103        .build()
104        .map_err(|e| e.to_string())?;
105    let response = client
106        .post(endpoint)
107        .json(post)
108        .send()
109        .await
110        .map_err(|e| e.to_string())?;
111    if response.status().is_success() {
112        Ok(())
113    } else {
114        Err(format!("endpoint returned {}", response.status()))
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use std::sync::{Arc, Mutex};
122
123    fn write_spec(dir: &Path) -> std::path::PathBuf {
124        let spec = HookStatusSpec {
125            version: kranz_engine::hook_status::SPEC_VERSION,
126            endpoint: "http://127.0.0.1:9/api/hook-status".to_string(),
127            token: "tok-1".to_string(),
128            mission_id: "m-1".to_string(),
129            run_id: "r-1".to_string(),
130        };
131        let path = dir.join("spec.json");
132        std::fs::write(&path, serde_json::to_string_pretty(&spec).unwrap()).unwrap();
133        path
134    }
135
136    /// A mapped payload is POSTed to the spec's endpoint in the shared wire
137    /// shape; every step's failure still exits 0.
138    #[tokio::test]
139    async fn hook_status_signal_relay_posts_mapped_payloads_and_never_fails() {
140        let dir = tempfile::tempdir().unwrap();
141        let config = write_spec(dir.path());
142        let captured: Arc<Mutex<Vec<(String, serde_json::Value)>>> =
143            Arc::new(Mutex::new(Vec::new()));
144        let sink = {
145            let captured = Arc::clone(&captured);
146            move |endpoint: &str, post: &SignalPost| {
147                let captured = Arc::clone(&captured);
148                let endpoint = endpoint.to_string();
149                let post = post.clone();
150                async move {
151                    captured
152                        .lock()
153                        .unwrap()
154                        .push((endpoint, serde_json::to_value(post).unwrap()));
155                    Ok(())
156                }
157            }
158        };
159
160        let payload = serde_json::json!({
161            "hook_event_name": "postToolUseFailure",
162            "tool_name": "Shell",
163            "failure_type": "permission_denied",
164        })
165        .to_string();
166        let code = run_hook_status(&config, &mut payload.as_bytes(), &sink).await;
167        assert_eq!(code, 0, "the relay always exits 0");
168
169        let captured = captured.lock().unwrap();
170        assert_eq!(captured.len(), 1);
171        assert_eq!(captured[0].0, "http://127.0.0.1:9/api/hook-status");
172        let body = &captured[0].1;
173        assert_eq!(body["token"], "tok-1");
174        assert_eq!(body["missionId"], "m-1");
175        assert_eq!(body["runId"], "r-1");
176        assert_eq!(body["signal"], "needs-input");
177        assert!(body["detail"].as_str().unwrap().contains("Shell"));
178    }
179
180    /// Unmapped / malformed / oversized inputs POST nothing and exit 0 —
181    /// the malformed-payloads-ignored acceptance hint at the relay seam.
182    #[tokio::test]
183    async fn hook_status_signal_relay_ignores_malformed_unmapped_and_oversized() {
184        let dir = tempfile::tempdir().unwrap();
185        let config = write_spec(dir.path());
186        let calls = Arc::new(Mutex::new(0usize));
187        let counting = {
188            let calls = Arc::clone(&calls);
189            move |_: &str, _: &SignalPost| {
190                let calls = Arc::clone(&calls);
191                async move {
192                    *calls.lock().unwrap() += 1;
193                    Ok(())
194                }
195            }
196        };
197
198        // Not JSON.
199        let mut bad = b"{not json".as_slice();
200        assert_eq!(run_hook_status(&config, &mut bad, &counting).await, 0);
201        // Valid JSON, unmapped event.
202        let payload = serde_json::json!({ "hook_event_name": "preCompact" }).to_string();
203        assert_eq!(
204            run_hook_status(&config, &mut payload.as_bytes(), &counting).await,
205            0
206        );
207        // Oversized body.
208        let oversized = vec![b'x'; STDIN_PAYLOAD_MAX_BYTES + 1];
209        assert_eq!(
210            run_hook_status(&config, &mut oversized.as_slice(), &counting).await,
211            0
212        );
213        // Missing spec file.
214        let missing = dir.path().join("no-such-spec.json");
215        let payload = serde_json::json!({ "hook_event_name": "sessionStart" }).to_string();
216        assert_eq!(
217            run_hook_status(&missing, &mut payload.as_bytes(), &counting).await,
218            0
219        );
220
221        assert_eq!(*calls.lock().unwrap(), 0, "nothing was POSTed");
222    }
223
224    /// A failing endpoint (connection refused, non-2xx) is a stderr note,
225    /// never a non-zero exit: the session must never feel the lane.
226    #[tokio::test]
227    async fn hook_status_signal_relay_swallows_endpoint_failures() {
228        let dir = tempfile::tempdir().unwrap();
229        let config = write_spec(dir.path());
230        let failing = |_: &str, _: &SignalPost| async move {
231            Err("endpoint returned 403".to_string()) as Result<(), String>
232        };
233        let payload = serde_json::json!({ "hook_event_name": "sessionStart" }).to_string();
234        assert_eq!(
235            run_hook_status(&config, &mut payload.as_bytes(), &failing).await,
236            0
237        );
238    }
239}