From 9890b8331373658ba3b50831fed312c4723f6ab9 Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Feb 2026 13:39:34 -0800 Subject: [PATCH] feat: workspace trust gate with persistent trust decisions Adds a pre-connection trust check that detects hooks, MCP servers, and custom slash commands in a workspace's .claude/ config before launching Claude Code. Shows a trust modal (permission sprite) listing all concerns by category. Trusted workspaces are persisted to config so the user is only prompted once per workspace path. Closes #163 --- src-tauri/src/commands.rs | 114 ++++++++++++++++++ src-tauri/src/config.rs | 6 + src-tauri/src/lib.rs | 1 + src/lib/components/ConfigSidebar.svelte | 1 + src/lib/components/StatusBar.svelte | 65 +++++++++- src/lib/components/WorkspaceTrustModal.svelte | 110 +++++++++++++++++ src/lib/stores/config.test.ts | 3 + src/lib/stores/config.ts | 3 + src/lib/types/messages.ts | 7 ++ 9 files changed, 305 insertions(+), 5 deletions(-) create mode 100644 src/lib/components/WorkspaceTrustModal.svelte diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ec07102..03b286e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -334,6 +334,120 @@ pub async fn answer_question( manager.send_tool_result(&conversation_id, &tool_use_id, answers) } +#[derive(Debug, Serialize)] +pub struct WorkspaceHookInfo { + pub has_concerns: bool, + pub hook_types: Vec, + pub mcp_servers: Vec, + pub custom_commands: Vec, +} + +/// Check whether a working directory has Claude Code hooks, MCP servers, or custom commands. +/// +/// Hikari Desktop runs Claude in `--output-format stream-json` (non-interactive mode), +/// which bypasses Claude's own workspace trust dialog. We therefore check for these +/// ourselves so the frontend can show its own trust gate before launching. +#[tauri::command] +pub async fn check_workspace_hooks(working_dir: String) -> WorkspaceHookInfo { + let use_wsl = cfg!(windows) && working_dir.starts_with('/'); + + let settings_paths = [ + format!("{}/.claude/settings.json", working_dir), + format!("{}/.claude/settings.local.json", working_dir), + ]; + + let mut all_hook_types: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut all_mcp_servers: std::collections::BTreeSet = std::collections::BTreeSet::new(); + + for path in &settings_paths { + let content = if use_wsl { + match read_file_via_wsl(path).await { + Ok(c) => c, + Err(_) => continue, + } + } else { + match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => continue, + } + }; + + let settings: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => continue, + }; + + if let Some(hooks) = settings.get("hooks").and_then(|h| h.as_object()) { + for key in hooks.keys() { + all_hook_types.insert(key.clone()); + } + } + + if let Some(servers) = settings.get("mcpServers").and_then(|s| s.as_object()) { + for key in servers.keys() { + all_mcp_servers.insert(key.clone()); + } + } + } + + let custom_commands = list_workspace_commands(&working_dir, use_wsl).await; + let hook_types: Vec = all_hook_types.into_iter().collect(); + let mcp_servers: Vec = all_mcp_servers.into_iter().collect(); + let has_concerns = !hook_types.is_empty() || !mcp_servers.is_empty() || !custom_commands.is_empty(); + + WorkspaceHookInfo { + has_concerns, + hook_types, + mcp_servers, + custom_commands, + } +} + +async fn list_workspace_commands(working_dir: &str, use_wsl: bool) -> Vec { + let commands_dir = format!("{}/.claude/commands", working_dir); + + if use_wsl { + let script = format!( + "if [ -d '{0}' ]; then for f in '{0}'/*.md; do [ -f \"$f\" ] && basename \"$f\" .md; done; fi", + commands_dir + ); + let Ok(output) = std::process::Command::new("wsl") + .args(["-e", "sh", "-c", &script]) + .output() + else { + return vec![]; + }; + String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect() + } else { + let dir = std::path::Path::new(&commands_dir); + if !dir.exists() { + return vec![]; + } + let Ok(entries) = std::fs::read_dir(dir) else { + return vec![]; + }; + let mut names: Vec = entries + .filter_map(|e| e.ok()) + .filter(|e| { + e.path() + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("md")) + }) + .filter_map(|e| { + e.path() + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + }) + .collect(); + names.sort(); + names + } +} + #[tauri::command] pub async fn list_skills() -> Result, String> { // On Windows, we need to use WSL to access the skills directory diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 6a21c23..91e5a2e 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -125,6 +125,9 @@ pub struct HikariConfig { #[serde(default)] pub disable_1m_context: bool, + + #[serde(default)] + pub trusted_workspaces: Vec, } impl Default for HikariConfig { @@ -159,6 +162,7 @@ impl Default for HikariConfig { discord_rpc_enabled: true, use_worktree: false, disable_1m_context: false, + trusted_workspaces: Vec::new(), } } } @@ -268,6 +272,7 @@ mod tests { assert!(config.discord_rpc_enabled); assert!(!config.use_worktree); assert!(!config.disable_1m_context); + assert!(config.trusted_workspaces.is_empty()); } #[test] @@ -302,6 +307,7 @@ mod tests { discord_rpc_enabled: true, use_worktree: true, disable_1m_context: false, + trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()], }; let json = serde_json::to_string(&config).unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 70aea97..e1b8b18 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -120,6 +120,7 @@ pub fn run() { get_persisted_stats, load_saved_achievements, answer_question, + check_workspace_hooks, send_windows_notification, send_simple_notification, send_windows_toast, diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index c4905f0..d22896c 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -55,6 +55,7 @@ show_thinking_blocks: true, use_worktree: false, disable_1m_context: false, + trusted_workspaces: [], }); let showCustomThemeEditor = $state(false); diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index e4099b9..eee7695 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -38,6 +38,8 @@ } from "$lib/utils/conversationUtils"; import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri"; import { debugConsoleStore } from "$lib/stores/debugConsole"; + import WorkspaceTrustModal from "./WorkspaceTrustModal.svelte"; + import type { WorkspaceHookInfo } from "$lib/types/messages"; const DISCORD_URL = "https://chat.nhcarrigan.com"; const DONATE_URL = "https://donate.nhcarrigan.com"; @@ -61,6 +63,8 @@ let showPluginPanel = $state(false); let showMcpPanel = $state(false); let isSummarising = $state(false); + let showWorkspaceTrust = $state(false); + let pendingHookInfo: WorkspaceHookInfo | null = $state(null); const progress = $derived($achievementProgress); const activeAgentCount = $derived($runningAgentCount); let currentConfig: HikariConfig = $state({ @@ -103,6 +107,7 @@ show_thinking_blocks: true, use_worktree: false, disable_1m_context: false, + trusted_workspaces: [], }); let streamerModeActive = $state(false); @@ -156,11 +161,7 @@ } } - async function handleConnect() { - if (isConnecting || connectionStatus === "connected") return; - - const targetDir = selectedDirectory || "/home/naomi"; - + async function doConnect(targetDir: string) { // Combine session-granted tools with config auto-granted tools const allAllowedTools = [ ...new Set([...grantedToolsList, ...currentConfig.auto_granted_tools]), @@ -200,6 +201,52 @@ } } + async function handleConnect() { + if (isConnecting || connectionStatus === "connected") return; + + const targetDir = selectedDirectory || "/home/naomi"; + + if (currentConfig.trusted_workspaces?.includes(targetDir)) { + await doConnect(targetDir); + return; + } + + try { + const hookInfo = await invoke("check_workspace_hooks", { + workingDir: targetDir, + }); + + if (hookInfo.has_concerns) { + pendingHookInfo = hookInfo; + showWorkspaceTrust = true; + return; + } + } catch (error) { + // Fail open: if we can't check hooks, proceed with connection + console.error("Failed to check workspace hooks:", error); + } + + await doConnect(targetDir); + } + + async function handleTrustAndConnect() { + showWorkspaceTrust = false; + const targetDir = selectedDirectory || "/home/naomi"; + pendingHookInfo = null; + const alreadyTrusted = currentConfig.trusted_workspaces?.includes(targetDir) ?? false; + if (!alreadyTrusted) { + await configStore.updateConfig({ + trusted_workspaces: [...(currentConfig.trusted_workspaces ?? []), targetDir], + }); + } + doConnect(targetDir); + } + + function handleCancelConnect() { + showWorkspaceTrust = false; + pendingHookInfo = null; + } + async function handleDisconnect() { try { const conversationId = get(claudeStore.activeConversationId); @@ -771,6 +818,14 @@ (showMcpPanel = false)} /> {/if} +{#if showWorkspaceTrust && pendingHookInfo} + +{/if} +