|
|
@@ -10,6 +10,7 @@ use crate::{PluginError, PluginHooks, PluginRegistry};
|
|
|
pub enum HookEvent {
|
|
|
PreToolUse,
|
|
|
PostToolUse,
|
|
|
+ PostToolUseFailure,
|
|
|
}
|
|
|
|
|
|
impl HookEvent {
|
|
|
@@ -17,6 +18,7 @@ impl HookEvent {
|
|
|
match self {
|
|
|
Self::PreToolUse => "PreToolUse",
|
|
|
Self::PostToolUse => "PostToolUse",
|
|
|
+ Self::PostToolUseFailure => "PostToolUseFailure",
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -24,6 +26,7 @@ impl HookEvent {
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
pub struct HookRunResult {
|
|
|
denied: bool,
|
|
|
+ failed: bool,
|
|
|
messages: Vec<String>,
|
|
|
}
|
|
|
|
|
|
@@ -32,6 +35,7 @@ impl HookRunResult {
|
|
|
pub fn allow(messages: Vec<String>) -> Self {
|
|
|
Self {
|
|
|
denied: false,
|
|
|
+ failed: false,
|
|
|
messages,
|
|
|
}
|
|
|
}
|
|
|
@@ -41,6 +45,11 @@ impl HookRunResult {
|
|
|
self.denied
|
|
|
}
|
|
|
|
|
|
+ #[must_use]
|
|
|
+ pub fn is_failed(&self) -> bool {
|
|
|
+ self.failed
|
|
|
+ }
|
|
|
+
|
|
|
#[must_use]
|
|
|
pub fn messages(&self) -> &[String] {
|
|
|
&self.messages
|
|
|
@@ -92,6 +101,23 @@ impl HookRunner {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
+ #[must_use]
|
|
|
+ pub fn run_post_tool_use_failure(
|
|
|
+ &self,
|
|
|
+ tool_name: &str,
|
|
|
+ tool_input: &str,
|
|
|
+ tool_error: &str,
|
|
|
+ ) -> HookRunResult {
|
|
|
+ self.run_commands(
|
|
|
+ HookEvent::PostToolUseFailure,
|
|
|
+ &self.hooks.post_tool_use_failure,
|
|
|
+ tool_name,
|
|
|
+ tool_input,
|
|
|
+ Some(tool_error),
|
|
|
+ true,
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
fn run_commands(
|
|
|
&self,
|
|
|
event: HookEvent,
|
|
|
@@ -105,15 +131,7 @@ impl HookRunner {
|
|
|
return HookRunResult::allow(Vec::new());
|
|
|
}
|
|
|
|
|
|
- let payload = json!({
|
|
|
- "hook_event_name": event.as_str(),
|
|
|
- "tool_name": tool_name,
|
|
|
- "tool_input": parse_tool_input(tool_input),
|
|
|
- "tool_input_json": tool_input,
|
|
|
- "tool_output": tool_output,
|
|
|
- "tool_result_is_error": is_error,
|
|
|
- })
|
|
|
- .to_string();
|
|
|
+ let payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string();
|
|
|
|
|
|
let mut messages = Vec::new();
|
|
|
|
|
|
@@ -138,10 +156,18 @@ impl HookRunner {
|
|
|
}));
|
|
|
return HookRunResult {
|
|
|
denied: true,
|
|
|
+ failed: false,
|
|
|
+ messages,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ HookCommandOutcome::Failed { message } => {
|
|
|
+ messages.push(message);
|
|
|
+ return HookRunResult {
|
|
|
+ denied: false,
|
|
|
+ failed: true,
|
|
|
messages,
|
|
|
};
|
|
|
}
|
|
|
- HookCommandOutcome::Warn { message } => messages.push(message),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -179,7 +205,7 @@ impl HookRunner {
|
|
|
match output.status.code() {
|
|
|
Some(0) => HookCommandOutcome::Allow { message },
|
|
|
Some(2) => HookCommandOutcome::Deny { message },
|
|
|
- Some(code) => HookCommandOutcome::Warn {
|
|
|
+ Some(code) => HookCommandOutcome::Failed {
|
|
|
message: format_hook_warning(
|
|
|
command,
|
|
|
code,
|
|
|
@@ -187,7 +213,7 @@ impl HookRunner {
|
|
|
stderr.as_str(),
|
|
|
),
|
|
|
},
|
|
|
- None => HookCommandOutcome::Warn {
|
|
|
+ None => HookCommandOutcome::Failed {
|
|
|
message: format!(
|
|
|
"{} hook `{command}` terminated by signal while handling `{tool_name}`",
|
|
|
event.as_str()
|
|
|
@@ -195,7 +221,7 @@ impl HookRunner {
|
|
|
},
|
|
|
}
|
|
|
}
|
|
|
- Err(error) => HookCommandOutcome::Warn {
|
|
|
+ Err(error) => HookCommandOutcome::Failed {
|
|
|
message: format!(
|
|
|
"{} hook `{command}` failed to start for `{tool_name}`: {error}",
|
|
|
event.as_str()
|
|
|
@@ -208,7 +234,34 @@ impl HookRunner {
|
|
|
enum HookCommandOutcome {
|
|
|
Allow { message: Option<String> },
|
|
|
Deny { message: Option<String> },
|
|
|
- Warn { message: String },
|
|
|
+ Failed { message: String },
|
|
|
+}
|
|
|
+
|
|
|
+fn hook_payload(
|
|
|
+ event: HookEvent,
|
|
|
+ tool_name: &str,
|
|
|
+ tool_input: &str,
|
|
|
+ tool_output: Option<&str>,
|
|
|
+ is_error: bool,
|
|
|
+) -> serde_json::Value {
|
|
|
+ match event {
|
|
|
+ HookEvent::PostToolUseFailure => json!({
|
|
|
+ "hook_event_name": event.as_str(),
|
|
|
+ "tool_name": tool_name,
|
|
|
+ "tool_input": parse_tool_input(tool_input),
|
|
|
+ "tool_input_json": tool_input,
|
|
|
+ "tool_error": tool_output,
|
|
|
+ "tool_result_is_error": true,
|
|
|
+ }),
|
|
|
+ _ => json!({
|
|
|
+ "hook_event_name": event.as_str(),
|
|
|
+ "tool_name": tool_name,
|
|
|
+ "tool_input": parse_tool_input(tool_input),
|
|
|
+ "tool_input_json": tool_input,
|
|
|
+ "tool_output": tool_output,
|
|
|
+ "tool_result_is_error": is_error,
|
|
|
+ }),
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
fn parse_tool_input(tool_input: &str) -> serde_json::Value {
|
|
|
@@ -216,8 +269,7 @@ fn parse_tool_input(tool_input: &str) -> serde_json::Value {
|
|
|
}
|
|
|
|
|
|
fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
|
|
|
- let mut message =
|
|
|
- format!("Hook `{command}` exited with status {code}; allowing tool execution to continue");
|
|
|
+ let mut message = format!("Hook `{command}` exited with status {code}");
|
|
|
if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
|
|
|
message.push_str(": ");
|
|
|
message.push_str(stdout);
|
|
|
@@ -309,7 +361,13 @@ mod tests {
|
|
|
std::env::temp_dir().join(format!("plugins-hook-runner-{label}-{nanos}"))
|
|
|
}
|
|
|
|
|
|
- fn write_hook_plugin(root: &Path, name: &str, pre_message: &str, post_message: &str) {
|
|
|
+ fn write_hook_plugin(
|
|
|
+ root: &Path,
|
|
|
+ name: &str,
|
|
|
+ pre_message: &str,
|
|
|
+ post_message: &str,
|
|
|
+ failure_message: &str,
|
|
|
+ ) {
|
|
|
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
|
|
|
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
|
|
|
fs::write(
|
|
|
@@ -322,10 +380,15 @@ mod tests {
|
|
|
format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
|
|
|
)
|
|
|
.expect("write post hook");
|
|
|
+ fs::write(
|
|
|
+ root.join("hooks").join("failure.sh"),
|
|
|
+ format!("#!/bin/sh\nprintf '%s\\n' '{failure_message}'\n"),
|
|
|
+ )
|
|
|
+ .expect("write failure hook");
|
|
|
fs::write(
|
|
|
root.join(".claude-plugin").join("plugin.json"),
|
|
|
format!(
|
|
|
- "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}"
|
|
|
+ "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"],\n \"PostToolUseFailure\": [\"./hooks/failure.sh\"]\n }}\n}}"
|
|
|
),
|
|
|
)
|
|
|
.expect("write plugin manifest");
|
|
|
@@ -333,6 +396,7 @@ mod tests {
|
|
|
|
|
|
#[test]
|
|
|
fn collects_and_runs_hooks_from_enabled_plugins() {
|
|
|
+ // given
|
|
|
let config_home = temp_dir("config");
|
|
|
let first_source_root = temp_dir("source-a");
|
|
|
let second_source_root = temp_dir("source-b");
|
|
|
@@ -341,12 +405,14 @@ mod tests {
|
|
|
"first",
|
|
|
"plugin pre one",
|
|
|
"plugin post one",
|
|
|
+ "plugin failure one",
|
|
|
);
|
|
|
write_hook_plugin(
|
|
|
&second_source_root,
|
|
|
"second",
|
|
|
"plugin pre two",
|
|
|
"plugin post two",
|
|
|
+ "plugin failure two",
|
|
|
);
|
|
|
|
|
|
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
|
|
@@ -358,8 +424,10 @@ mod tests {
|
|
|
.expect("second plugin install should succeed");
|
|
|
let registry = manager.plugin_registry().expect("registry should build");
|
|
|
|
|
|
+ // when
|
|
|
let runner = HookRunner::from_registry(®istry).expect("plugin hooks should load");
|
|
|
|
|
|
+ // then
|
|
|
assert_eq!(
|
|
|
runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#),
|
|
|
HookRunResult::allow(vec![
|
|
|
@@ -374,6 +442,13 @@ mod tests {
|
|
|
"plugin post two".to_string(),
|
|
|
])
|
|
|
);
|
|
|
+ assert_eq!(
|
|
|
+ runner.run_post_tool_use_failure("Read", r#"{"path":"README.md"}"#, "tool failed",),
|
|
|
+ HookRunResult::allow(vec![
|
|
|
+ "plugin failure one".to_string(),
|
|
|
+ "plugin failure two".to_string(),
|
|
|
+ ])
|
|
|
+ );
|
|
|
|
|
|
let _ = fs::remove_dir_all(config_home);
|
|
|
let _ = fs::remove_dir_all(first_source_root);
|
|
|
@@ -382,14 +457,45 @@ mod tests {
|
|
|
|
|
|
#[test]
|
|
|
fn pre_tool_use_denies_when_plugin_hook_exits_two() {
|
|
|
+ // given
|
|
|
let runner = HookRunner::new(crate::PluginHooks {
|
|
|
pre_tool_use: vec!["printf 'blocked by plugin'; exit 2".to_string()],
|
|
|
post_tool_use: Vec::new(),
|
|
|
+ post_tool_use_failure: Vec::new(),
|
|
|
});
|
|
|
|
|
|
+ // when
|
|
|
let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
|
|
|
|
|
|
+ // then
|
|
|
assert!(result.is_denied());
|
|
|
assert_eq!(result.messages(), &["blocked by plugin".to_string()]);
|
|
|
}
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn propagates_plugin_hook_failures() {
|
|
|
+ // given
|
|
|
+ let runner = HookRunner::new(crate::PluginHooks {
|
|
|
+ pre_tool_use: vec![
|
|
|
+ "printf 'broken plugin hook'; exit 1".to_string(),
|
|
|
+ "printf 'later plugin hook'".to_string(),
|
|
|
+ ],
|
|
|
+ post_tool_use: Vec::new(),
|
|
|
+ post_tool_use_failure: Vec::new(),
|
|
|
+ });
|
|
|
+
|
|
|
+ // when
|
|
|
+ let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
|
|
|
+
|
|
|
+ // then
|
|
|
+ assert!(result.is_failed());
|
|
|
+ assert!(result
|
|
|
+ .messages()
|
|
|
+ .iter()
|
|
|
+ .any(|message| message.contains("broken plugin hook")));
|
|
|
+ assert!(!result
|
|
|
+ .messages()
|
|
|
+ .iter()
|
|
|
+ .any(|message| message == "later plugin hook"));
|
|
|
+ }
|
|
|
}
|