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)
|
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]
|
#[tauri::command]
|
||||||
pub async fn list_skills() -> Result<Vec<String>, String> {
|
pub async fn list_skills() -> Result<Vec<String>, String> {
|
||||||
// On Windows, we need to use WSL to access the skills directory
|
// On Windows, we need to use WSL to access the skills directory
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ pub struct HikariConfig {
|
|||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub disable_1m_context: bool,
|
pub disable_1m_context: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub trusted_workspaces: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HikariConfig {
|
impl Default for HikariConfig {
|
||||||
@@ -159,6 +162,7 @@ impl Default for HikariConfig {
|
|||||||
discord_rpc_enabled: true,
|
discord_rpc_enabled: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
trusted_workspaces: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,6 +272,7 @@ mod tests {
|
|||||||
assert!(config.discord_rpc_enabled);
|
assert!(config.discord_rpc_enabled);
|
||||||
assert!(!config.use_worktree);
|
assert!(!config.use_worktree);
|
||||||
assert!(!config.disable_1m_context);
|
assert!(!config.disable_1m_context);
|
||||||
|
assert!(config.trusted_workspaces.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -302,6 +307,7 @@ mod tests {
|
|||||||
discord_rpc_enabled: true,
|
discord_rpc_enabled: true,
|
||||||
use_worktree: true,
|
use_worktree: true,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()],
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ pub fn run() {
|
|||||||
get_persisted_stats,
|
get_persisted_stats,
|
||||||
load_saved_achievements,
|
load_saved_achievements,
|
||||||
answer_question,
|
answer_question,
|
||||||
|
check_workspace_hooks,
|
||||||
send_windows_notification,
|
send_windows_notification,
|
||||||
send_simple_notification,
|
send_simple_notification,
|
||||||
send_windows_toast,
|
send_windows_toast,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
trusted_workspaces: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
let showCustomThemeEditor = $state(false);
|
let showCustomThemeEditor = $state(false);
|
||||||
|
|||||||
@@ -38,6 +38,8 @@
|
|||||||
} from "$lib/utils/conversationUtils";
|
} from "$lib/utils/conversationUtils";
|
||||||
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
||||||
import { debugConsoleStore } from "$lib/stores/debugConsole";
|
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 DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||||
const DONATE_URL = "https://donate.nhcarrigan.com";
|
const DONATE_URL = "https://donate.nhcarrigan.com";
|
||||||
@@ -61,6 +63,8 @@
|
|||||||
let showPluginPanel = $state(false);
|
let showPluginPanel = $state(false);
|
||||||
let showMcpPanel = $state(false);
|
let showMcpPanel = $state(false);
|
||||||
let isSummarising = $state(false);
|
let isSummarising = $state(false);
|
||||||
|
let showWorkspaceTrust = $state(false);
|
||||||
|
let pendingHookInfo: WorkspaceHookInfo | null = $state(null);
|
||||||
const progress = $derived($achievementProgress);
|
const progress = $derived($achievementProgress);
|
||||||
const activeAgentCount = $derived($runningAgentCount);
|
const activeAgentCount = $derived($runningAgentCount);
|
||||||
let currentConfig: HikariConfig = $state({
|
let currentConfig: HikariConfig = $state({
|
||||||
@@ -103,6 +107,7 @@
|
|||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
trusted_workspaces: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
let streamerModeActive = $state(false);
|
let streamerModeActive = $state(false);
|
||||||
@@ -156,11 +161,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConnect() {
|
async function doConnect(targetDir: string) {
|
||||||
if (isConnecting || connectionStatus === "connected") return;
|
|
||||||
|
|
||||||
const targetDir = selectedDirectory || "/home/naomi";
|
|
||||||
|
|
||||||
// Combine session-granted tools with config auto-granted tools
|
// Combine session-granted tools with config auto-granted tools
|
||||||
const allAllowedTools = [
|
const allAllowedTools = [
|
||||||
...new Set([...grantedToolsList, ...currentConfig.auto_granted_tools]),
|
...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() {
|
async function handleDisconnect() {
|
||||||
try {
|
try {
|
||||||
const conversationId = get(claudeStore.activeConversationId);
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
@@ -771,6 +818,14 @@
|
|||||||
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
|
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showWorkspaceTrust && pendingHookInfo}
|
||||||
|
<WorkspaceTrustModal
|
||||||
|
hookInfo={pendingHookInfo}
|
||||||
|
onTrust={handleTrustAndConnect}
|
||||||
|
onCancel={handleCancelConnect}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Responsive status bar styling */
|
/* Responsive status bar styling */
|
||||||
.status-bar {
|
.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>
|
||||||
@@ -196,6 +196,7 @@ describe("config store", () => {
|
|||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
trusted_workspaces: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBe("claude-sonnet-4");
|
expect(config.model).toBe("claude-sonnet-4");
|
||||||
@@ -244,6 +245,7 @@ describe("config store", () => {
|
|||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
trusted_workspaces: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBeNull();
|
expect(config.model).toBeNull();
|
||||||
@@ -791,6 +793,7 @@ describe("config store", () => {
|
|||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
trusted_workspaces: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockInvokeImpl = vi.mocked(invoke);
|
const mockInvokeImpl = vi.mocked(invoke);
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export interface HikariConfig {
|
|||||||
use_worktree: boolean;
|
use_worktree: boolean;
|
||||||
// Disable 1M context window
|
// Disable 1M context window
|
||||||
disable_1m_context: boolean;
|
disable_1m_context: boolean;
|
||||||
|
// Workspaces the user has explicitly trusted
|
||||||
|
trusted_workspaces: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: HikariConfig = {
|
const defaultConfig: HikariConfig = {
|
||||||
@@ -93,6 +95,7 @@ const defaultConfig: HikariConfig = {
|
|||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
trusted_workspaces: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function createConfigStore() {
|
function createConfigStore() {
|
||||||
|
|||||||
@@ -174,6 +174,13 @@ export interface Attachment {
|
|||||||
previewUrl?: string; // For images, a data URL or object URL for preview
|
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 {
|
export interface UpdateInfo {
|
||||||
current_version: string;
|
current_version: string;
|
||||||
latest_version: string;
|
latest_version: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user