use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::time::Duration;
pub const SPEC_VERSION: u32 = 1;
const HOOK_TIMEOUT_SECS: u32 = 10;
pub const STDIN_PAYLOAD_MAX_BYTES: usize = 64 * 1024;
pub const SIGNAL_BODY_MAX_BYTES: usize = 16 * 1024;
const DETAIL_MAX_CHARS: usize = 200;
pub const REGISTRATION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const READ_CAP: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HookSignal {
Running,
NeedsInput,
Interrupted,
TurnFinished,
}
impl HookSignal {
pub fn as_str(self) -> &'static str {
match self {
HookSignal::Running => "running",
HookSignal::NeedsInput => "needs-input",
HookSignal::Interrupted => "interrupted",
HookSignal::TurnFinished => "turn-finished",
}
}
}
pub fn map_cursor_hook(payload: &Value) -> Option<(HookSignal, Option<String>)> {
let event = payload.get("hook_event_name")?.as_str()?;
match event {
"sessionStart" => Some((HookSignal::Running, None)),
"stop" => Some((HookSignal::TurnFinished, None)),
"sessionEnd" => {
let reason = payload.get("reason").and_then(Value::as_str)?;
let detail = match payload.get("error_message").and_then(Value::as_str) {
Some(message) if !message.is_empty() => {
Some(format!("session ended: {reason} ({message})"))
}
_ => Some(format!("session ended: {reason}")),
};
match reason {
"completed" => Some((HookSignal::TurnFinished, detail)),
"aborted" | "error" | "window_close" | "user_close" => {
Some((HookSignal::Interrupted, detail))
}
_ => None,
}
}
"postToolUseFailure" => {
let failure_type = payload.get("failure_type").and_then(Value::as_str)?;
if failure_type != "permission_denied" {
return None;
}
let tool = payload
.get("tool_name")
.and_then(Value::as_str)
.unwrap_or("tool");
Some((
HookSignal::NeedsInput,
Some(format!(
"{tool} was refused by the session's permission posture"
)),
))
}
_ => None,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HookStatusSpec {
pub version: u32,
pub endpoint: String,
pub token: String,
pub mission_id: String,
pub run_id: String,
}
impl HookStatusSpec {
pub fn load(path: &Path) -> std::io::Result<Self> {
let text = std::fs::read_to_string(path)?;
serde_json::from_str(&text)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
}
#[derive(Debug, Clone)]
pub struct HookStatusSeed {
pub endpoint: String,
pub token: String,
pub mission_id: String,
pub run_id: String,
}
fn hook_status_session_dir(session_id: &str) -> PathBuf {
crate::backend_claude::scratch_home_root(session_id).join("hook-status")
}
pub fn spec_file(session_id: &str) -> PathBuf {
hook_status_session_dir(session_id).join("spec.json")
}
pub fn cursor_hooks_json(command: &str) -> Value {
let handler = json!({ "command": command, "timeout": HOOK_TIMEOUT_SECS });
json!({
"version": 1,
"hooks": {
"sessionStart": [handler],
"stop": [handler],
"sessionEnd": [handler],
"postToolUseFailure": [handler],
}
})
}
pub fn install_cursor_hook_status(
session_home: &Path,
seed: &HookStatusSeed,
session_id: &str,
) -> std::io::Result<()> {
let spec = HookStatusSpec {
version: SPEC_VERSION,
endpoint: seed.endpoint.clone(),
token: seed.token.clone(),
mission_id: seed.mission_id.clone(),
run_id: seed.run_id.clone(),
};
let spec_path = spec_file(session_id);
if let Some(parent) = spec_path.parent() {
std::fs::create_dir_all(parent)?;
}
let spec_text = serde_json::to_string_pretty(&spec).map_err(std::io::Error::other)?;
std::fs::write(&spec_path, spec_text)?;
let exe = std::env::current_exe()?;
let command = format!(
"{} hook-status --config {}",
shell_quote(&exe),
shell_quote(&spec_path)
);
let cursor_dir = session_home.join(".cursor");
std::fs::create_dir_all(&cursor_dir)?;
let hooks_text = serde_json::to_string_pretty(&cursor_hooks_json(&command))
.map_err(std::io::Error::other)?;
std::fs::write(cursor_dir.join("hooks.json"), hooks_text)
}
fn shell_quote(path: &Path) -> String {
format!("'{}'", path.display().to_string().replace('\'', r"'\''"))
}
pub fn hook_status_dir(repo_root: &Path) -> PathBuf {
repo_root.join(".kranz").join("hook-status")
}
fn mission_dir(repo_root: &Path, mission_id: &str) -> PathBuf {
hook_status_dir(repo_root).join(mission_id)
}
fn run_file(repo_root: &Path, mission_id: &str, run_id: &str) -> PathBuf {
mission_dir(repo_root, mission_id).join(format!("{run_id}.json"))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RunHookStatus {
pub version: u32,
pub token_hash: String,
pub registered_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signal: Option<SignalRecord>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignalRecord {
pub signal: HookSignal,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
pub received_at: DateTime<Utc>,
}
pub fn mint_token() -> String {
uuid::Uuid::new_v4().simple().to_string()
}
fn token_hash(token: &str) -> String {
use sha2::Digest as _;
let digest = sha2::Sha256::digest(token.as_bytes());
digest.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn register(
repo_root: &Path,
mission_id: &str,
run_id: &str,
token: &str,
now: DateTime<Utc>,
) -> std::io::Result<PathBuf> {
if !crate::paths::MissionPaths::is_safe_id(mission_id)
|| !crate::paths::MissionPaths::is_safe_id(run_id)
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"hook-status ids must be safe path components",
));
}
let entry = RunHookStatus {
version: SPEC_VERSION,
token_hash: token_hash(token),
registered_at: now,
signal: None,
};
let path = run_file(repo_root, mission_id, run_id);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let text = serde_json::to_string_pretty(&entry).map_err(std::io::Error::other)?;
std::fs::write(&path, text)?;
Ok(path)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecordRejection {
UnsafeId,
UnknownRun,
RegistrationUnreadable,
TokenMismatch,
Stale,
}
pub fn record_signal(
repo_root: &Path,
mission_id: &str,
run_id: &str,
presented_token: &str,
signal: HookSignal,
detail: Option<&str>,
now: DateTime<Utc>,
) -> std::result::Result<RunHookStatus, RecordRejection> {
if !crate::paths::MissionPaths::is_safe_id(mission_id)
|| !crate::paths::MissionPaths::is_safe_id(run_id)
{
return Err(RecordRejection::UnsafeId);
}
let path = run_file(repo_root, mission_id, run_id);
let text = std::fs::read_to_string(&path).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => RecordRejection::UnknownRun,
_ => RecordRejection::RegistrationUnreadable,
})?;
let mut entry: RunHookStatus =
serde_json::from_str(&text).map_err(|_| RecordRejection::RegistrationUnreadable)?;
use subtle::ConstantTimeEq as _;
let presented_hash = token_hash(presented_token);
if !bool::from(presented_hash.as_bytes().ct_eq(entry.token_hash.as_bytes())) {
return Err(RecordRejection::TokenMismatch);
}
let age = now
.signed_duration_since(entry.registered_at)
.to_std()
.unwrap_or(Duration::ZERO);
if age > REGISTRATION_TTL {
return Err(RecordRejection::Stale);
}
entry.signal = Some(SignalRecord {
signal,
detail: detail
.filter(|d| !d.trim().is_empty())
.map(|d| crate::scrub::scrub_and_truncate(d, DETAIL_MAX_CHARS)),
received_at: now,
});
let tmp = path.with_extension("json.tmp");
let out = serde_json::to_string_pretty(&entry)
.map_err(|_| RecordRejection::RegistrationUnreadable)?;
std::fs::write(&tmp, out).map_err(|_| RecordRejection::RegistrationUnreadable)?;
std::fs::rename(&tmp, &path).map_err(|_| RecordRejection::RegistrationUnreadable)?;
Ok(entry)
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RunHookStatusView {
pub run_id: String,
pub registered_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub signal: Option<SignalRecord>,
}
pub fn read_mission_signals(repo_root: &Path, mission_id: &str) -> Vec<RunHookStatusView> {
if !crate::paths::MissionPaths::is_safe_id(mission_id) {
return Vec::new();
}
let dir = mission_dir(repo_root, mission_id);
let read_dir = match std::fs::read_dir(&dir) {
Ok(read_dir) => read_dir,
Err(_) => return Vec::new(),
};
let mut entries: Vec<RunHookStatusView> = Vec::new();
for entry in read_dir.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Some(run_id) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let parsed = std::fs::read_to_string(&path)
.ok()
.and_then(|text| serde_json::from_str::<RunHookStatus>(&text).ok());
if let Some(status) = parsed {
entries.push(RunHookStatusView {
run_id: run_id.to_string(),
registered_at: status.registered_at,
signal: status.signal,
});
}
}
entries.sort_by_key(|entry| std::cmp::Reverse(entry.registered_at));
entries.truncate(READ_CAP);
entries
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignalPost {
pub token: String,
pub mission_id: String,
pub run_id: String,
pub signal: HookSignal,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
pub fn signal_post_for(spec: &HookStatusSpec, payload: &Value) -> Option<SignalPost> {
let (signal, detail) = map_cursor_hook(payload)?;
Some(SignalPost {
token: spec.token.clone(),
mission_id: spec.mission_id.clone(),
run_id: spec.run_id.clone(),
signal,
detail,
})
}
pub fn endpoint_is_loopback_http(endpoint: &str) -> bool {
let Some(rest) = endpoint
.strip_prefix("http://")
.or_else(|| endpoint.strip_prefix("https://"))
else {
return false;
};
let authority = rest.split('/').next().unwrap_or("");
let host = if let Some(bracketed) = authority.strip_prefix('[') {
match bracketed.split_once(']') {
Some((host, _)) => host,
None => return false,
}
} else {
authority.split(':').next().unwrap_or("")
};
host == "localhost"
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
}
pub fn resolved_endpoint(config: &crate::types::HookStatusConfig) -> Option<&str> {
if config.enabled && !config.endpoint.trim().is_empty() {
Some(config.endpoint.trim())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn seed() -> HookStatusSeed {
HookStatusSeed {
endpoint: "http://127.0.0.1:4560/api/hook-status".to_string(),
token: "tok-1".to_string(),
mission_id: "m-1".to_string(),
run_id: "r-1".to_string(),
}
}
#[test]
fn hook_status_signal_cursor_mapping_covers_the_vocabulary() {
let session_start = json!({ "hook_event_name": "sessionStart" });
assert_eq!(
map_cursor_hook(&session_start),
Some((HookSignal::Running, None))
);
let stop = json!({ "hook_event_name": "stop", "status": "completed", "loop_count": 0 });
assert_eq!(
map_cursor_hook(&stop),
Some((HookSignal::TurnFinished, None))
);
let ended_ok = json!({ "hook_event_name": "sessionEnd", "reason": "completed" });
assert_eq!(
map_cursor_hook(&ended_ok),
Some((
HookSignal::TurnFinished,
Some("session ended: completed".into())
))
);
let ended_err = json!({
"hook_event_name": "sessionEnd",
"reason": "error",
"error_message": "model exploded",
});
assert_eq!(
map_cursor_hook(&ended_err),
Some((
HookSignal::Interrupted,
Some("session ended: error (model exploded)".into())
))
);
for reason in ["aborted", "window_close", "user_close"] {
let ended = json!({ "hook_event_name": "sessionEnd", "reason": reason });
assert!(
matches!(map_cursor_hook(&ended), Some((HookSignal::Interrupted, _))),
"{reason} must map to interrupted"
);
}
let denied = json!({
"hook_event_name": "postToolUseFailure",
"tool_name": "Shell",
"failure_type": "permission_denied",
});
assert_eq!(
map_cursor_hook(&denied),
Some((
HookSignal::NeedsInput,
Some("Shell was refused by the session's permission posture".into())
))
);
}
#[test]
fn hook_status_signal_malformed_payloads_map_to_nothing() {
for payload in [
json!({}),
json!({ "hook_event_name": "beforeSubmitPrompt" }),
json!({ "hook_event_name": "sessionEnd" }),
json!({ "hook_event_name": "sessionEnd", "reason": "melted" }),
json!({ "hook_event_name": "postToolUseFailure", "failure_type": "timeout" }),
json!({ "hook_event_name": "postToolUseFailure", "failure_type": "error" }),
json!("not an object"),
json!(null),
] {
assert_eq!(map_cursor_hook(&payload), None, "{payload}");
}
}
#[test]
fn hook_status_signal_hooks_json_matches_the_cursor_schema() {
let hooks =
cursor_hooks_json("'/usr/local/bin/kranz' hook-status --config '/tmp/s/spec.json'");
assert_eq!(hooks["version"], 1);
for event in ["sessionStart", "stop", "sessionEnd", "postToolUseFailure"] {
let handlers = hooks["hooks"][event].as_array().expect(event);
assert_eq!(handlers.len(), 1, "{event}");
assert_eq!(
handlers[0]["command"],
"'/usr/local/bin/kranz' hook-status --config '/tmp/s/spec.json'"
);
assert!(handlers[0]["timeout"].as_u64().unwrap() <= 30, "{event}");
}
let object = hooks["hooks"].as_object().unwrap();
assert_eq!(object.len(), 4, "{object:?}");
}
#[test]
fn hook_status_signal_install_writes_only_the_session_home() {
let home = tempfile::tempdir().unwrap();
let primary_checkout = tempfile::tempdir().unwrap();
let session_id = format!("hook-status-install-{}", uuid::Uuid::new_v4());
install_cursor_hook_status(home.path(), &seed(), &session_id).unwrap();
let hooks_text =
std::fs::read_to_string(home.path().join(".cursor").join("hooks.json")).unwrap();
let hooks: Value = serde_json::from_str(&hooks_text).unwrap();
let command = hooks["hooks"]["sessionStart"][0]["command"]
.as_str()
.unwrap();
assert!(command.contains("hook-status"), "{command}");
assert!(command.contains("--config"), "{command}");
let spec = HookStatusSpec::load(&spec_file(&session_id)).unwrap();
assert_eq!(spec.version, SPEC_VERSION);
assert_eq!(spec.endpoint, "http://127.0.0.1:4560/api/hook-status");
assert_eq!(spec.token, "tok-1");
assert_eq!(spec.mission_id, "m-1");
assert_eq!(spec.run_id, "r-1");
assert_eq!(
std::fs::read_dir(primary_checkout.path()).unwrap().count(),
0,
"install must never write into the primary tracked tree"
);
let _ = std::fs::remove_dir_all(crate::backend_claude::scratch_home_root(&session_id));
}
#[test]
fn hook_status_signal_projection_round_trip() {
let repo = tempfile::tempdir().unwrap();
let now = Utc::now();
register(repo.path(), "m-1", "r-1", "tok-1", now).unwrap();
let raw = std::fs::read_to_string(run_file(repo.path(), "m-1", "r-1")).unwrap();
assert!(!raw.contains("tok-1"), "{raw}");
let entry = record_signal(
repo.path(),
"m-1",
"r-1",
"tok-1",
HookSignal::Running,
None,
now,
)
.unwrap();
assert!(entry.signal.is_some());
let entry = record_signal(
repo.path(),
"m-1",
"r-1",
"tok-1",
HookSignal::NeedsInput,
Some("Shell was refused"),
now,
)
.unwrap();
assert_eq!(
entry.signal.as_ref().map(|s| s.signal),
Some(HookSignal::NeedsInput),
"the latest signal wins"
);
let views = read_mission_signals(repo.path(), "m-1");
assert_eq!(views.len(), 1);
assert_eq!(views[0].run_id, "r-1");
assert_eq!(
views[0].signal.as_ref().map(|s| s.signal),
Some(HookSignal::NeedsInput)
);
let public = serde_json::to_string(&views[0]).unwrap();
assert!(!public.contains("tokenHash"), "{public}");
}
#[test]
fn hook_status_signal_record_rejects_traversal_stale_and_wrong_token() {
let repo = tempfile::tempdir().unwrap();
let now = Utc::now();
register(repo.path(), "m-1", "r-1", "tok-1", now).unwrap();
for (mission, run) in [("../m-1", "r-1"), ("m-1", "../r-1"), ("m/1", "r-1")] {
assert_eq!(
record_signal(
repo.path(),
mission,
run,
"tok-1",
HookSignal::Running,
None,
now
),
Err(RecordRejection::UnsafeId)
);
assert_eq!(
register(repo.path(), mission, run, "tok-1", now)
.unwrap_err()
.kind(),
std::io::ErrorKind::InvalidInput
);
}
assert_eq!(
record_signal(
repo.path(),
"m-1",
"r-9",
"tok-1",
HookSignal::Running,
None,
now
),
Err(RecordRejection::UnknownRun)
);
assert_eq!(
record_signal(
repo.path(),
"m-1",
"r-1",
"tok-2",
HookSignal::Running,
None,
now
),
Err(RecordRejection::TokenMismatch)
);
let old = now - chrono::Duration::seconds(REGISTRATION_TTL.as_secs() as i64 + 60);
register(repo.path(), "m-1", "r-old", "tok-1", old).unwrap();
assert_eq!(
record_signal(
repo.path(),
"m-1",
"r-old",
"tok-1",
HookSignal::Running,
None,
now
),
Err(RecordRejection::Stale)
);
let views = read_mission_signals(repo.path(), "m-1");
assert!(views.iter().all(|v| v.signal.is_none()), "{views:?}");
}
#[test]
fn hook_status_signal_detail_is_scrubbed_and_bounded() {
let repo = tempfile::tempdir().unwrap();
let now = Utc::now();
register(repo.path(), "m-1", "r-1", "tok-1", now).unwrap();
let long = "x".repeat(DETAIL_MAX_CHARS * 3);
let entry = record_signal(
repo.path(),
"m-1",
"r-1",
"tok-1",
HookSignal::Interrupted,
Some(&long),
now,
)
.unwrap();
let detail = entry.signal.unwrap().detail.unwrap();
assert!(
detail.chars().count() <= DETAIL_MAX_CHARS + "… [truncated]".chars().count(),
"{detail}"
);
assert!(detail.ends_with(" [truncated]"), "{detail}");
}
#[test]
fn hook_status_signal_post_body_shares_the_wire_shape() {
let spec = HookStatusSpec {
version: SPEC_VERSION,
endpoint: "http://127.0.0.1:9/api/hook-status".to_string(),
token: "tok-1".to_string(),
mission_id: "m-1".to_string(),
run_id: "r-1".to_string(),
};
let payload = json!({ "hook_event_name": "sessionStart" });
let post = signal_post_for(&spec, &payload).unwrap();
assert_eq!(post.token, "tok-1");
assert_eq!(post.mission_id, "m-1");
assert_eq!(post.run_id, "r-1");
assert_eq!(post.signal, HookSignal::Running);
let wire = serde_json::to_value(&post).unwrap();
assert_eq!(wire["signal"], "running");
assert!(wire.get("detail").is_none());
let ignored = json!({ "hook_event_name": "preCompact" });
assert!(signal_post_for(&spec, &ignored).is_none());
}
#[test]
fn hook_status_signal_endpoint_gate_accepts_loopback_only() {
for ok in [
"http://127.0.0.1:4560/api/hook-status",
"http://localhost:4560/api/hook-status",
"http://[::1]:4560/api/hook-status",
"https://127.0.0.1/api/hook-status",
] {
assert!(endpoint_is_loopback_http(ok), "{ok}");
}
for bad in [
"http://example.com/api/hook-status",
"http://192.168.1.5/api/hook-status",
"ftp://127.0.0.1/x",
"127.0.0.1:4560/api/hook-status",
"",
] {
assert!(!endpoint_is_loopback_http(bad), "{bad}");
}
}
#[test]
fn hook_status_signal_config_resolution_is_off_by_default() {
let off = crate::types::HookStatusConfig::default();
assert!(resolved_endpoint(&off).is_none());
let disabled_with_endpoint = crate::types::HookStatusConfig {
enabled: false,
endpoint: "http://127.0.0.1:4560/api/hook-status".to_string(),
};
assert!(resolved_endpoint(&disabled_with_endpoint).is_none());
let on = crate::types::HookStatusConfig {
enabled: true,
endpoint: " http://127.0.0.1:4560/api/hook-status ".to_string(),
};
assert_eq!(
resolved_endpoint(&on),
Some("http://127.0.0.1:4560/api/hook-status")
);
}
}