use std::io::{Read, Write};
use claude_settings::PermissionRule;
use serde::{Deserialize, Serialize};
use tracing::{Level, instrument};
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum HookInput {
ToolUse(ToolUseHookInput),
SessionStart(SessionStartHookInput),
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ToolUseHookInput {
pub session_id: String,
pub transcript_path: String,
pub cwd: String,
pub permission_mode: String,
pub hook_event_name: String,
pub tool_name: String,
pub tool_input: serde_json::Value,
pub tool_use_id: Option<String>,
#[serde(default)]
pub tool_response: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct SessionStartHookInput {
pub session_id: String,
pub transcript_path: String,
pub cwd: String,
#[serde(default)]
pub permission_mode: Option<String>,
pub hook_event_name: String,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub model: Option<String>,
}
impl SessionStartHookInput {
#[instrument(level = Level::TRACE, skip(reader))]
pub fn from_reader(reader: impl Read) -> anyhow::Result<Self> {
Ok(serde_json::from_reader(reader)?)
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct StopHookInput {
pub session_id: String,
pub transcript_path: String,
pub cwd: String,
pub hook_event_name: String,
}
impl StopHookInput {
#[instrument(level = Level::TRACE, skip(reader))]
pub fn from_reader(reader: impl Read) -> anyhow::Result<Self> {
Ok(serde_json::from_reader(reader)?)
}
}
impl HookInput {
#[instrument(level = Level::TRACE, skip(reader))]
pub fn from_reader(reader: impl Read) -> anyhow::Result<Self> {
Ok(serde_json::from_reader(reader)?)
}
#[instrument(level = Level::TRACE)]
pub fn from_stdin() -> anyhow::Result<Self> {
Self::from_reader(std::io::stdin().lock())
}
pub fn hook_event_name(&self) -> &str {
match self {
HookInput::ToolUse(input) => &input.hook_event_name,
HookInput::SessionStart(input) => &input.hook_event_name,
}
}
pub fn session_id(&self) -> &str {
match self {
HookInput::ToolUse(input) => &input.session_id,
HookInput::SessionStart(input) => &input.session_id,
}
}
pub fn as_tool_use(&self) -> Option<&ToolUseHookInput> {
match self {
HookInput::ToolUse(input) => Some(input),
_ => None,
}
}
pub fn as_session_start(&self) -> Option<&SessionStartHookInput> {
match self {
HookInput::SessionStart(input) => Some(input),
_ => None,
}
}
}
impl ToolUseHookInput {
#[instrument(level = Level::TRACE, skip(reader))]
pub fn from_reader(reader: impl Read) -> anyhow::Result<Self> {
Ok(serde_json::from_reader(reader)?)
}
#[instrument(level = Level::TRACE, skip(self))]
pub fn typed_tool_input(&self) -> crate::claude::tools::ToolInput {
crate::claude::tools::ToolInput::parse(&self.tool_name, self.tool_input.clone())
}
}
pub use crate::claude::tools::{BashInput, EditInput, ReadInput, ToolInput, WriteInput};
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PreToolUseOutput {
pub hook_event_name: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub permission_decision: Option<PermissionRule>,
#[serde(skip_serializing_if = "Option::is_none")]
pub permission_decision_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_input: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub additional_context: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum PermissionBehavior {
Allow,
Deny,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PermissionDecision {
pub behavior: PermissionBehavior,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_input: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub interrupt: Option<bool>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PermissionRequestOutput {
pub hook_event_name: &'static str,
pub decision: PermissionDecision,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SessionStartOutput {
pub hook_event_name: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub additional_context: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PostToolUseOutput {
pub hook_event_name: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub additional_context: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(untagged)]
pub enum HookSpecificOutput {
PreToolUse(PreToolUseOutput),
PostToolUse(PostToolUseOutput),
PermissionRequest(PermissionRequestOutput),
SessionStart(SessionStartOutput),
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct HookOutput {
#[serde(rename = "continue")]
pub should_continue: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub hook_specific_output: Option<HookSpecificOutput>,
}
impl HookOutput {
fn pretooluse_output(
decision: PermissionRule,
reason: Option<String>,
context: Option<String>,
updated_input: Option<serde_json::Value>,
) -> Self {
Self {
should_continue: true,
hook_specific_output: Some(HookSpecificOutput::PreToolUse(PreToolUseOutput {
hook_event_name: "PreToolUse",
permission_decision: Some(decision),
permission_decision_reason: reason,
updated_input,
additional_context: context,
})),
}
}
#[instrument(level = Level::TRACE)]
pub fn allow(reason: Option<String>, context: Option<String>) -> Self {
Self::pretooluse_output(PermissionRule::Allow, reason, context, None)
}
#[instrument(level = Level::TRACE)]
pub fn deny(reason: String, context: Option<String>) -> Self {
Self::pretooluse_output(PermissionRule::Deny, Some(reason), context, None)
}
#[instrument(level = Level::TRACE)]
pub fn ask(reason: Option<String>, context: Option<String>) -> Self {
Self::pretooluse_output(PermissionRule::Ask, reason, context, None)
}
#[instrument(level = Level::TRACE)]
pub fn approve_permission(updated_input: Option<serde_json::Value>) -> Self {
Self {
should_continue: true,
hook_specific_output: Some(HookSpecificOutput::PermissionRequest(
PermissionRequestOutput {
hook_event_name: "PermissionRequest",
decision: PermissionDecision {
behavior: PermissionBehavior::Allow,
updated_input,
message: None,
interrupt: None,
},
},
)),
}
}
#[instrument(level = Level::TRACE)]
pub fn deny_permission(message: String, interrupt: bool) -> Self {
Self {
should_continue: true,
hook_specific_output: Some(HookSpecificOutput::PermissionRequest(
PermissionRequestOutput {
hook_event_name: "PermissionRequest",
decision: PermissionDecision {
behavior: PermissionBehavior::Deny,
updated_input: None,
message: Some(message),
interrupt: Some(interrupt),
},
},
)),
}
}
#[instrument(level = Level::TRACE, skip(self))]
pub fn set_updated_input(&mut self, updated_input: serde_json::Value) {
if let Some(HookSpecificOutput::PreToolUse(ref mut pre)) = self.hook_specific_output {
pre.updated_input = Some(updated_input);
}
}
#[instrument(level = Level::TRACE)]
pub fn session_start(additional_context: Option<String>) -> Self {
Self {
should_continue: true,
hook_specific_output: Some(HookSpecificOutput::SessionStart(SessionStartOutput {
hook_event_name: "SessionStart",
additional_context,
})),
}
}
#[instrument(level = Level::TRACE)]
pub fn post_tool_use(additional_context: Option<String>) -> Self {
match additional_context {
Some(ctx) => Self {
should_continue: true,
hook_specific_output: Some(HookSpecificOutput::PostToolUse(PostToolUseOutput {
hook_event_name: "PostToolUse",
additional_context: Some(ctx),
})),
},
None => Self::continue_execution(),
}
}
#[instrument(level = Level::TRACE)]
pub fn continue_execution() -> Self {
Self {
should_continue: true,
hook_specific_output: None,
}
}
#[instrument(level = Level::TRACE, skip(self, writer))]
pub fn write_to(&self, mut writer: impl Write) -> anyhow::Result<()> {
serde_json::to_writer(&mut writer, self)?;
writeln!(writer)?;
Ok(())
}
#[instrument(level = Level::TRACE, skip(self))]
pub fn write_stdout(&self) -> anyhow::Result<()> {
self.write_to(std::io::stdout().lock())
}
}
pub fn is_interactive_tool(tool_name: &str) -> bool {
crate::claude::tools::is_interactive(tool_name)
}
pub mod exit_code {
pub const SUCCESS: i32 = 0;
pub const BLOCKING_ERROR: i32 = 2;
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_tool_use_json() -> &'static str {
r#"{
"session_id": "test-session",
"transcript_path": "/tmp/transcript.jsonl",
"cwd": "/home/user/project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {"command": "git status", "timeout": 120000},
"tool_use_id": "toolu_01ABC"
}"#
}
#[test]
fn test_parse_tool_use_input() {
let input = HookInput::from_reader(sample_tool_use_json().as_bytes()).unwrap();
assert_eq!(input.session_id(), "test-session");
assert_eq!(input.hook_event_name(), "PreToolUse");
let tool_use = input.as_tool_use().expect("Should be ToolUse variant");
assert_eq!(tool_use.tool_name, "Bash");
}
#[test]
fn test_typed_bash_input() {
let input = ToolUseHookInput::from_reader(sample_tool_use_json().as_bytes()).unwrap();
match input.typed_tool_input() {
ToolInput::Bash(bash) => {
assert_eq!(bash.command, "git status");
assert_eq!(bash.timeout, Some(120000));
}
other => panic!("Expected Bash input, got {:?}", other),
}
}
#[test]
fn test_typed_write_input() {
let json = r#"{
"session_id": "test",
"transcript_path": "/tmp/t.jsonl",
"cwd": "/tmp",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": {"file_path": "/tmp/test.txt", "content": "hello world"},
"tool_use_id": "toolu_02"
}"#;
let input = ToolUseHookInput::from_reader(json.as_bytes()).unwrap();
match input.typed_tool_input() {
ToolInput::Write(write) => {
assert_eq!(write.file_path, "/tmp/test.txt");
assert_eq!(write.content, "hello world");
}
other => panic!("Expected Write input, got {:?}", other),
}
}
#[test]
fn test_output_allow() {
let output = HookOutput::allow(Some("Safe command".into()), None);
let mut buf = Vec::new();
output.write_to(&mut buf).unwrap();
let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "allow");
assert_eq!(
json["hookSpecificOutput"]["permissionDecisionReason"],
"Safe command"
);
}
#[test]
fn test_output_deny() {
let output = HookOutput::deny("Dangerous command".into(), None);
let mut buf = Vec::new();
output.write_to(&mut buf).unwrap();
let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "deny");
assert_eq!(
json["hookSpecificOutput"]["permissionDecisionReason"],
"Dangerous command"
);
}
#[test]
fn test_output_ask() {
let output = HookOutput::ask(None, None);
let mut buf = Vec::new();
output.write_to(&mut buf).unwrap();
let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "ask");
assert!(json["hookSpecificOutput"]["permissionDecisionReason"].is_null());
}
#[test]
fn test_approve_permission() {
let output = HookOutput::approve_permission(None);
let mut buf = Vec::new();
output.write_to(&mut buf).unwrap();
let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
assert_eq!(
json["hookSpecificOutput"]["hookEventName"],
"PermissionRequest"
);
assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "allow");
assert!(json["hookSpecificOutput"]["decision"]["updatedInput"].is_null());
}
#[test]
fn test_approve_permission_with_updated_input() {
let updated = serde_json::json!({"command": "ls -la"});
let output = HookOutput::approve_permission(Some(updated.clone()));
let mut buf = Vec::new();
output.write_to(&mut buf).unwrap();
let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
assert_eq!(
json["hookSpecificOutput"]["hookEventName"],
"PermissionRequest"
);
assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "allow");
assert_eq!(
json["hookSpecificOutput"]["decision"]["updatedInput"],
updated
);
}
#[test]
fn test_deny_permission() {
let output = HookOutput::deny_permission("Not allowed".into(), true);
let mut buf = Vec::new();
output.write_to(&mut buf).unwrap();
let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
assert_eq!(
json["hookSpecificOutput"]["hookEventName"],
"PermissionRequest"
);
assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "deny");
assert_eq!(
json["hookSpecificOutput"]["decision"]["message"],
"Not allowed"
);
assert_eq!(json["hookSpecificOutput"]["decision"]["interrupt"], true);
}
#[test]
fn test_deny_permission_no_interrupt() {
let output = HookOutput::deny_permission("Try again".into(), false);
let mut buf = Vec::new();
output.write_to(&mut buf).unwrap();
let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "deny");
assert_eq!(json["hookSpecificOutput"]["decision"]["interrupt"], false);
}
}