kranz_engine/hook_status.rs
1//! Hook-derived status signals: an OPTIONAL, non-authoritative observability
2//! lane for CLI backends that expose lifecycle hooks (ticket
3//! `.kranz/tickets/agent-hooks-status-signals.md`; the consumer backend is
4//! [`crate::backend_cursor`]).
5//!
6//! NOT [`crate::hooks`] — that module is the D-F webhook triggers the engine
7//! EMITS to the operator — and NOT [`crate::hook_gates`], which projects
8//! deterministic gates onto Claude Code's in-session `PreToolUse` hooks.
9//! This module is the third, deliberately smallest hook lane: the backend
10//! CLI's LIFECYCLE hooks (`sessionStart`, `stop`, `sessionEnd`, …) are
11//! projected onto a kranz-managed command that reports a coarse signal —
12//! "running", "needs input", "interrupted", "turn finished" — so the
13//! dashboard and Slack can say SOMETHING honest about a session whose
14//! output stream has gone quiet.
15//!
16//! # Why signals are never mission state
17//!
18//! The event fold is the only source of mission truth. A hook payload is
19//! worker-reachable input (the per-session spec file lives in the
20//! session-writable scratch root, so a hostile or confused session can POST
21//! anything its token allows), and a lifecycle hook can simply never fire
22//! (killed process, pre-hooks CLI, crashed endpoint). Neither property is
23//! acceptable for state transitions, so this lane is structurally incapable
24//! of touching it: signals land ONLY in an ephemeral derived projection
25//! under the gitignored runtime dir (`.kranz/hook-status/`), keyed by
26//! `(mission_id, run_id)`, and NO reducer-driving EventKind is added — the
27//! module has no `EventKind` reference at all. A durable additive
28//! observability event for hook receipts is an explicitly separate decision
29//! (ticket's persistence constraint), not this lane.
30//!
31//! # The verified cursor hook surface (ground truth)
32//!
33//! Verified 2026-08-06 against the live docs (<https://cursor.com/docs/hooks>
34//! and <https://cursor.com/docs/cli/changelog>; the local `agent --help`
35//! prints no hook documentation):
36//!
37//! - Hooks are declared in `hooks.json` files; the USER-level file is
38//! `~/.cursor/hooks.json` (the project-level `<root>/.cursor/hooks.json`
39//! is a tracked-tree file this lane NEVER writes — see install hygiene
40//! below). Shape:
41//! `{ "version": 1, "hooks": { "<event>": [ { "command": "...", "timeout": 10 } ] } }`.
42//! - Command hooks are spawned processes receiving the payload JSON on
43//! STDIN (argv delivery was replaced by stdin in the 2026-05-20 CLI
44//! changelog entry) and returning JSON on stdout; exit 0 = ok, exit 2 =
45//! block the action, any other code = hook failed and the action
46//! proceeds (fail-open). There is NO HTTP/URL hook type — delivery to
47//! kranz's endpoint is done by the installed `kranz hook-status` command.
48//! - Documented agent lifecycle events include `sessionStart`, `stop`
49//! (`{status, loop_count}`), `sessionEnd` (`{reason: completed|aborted|
50//! error|window_close|user_close, error_message?}`), and
51//! `postToolUseFailure` (`{tool_name, failure_type: timeout|error|
52//! permission_denied, error_message}`). CLI hook support dates from the
53//! January 2026 CLI changelog entry; no docs page names a version floor,
54//! so a pre-hooks CLI is a degradation (hooks never fire — the lane says
55//! nothing), never an error.
56//!
57//! # The lane, end to end
58//!
59//! 1. Config opt-in (`hookStatus` in the mission config, off by default)
60//! plus a hook-capable backend ([`crate::types::BackendKind::
61//! supports_hook_status_signals`] — cursor only today) makes the runner
62//! mint a per-run capability token, REGISTER it in the projection store
63//! (the gitignored `.kranz/hook-status/<mission>/<run>.json` file), and
64//! seed the session spec ([`crate::backend::SessionSpec::hook_status`]).
65//! 2. The cursor backend installs the session's hook config AT SPAWN: a
66//! `hooks.json` in the SESSION-PRIVATE scratch HOME (`<home>/.cursor/
67//! hooks.json`, the same per-session seeding channel as the account/
68//! config seed) pointing every mapped lifecycle event at
69//! `kranz hook-status --config <scratch>/hook-status/spec.json`, plus
70//! that spec file (endpoint, token, mission/run ids).
71//! 3. The cursor CLI fires a lifecycle hook → `kranz hook-status` reads
72//! the payload on stdin (bounded), maps it to a [`HookSignal`]
73//! ([`map_cursor_hook`]), and POSTs `{token, missionId, runId, signal,
74//! detail}` to the configured loopback endpoint. Every failure exits 0:
75//! the lane is observational and must never block or fail a session.
76//! 4. The server endpoint (`kranz serve`, loopback + capability-token
77//! gated — NOT the serve mutation token, which never crosses into a
78//! worker-readable file) validates the POST against the registration
79//! (constant-time token-hash compare, safe ids, staleness TTL, bounded
80//! body) and rewrites the run's projection entry. Untrusted-payload
81//! discipline: path traversal, stale ids, oversized bodies are all
82//! rejected, and the endpoint's ONLY write is the projection file.
83//! 5. `GET /api/missions/:id/hook-status` re-reads the projection from
84//! disk per request (the server's every-read-is-a-reread idiom); the
85//! dashboard renders it beside the pending-decision chrome and Slack
86//! appends it to `/kranz status`, always labelled hook-derived and
87//! non-authoritative.
88//!
89//! # Install hygiene (non-negotiable, per the ticket)
90//!
91//! The lane writes hook config ONLY into the session-private scratch HOME
92//! — never the primary checkout's tracked `.cursor/hooks.json`, and never
93//! as a side effect of `kranz serve` (the server process writes nothing
94//! but projection files under the gitignored runtime dir). A repo-local
95//! hook install, if ever offered, must be an explicit operator action
96//! producing a reviewable diff — no such action exists today.
97
98use chrono::{DateTime, Utc};
99use serde::{Deserialize, Serialize};
100use serde_json::{json, Value};
101use std::path::{Path, PathBuf};
102use std::time::Duration;
103
104/// Schema version of the per-session spec file ([`HookStatusSpec`]) and of
105/// the projection entries ([`RunHookStatus`]) — both bump together.
106pub const SPEC_VERSION: u32 = 1;
107
108/// Bounds a wedged hook invocation (seconds), mirroring the hook-gate
109/// lane's discipline: the CLI's default hook timeout is far too generous
110/// for a local stdin→HTTP relay.
111const HOOK_TIMEOUT_SECS: u32 = 10;
112
113/// Max bytes of hook payload `kranz hook-status` reads from stdin. Real
114/// cursor lifecycle payloads are a few KB; a boundless read would let a
115/// hostile or broken CLI exhaust memory in the relay.
116pub const STDIN_PAYLOAD_MAX_BYTES: usize = 64 * 1024;
117
118/// Max bytes the endpoint accepts for one signal POST (the relay's body is
119/// a handful of small fields; the server's route-level body limit is set
120/// to this same bound).
121pub const SIGNAL_BODY_MAX_BYTES: usize = 16 * 1024;
122
123/// Max chars kept on a recorded signal's detail (the detail is derived
124/// from a hook payload — worker-reachable input — so every persisted
125/// string is scrubbed AND bounded, same discipline as `worker.message`).
126const DETAIL_MAX_CHARS: usize = 200;
127
128/// How long a registration accepts signals. A run outliving this TTL has
129/// its late POSTs rejected as stale (`stale ids` per the ticket): the
130/// registration file is runtime cruft from a run the engine has long
131/// reaped, and an unbounded acceptance window would let a leaked token
132/// rewrite history forever. 24h is generous for any real run.
133pub const REGISTRATION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
134
135/// Cap on runs returned per mission by [`read_mission_signals`] (most
136/// recently registered first) — bounds the read side against a flooded
137/// registrations dir.
138const READ_CAP: usize = 64;
139
140// ---------------------------------------------------------------------------
141// Signal vocabulary (the projection's whole state space)
142// ---------------------------------------------------------------------------
143
144/// One coarse lifecycle signal. This is the COMPLETE vocabulary the lane
145/// can express — deliberately much smaller than mission status, so no hook
146/// payload can ever spell a state transition (`FeatureFailed`, `Blocked`,
147/// `Complete`, grant mutations): the mapping is an enum, not a string.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "kebab-case")]
150pub enum HookSignal {
151 /// The session is alive and working (`sessionStart`).
152 Running,
153 /// The session appears to want operator attention (cursor: a headless
154 /// tool call was refused by the permission posture —
155 /// `postToolUseFailure` with `failure_type == "permission_denied"`).
156 NeedsInput,
157 /// The session ended abnormally (`sessionEnd` with
158 /// `aborted`/`error`/`window_close`/`user_close`).
159 Interrupted,
160 /// The session finished its turn (`stop`, or `sessionEnd` with
161 /// `completed`).
162 TurnFinished,
163}
164
165impl HookSignal {
166 /// The wire spelling (kebab-case, matching serde).
167 pub fn as_str(self) -> &'static str {
168 match self {
169 HookSignal::Running => "running",
170 HookSignal::NeedsInput => "needs-input",
171 HookSignal::Interrupted => "interrupted",
172 HookSignal::TurnFinished => "turn-finished",
173 }
174 }
175}
176
177/// Map one cursor lifecycle-hook payload (the JSON the CLI pipes to the
178/// hook command's stdin) to a signal and an optional human-readable
179/// detail. `None` = the event carries no status meaning for this lane and
180/// is IGNORED (malformed or unmapped payloads are always ignorable — the
181/// relay treats them as success and never retries).
182///
183/// The mapping is deliberately honest about what a headless
184/// `--print` session can produce:
185///
186/// - `sessionStart` → [`HookSignal::Running`].
187/// - `stop` → [`HookSignal::TurnFinished`]. In an INTERACTIVE cursor
188/// session a stop means "idle at the prompt"; kranz's cursor backend is
189/// always headless single-shot (`agent --print`, see
190/// [`crate::backend_cursor`] module docs), where the agent stopping IS
191/// the turn finishing.
192/// - `sessionEnd` → `completed` maps to [`HookSignal::TurnFinished`];
193/// `aborted` / `error` / `window_close` / `user_close` map to
194/// [`HookSignal::Interrupted`] with the reason (and any `error_message`)
195/// as detail. An absent reason is unmapped — never guessed at.
196/// - `postToolUseFailure` with `failure_type == "permission_denied"` →
197/// [`HookSignal::NeedsInput`]: a headless session wanted a capability
198/// its posture refused, which is exactly the "a human should look"
199/// signal this lane exists to surface. Other failure types
200/// (`timeout`/`error`) are ordinary tool noise, not status.
201pub fn map_cursor_hook(payload: &Value) -> Option<(HookSignal, Option<String>)> {
202 let event = payload.get("hook_event_name")?.as_str()?;
203 match event {
204 "sessionStart" => Some((HookSignal::Running, None)),
205 "stop" => Some((HookSignal::TurnFinished, None)),
206 "sessionEnd" => {
207 let reason = payload.get("reason").and_then(Value::as_str)?;
208 let detail = match payload.get("error_message").and_then(Value::as_str) {
209 Some(message) if !message.is_empty() => {
210 Some(format!("session ended: {reason} ({message})"))
211 }
212 _ => Some(format!("session ended: {reason}")),
213 };
214 match reason {
215 "completed" => Some((HookSignal::TurnFinished, detail)),
216 "aborted" | "error" | "window_close" | "user_close" => {
217 Some((HookSignal::Interrupted, detail))
218 }
219 _ => None,
220 }
221 }
222 "postToolUseFailure" => {
223 let failure_type = payload.get("failure_type").and_then(Value::as_str)?;
224 if failure_type != "permission_denied" {
225 return None;
226 }
227 let tool = payload
228 .get("tool_name")
229 .and_then(Value::as_str)
230 .unwrap_or("tool");
231 Some((
232 HookSignal::NeedsInput,
233 Some(format!(
234 "{tool} was refused by the session's permission posture"
235 )),
236 ))
237 }
238 _ => None,
239 }
240}
241
242// ---------------------------------------------------------------------------
243// Per-session spec file (engine-written, the `kranz hook-status` relay reads)
244// ---------------------------------------------------------------------------
245
246/// Everything `kranz hook-status` needs to report one signal, written by
247/// the backend at spawn time into the session-private scratch root. The
248/// hook command line carries ONLY this file's path (mirroring the
249/// hook-gate lane: no new env or credential channel — the session's
250/// already-cleared env is the whole channel).
251///
252/// The `token` is a per-RUN capability that authorizes exactly one thing —
253/// writing this run's projection entry — never the serve mutation token
254/// (`serve.token` is mutation authority and must stay unreadable inside
255/// sandboxes; a worker-readable file can only ever carry a token whose
256/// forgery ceiling is lying about its own run's status).
257#[derive(Debug, Clone, Serialize, Deserialize)]
258#[serde(rename_all = "camelCase")]
259pub struct HookStatusSpec {
260 /// [`SPEC_VERSION`] at write time.
261 pub version: u32,
262 /// The full signal POST URL (e.g. `http://127.0.0.1:4560/api/hook-status`).
263 pub endpoint: String,
264 /// The per-run capability token (registered server-side as a hash).
265 pub token: String,
266 pub mission_id: String,
267 pub run_id: String,
268}
269
270impl HookStatusSpec {
271 /// Load the spec the hook command was pointed at. Any IO/parse failure
272 /// is the relay's ignore-and-exit-0 branch.
273 pub fn load(path: &Path) -> std::io::Result<Self> {
274 let text = std::fs::read_to_string(path)?;
275 serde_json::from_str(&text)
276 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
277 }
278}
279
280/// The runner-side seed carried on the session spec: what the backend
281/// needs to install the lane. Backend-neutral (the runner mints it without
282/// knowing which backend impl will consume it); backends without a
283/// lifecycle-hook surface ignore it exactly like `settings_json`.
284#[derive(Debug, Clone)]
285pub struct HookStatusSeed {
286 /// The full signal POST URL (from config `hookStatus.endpoint`).
287 pub endpoint: String,
288 /// The freshly minted per-run capability token.
289 pub token: String,
290 pub mission_id: String,
291 pub run_id: String,
292}
293
294/// The per-session hook-status dir under the session-private scratch root
295/// (the one tree every sandbox tier keeps worker-writable — see
296/// [`crate::hook_gates`] module docs).
297fn hook_status_session_dir(session_id: &str) -> PathBuf {
298 crate::backend_claude::scratch_home_root(session_id).join("hook-status")
299}
300
301/// The backend-written spec file the hook command is pointed at.
302pub fn spec_file(session_id: &str) -> PathBuf {
303 hook_status_session_dir(session_id).join("spec.json")
304}
305
306// ---------------------------------------------------------------------------
307// Install (backend side, at spawn)
308// ---------------------------------------------------------------------------
309
310/// The `~/.cursor/hooks.json` content wiring the mapped lifecycle events
311/// to `command`. Pure so the exact wire shape is unit-testable without
312/// spawning anything. Uses only the long-stable documented subset —
313/// `version: 1`, per-event `[{command, timeout}]` handler lists (see
314/// module docs for the verified surface). The mapped events are exactly
315/// the ones [`map_cursor_hook`] consumes; installing MORE would only add
316/// hook-spawn overhead for payloads the relay ignores.
317pub fn cursor_hooks_json(command: &str) -> Value {
318 let handler = json!({ "command": command, "timeout": HOOK_TIMEOUT_SECS });
319 json!({
320 "version": 1,
321 "hooks": {
322 "sessionStart": [handler],
323 "stop": [handler],
324 "sessionEnd": [handler],
325 "postToolUseFailure": [handler],
326 }
327 })
328}
329
330/// Install the lane into one cursor session: write the per-session spec
331/// file plus `<session_home>/.cursor/hooks.json`. `session_home` MUST be
332/// the session-private HOME the child will actually receive (the backend
333/// resolves it from the same seeding logic as its env channel) — this is
334/// the install-hygiene invariant: hook config only ever lands in the
335/// throwaway per-session HOME, never the primary checkout's tracked tree.
336///
337/// A pre-existing `hooks.json` in that HOME is overwritten only when it is
338/// EXACTLY the file a previous install for this session wrote (same
339/// session-private path, never operator content — the account/config seed
340/// deliberately never copies `hooks.json`, so the only way the file exists
341/// is this lane).
342pub fn install_cursor_hook_status(
343 session_home: &Path,
344 seed: &HookStatusSeed,
345 session_id: &str,
346) -> std::io::Result<()> {
347 let spec = HookStatusSpec {
348 version: SPEC_VERSION,
349 endpoint: seed.endpoint.clone(),
350 token: seed.token.clone(),
351 mission_id: seed.mission_id.clone(),
352 run_id: seed.run_id.clone(),
353 };
354 let spec_path = spec_file(session_id);
355 if let Some(parent) = spec_path.parent() {
356 std::fs::create_dir_all(parent)?;
357 }
358 let spec_text = serde_json::to_string_pretty(&spec).map_err(std::io::Error::other)?;
359 std::fs::write(&spec_path, spec_text)?;
360
361 let exe = std::env::current_exe()?;
362 let command = format!(
363 "{} hook-status --config {}",
364 shell_quote(&exe),
365 shell_quote(&spec_path)
366 );
367 let cursor_dir = session_home.join(".cursor");
368 std::fs::create_dir_all(&cursor_dir)?;
369 let hooks_text = serde_json::to_string_pretty(&cursor_hooks_json(&command))
370 .map_err(std::io::Error::other)?;
371 std::fs::write(cursor_dir.join("hooks.json"), hooks_text)
372}
373
374/// Single-quote a path for the shell-form hook command line (`sh -c`
375/// semantics) — identical discipline to the hook-gate lane: the only safe
376/// interpolation is none at all.
377fn shell_quote(path: &Path) -> String {
378 format!("'{}'", path.display().to_string().replace('\'', r"'\''"))
379}
380
381// ---------------------------------------------------------------------------
382// Projection store (gitignored runtime: .kranz/hook-status/)
383// ---------------------------------------------------------------------------
384
385/// The repo-level projection dir (gitignored runtime — see
386/// [`crate::paths::KRANZ_GITIGNORE_RULES`], which carries `hook-status/`).
387pub fn hook_status_dir(repo_root: &Path) -> PathBuf {
388 repo_root.join(".kranz").join("hook-status")
389}
390
391/// One mission's projection dir (one file per run).
392fn mission_dir(repo_root: &Path, mission_id: &str) -> PathBuf {
393 hook_status_dir(repo_root).join(mission_id)
394}
395
396/// The run's projection file. Caller must have validated both ids with
397/// [`crate::paths::MissionPaths::is_safe_id`] — they are joined into
398/// filesystem paths.
399fn run_file(repo_root: &Path, mission_id: &str, run_id: &str) -> PathBuf {
400 mission_dir(repo_root, mission_id).join(format!("{run_id}.json"))
401}
402
403/// One run's projection entry: the registration (who may write) plus the
404/// latest signal (what was last heard). The token lives here ONLY as a
405/// SHA-256 hash — the cleartext token exists in exactly two places (the
406/// runner's memory and the session's spec file), so a read of the
407/// projection dir yields nothing replayable.
408#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
409#[serde(rename_all = "camelCase")]
410pub struct RunHookStatus {
411 /// [`SPEC_VERSION`] at write time.
412 pub version: u32,
413 /// SHA-256 hex of the per-run capability token.
414 pub token_hash: String,
415 pub registered_at: DateTime<Utc>,
416 /// The latest accepted signal; `None` = registered but never heard
417 /// from (hooks not fired, relay down, or a pre-hooks CLI).
418 #[serde(default, skip_serializing_if = "Option::is_none")]
419 pub signal: Option<SignalRecord>,
420}
421
422/// One accepted signal occurrence.
423#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
424#[serde(rename_all = "camelCase")]
425pub struct SignalRecord {
426 pub signal: HookSignal,
427 /// Scrubbed + bounded human-readable detail (worker-reachable input).
428 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub detail: Option<String>,
430 pub received_at: DateTime<Utc>,
431}
432
433/// Mint a per-run capability token (uuid v4 simple hex, same idiom as the
434/// serve tokens).
435pub fn mint_token() -> String {
436 uuid::Uuid::new_v4().simple().to_string()
437}
438
439/// SHA-256 hex of a cleartext token — the only form the projection stores.
440fn token_hash(token: &str) -> String {
441 use sha2::Digest as _;
442 let digest = sha2::Sha256::digest(token.as_bytes());
443 digest.iter().map(|b| format!("{b:02x}")).collect()
444}
445
446/// Register one run's lane: create the projection entry carrying the
447/// token hash. Called by the runner at spec time. Ids are validated before
448/// any path is joined; a failure here degrades to NO lane (the caller
449/// logs and leaves the spec seed unset) — never to a spawn error.
450pub fn register(
451 repo_root: &Path,
452 mission_id: &str,
453 run_id: &str,
454 token: &str,
455 now: DateTime<Utc>,
456) -> std::io::Result<PathBuf> {
457 if !crate::paths::MissionPaths::is_safe_id(mission_id)
458 || !crate::paths::MissionPaths::is_safe_id(run_id)
459 {
460 return Err(std::io::Error::new(
461 std::io::ErrorKind::InvalidInput,
462 "hook-status ids must be safe path components",
463 ));
464 }
465 let entry = RunHookStatus {
466 version: SPEC_VERSION,
467 token_hash: token_hash(token),
468 registered_at: now,
469 signal: None,
470 };
471 let path = run_file(repo_root, mission_id, run_id);
472 if let Some(parent) = path.parent() {
473 std::fs::create_dir_all(parent)?;
474 }
475 let text = serde_json::to_string_pretty(&entry).map_err(std::io::Error::other)?;
476 std::fs::write(&path, text)?;
477 Ok(path)
478}
479
480/// Why a signal POST was rejected. The endpoint maps these to statuses
481/// that oracle nothing about neighboring runs.
482#[derive(Debug, Clone, Copy, PartialEq, Eq)]
483pub enum RecordRejection {
484 /// Mission/run id failed the safe-id check (path traversal).
485 UnsafeId,
486 /// No registration exists for this run (unknown, or cleaned up).
487 UnknownRun,
488 /// A registration exists but is unreadable/corrupt — treated as
489 /// unknown (the run's lane is broken, not the request's problem).
490 RegistrationUnreadable,
491 /// The presented token does not match the registration's hash.
492 TokenMismatch,
493 /// The registration is older than [`REGISTRATION_TTL`].
494 Stale,
495}
496
497/// Record one signal against its registration — the endpoint's ONLY write.
498/// Every untrusted-input check lives here so the route handler stays a
499/// thin shell: safe ids, registration present and readable, constant-time
500/// token-hash compare, staleness TTL. The detail is scrubbed and bounded
501/// before it persists. The rewrite is atomic (tmp + rename) so a reader
502/// never observes a torn entry.
503pub fn record_signal(
504 repo_root: &Path,
505 mission_id: &str,
506 run_id: &str,
507 presented_token: &str,
508 signal: HookSignal,
509 detail: Option<&str>,
510 now: DateTime<Utc>,
511) -> std::result::Result<RunHookStatus, RecordRejection> {
512 if !crate::paths::MissionPaths::is_safe_id(mission_id)
513 || !crate::paths::MissionPaths::is_safe_id(run_id)
514 {
515 return Err(RecordRejection::UnsafeId);
516 }
517 let path = run_file(repo_root, mission_id, run_id);
518 let text = std::fs::read_to_string(&path).map_err(|e| match e.kind() {
519 std::io::ErrorKind::NotFound => RecordRejection::UnknownRun,
520 _ => RecordRejection::RegistrationUnreadable,
521 })?;
522 let mut entry: RunHookStatus =
523 serde_json::from_str(&text).map_err(|_| RecordRejection::RegistrationUnreadable)?;
524 use subtle::ConstantTimeEq as _;
525 let presented_hash = token_hash(presented_token);
526 if !bool::from(presented_hash.as_bytes().ct_eq(entry.token_hash.as_bytes())) {
527 return Err(RecordRejection::TokenMismatch);
528 }
529 let age = now
530 .signed_duration_since(entry.registered_at)
531 .to_std()
532 .unwrap_or(Duration::ZERO);
533 if age > REGISTRATION_TTL {
534 return Err(RecordRejection::Stale);
535 }
536 entry.signal = Some(SignalRecord {
537 signal,
538 detail: detail
539 .filter(|d| !d.trim().is_empty())
540 .map(|d| crate::scrub::scrub_and_truncate(d, DETAIL_MAX_CHARS)),
541 received_at: now,
542 });
543 let tmp = path.with_extension("json.tmp");
544 let out = serde_json::to_string_pretty(&entry)
545 .map_err(|_| RecordRejection::RegistrationUnreadable)?;
546 std::fs::write(&tmp, out).map_err(|_| RecordRejection::RegistrationUnreadable)?;
547 std::fs::rename(&tmp, &path).map_err(|_| RecordRejection::RegistrationUnreadable)?;
548 Ok(entry)
549}
550
551/// The public (tokenless) view of one run's entry, served by
552/// `GET /api/missions/:id/hook-status` and rendered by dashboard/Slack.
553#[derive(Debug, Clone, Serialize)]
554#[serde(rename_all = "camelCase")]
555pub struct RunHookStatusView {
556 pub run_id: String,
557 pub registered_at: DateTime<Utc>,
558 #[serde(skip_serializing_if = "Option::is_none")]
559 pub signal: Option<SignalRecord>,
560}
561
562/// Read one mission's projection (most recently registered first, capped
563/// at [`READ_CAP`]). Best-effort per entry: an unreadable/corrupt run file
564/// is skipped rather than failing the whole read — the projection is
565/// runtime state, and a partial honest answer beats none. Returns an empty
566/// vec when the lane was never used for this mission.
567pub fn read_mission_signals(repo_root: &Path, mission_id: &str) -> Vec<RunHookStatusView> {
568 if !crate::paths::MissionPaths::is_safe_id(mission_id) {
569 return Vec::new();
570 }
571 let dir = mission_dir(repo_root, mission_id);
572 let read_dir = match std::fs::read_dir(&dir) {
573 Ok(read_dir) => read_dir,
574 Err(_) => return Vec::new(),
575 };
576 let mut entries: Vec<RunHookStatusView> = Vec::new();
577 for entry in read_dir.flatten() {
578 let path = entry.path();
579 if path.extension().and_then(|e| e.to_str()) != Some("json") {
580 continue;
581 }
582 let Some(run_id) = path.file_stem().and_then(|s| s.to_str()) else {
583 continue;
584 };
585 let parsed = std::fs::read_to_string(&path)
586 .ok()
587 .and_then(|text| serde_json::from_str::<RunHookStatus>(&text).ok());
588 if let Some(status) = parsed {
589 entries.push(RunHookStatusView {
590 run_id: run_id.to_string(),
591 registered_at: status.registered_at,
592 signal: status.signal,
593 });
594 }
595 }
596 entries.sort_by_key(|entry| std::cmp::Reverse(entry.registered_at));
597 entries.truncate(READ_CAP);
598 entries
599}
600
601/// The JSON body `kranz hook-status` POSTs (and the endpoint consumes).
602/// Kept in the engine so the relay and the server share one wire shape.
603#[derive(Debug, Clone, Serialize, Deserialize)]
604#[serde(rename_all = "camelCase")]
605pub struct SignalPost {
606 pub token: String,
607 pub mission_id: String,
608 pub run_id: String,
609 pub signal: HookSignal,
610 #[serde(default, skip_serializing_if = "Option::is_none")]
611 pub detail: Option<String>,
612}
613
614/// Build the POST body for one mapped payload (relay side). `None` when
615/// the payload maps to no signal — the relay then has nothing to send.
616pub fn signal_post_for(spec: &HookStatusSpec, payload: &Value) -> Option<SignalPost> {
617 let (signal, detail) = map_cursor_hook(payload)?;
618 Some(SignalPost {
619 token: spec.token.clone(),
620 mission_id: spec.mission_id.clone(),
621 run_id: spec.run_id.clone(),
622 signal,
623 detail,
624 })
625}
626
627/// The full signal POST path for a configured base endpoint: callers
628/// configure the bare endpoint URL (e.g. `http://127.0.0.1:4560/api/
629/// hook-status`) verbatim — no path munging here; config validation owns
630/// its shape.
631pub fn endpoint_is_loopback_http(endpoint: &str) -> bool {
632 let Some(rest) = endpoint
633 .strip_prefix("http://")
634 .or_else(|| endpoint.strip_prefix("https://"))
635 else {
636 return false;
637 };
638 let authority = rest.split('/').next().unwrap_or("");
639 // Bracketed v6 literals carry their own ':'s — split them off first.
640 let host = if let Some(bracketed) = authority.strip_prefix('[') {
641 match bracketed.split_once(']') {
642 Some((host, _)) => host,
643 None => return false,
644 }
645 } else {
646 authority.split(':').next().unwrap_or("")
647 };
648 host == "localhost"
649 || host
650 .parse::<std::net::IpAddr>()
651 .is_ok_and(|ip| ip.is_loopback())
652}
653
654/// The engine-side unit of the lane's config gate: everything the runner
655/// needs from `hookStatus` resolved to "install or not, and where to".
656/// Defined here (not in `types.rs`) so `MissionConfig` stays POD — see
657/// [`crate::types::HookStatusConfig`].
658pub fn resolved_endpoint(config: &crate::types::HookStatusConfig) -> Option<&str> {
659 if config.enabled && !config.endpoint.trim().is_empty() {
660 Some(config.endpoint.trim())
661 } else {
662 None
663 }
664}
665
666#[cfg(test)]
667mod tests {
668 use super::*;
669
670 fn seed() -> HookStatusSeed {
671 HookStatusSeed {
672 endpoint: "http://127.0.0.1:4560/api/hook-status".to_string(),
673 token: "tok-1".to_string(),
674 mission_id: "m-1".to_string(),
675 run_id: "r-1".to_string(),
676 }
677 }
678
679 /// The mapping covers the four ticket-named signals and ignores
680 /// everything else (malformed or unmapped payloads are never a signal).
681 #[test]
682 fn hook_status_signal_cursor_mapping_covers_the_vocabulary() {
683 let session_start = json!({ "hook_event_name": "sessionStart" });
684 assert_eq!(
685 map_cursor_hook(&session_start),
686 Some((HookSignal::Running, None))
687 );
688
689 let stop = json!({ "hook_event_name": "stop", "status": "completed", "loop_count": 0 });
690 assert_eq!(
691 map_cursor_hook(&stop),
692 Some((HookSignal::TurnFinished, None))
693 );
694
695 let ended_ok = json!({ "hook_event_name": "sessionEnd", "reason": "completed" });
696 assert_eq!(
697 map_cursor_hook(&ended_ok),
698 Some((
699 HookSignal::TurnFinished,
700 Some("session ended: completed".into())
701 ))
702 );
703 let ended_err = json!({
704 "hook_event_name": "sessionEnd",
705 "reason": "error",
706 "error_message": "model exploded",
707 });
708 assert_eq!(
709 map_cursor_hook(&ended_err),
710 Some((
711 HookSignal::Interrupted,
712 Some("session ended: error (model exploded)".into())
713 ))
714 );
715 for reason in ["aborted", "window_close", "user_close"] {
716 let ended = json!({ "hook_event_name": "sessionEnd", "reason": reason });
717 assert!(
718 matches!(map_cursor_hook(&ended), Some((HookSignal::Interrupted, _))),
719 "{reason} must map to interrupted"
720 );
721 }
722
723 let denied = json!({
724 "hook_event_name": "postToolUseFailure",
725 "tool_name": "Shell",
726 "failure_type": "permission_denied",
727 });
728 assert_eq!(
729 map_cursor_hook(&denied),
730 Some((
731 HookSignal::NeedsInput,
732 Some("Shell was refused by the session's permission posture".into())
733 ))
734 );
735 }
736
737 /// Malformed payloads are ignored: no event name, an unmapped event, a
738 /// reason-less sessionEnd, a non-denial tool failure, and a non-object
739 /// body all map to None (the relay exits 0 without POSTing).
740 #[test]
741 fn hook_status_signal_malformed_payloads_map_to_nothing() {
742 for payload in [
743 json!({}),
744 json!({ "hook_event_name": "beforeSubmitPrompt" }),
745 json!({ "hook_event_name": "sessionEnd" }),
746 json!({ "hook_event_name": "sessionEnd", "reason": "melted" }),
747 json!({ "hook_event_name": "postToolUseFailure", "failure_type": "timeout" }),
748 json!({ "hook_event_name": "postToolUseFailure", "failure_type": "error" }),
749 json!("not an object"),
750 json!(null),
751 ] {
752 assert_eq!(map_cursor_hook(&payload), None, "{payload}");
753 }
754 }
755
756 /// The generated hooks.json has exactly the documented shape: version 1,
757 /// one command handler per mapped lifecycle event, bounded timeout.
758 #[test]
759 fn hook_status_signal_hooks_json_matches_the_cursor_schema() {
760 let hooks =
761 cursor_hooks_json("'/usr/local/bin/kranz' hook-status --config '/tmp/s/spec.json'");
762 assert_eq!(hooks["version"], 1);
763 for event in ["sessionStart", "stop", "sessionEnd", "postToolUseFailure"] {
764 let handlers = hooks["hooks"][event].as_array().expect(event);
765 assert_eq!(handlers.len(), 1, "{event}");
766 assert_eq!(
767 handlers[0]["command"],
768 "'/usr/local/bin/kranz' hook-status --config '/tmp/s/spec.json'"
769 );
770 assert!(handlers[0]["timeout"].as_u64().unwrap() <= 30, "{event}");
771 }
772 // Nothing beyond the mapped set is installed.
773 let object = hooks["hooks"].as_object().unwrap();
774 assert_eq!(object.len(), 4, "{object:?}");
775 }
776
777 /// Install writes the spec file + the session-HOME hooks.json — and
778 /// nothing anywhere else (the install-hygiene invariant).
779 #[test]
780 fn hook_status_signal_install_writes_only_the_session_home() {
781 let home = tempfile::tempdir().unwrap();
782 let primary_checkout = tempfile::tempdir().unwrap();
783 let session_id = format!("hook-status-install-{}", uuid::Uuid::new_v4());
784
785 install_cursor_hook_status(home.path(), &seed(), &session_id).unwrap();
786
787 let hooks_text =
788 std::fs::read_to_string(home.path().join(".cursor").join("hooks.json")).unwrap();
789 let hooks: Value = serde_json::from_str(&hooks_text).unwrap();
790 let command = hooks["hooks"]["sessionStart"][0]["command"]
791 .as_str()
792 .unwrap();
793 assert!(command.contains("hook-status"), "{command}");
794 assert!(command.contains("--config"), "{command}");
795
796 let spec = HookStatusSpec::load(&spec_file(&session_id)).unwrap();
797 assert_eq!(spec.version, SPEC_VERSION);
798 assert_eq!(spec.endpoint, "http://127.0.0.1:4560/api/hook-status");
799 assert_eq!(spec.token, "tok-1");
800 assert_eq!(spec.mission_id, "m-1");
801 assert_eq!(spec.run_id, "r-1");
802
803 // The primary checkout is byte-untouched — no `.cursor` dir, no
804 // hooks.json, nothing (the ticket's non-negotiable install rule).
805 assert_eq!(
806 std::fs::read_dir(primary_checkout.path()).unwrap().count(),
807 0,
808 "install must never write into the primary tracked tree"
809 );
810
811 let _ = std::fs::remove_dir_all(crate::backend_claude::scratch_home_root(&session_id));
812 }
813
814 /// Register → record → read round-trip: the projection keeps only the
815 /// token HASH, the latest signal wins, and the public view carries no
816 /// hash.
817 #[test]
818 fn hook_status_signal_projection_round_trip() {
819 let repo = tempfile::tempdir().unwrap();
820 let now = Utc::now();
821 register(repo.path(), "m-1", "r-1", "tok-1", now).unwrap();
822
823 // The stored file carries no cleartext token.
824 let raw = std::fs::read_to_string(run_file(repo.path(), "m-1", "r-1")).unwrap();
825 assert!(!raw.contains("tok-1"), "{raw}");
826
827 let entry = record_signal(
828 repo.path(),
829 "m-1",
830 "r-1",
831 "tok-1",
832 HookSignal::Running,
833 None,
834 now,
835 )
836 .unwrap();
837 assert!(entry.signal.is_some());
838 let entry = record_signal(
839 repo.path(),
840 "m-1",
841 "r-1",
842 "tok-1",
843 HookSignal::NeedsInput,
844 Some("Shell was refused"),
845 now,
846 )
847 .unwrap();
848 assert_eq!(
849 entry.signal.as_ref().map(|s| s.signal),
850 Some(HookSignal::NeedsInput),
851 "the latest signal wins"
852 );
853
854 let views = read_mission_signals(repo.path(), "m-1");
855 assert_eq!(views.len(), 1);
856 assert_eq!(views[0].run_id, "r-1");
857 assert_eq!(
858 views[0].signal.as_ref().map(|s| s.signal),
859 Some(HookSignal::NeedsInput)
860 );
861 // The public view serializes without the token hash.
862 let public = serde_json::to_string(&views[0]).unwrap();
863 assert!(!public.contains("tokenHash"), "{public}");
864 }
865
866 /// Untrusted-payload discipline at the store boundary: path traversal,
867 /// unknown runs, wrong tokens, and stale registrations are all
868 /// rejected, and none of them write anything.
869 #[test]
870 fn hook_status_signal_record_rejects_traversal_stale_and_wrong_token() {
871 let repo = tempfile::tempdir().unwrap();
872 let now = Utc::now();
873 register(repo.path(), "m-1", "r-1", "tok-1", now).unwrap();
874
875 // Path traversal in either id is refused before any path is joined.
876 for (mission, run) in [("../m-1", "r-1"), ("m-1", "../r-1"), ("m/1", "r-1")] {
877 assert_eq!(
878 record_signal(
879 repo.path(),
880 mission,
881 run,
882 "tok-1",
883 HookSignal::Running,
884 None,
885 now
886 ),
887 Err(RecordRejection::UnsafeId)
888 );
889 assert_eq!(
890 register(repo.path(), mission, run, "tok-1", now)
891 .unwrap_err()
892 .kind(),
893 std::io::ErrorKind::InvalidInput
894 );
895 }
896
897 // Unknown run.
898 assert_eq!(
899 record_signal(
900 repo.path(),
901 "m-1",
902 "r-9",
903 "tok-1",
904 HookSignal::Running,
905 None,
906 now
907 ),
908 Err(RecordRejection::UnknownRun)
909 );
910
911 // Wrong token (constant-time compare; only the hash is stored).
912 assert_eq!(
913 record_signal(
914 repo.path(),
915 "m-1",
916 "r-1",
917 "tok-2",
918 HookSignal::Running,
919 None,
920 now
921 ),
922 Err(RecordRejection::TokenMismatch)
923 );
924
925 // Stale registration: older than the TTL rejects new signals.
926 let old = now - chrono::Duration::seconds(REGISTRATION_TTL.as_secs() as i64 + 60);
927 register(repo.path(), "m-1", "r-old", "tok-1", old).unwrap();
928 assert_eq!(
929 record_signal(
930 repo.path(),
931 "m-1",
932 "r-old",
933 "tok-1",
934 HookSignal::Running,
935 None,
936 now
937 ),
938 Err(RecordRejection::Stale)
939 );
940
941 // None of the rejections changed the honest entry.
942 let views = read_mission_signals(repo.path(), "m-1");
943 assert!(views.iter().all(|v| v.signal.is_none()), "{views:?}");
944 }
945
946 /// The detail is worker-reachable input: scrubbed and bounded before it
947 /// persists.
948 #[test]
949 fn hook_status_signal_detail_is_scrubbed_and_bounded() {
950 let repo = tempfile::tempdir().unwrap();
951 let now = Utc::now();
952 register(repo.path(), "m-1", "r-1", "tok-1", now).unwrap();
953 let long = "x".repeat(DETAIL_MAX_CHARS * 3);
954 let entry = record_signal(
955 repo.path(),
956 "m-1",
957 "r-1",
958 "tok-1",
959 HookSignal::Interrupted,
960 Some(&long),
961 now,
962 )
963 .unwrap();
964 let detail = entry.signal.unwrap().detail.unwrap();
965 // House truncation semantics (`scrub::truncate_chars`): at most
966 // DETAIL_MAX_CHARS of content plus the truncation marker.
967 assert!(
968 detail.chars().count() <= DETAIL_MAX_CHARS + "… [truncated]".chars().count(),
969 "{detail}"
970 );
971 assert!(detail.ends_with(" [truncated]"), "{detail}");
972 }
973
974 /// `signal_post_for` shares one wire shape between the relay and the
975 /// endpoint, and maps nothing for ignored payloads.
976 #[test]
977 fn hook_status_signal_post_body_shares_the_wire_shape() {
978 let spec = HookStatusSpec {
979 version: SPEC_VERSION,
980 endpoint: "http://127.0.0.1:9/api/hook-status".to_string(),
981 token: "tok-1".to_string(),
982 mission_id: "m-1".to_string(),
983 run_id: "r-1".to_string(),
984 };
985 let payload = json!({ "hook_event_name": "sessionStart" });
986 let post = signal_post_for(&spec, &payload).unwrap();
987 assert_eq!(post.token, "tok-1");
988 assert_eq!(post.mission_id, "m-1");
989 assert_eq!(post.run_id, "r-1");
990 assert_eq!(post.signal, HookSignal::Running);
991 let wire = serde_json::to_value(&post).unwrap();
992 assert_eq!(wire["signal"], "running");
993 assert!(wire.get("detail").is_none());
994
995 let ignored = json!({ "hook_event_name": "preCompact" });
996 assert!(signal_post_for(&spec, &ignored).is_none());
997 }
998
999 /// The endpoint gate: loopback http(s) only — the capability token
1000 /// rides this URL, so it must never point at a remote host.
1001 #[test]
1002 fn hook_status_signal_endpoint_gate_accepts_loopback_only() {
1003 for ok in [
1004 "http://127.0.0.1:4560/api/hook-status",
1005 "http://localhost:4560/api/hook-status",
1006 "http://[::1]:4560/api/hook-status",
1007 "https://127.0.0.1/api/hook-status",
1008 ] {
1009 assert!(endpoint_is_loopback_http(ok), "{ok}");
1010 }
1011 for bad in [
1012 "http://example.com/api/hook-status",
1013 "http://192.168.1.5/api/hook-status",
1014 "ftp://127.0.0.1/x",
1015 "127.0.0.1:4560/api/hook-status",
1016 "",
1017 ] {
1018 assert!(!endpoint_is_loopback_http(bad), "{bad}");
1019 }
1020 }
1021
1022 /// The config resolution: disabled or endpoint-less means NO lane.
1023 #[test]
1024 fn hook_status_signal_config_resolution_is_off_by_default() {
1025 let off = crate::types::HookStatusConfig::default();
1026 assert!(resolved_endpoint(&off).is_none());
1027 let disabled_with_endpoint = crate::types::HookStatusConfig {
1028 enabled: false,
1029 endpoint: "http://127.0.0.1:4560/api/hook-status".to_string(),
1030 };
1031 assert!(resolved_endpoint(&disabled_with_endpoint).is_none());
1032 let on = crate::types::HookStatusConfig {
1033 enabled: true,
1034 endpoint: " http://127.0.0.1:4560/api/hook-status ".to_string(),
1035 };
1036 assert_eq!(
1037 resolved_endpoint(&on),
1038 Some("http://127.0.0.1:4560/api/hook-status")
1039 );
1040 }
1041}