generated from nhcarrigan/template
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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user