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
This commit is contained in:
2026-02-25 13:39:34 -08:00
committed by Naomi Carrigan
parent 1bb7eb4d26
commit 9890b83313
9 changed files with 305 additions and 5 deletions
+114
View File
@@ -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<String>,
pub mcp_servers: Vec<String>,
pub custom_commands: Vec<String>,
}
/// 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<String> = std::collections::BTreeSet::new();
let mut all_mcp_servers: std::collections::BTreeSet<String> = 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<String> = all_hook_types.into_iter().collect();
let mcp_servers: Vec<String> = 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<String> {
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<String> = 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<Vec<String>, String> {
// On Windows, we need to use WSL to access the skills directory
+6
View File
@@ -125,6 +125,9 @@ pub struct HikariConfig {
#[serde(default)]
pub disable_1m_context: bool,
#[serde(default)]
pub trusted_workspaces: Vec<String>,
}
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();
+1
View File
@@ -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,
+1
View File
@@ -55,6 +55,7 @@
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
trusted_workspaces: [],
});
let showCustomThemeEditor = $state(false);
+60 -5
View File
@@ -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<WorkspaceHookInfo>("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 @@
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
{/if}
{#if showWorkspaceTrust && pendingHookInfo}
<WorkspaceTrustModal
hookInfo={pendingHookInfo}
onTrust={handleTrustAndConnect}
onCancel={handleCancelConnect}
/>
{/if}
<style>
/* Responsive status bar styling */
.status-bar {
@@ -0,0 +1,110 @@
<script lang="ts">
import { characterState } from "$lib/stores/character";
import type { WorkspaceHookInfo } from "$lib/types/messages";
interface Props {
hookInfo: WorkspaceHookInfo;
onTrust: () => void;
onCancel: () => void;
}
const { hookInfo, onTrust, onCancel }: Props = $props();
$effect(() => {
characterState.setState("permission");
});
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onclick={onCancel}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 max-w-md w-full mx-4 shadow-xl"
onclick={(e) => e.stopPropagation()}
>
<div class="flex items-center gap-3 mb-4">
<svg
class="w-6 h-6 text-yellow-400 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Workspace Trust Required</h2>
</div>
<p class="text-sm text-[var(--text-secondary)] mb-4">
This workspace contains configuration that can execute code on your system. Review what was
found before connecting.
</p>
<div class="space-y-3 mb-4">
{#if hookInfo.hook_types.length > 0}
<div class="bg-[var(--bg-primary)] rounded-md p-3">
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
Hooks (run shell commands automatically):
</p>
<ul class="space-y-1">
{#each hookInfo.hook_types as hookType (hookType)}
<li class="text-sm text-yellow-400 font-mono">{hookType}</li>
{/each}
</ul>
</div>
{/if}
{#if hookInfo.mcp_servers.length > 0}
<div class="bg-[var(--bg-primary)] rounded-md p-3">
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
MCP servers (run as local processes with system access):
</p>
<ul class="space-y-1">
{#each hookInfo.mcp_servers as server (server)}
<li class="text-sm text-yellow-400 font-mono">{server}</li>
{/each}
</ul>
</div>
{/if}
{#if hookInfo.custom_commands.length > 0}
<div class="bg-[var(--bg-primary)] rounded-md p-3">
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
Custom slash commands (can execute arbitrary instructions):
</p>
<ul class="space-y-1">
{#each hookInfo.custom_commands as cmd (cmd)}
<li class="text-sm text-yellow-400 font-mono">• /{cmd}</li>
{/each}
</ul>
</div>
{/if}
</div>
<p class="text-xs text-[var(--text-secondary)] mb-6">
Only connect to workspaces you trust. Trusting this workspace will remember your choice for
future sessions.
</p>
<div class="flex gap-3 justify-end">
<button
onclick={onCancel}
class="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border-color)] rounded-md transition-colors"
>
Cancel
</button>
<button
onclick={onTrust}
class="px-4 py-2 text-sm bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 border border-yellow-500/30 rounded-md transition-colors"
>
Trust and Connect
</button>
</div>
</div>
</div>
+3
View File
@@ -196,6 +196,7 @@ describe("config store", () => {
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
trusted_workspaces: [],
};
expect(config.model).toBe("claude-sonnet-4");
@@ -244,6 +245,7 @@ describe("config store", () => {
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
trusted_workspaces: [],
};
expect(config.model).toBeNull();
@@ -791,6 +793,7 @@ describe("config store", () => {
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
trusted_workspaces: [],
};
const mockInvokeImpl = vi.mocked(invoke);
+3
View File
@@ -51,6 +51,8 @@ export interface HikariConfig {
use_worktree: boolean;
// Disable 1M context window
disable_1m_context: boolean;
// Workspaces the user has explicitly trusted
trusted_workspaces: string[];
}
const defaultConfig: HikariConfig = {
@@ -93,6 +95,7 @@ const defaultConfig: HikariConfig = {
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
trusted_workspaces: [],
};
function createConfigStore() {
+7
View File
@@ -174,6 +174,13 @@ export interface Attachment {
previewUrl?: string; // For images, a data URL or object URL for preview
}
export interface WorkspaceHookInfo {
has_concerns: boolean;
hook_types: string[];
mcp_servers: string[];
custom_commands: string[];
}
export interface UpdateInfo {
current_version: string;
latest_version: string;