@@ -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+
11231188fn 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) ]
18551947mod plugin_config_summary_tests {
18561948 use super :: { parse_plugin_config_summary, read_plugin_config_summary} ;
0 commit comments