diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 0f04038..097b67f 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -344,6 +344,16 @@ pub struct TaskCreatedEvent { pub conversation_id: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PermissionDeniedEvent { + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentStartEvent { pub tool_use_id: String, @@ -860,4 +870,44 @@ mod tests { assert!(!serialized.contains("parent_tool_use_id")); assert!(!serialized.contains("conversation_id")); } + + #[test] + fn test_permission_denied_event_serialization() { + let event = PermissionDeniedEvent { + tool_name: Some("Bash".to_string()), + reason: Some("Tool not in allow list".to_string()), + conversation_id: Some("conv-abc".to_string()), + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"tool_name\":\"Bash\"")); + assert!(serialized.contains("\"reason\":\"Tool not in allow list\"")); + assert!(serialized.contains("\"conversation_id\":\"conv-abc\"")); + } + + #[test] + fn test_permission_denied_event_omits_none_fields() { + let event = PermissionDeniedEvent { + tool_name: None, + reason: None, + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert_eq!(serialized, "{}"); + } + + #[test] + fn test_permission_denied_event_partial_fields() { + let event = PermissionDeniedEvent { + tool_name: Some("Edit".to_string()), + reason: None, + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"tool_name\":\"Edit\"")); + assert!(!serialized.contains("reason")); + assert!(!serialized.contains("conversation_id")); + } } diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 819ece3..ffdbb2c 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -16,10 +16,10 @@ use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats}; use crate::types::{ AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, CwdChangedEvent, ElicitationEvent, ElicitationResultEvent, - FileChangedEvent, MessageCost, OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, - PostCompactEvent, QuestionOption, SessionEvent, StateChangeEvent, StopFailureEvent, - TaskCreatedEvent, TodoItem, TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, - WorktreeEvent, WorktreeInfo, + FileChangedEvent, MessageCost, OutputEvent, PermissionDeniedEvent, PermissionPromptEvent, + PermissionPromptEventItem, PostCompactEvent, QuestionOption, SessionEvent, StateChangeEvent, + StopFailureEvent, TaskCreatedEvent, TodoItem, TodoUpdateEvent, UserQuestionEvent, + WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo, }; use parking_lot::RwLock; use std::cell::RefCell; @@ -1082,6 +1082,7 @@ fn handle_stderr( let is_cwd_changed = line.contains("[CwdChanged Hook]"); let is_file_changed = line.contains("[FileChanged Hook]"); let is_task_created = line.contains("[TaskCreated Hook]"); + let is_permission_denied = line.contains("[PermissionDenied Hook]"); let line_type = if is_worktree_create || is_worktree_remove { "worktree" @@ -1099,6 +1100,8 @@ fn handle_stderr( "file-changed" } else if is_task_created { "task-created" + } else if is_permission_denied { + "permission-denied" } else { "error" }; @@ -1316,6 +1319,45 @@ fn handle_stderr( parent_tool_use_id: None, }, ); + } else if is_permission_denied { + let data = parse_permission_denied_hook(&line); + let friendly_content = match (data.tool_name.as_deref(), data.reason.as_deref()) { + (Some(tool), Some(reason)) => { + format!("Permission denied for {}: {}", tool, reason) + } + (Some(tool), None) => format!("Permission denied for {}", tool), + _ => "Permission denied".to_string(), + }; + + let _ = app.emit( + "claude:state-change", + StateChangeEvent { + state: CharacterState::Permission, + tool_name: data.tool_name.clone(), + conversation_id: conversation_id.clone(), + }, + ); + + let _ = app.emit( + "claude:permission-denied", + PermissionDeniedEvent { + tool_name: data.tool_name, + reason: data.reason, + conversation_id: conversation_id.clone(), + }, + ); + + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "permission-denied".to_string(), + content: friendly_content, + tool_name: None, + conversation_id: conversation_id.clone(), + cost: None, + parent_tool_use_id: None, + }, + ); } else { let _ = app.emit( "claude:output", @@ -1591,6 +1633,23 @@ fn parse_task_created_hook(line: &str) -> TaskCreatedData { TaskCreatedData { task_id, description, parent_tool_use_id } } +#[derive(Debug)] +struct PermissionDeniedData { + tool_name: Option, + reason: Option, +} + +fn parse_permission_denied_hook(line: &str) -> PermissionDeniedData { + let tool_name = extract_quoted_value(line, "tool_name") + .or_else(|| extract_quoted_value(line, "tool")) + .or_else(|| extract_debug_string_value(line, "tool_name")); + + let reason = extract_quoted_value(line, "reason") + .or_else(|| extract_quoted_value(line, "message")); + + PermissionDeniedData { tool_name, reason } +} + /// Extracts a double-quoted string value from a `key="value"` pair in a hook line. /// Handles escape sequences within the quoted value. fn extract_quoted_value(line: &str, key: &str) -> Option { @@ -3831,6 +3890,38 @@ mod tests { assert_eq!(data.parent_tool_use_id, None); } + #[test] + fn test_parse_permission_denied_hook_with_all_fields() { + let line = r#"[PermissionDenied Hook] tool_name="Bash", reason="Tool not in allow list""#; + let data = parse_permission_denied_hook(line); + assert_eq!(data.tool_name, Some("Bash".to_string())); + assert_eq!(data.reason, Some("Tool not in allow list".to_string())); + } + + #[test] + fn test_parse_permission_denied_hook_tool_only() { + let line = r#"[PermissionDenied Hook] tool_name="Edit""#; + let data = parse_permission_denied_hook(line); + assert_eq!(data.tool_name, Some("Edit".to_string())); + assert_eq!(data.reason, None); + } + + #[test] + fn test_parse_permission_denied_hook_tool_key_alias() { + let line = r#"[PermissionDenied Hook] tool="Write", reason="Workspace not trusted""#; + let data = parse_permission_denied_hook(line); + assert_eq!(data.tool_name, Some("Write".to_string())); + assert_eq!(data.reason, Some("Workspace not trusted".to_string())); + } + + #[test] + fn test_parse_permission_denied_hook_empty_line() { + let line = "[PermissionDenied Hook]"; + let data = parse_permission_denied_hook(line); + assert_eq!(data.tool_name, None); + assert_eq!(data.reason, None); + } + #[test] fn test_build_stop_failure_message_no_fields() { let data = StopFailureData {