Skip to content

Commit 2f6227a

Browse files
leogrpoiana
authored andcommitted
fix(ctl): treat empty interceptor stdout as a healthy defer in ctl health
Signed-off-by: Leonardo Grasso <me@leonardograsso.com>
1 parent cb2e6a0 commit 2f6227a

1 file changed

Lines changed: 141 additions & 49 deletions

File tree

tools/premptictl/src/main.rs

Lines changed: 141 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,6 +1120,71 @@ fn uninstall(prefix: &PathBuf, keep_user_rules: bool) {
11201120
// Health check
11211121
// ---------------------------------------------------------------------------
11221122

1123+
/// Classify the Claude interceptor's stdout (on exit 0) from the synthetic
1124+
/// health event into a health message. Pure so every branch — including the
1125+
/// `defer` empty-stdout case — is unit-testable without a live broker.
1126+
/// Returns `Ok(msg)` for a healthy pipeline, `Err(msg)` for a failure.
1127+
fn classify_health_stdout(stdout: &str) -> Result<String, String> {
1128+
let trimmed = stdout.trim();
1129+
1130+
// A `defer` verdict renders as empty stdout + exit 0. The broker resolves
1131+
// a no-match event as defer in monitor mode, passthrough mode, and
1132+
// guardrails + `default_action: defer` — so the synthetic health event
1133+
// (which matches no deny/ask rule) lands here in those configurations.
1134+
// The broker DID respond (a broker outage fails closed to an explicit
1135+
// deny, not empty stdout), so the pipeline is healthy: Prempti chose to
1136+
// step aside.
1137+
if trimmed.is_empty() {
1138+
return Ok("OK: pipeline healthy (synthetic event → defer / no decision)".to_string());
1139+
}
1140+
1141+
let parsed: serde_json::Value = match serde_json::from_str(trimmed) {
1142+
Ok(v) => v,
1143+
Err(_) => {
1144+
return Err(format!(
1145+
"FAIL: interceptor returned malformed JSON\n Output: {trimmed}"
1146+
));
1147+
}
1148+
};
1149+
1150+
let decision = parsed
1151+
.pointer("/hookSpecificOutput/permissionDecision")
1152+
.and_then(|v| v.as_str())
1153+
.unwrap_or("");
1154+
let reason = parsed
1155+
.pointer("/hookSpecificOutput/permissionDecisionReason")
1156+
.and_then(|v| v.as_str())
1157+
.unwrap_or("");
1158+
1159+
if decision.is_empty() {
1160+
return Err(format!(
1161+
"FAIL: interceptor returned unexpected output\n Output: {trimmed}"
1162+
));
1163+
}
1164+
1165+
// Denies caused by infrastructure failure (not real rule matches) indicate
1166+
// a broken pipeline. Detect both forms of broker failure:
1167+
// - "broker response timeout": socket connected but no verdict arrived
1168+
// - "broker unavailable": connection refused (service not running)
1169+
if decision == "deny"
1170+
&& (reason.contains("broker response timeout") || reason.contains("broker unavailable"))
1171+
{
1172+
return Err(format!(
1173+
"FAIL: broker unreachable or timed out while waiting for verdict\n Reason: {reason}"
1174+
));
1175+
}
1176+
1177+
Ok(match decision {
1178+
"allow" => "OK: pipeline healthy (synthetic event → allow)".to_string(),
1179+
"deny" => "OK: pipeline healthy (synthetic event → deny)\n \
1180+
Note: a deny rule matched the health-check event.\n \
1181+
This is expected if you have rules matching Bash commands."
1182+
.to_string(),
1183+
"ask" => "OK: pipeline healthy (synthetic event → ask)".to_string(),
1184+
_ => format!("OK: pipeline responded (unexpected verdict)\n Response: {trimmed}"),
1185+
})
1186+
}
1187+
11231188
fn health(prefix: &PathBuf) {
11241189
#[cfg(unix)]
11251190
let interceptor = prefix.join("bin/claude-interceptor");
@@ -1153,8 +1218,11 @@ fn health(prefix: &PathBuf) {
11531218
process::exit(1);
11541219
}
11551220

1156-
// Send a synthetic event through the full pipeline.
1157-
// Uses a harmless Bash "echo" command that should resolve as allow.
1221+
// Send a synthetic event through the full pipeline. Uses a harmless Bash
1222+
// "echo" command that matches no deny/ask rule, so it resolves via the
1223+
// no-match floor: allow (permissionDecision JSON) under guardrails +
1224+
// default_action: allow, or defer (empty stdout) under monitor /
1225+
// passthrough / default_action: defer. Both mean the pipeline is healthy.
11581226
let test_event = r#"{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo health-check"},"session_id":"health-check","cwd":"/tmp","tool_use_id":"health-check"}"#;
11591227

11601228
let output = Command::new(&interceptor)
@@ -1176,55 +1244,12 @@ fn health(prefix: &PathBuf) {
11761244
match output {
11771245
Ok(out) if out.status.success() => {
11781246
let stdout = String::from_utf8_lossy(&out.stdout);
1179-
let parsed: serde_json::Value = match serde_json::from_str(stdout.trim()) {
1180-
Ok(v) => v,
1181-
Err(_) => {
1182-
eprintln!("FAIL: interceptor returned malformed JSON");
1183-
eprintln!(" Output: {}", stdout.trim());
1247+
match classify_health_stdout(&stdout) {
1248+
Ok(msg) => println!("{msg}"),
1249+
Err(msg) => {
1250+
eprintln!("{msg}");
11841251
process::exit(1);
11851252
}
1186-
};
1187-
1188-
let decision = parsed
1189-
.pointer("/hookSpecificOutput/permissionDecision")
1190-
.and_then(|v| v.as_str())
1191-
.unwrap_or("");
1192-
let reason = parsed
1193-
.pointer("/hookSpecificOutput/permissionDecisionReason")
1194-
.and_then(|v| v.as_str())
1195-
.unwrap_or("");
1196-
1197-
if decision.is_empty() {
1198-
eprintln!("FAIL: interceptor returned unexpected output");
1199-
eprintln!(" Output: {}", stdout.trim());
1200-
process::exit(1);
1201-
}
1202-
1203-
// Denies caused by infrastructure failure (not real rule matches)
1204-
// indicate a broken pipeline. Detect both forms of broker failure:
1205-
// - "broker response timeout": socket connected but no verdict arrived
1206-
// - "broker unavailable": connection refused (service not running)
1207-
if decision == "deny"
1208-
&& (reason.contains("broker response timeout")
1209-
|| reason.contains("broker unavailable"))
1210-
{
1211-
eprintln!("FAIL: broker unreachable or timed out while waiting for verdict");
1212-
eprintln!(" Reason: {}", reason);
1213-
process::exit(1);
1214-
}
1215-
1216-
// Parse to show a cleaner message.
1217-
if decision == "allow" {
1218-
println!("OK: pipeline healthy (synthetic event → allow)");
1219-
} else if decision == "deny" {
1220-
println!("OK: pipeline healthy (synthetic event → deny)");
1221-
println!(" Note: a deny rule matched the health-check event.");
1222-
println!(" This is expected if you have rules matching Bash commands.");
1223-
} else if decision == "ask" {
1224-
println!("OK: pipeline healthy (synthetic event → ask)");
1225-
} else {
1226-
println!("OK: pipeline responded (unexpected verdict)");
1227-
println!(" Response: {}", stdout.trim());
12281253
}
12291254
}
12301255
Ok(out) => {
@@ -1851,6 +1876,73 @@ mod default_action_tests {
18511876
}
18521877
}
18531878

1879+
#[cfg(test)]
1880+
mod health_tests {
1881+
use super::classify_health_stdout;
1882+
1883+
#[test]
1884+
fn allow_is_healthy() {
1885+
let out = classify_health_stdout(
1886+
r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":""}}"#,
1887+
)
1888+
.expect("allow is healthy");
1889+
assert!(out.contains("allow"), "got: {out}");
1890+
}
1891+
1892+
#[test]
1893+
fn defer_empty_stdout_is_healthy() {
1894+
// Regression guard: a `defer` verdict renders as empty stdout, which
1895+
// must read as healthy (Prempti stepped aside), not malformed JSON.
1896+
// Whitespace-only counts as empty.
1897+
let out = classify_health_stdout("").expect("empty stdout is healthy defer");
1898+
assert!(out.contains("defer"), "got: {out}");
1899+
let out_ws = classify_health_stdout(" \n").expect("whitespace stdout is healthy defer");
1900+
assert!(out_ws.contains("defer"), "got: {out_ws}");
1901+
}
1902+
1903+
#[test]
1904+
fn ask_is_healthy() {
1905+
let out = classify_health_stdout(
1906+
r#"{"hookSpecificOutput":{"permissionDecision":"ask","permissionDecisionReason":"confirm"}}"#,
1907+
)
1908+
.expect("ask is healthy");
1909+
assert!(out.contains("ask"), "got: {out}");
1910+
}
1911+
1912+
#[test]
1913+
fn rule_deny_is_healthy() {
1914+
// A real rule-match deny means the pipeline works end to end.
1915+
let out = classify_health_stdout(
1916+
r#"{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"Deny rm -rf: blocked"}}"#,
1917+
)
1918+
.expect("rule deny is healthy");
1919+
assert!(out.contains("deny"), "got: {out}");
1920+
}
1921+
1922+
#[test]
1923+
fn infra_deny_is_failure() {
1924+
// A fail-closed deny caused by a broker outage must report FAIL.
1925+
let err = classify_health_stdout(
1926+
r#"{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"broker unavailable"}}"#,
1927+
)
1928+
.expect_err("infra deny is failure");
1929+
assert!(err.contains("broker unreachable"), "got: {err}");
1930+
}
1931+
1932+
#[test]
1933+
fn malformed_json_is_failure() {
1934+
let err = classify_health_stdout("this is not json").expect_err("malformed is failure");
1935+
assert!(err.contains("malformed JSON"), "got: {err}");
1936+
}
1937+
1938+
#[test]
1939+
fn json_without_decision_is_failure() {
1940+
let err = classify_health_stdout(r#"{"hookSpecificOutput":{}}"#)
1941+
.expect_err("missing decision is failure");
1942+
assert!(err.contains("unexpected output"), "got: {err}");
1943+
}
1944+
}
1945+
18541946
#[cfg(test)]
18551947
mod plugin_config_summary_tests {
18561948
use super::{parse_plugin_config_summary, read_plugin_config_summary};

0 commit comments

Comments
 (0)