Skip to main content

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}