Skip to content

Commit e874bc6

Browse files
committed
Improve malformed hook failures so operators can diagnose broken JSON
Malformed hook stdout that looks like JSON was collapsing into low-signal failure text during hook execution. This change preserves plain-text hook feedback for normal text hooks, but upgrades malformed JSON-like output into an explicit hook_invalid_json diagnostic that includes phase, tool, command, and bounded stdout/stderr previews. It also adds a regression test for malformed-but-nonempty output. Constraint: User scoped the implementation to rust/crates/runtime/src/hooks.rs and tests only Constraint: Existing plain-text hook feedback must remain intact for non-JSON hook output Rejected: Treat every non-JSON stdout payload as invalid JSON | would break legitimate plain-text hook feedback Confidence: high Scope-risk: narrow Directive: Keep malformed-hook diagnostics bounded and preserve the plain-text fallback for hooks that intentionally emit text Tested: cargo test --manifest-path rust/Cargo.toml -p runtime hooks::tests:: -- --nocapture Tested: cargo test --manifest-path rust/Cargo.toml -p runtime -- --nocapture Tested: cargo clippy --manifest-path rust/Cargo.toml -p runtime --all-targets -- -D warnings Not-tested: Full workspace clippy/test sweep outside runtime crate
1 parent 6a95756 commit e874bc6

1 file changed

Lines changed: 136 additions & 7 deletions

File tree

rust/crates/runtime/src/hooks.rs

Lines changed: 136 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::ffi::OsStr;
2+
use std::fmt::Write as FmtWrite;
23
use std::io::Write;
34
use std::process::{Command, Stdio};
45
use std::sync::{
@@ -13,6 +14,8 @@ use serde_json::{json, Value};
1314
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
1415
use crate::permissions::PermissionOverride;
1516

17+
const HOOK_PREVIEW_CHAR_LIMIT: usize = 160;
18+
1619
pub type HookPermissionDecision = PermissionOverride;
1720

1821
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -437,7 +440,7 @@ impl HookRunner {
437440
Ok(CommandExecution::Finished(output)) => {
438441
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
439442
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
440-
let parsed = parse_hook_output(&stdout);
443+
let parsed = parse_hook_output(event, tool_name, command, &stdout, &stderr);
441444
let primary_message = parsed.primary_message().map(ToOwned::to_owned);
442445
match output.status.code() {
443446
Some(0) => {
@@ -532,16 +535,54 @@ fn merge_parsed_hook_output(target: &mut HookRunResult, parsed: ParsedHookOutput
532535
}
533536
}
534537

535-
fn parse_hook_output(stdout: &str) -> ParsedHookOutput {
538+
fn parse_hook_output(
539+
event: HookEvent,
540+
tool_name: &str,
541+
command: &str,
542+
stdout: &str,
543+
stderr: &str,
544+
) -> ParsedHookOutput {
536545
if stdout.is_empty() {
537546
return ParsedHookOutput::default();
538547
}
539548

540-
let Ok(Value::Object(root)) = serde_json::from_str::<Value>(stdout) else {
541-
return ParsedHookOutput {
542-
messages: vec![stdout.to_string()],
543-
..ParsedHookOutput::default()
544-
};
549+
let root = match serde_json::from_str::<Value>(stdout) {
550+
Ok(Value::Object(root)) => root,
551+
Ok(value) => {
552+
return ParsedHookOutput {
553+
messages: vec![format_invalid_hook_output(
554+
event,
555+
tool_name,
556+
command,
557+
&format!(
558+
"expected top-level JSON object, got {}",
559+
json_type_name(&value)
560+
),
561+
stdout,
562+
stderr,
563+
)],
564+
..ParsedHookOutput::default()
565+
};
566+
}
567+
Err(error) if looks_like_json_attempt(stdout) => {
568+
return ParsedHookOutput {
569+
messages: vec![format_invalid_hook_output(
570+
event,
571+
tool_name,
572+
command,
573+
&error.to_string(),
574+
stdout,
575+
stderr,
576+
)],
577+
..ParsedHookOutput::default()
578+
};
579+
}
580+
Err(_) => {
581+
return ParsedHookOutput {
582+
messages: vec![stdout.to_string()],
583+
..ParsedHookOutput::default()
584+
};
585+
}
545586
};
546587

547588
let mut parsed = ParsedHookOutput::default();
@@ -619,6 +660,69 @@ fn parse_tool_input(tool_input: &str) -> Value {
619660
serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
620661
}
621662

663+
fn format_invalid_hook_output(
664+
event: HookEvent,
665+
tool_name: &str,
666+
command: &str,
667+
detail: &str,
668+
stdout: &str,
669+
stderr: &str,
670+
) -> String {
671+
let stdout_preview = bounded_hook_preview(stdout).unwrap_or_else(|| "<empty>".to_string());
672+
let stderr_preview = bounded_hook_preview(stderr).unwrap_or_else(|| "<empty>".to_string());
673+
let command_preview = bounded_hook_preview(command).unwrap_or_else(|| "<empty>".to_string());
674+
675+
format!(
676+
"hook_invalid_json: phase={} tool={} command={} detail={} stdout_preview={} stderr_preview={}",
677+
event.as_str(),
678+
tool_name,
679+
command_preview,
680+
detail,
681+
stdout_preview,
682+
stderr_preview
683+
)
684+
}
685+
686+
fn bounded_hook_preview(value: &str) -> Option<String> {
687+
let trimmed = value.trim();
688+
if trimmed.is_empty() {
689+
return None;
690+
}
691+
692+
let mut preview = String::new();
693+
for (count, ch) in trimmed.chars().enumerate() {
694+
if count == HOOK_PREVIEW_CHAR_LIMIT {
695+
preview.push('…');
696+
break;
697+
}
698+
match ch {
699+
'\n' => preview.push_str("\\n"),
700+
'\r' => preview.push_str("\\r"),
701+
'\t' => preview.push_str("\\t"),
702+
control if control.is_control() => {
703+
let _ = write!(&mut preview, "\\u{{{:x}}}", control as u32);
704+
}
705+
_ => preview.push(ch),
706+
}
707+
}
708+
Some(preview)
709+
}
710+
711+
fn json_type_name(value: &Value) -> &'static str {
712+
match value {
713+
Value::Null => "null",
714+
Value::Bool(_) => "boolean",
715+
Value::Number(_) => "number",
716+
Value::String(_) => "string",
717+
Value::Array(_) => "array",
718+
Value::Object(_) => "object",
719+
}
720+
}
721+
722+
fn looks_like_json_attempt(value: &str) -> bool {
723+
matches!(value.trim_start().chars().next(), Some('{' | '['))
724+
}
725+
622726
fn format_hook_failure(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
623727
let mut message = format!("Hook `{command}` exited with status {code}");
624728
if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
@@ -935,6 +1039,31 @@ mod tests {
9351039
assert!(!result.messages().iter().any(|message| message == "later"));
9361040
}
9371041

1042+
#[test]
1043+
fn malformed_nonempty_hook_output_reports_explicit_diagnostic_with_previews() {
1044+
let runner = HookRunner::new(RuntimeHookConfig::new(
1045+
vec![shell_snippet(
1046+
"printf '{not-json\nsecond line'; printf 'stderr warning' >&2; exit 1",
1047+
)],
1048+
Vec::new(),
1049+
Vec::new(),
1050+
));
1051+
1052+
let result = runner.run_pre_tool_use("Edit", r#"{"file":"src/lib.rs"}"#);
1053+
1054+
assert!(result.is_failed());
1055+
let rendered = result.messages().join("\n");
1056+
assert!(rendered.contains("hook_invalid_json:"));
1057+
assert!(rendered.contains("phase=PreToolUse"));
1058+
assert!(rendered.contains("tool=Edit"));
1059+
assert!(rendered.contains("command=printf '{not-json"));
1060+
assert!(rendered.contains("printf 'stderr warning' >&2; exit 1"));
1061+
assert!(rendered.contains("detail=key must be a string"));
1062+
assert!(rendered.contains("stdout_preview={not-json"));
1063+
assert!(rendered.contains("second line stderr_preview=stderr warning"));
1064+
assert!(rendered.contains("stderr_preview=stderr warning"));
1065+
}
1066+
9381067
#[test]
9391068
fn abort_signal_cancels_long_running_hook_and_reports_progress() {
9401069
let runner = HookRunner::new(RuntimeHookConfig::new(

0 commit comments

Comments
 (0)