feat: MEMORY.md 访问保护、格式简化及预存问题修复

MEMORY.md 保护: 新建 file-guard.ts 共享守卫模块; read_file/write_file/search_files/run_command 禁止访问根目录 MEMORY.md; permissions.ts 增加 deniedPatterns 深度防御。格式简化: 元数据从 # 注释改为 > 引用语法; 校验规则从 6 条简化为 3 条; context-builder extractContent 适配。预存问题: 修复路径遍历前缀碰撞漏洞; 移除 browser_extract 内容截断; SearXNG 配置实时读取; web_search/web_fetch 移除截断; 侧边栏动态获取工具列表; 版本号 0.1.1
This commit is contained in:
thzxx
2026-07-05 21:24:22 +08:00
parent f4532a2bb2
commit 8cdb93f8bf
29 changed files with 2472 additions and 353 deletions
+17 -12
View File
@@ -135,28 +135,33 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
function ToolManagerPanel() {
const [expanded, setExpanded] = useState(false);
const tools = [
{ name: 'read_file', label: '读取文件', risk: 'SAFE' }, { name: 'write_file', label: '写入文件', risk: 'MEDIUM' },
{ name: 'list_directory', label: '列出目录', risk: 'SAFE' }, { name: 'search_files', label: '搜索文件', risk: 'SAFE' },
{ name: 'web_search', label: '网络搜索', risk: 'LOW' }, { name: 'web_extract', label: '网页抓取', risk: 'LOW' },
{ name: 'memory_store', label: '存储记忆', risk: 'MEDIUM' }, { name: 'memory_search', label: '搜索记忆', risk: 'SAFE' },
{ name: 'run_command', label: '执行命令', risk: 'HIGH' },
];
const riskColors: Record<string, string> = { SAFE: 'success.main', LOW: 'info.main', MEDIUM: 'warning.main', HIGH: 'error.main' };
const [tools, setTools] = useState<Array<{ name: string; description: string; category: string; riskLevel: string; requiresPermission: boolean; enabled: boolean }>>([]);
useEffect(() => {
if (window.metona?.tools?.list) {
window.metona.tools.list().then((list) => {
setTools(list as MetonaToolInfo[]);
}).catch((err) => { console.error('[Sidebar]', err); });
}
}, []);
const readyCount = tools.filter((t) => t.enabled).length;
const riskColors: Record<string, string> = { safe: 'success.main', low: 'info.main', medium: 'warning.main', high: 'error.main' };
const riskLabels: Record<string, string> = { safe: 'SAFE', low: 'LOW', medium: 'MEDIUM', high: 'HIGH' };
return (
<Box>
<ListItemButton onClick={() => setExpanded(!expanded)} dense sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}>
<ListItemIcon sx={{ minWidth: 24 }}>{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}<Wrench size={12} style={{ marginLeft: 4 }} /></ListItemIcon>
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12 }}></Typography>} />
<Typography variant="caption" sx={{ color: 'success.main', fontSize: 10 }}>9 </Typography>
<Typography variant="caption" sx={{ color: readyCount > 0 ? 'success.main' : 'text.disabled', fontSize: 10 }}>{readyCount} </Typography>
</ListItemButton>
<Collapse in={expanded}>
<List dense disablePadding sx={{ pl: 3 }}>
{tools.map((t) => (
<ListItemButton key={t.name} dense sx={{ py: 0.25, px: 1, borderRadius: 1 }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: riskColors[t.risk], mr: 1, flexShrink: 0 }} />
<Typography variant="caption" sx={{ flex: 1, fontSize: 11, color: 'text.secondary' }}>{t.label}</Typography>
<Typography variant="caption" sx={{ fontSize: 9, color: riskColors[t.risk] }}>{t.risk}</Typography>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: t.enabled ? riskColors[t.riskLevel] ?? 'text.disabled' : 'text.disabled', mr: 1, flexShrink: 0 }} />
<Typography variant="caption" sx={{ flex: 1, fontSize: 11, color: t.enabled ? 'text.secondary' : 'text.disabled' }}>{t.name}</Typography>
<Typography variant="caption" sx={{ fontSize: 9, color: t.enabled ? (riskColors[t.riskLevel] ?? 'text.disabled') : 'text.disabled' }}>{riskLabels[t.riskLevel] ?? t.riskLevel}</Typography>
</ListItemButton>
))}
</List>
+1 -1
View File
@@ -18,7 +18,7 @@ export function StatusBar(): React.JSX.Element {
const model = useAgentStore((s) => s.model);
const tokenUsage = useAgentStore((s) => s.tokenUsage);
const openSettings = useUIStore((s) => s.openSettings);
const [version, setVersion] = useState('v0.1.0');
const [version, setVersion] = useState('v0.1.1');
useEffect(() => {
if (window.metona?.app?.getVersion) {
+193 -4
View File
@@ -3,16 +3,17 @@
*/
import { useState, useEffect, useCallback } from 'react';
import { Dialog, DialogContent, DialogTitle, Button, TextField, Select, MenuItem, Tabs, Tab, Checkbox, Box, Typography, Stack, Divider, FormControlLabel, InputLabel, FormControl, IconButton, Chip } from '@mui/material';
import { X, Settings, Bot, Wrench, Server, Palette, FileText, Eye, EyeOff, FolderOpen } from 'lucide-react';
import { Dialog, DialogContent, DialogTitle, Button, TextField, Select, MenuItem, Tabs, Tab, Checkbox, Box, Typography, Stack, Divider, FormControlLabel, InputLabel, FormControl, IconButton, Chip, Switch, Alert, CircularProgress } from '@mui/material';
import { X, Settings, Bot, Wrench, Server, Palette, FileText, Eye, EyeOff, FolderOpen, Globe } from 'lucide-react';
import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store';
import { useAgentStore } from '@renderer/stores/agent-store';
type SettingsTab = 'workspace' | 'llm' | 'agent' | 'tools' | 'mcp' | 'appearance' | 'logs';
type SettingsTab = 'workspace' | 'llm' | 'agent' | 'tools' | 'mcp' | 'searxng' | 'appearance' | 'logs';
const TABS: { id: SettingsTab; label: string; icon: typeof Settings }[] = [
{ id: 'workspace', label: '工作空间', icon: FolderOpen },
{ id: 'llm', label: 'LLM 配置', icon: Bot }, { id: 'agent', label: 'Agent 配置', icon: Settings },
{ id: 'tools', label: '工具管理', icon: Wrench }, { id: 'mcp', label: 'MCP 服务', icon: Server },
{ id: 'searxng', label: 'SearXNG', icon: Globe },
{ id: 'appearance', label: '外观', icon: Palette }, { id: 'logs', label: '日志与数据', icon: FileText },
];
@@ -50,6 +51,7 @@ export function SettingsModal(): React.JSX.Element | null {
{tab === 'agent' && <AgentSettings />}
{tab === 'tools' && <ToolsSettings />}
{tab === 'mcp' && <MCPSettings />}
{tab === 'searxng' && <SearXNGSettings />}
{tab === 'appearance' && <AppearanceSettings theme={theme} setTheme={setTheme} />}
{tab === 'logs' && <LogsSettings />}
</Box>
@@ -193,7 +195,7 @@ function AgentSettings() {
function ToolsSettings() {
const [tools, setTools] = useState<Array<{ name: string; description: string; riskLevel: string; enabled: boolean }>>([]);
useEffect(() => { if (window.metona?.tools?.list) window.metona.tools.list().then((l) => setTools((l as MetonaToolInfo[]).map((t) => ({ ...t, enabled: true })))).catch((err) => { console.error('[SettingsModal]', err); }); }, []);
useEffect(() => { if (window.metona?.tools?.list) window.metona.tools.list().then((l) => setTools((l as MetonaToolInfo[]).map((t) => ({ name: t.name, description: t.description, riskLevel: t.riskLevel, enabled: t.enabled })))).catch((err) => { console.error('[SettingsModal]', err); }); }, []);
const handleToggle = (name: string, enabled: boolean) => { setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t)); window.metona?.tools?.toggle(name, enabled).catch((err) => { console.error('[SettingsModal]', err); }); };
const riskColors: Record<string, 'success' | 'info' | 'warning' | 'error'> = { safe: 'success', low: 'info', medium: 'warning', high: 'error' };
@@ -259,6 +261,193 @@ function MCPSettings() {
);
}
function SearXNGSettings() {
// ===== 12 项配置(useConfig 实时持久化) =====
const [enabled, setEnabled] = useConfig('searxng.enabled', false);
const [url, setUrl] = useConfig('searxng.url', '');
const [engines, setEngines] = useConfig('searxng.engines', '');
const [language, setLanguage] = useConfig('searxng.language', 'zh-CN');
const [safesearch, setSafesearch] = useConfig('searxng.safesearch', 1);
const [timeRange, setTimeRange] = useConfig('searxng.time_range', '');
const [maxResults, setMaxResults] = useConfig('searxng.max_results', 0);
const [authKey, setAuthKey] = useConfig('searxng.auth_key', '');
const [authType, setAuthType] = useConfig('searxng.auth_type', 'bearer');
const [format, setFormat] = useConfig('searxng.format', 'json');
const [fetchCount, setFetchCount] = useConfig('searxng.fetch_count', 0);
const [fetchMode, setFetchMode] = useConfig('searxng.fetch_mode', 'sequential');
const [showKey, setShowKey] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const urlError = !!url && !/^https?:\/\//.test(url);
const handleTest = async () => {
if (!url.trim() || urlError) return;
setTesting(true);
setTestResult(null);
try {
const result = await window.metona?.searxng?.testConnection(url.trim(), authKey, authType);
if (result?.success) {
setTestResult({ success: true, message: `连接成功(${result.latencyMs}ms` });
} else {
setTestResult({ success: false, message: result?.error || `连接失败(HTTP ${result?.statusCode}` });
}
} catch (e) {
setTestResult({ success: false, message: (e as Error).message });
}
setTesting(false);
};
return (
<Stack spacing={2}>
{/* 标题 + 状态徽章 */}
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>SearXNG </Typography>
<Chip label={enabled ? '已启用' : '未启用'} size="small" color={enabled ? 'success' : 'default'} variant={enabled ? 'filled' : 'outlined'} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
SearXNG 70+ 退 Bing + + + 360
</Typography>
{/* 启用开关 */}
<FormControlLabel control={<Switch checked={enabled} onChange={(e) => setEnabled(e.target.checked)} size="small" />} label={<Typography variant="body2"> SearXNG</Typography>} />
<Divider />
{/* API 地址 + 连接测试 */}
<Stack direction="row" spacing={1} sx={{ alignItems: 'flex-start' }}>
<TextField
size="small"
label="API 地址"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="如 https://searxng.example.com"
error={urlError}
helperText={urlError ? '需以 http:// 或 https:// 开头' : '实例根地址(不含 /search 路径)'}
sx={{ flex: 1 }}
/>
<Button variant="outlined" size="small" onClick={handleTest} disabled={!url.trim() || urlError || testing} sx={{ mt: 0.5, minWidth: 90, height: 40 }}>
{testing ? <CircularProgress size={14} /> : '测试连接'}
</Button>
</Stack>
{/* 测试结果 */}
{testResult && (
<Alert severity={testResult.success ? 'success' : 'error'} sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
{testResult.message}
</Alert>
)}
{/* 搜索引擎 */}
<TextField
size="small"
label="搜索引擎(逗号分隔)"
value={engines}
onChange={(e) => setEngines(e.target.value)}
placeholder="如 google,bing,duckduckgo(留空使用实例默认)"
/>
{/* 语言 + 安全搜索 */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<FormControl size="small"><InputLabel></InputLabel>
<Select value={language} label="语言" onChange={(e) => setLanguage(e.target.value)}>
<MenuItem value="zh-CN"></MenuItem>
<MenuItem value="zh-TW"></MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="ja"></MenuItem>
<MenuItem value="ko"></MenuItem>
<MenuItem value="auto"></MenuItem>
</Select>
</FormControl>
<FormControl size="small"><InputLabel></InputLabel>
<Select value={safesearch} label="安全搜索" onChange={(e) => setSafesearch(e.target.value as number)}>
<MenuItem value={0}></MenuItem>
<MenuItem value={1}></MenuItem>
<MenuItem value={2}></MenuItem>
</Select>
</FormControl>
</Box>
{/* 时间范围 + 返回格式 */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<FormControl size="small"><InputLabel></InputLabel>
<Select value={timeRange} label="时间范围" onChange={(e) => setTimeRange(e.target.value)}>
<MenuItem value=""></MenuItem>
<MenuItem value="day"></MenuItem>
<MenuItem value="week"></MenuItem>
<MenuItem value="month"></MenuItem>
<MenuItem value="year"></MenuItem>
</Select>
</FormControl>
<FormControl size="small"><InputLabel></InputLabel>
<Select value={format} label="返回格式" onChange={(e) => setFormat(e.target.value)}>
<MenuItem value="json">JSON</MenuItem>
<MenuItem value="html">HTML</MenuItem>
</Select>
</FormControl>
</Box>
{/* 最大结果数 + 自动抓取条数 */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<TextField
size="small"
label="最大结果数"
type="number"
value={maxResults}
onChange={(e) => setMaxResults(Number(e.target.value))}
placeholder="0 表示使用默认"
slotProps={{ htmlInput: { min: 0, max: 50 } }}
/>
<TextField
size="small"
label="自动抓取条数"
type="number"
value={fetchCount}
onChange={(e) => setFetchCount(Number(e.target.value))}
placeholder="0 表示由 AI 决定"
slotProps={{ htmlInput: { min: 0, max: 8 } }}
/>
</Box>
{/* 抓取类型 */}
<FormControl size="small"><InputLabel></InputLabel>
<Select value={fetchMode} label="抓取类型" onChange={(e) => setFetchMode(e.target.value)}>
<MenuItem value="sequential"></MenuItem>
<MenuItem value="random"></MenuItem>
</Select>
</FormControl>
<Divider />
{/* 认证设置 */}
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}></Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 2 }}>
<FormControl size="small"><InputLabel></InputLabel>
<Select value={authType} label="认证类型" onChange={(e) => setAuthType(e.target.value)}>
<MenuItem value="bearer">Bearer Token</MenuItem>
<MenuItem value="basic">Basic Auth</MenuItem>
</Select>
</FormControl>
<TextField
size="small"
label={authType === 'bearer' ? 'Token' : '用户名:密码'}
type={showKey ? 'text' : 'password'}
value={authKey}
onChange={(e) => setAuthKey(e.target.value)}
placeholder={authType === 'bearer' ? '访问令牌原值' : 'username:password'}
slotProps={{ input: { endAdornment: <IconButton size="small" onClick={() => setShowKey(!showKey)}>{showKey ? <EyeOff size={14} /> : <Eye size={14} />}</IconButton> } }}
/>
</Box>
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
{authType === 'bearer'
? 'Bearer: 直接填写令牌原值,原样透传到 Authorization 头。建议配合 HTTPS 使用。'
: 'Basic: 填写 username:password 明文串,系统自动 Base64 编码。必须配合 HTTPS 使用。'}
</Typography>
</Stack>
);
}
function AppearanceSettings({ theme, setTheme }: { theme: ThemeMode; setTheme: (t: ThemeMode) => void }) {
return (
<Stack spacing={2}>
+2 -2
View File
@@ -159,8 +159,8 @@ export const appAPI = {
return getBridge().app.openExternal(url);
},
showItemInFolder(path: string): void {
getBridge().app.showItemInFolder(path);
showItemInFolder(path: string): Promise<void> {
return getBridge().app.showItemInFolder(path);
},
};
+17 -1
View File
@@ -157,7 +157,7 @@ interface MetonaAppAPI {
getVersion: () => Promise<string>;
getAppDataPath: () => Promise<string>;
openExternal: (url: string) => Promise<void>;
showItemInFolder: (path: string) => void;
showItemInFolder: (path: string) => Promise<void>;
selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>;
}
@@ -179,6 +179,7 @@ interface MetonaToolInfo {
category: string;
riskLevel: string;
requiresPermission: boolean;
enabled: boolean;
}
interface MetonaToolsAPI {
@@ -186,6 +187,20 @@ interface MetonaToolsAPI {
toggle: (toolName: string, enabled: boolean) => Promise<{ success: boolean }>;
}
// ===== SearXNG API =====
interface MetonaSearXNGTestResult {
success: boolean;
statusCode?: number;
latencyMs?: number;
error?: string;
}
interface MetonaSearXNGAPI {
/** 测试 SearXNG 实例连接可达性与认证有效性 */
testConnection: (url: string, authKey: string, authType: string) => Promise<MetonaSearXNGTestResult>;
}
// ===== Data API =====
interface MetonaDataAPI {
@@ -207,6 +222,7 @@ interface MetonaBridge {
toast: MetonaToastAPI;
tools: MetonaToolsAPI;
data: MetonaDataAPI;
searxng: MetonaSearXNGAPI;
}
// ===== 全局 Window 增强 =====