From b2146b54cb29dfc0d4bd1fc20a3dfe7bb50b5f63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sun, 24 May 2026 22:08:12 +0800 Subject: [PATCH] feat(hermes): add tirith security settings --- scripts/dev-api.js | 53 +++++++ src-tauri/src/commands/hermes.rs | 198 +++++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 + src/engines/hermes/pages/config.js | 107 ++++++++++++++- src/lib/tauri-api.js | 2 + src/locales/modules/engine.js | 12 ++ tests/hermes-config-page-ui.test.js | 13 ++ tests/hermes-security-config.test.js | 72 ++++++++++ 8 files changed, 456 insertions(+), 3 deletions(-) create mode 100644 tests/hermes-security-config.test.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 4a7f77b..c0f3ad3 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3699,6 +3699,38 @@ export function mergeHermesUnauthorizedDmConfig(config = {}, form = {}) { return next } +export function buildHermesSecurityConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const security = root.security && typeof root.security === 'object' && !Array.isArray(root.security) + ? root.security + : {} + const tirithPath = typeof security.tirith_path === 'string' && security.tirith_path.trim() + ? security.tirith_path.trim() + : 'tirith' + return { + tirithEnabled: readHermesBool(security.tirith_enabled, true), + tirithPath, + tirithTimeout: parseHermesInteger(security.tirith_timeout, 'security.tirith_timeout', 5, 1, 300, false), + tirithFailOpen: readHermesBool(security.tirith_fail_open, true), + } +} + +export function mergeHermesSecurityConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesSecurityConfigValues(next) + const security = next.security && typeof next.security === 'object' && !Array.isArray(next.security) + ? mergeConfigsPreservingFields(next.security, {}) + : {} + const tirithPath = String(Object.hasOwn(form, 'tirithPath') ? form.tirithPath : currentValues.tirithPath).trim() + if (!tirithPath) throw new Error('security.tirith_path 不能为空') + security.tirith_enabled = formHermesBool(form, 'tirithEnabled', currentValues.tirithEnabled) + security.tirith_path = tirithPath + security.tirith_timeout = parseHermesInteger(Object.hasOwn(form, 'tirithTimeout') ? form.tirithTimeout : currentValues.tirithTimeout, 'security.tirith_timeout', 5, 1, 300, true) + security.tirith_fail_open = formHermesBool(form, 'tirithFailOpen', currentValues.tirithFailOpen) + next.security = security + return next +} + export function buildHermesStreamingConfigValues(config = {}) { const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} const streaming = hermesStreamingConfigSource(root) @@ -10226,6 +10258,27 @@ const handlers = { } }, + hermes_security_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesSecurityConfigValues(config), + } + }, + + hermes_security_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesSecurityConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesSecurityConfigValues(next), + } + }, + hermes_streaming_config_read() { const { configPath, exists, config } = readHermesConfigYamlObject() return { diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index b01a3e8..ae4b163 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -3905,6 +3905,85 @@ fn merge_hermes_unauthorized_dm_config( Ok(()) } +fn build_hermes_security_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let security = root.and_then(|map| yaml_get_mapping(map, "security")); + + let tirith_enabled = security + .and_then(|map| yaml_bool_field(map, "tirith_enabled")) + .unwrap_or(true); + let tirith_path = security + .and_then(|map| yaml_string_field(map, "tirith_path")) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "tirith".to_string()); + let tirith_timeout = security + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "tirith_timeout"), 5, 1, 300)) + .unwrap_or(5); + let tirith_fail_open = security + .and_then(|map| yaml_bool_field(map, "tirith_fail_open")) + .unwrap_or(true); + + serde_json::json!({ + "tirithEnabled": tirith_enabled, + "tirithPath": tirith_path, + "tirithTimeout": tirith_timeout, + "tirithFailOpen": tirith_fail_open, + }) +} + +fn merge_hermes_security_config( + config: &mut serde_yaml::Value, + form: &Value, +) -> Result<(), String> { + let current = build_hermes_security_config_values(config); + let tirith_path = form_string(form, "tirithPath") + .or_else(|| current["tirithPath"].as_str().map(ToString::to_string)) + .unwrap_or_else(|| "tirith".to_string()) + .trim() + .to_string(); + if tirith_path.is_empty() { + return Err("security.tirith_path 不能为空".to_string()); + } + + let root = ensure_yaml_object(config)?; + let tirith_timeout = validate_hermes_i64( + if form.get("tirithTimeout").is_some() { + form_i64(form, "tirithTimeout") + } else { + Some(current["tirithTimeout"].as_i64().unwrap_or(5)) + }, + "security.tirith_timeout", + 5, + 1, + 300, + )?; + let security = yaml_child_object(root, "security")?; + security.insert( + yaml_key("tirith_enabled"), + serde_yaml::Value::Bool( + form_bool(form, "tirithEnabled") + .unwrap_or_else(|| current["tirithEnabled"].as_bool().unwrap_or(true)), + ), + ); + security.insert( + yaml_key("tirith_path"), + serde_yaml::Value::String(tirith_path), + ); + security.insert( + yaml_key("tirith_timeout"), + serde_yaml::Value::Number(tirith_timeout.into()), + ); + security.insert( + yaml_key("tirith_fail_open"), + serde_yaml::Value::Bool( + form_bool(form, "tirithFailOpen") + .unwrap_or_else(|| current["tirithFailOpen"].as_bool().unwrap_or(true)), + ), + ); + Ok(()) +} + fn normalize_hermes_streaming_transport( value: Option, strict: bool, @@ -5447,6 +5526,30 @@ pub fn hermes_unauthorized_dm_config_save(form: Value) -> Result })) } +#[tauri::command] +pub fn hermes_security_config_read() -> Result { + let (config_path, exists, config) = read_hermes_channel_yaml_config()?; + ensure_yaml_object(&mut config.clone())?; + Ok(serde_json::json!({ + "exists": exists, + "configPath": config_path.to_string_lossy(), + "values": build_hermes_security_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_security_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_security_config(&mut config, &form)?; + let backup = write_hermes_yaml_config(&config_path, &config)?; + Ok(serde_json::json!({ + "ok": true, + "configPath": config_path.to_string_lossy(), + "backup": backup, + "values": build_hermes_security_config_values(&config), + })) +} + #[tauri::command] pub fn hermes_streaming_config_read() -> Result { let (config_path, exists, config) = read_hermes_channel_yaml_config()?; @@ -11540,6 +11643,101 @@ memory: } } +#[cfg(test)] +mod hermes_security_config_tests { + use super::{build_hermes_security_config_values, merge_hermes_security_config}; + use serde_json::json; + + #[test] + fn security_values_have_tirith_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_security_config_values(&config); + assert_eq!(values["tirithEnabled"], true); + assert_eq!(values["tirithPath"], "tirith"); + assert_eq!(values["tirithTimeout"], 5); + assert_eq!(values["tirithFailOpen"], true); + } + + #[test] + fn security_values_read_yaml_fields() { + let config: serde_yaml::Value = serde_yaml::from_str( + r#" +security: + tirith_enabled: false + tirith_path: C:/tools/tirith.exe + tirith_timeout: 12 + tirith_fail_open: false +"#, + ) + .unwrap(); + let values = build_hermes_security_config_values(&config); + assert_eq!(values["tirithEnabled"], false); + assert_eq!(values["tirithPath"], "C:/tools/tirith.exe"); + assert_eq!(values["tirithTimeout"], 12); + assert_eq!(values["tirithFailOpen"], false); + } + + #[test] + fn merge_security_config_preserves_unknown_fields() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: anthropic +security: + allow_private_urls: false + website_blocklist: + enabled: true + domains: + - example.com + custom_flag: keep-security +terminal: + backend: docker +"#, + ) + .unwrap(); + + merge_hermes_security_config( + &mut config, + &json!({ + "tirithEnabled": false, + "tirithPath": "~/bin/tirith", + "tirithTimeout": 9, + "tirithFailOpen": false, + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("anthropic")); + assert_eq!(config["terminal"]["backend"].as_str(), Some("docker")); + assert_eq!( + config["security"]["custom_flag"].as_str(), + Some("keep-security") + ); + assert_eq!(config["security"]["tirith_enabled"].as_bool(), Some(false)); + assert_eq!( + config["security"]["tirith_path"].as_str(), + Some("~/bin/tirith") + ); + assert_eq!(config["security"]["tirith_timeout"].as_i64(), Some(9)); + assert_eq!( + config["security"]["tirith_fail_open"].as_bool(), + Some(false) + ); + } + + #[test] + fn merge_security_config_rejects_invalid_values() { + let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + let err = + merge_hermes_security_config(&mut config, &json!({ "tirithTimeout": 0 })).unwrap_err(); + assert!(err.contains("security.tirith_timeout")); + + let err = + merge_hermes_security_config(&mut config, &json!({ "tirithPath": "" })).unwrap_err(); + assert!(err.contains("security.tirith_path")); + } +} + #[cfg(test)] mod hermes_channel_tests { use super::{ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f181f7c..fbd9f84 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -271,6 +271,8 @@ pub fn run() { hermes::hermes_quick_commands_config_save, hermes::hermes_unauthorized_dm_config_read, hermes::hermes_unauthorized_dm_config_save, + hermes::hermes_security_config_read, + hermes::hermes_security_config_save, hermes::hermes_streaming_config_read, hermes::hermes_streaming_config_save, hermes::hermes_execution_limits_config_read, diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index ddcc401..6eed013 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -56,6 +56,13 @@ const UNAUTHORIZED_DM_DEFAULTS = { unauthorizedDmBehavior: 'pair', } +const SECURITY_DEFAULTS = { + tirithEnabled: true, + tirithPath: 'tirith', + tirithTimeout: 5, + tirithFailOpen: true, +} + const STREAMING_DEFAULTS = { enabled: false, transport: 'edit', @@ -109,6 +116,7 @@ export function render() { let skillsValues = { ...SKILLS_DEFAULTS } let quickCommandsValues = { ...QUICK_COMMANDS_DEFAULTS } let unauthorizedDmValues = { ...UNAUTHORIZED_DM_DEFAULTS } + let securityValues = { ...SECURITY_DEFAULTS } let streamingValues = { ...STREAMING_DEFAULTS } let executionLimitsValues = { ...EXECUTION_LIMITS_DEFAULTS } let terminalValues = { ...TERMINAL_DEFAULTS } @@ -120,6 +128,7 @@ export function render() { let skillsLoading = true let quickCommandsLoading = true let unauthorizedDmLoading = true + let securityLoading = true let streamingLoading = true let executionLimitsLoading = true let terminalLoading = true @@ -131,6 +140,7 @@ export function render() { let skillsSaving = false let quickCommandsSaving = false let unauthorizedDmSaving = false + let securitySaving = false let streamingSaving = false let executionLimitsSaving = false let terminalSaving = false @@ -142,6 +152,7 @@ export function render() { let skillsError = null let quickCommandsError = null let unauthorizedDmError = null + let securityError = null let streamingError = null let executionLimitsError = null let terminalError = null @@ -155,7 +166,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || unauthorizedDmLoading || streamingLoading || executionLimitsLoading || terminalLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || terminalSaving + return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || unauthorizedDmLoading || securityLoading || streamingLoading || executionLimitsLoading || terminalLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || securitySaving || streamingSaving || executionLimitsSaving || terminalSaving } function option(labelKey, value, selected) { @@ -440,7 +451,7 @@ export function render() { } function renderUnauthorizedDmConfigPanel() { - const disabled = loading || saving || unauthorizedDmLoading || unauthorizedDmSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || streamingSaving || executionLimitsSaving || terminalSaving + const disabled = loading || saving || unauthorizedDmLoading || unauthorizedDmSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || securitySaving || streamingSaving || executionLimitsSaving || terminalSaving return `
@@ -469,8 +480,50 @@ export function render() { ` } + function renderSecurityConfigPanel() { + const disabled = loading || saving || securityLoading || securitySaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || terminalSaving + return ` +
+
+
+
${t('engine.hermesSecurityConfigTitle')}
+
${t('engine.hermesSecurityConfigDesc')}
+
+
+ ${securitySaving ? t('engine.hermesConfigStatusSaving') : securityLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesSecurityConfigStatusReady')} + +
+
+
+ ${renderError(securityError)} +
+ + +
+
+ + +
+
${t('engine.hermesSecurityConfigFootnote')}
+
+
+ ` + } + function renderStreamingPanel() { - const disabled = loading || saving || streamingLoading || streamingSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || executionLimitsSaving || terminalSaving + const disabled = loading || saving || streamingLoading || streamingSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || securitySaving || executionLimitsSaving || terminalSaving return `
@@ -688,6 +741,7 @@ export function render() { ${renderSkillsConfigPanel()} ${renderQuickCommandsConfigPanel()} ${renderUnauthorizedDmConfigPanel()} + ${renderSecurityConfigPanel()}
@@ -714,6 +768,7 @@ export function render() { el.querySelector('#hm-skills-config-save')?.addEventListener('click', saveSkillsConfig) el.querySelector('#hm-quick-commands-save')?.addEventListener('click', saveQuickCommandsConfig) el.querySelector('#hm-unauthorized-dm-save')?.addEventListener('click', saveUnauthorizedDmConfig) + el.querySelector('#hm-security-save')?.addEventListener('click', saveSecurityConfig) el.querySelector('#hm-streaming-save')?.addEventListener('click', saveStreaming) el.querySelector('#hm-execution-limits-save')?.addEventListener('click', saveExecutionLimits) el.querySelector('#hm-terminal-save')?.addEventListener('click', saveTerminal) @@ -759,6 +814,11 @@ export function render() { unauthorizedDmValues = { ...UNAUTHORIZED_DM_DEFAULTS, ...(data?.values || {}) } } + async function loadSecurityConfig() { + const data = await api.hermesSecurityConfigRead() + securityValues = { ...SECURITY_DEFAULTS, ...(data?.values || {}) } + } + async function loadStreaming() { const data = await api.hermesStreamingConfigRead() streamingValues = { ...STREAMING_DEFAULTS, ...(data?.values || {}) } @@ -783,6 +843,7 @@ export function render() { skillsLoading = true quickCommandsLoading = true unauthorizedDmLoading = true + securityLoading = true streamingLoading = true executionLimitsLoading = true terminalLoading = true @@ -794,6 +855,7 @@ export function render() { skillsError = null quickCommandsError = null unauthorizedDmError = null + securityError = null streamingError = null executionLimitsError = null terminalError = null @@ -885,6 +947,14 @@ export function render() { unauthorizedDmLoading = false draw() } + try { + await loadSecurityConfig() + } catch (err) { + securityError = humanizeError(err, t('engine.hermesSecurityConfigLoadFailed') || 'Load security config failed') + } finally { + securityLoading = false + draw() + } } async function refreshRawAfterStructuredSave() { @@ -927,6 +997,9 @@ export function render() { try { await loadUnauthorizedDmConfig() } catch {} + try { + await loadSecurityConfig() + } catch {} try { await loadStreaming() } catch {} @@ -1142,6 +1215,34 @@ export function render() { } } + async function saveSecurityConfig() { + const form = { + tirithEnabled: !!el.querySelector('#hm-security-tirith-enabled')?.checked, + tirithPath: el.querySelector('#hm-security-tirith-path')?.value || 'tirith', + tirithTimeout: el.querySelector('#hm-security-tirith-timeout')?.value || '5', + tirithFailOpen: !!el.querySelector('#hm-security-tirith-fail-open')?.checked, + } + securitySaving = true + securityError = null + draw() + try { + const result = await api.hermesSecurityConfigSave(form) + securityValues = { ...SECURITY_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesSecurityConfigSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + securityError = humanizeError(err, t('engine.hermesSecurityConfigSaveFailed') || 'Save security config failed') + toast(securityError, 'error') + } finally { + securitySaving = false + draw() + } + } + async function saveStreaming() { const form = { enabled: !!el.querySelector('#hm-streaming-enabled')?.checked, diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 56321bc..4cdca44 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -523,6 +523,8 @@ export const api = { hermesQuickCommandsConfigSave: (form) => invoke('hermes_quick_commands_config_save', { form }), hermesUnauthorizedDmConfigRead: () => invoke('hermes_unauthorized_dm_config_read'), hermesUnauthorizedDmConfigSave: (form) => invoke('hermes_unauthorized_dm_config_save', { form }), + hermesSecurityConfigRead: () => invoke('hermes_security_config_read'), + hermesSecurityConfigSave: (form) => invoke('hermes_security_config_save', { form }), hermesStreamingConfigRead: () => invoke('hermes_streaming_config_read'), hermesStreamingConfigSave: (form) => invoke('hermes_streaming_config_save', { form }), hermesExecutionLimitsConfigRead: () => invoke('hermes_execution_limits_config_read'), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index df1ec83..043a839 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -640,6 +640,18 @@ export default { hermesUnauthorizedDmConfigBehavior_pair: _('回复配对码', 'Reply with pairing code', '回覆配對碼'), hermesUnauthorizedDmConfigBehavior_ignore: _('静默忽略', 'Silently ignore', '靜默忽略'), hermesUnauthorizedDmConfigFootnote: _('pair 是默认值,会拒绝访问但在私信中回复一次性配对码;ignore 会静默丢弃陌生私信。平台级覆盖仍可在渠道配置或 raw YAML 中单独设置。', 'pair is the default: Hermes denies access but replies with a one-time pairing code in DMs. ignore silently drops unknown DMs. Platform-level overrides can still be set in channel settings or raw YAML.', 'pair 是預設值,會拒絕存取但在私訊中回覆一次性配對碼;ignore 會靜默丟棄陌生私訊。平台級覆蓋仍可在頻道設定或 raw YAML 中單獨設定。'), + hermesSecurityConfigTitle: _('Tirith 安全扫描', 'Tirith security scanning', 'Tirith 安全掃描'), + hermesSecurityConfigDesc: _('控制终端命令执行前的 Tirith 内容扫描,拦截明显的 URL 伪装、管道执行和注入风险。', 'Control Tirith content scanning before terminal commands run to catch obvious URL spoofing, pipe-to-shell, and injection risks.', '控制終端命令執行前的 Tirith 內容掃描,攔截明顯的 URL 偽裝、管道執行和注入風險。'), + hermesSecurityConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), + hermesSecurityConfigSave: _('保存安全设置', 'Save security settings', '儲存安全設定'), + hermesSecurityConfigSaveSuccess: _('Tirith 安全扫描配置已保存,建议重启 Hermes Gateway 生效', 'Tirith security settings saved. Restart Hermes Gateway to take effect.', 'Tirith 安全掃描設定已儲存,建議重啟 Hermes Gateway 生效'), + hermesSecurityConfigLoadFailed: _('加载 Tirith 安全扫描配置失败', 'Load Tirith security settings failed', '載入 Tirith 安全掃描設定失敗'), + hermesSecurityConfigSaveFailed: _('保存 Tirith 安全扫描配置失败', 'Save Tirith security settings failed', '儲存 Tirith 安全掃描設定失敗'), + hermesSecurityConfigTirithEnabled: _('启用 Tirith 扫描', 'Enable Tirith scanning', '啟用 Tirith 掃描'), + hermesSecurityConfigTirithPath: _('Tirith 可执行文件路径', 'Tirith executable path', 'Tirith 可執行檔路徑'), + hermesSecurityConfigTirithTimeout: _('扫描超时(秒)', 'Scan timeout (s)', '掃描逾時(秒)'), + hermesSecurityConfigTirithFailOpen: _('Tirith 不可用时放行', 'Allow when Tirith is unavailable', 'Tirith 不可用時放行'), + hermesSecurityConfigFootnote: _('启用后,Hermes 会在终端命令执行前调用 Tirith 进行内容级安全扫描;Tirith 不可用时是否放行取决于 fail-open。Windows 平台通常会静默跳过 Tirith,真实执行能力仍受宿主环境影响。', 'When enabled, Hermes runs Tirith before terminal commands for content-level scanning. Whether commands pass when Tirith is unavailable depends on fail-open. On Windows, Tirith is usually skipped silently, and actual execution still depends on the host environment.', '啟用後,Hermes 會在終端命令執行前呼叫 Tirith 進行內容級安全掃描;Tirith 不可用時是否放行取決於 fail-open。Windows 平台通常會靜默跳過 Tirith,真實執行能力仍受主機環境影響。'), // Batch 1 §E: 会话导出 sessionsExport: _('导出', 'Export', '匯出'), sessionsExportSuccess: _('已导出', 'Exported', '已匯出'), diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index f99597a..4ced529 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -67,6 +67,18 @@ test('Hermes 配置页会暴露未授权 DM 全局策略字段', () => { } }) +test('Hermes 配置页会暴露 Tirith 安全扫描结构化配置字段', () => { + for (const id of [ + 'hm-security-save', + 'hm-security-tirith-enabled', + 'hm-security-tirith-path', + 'hm-security-tirith-timeout', + 'hm-security-tirith-fail-open', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + test('Hermes 配置页会暴露网关流式结构化配置字段', () => { for (const id of [ 'hm-streaming-save', @@ -128,6 +140,7 @@ test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => { key.includes('SkillsConfig') || key.includes('QuickCommandsConfig') || key.includes('UnauthorizedDmConfig') || + key.includes('SecurityConfig') || key.includes('StreamingConfig') || key.includes('ExecutionLimits') || key.includes('TerminalConfig') diff --git a/tests/hermes-security-config.test.js b/tests/hermes-security-config.test.js new file mode 100644 index 0000000..2679e57 --- /dev/null +++ b/tests/hermes-security-config.test.js @@ -0,0 +1,72 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesSecurityConfigValues, + mergeHermesSecurityConfig, +} from '../scripts/dev-api.js' + +test('Hermes 安全扫描配置读取会提供 Tirith 默认值', () => { + const values = buildHermesSecurityConfigValues({}) + + assert.deepEqual(values, { + tirithEnabled: true, + tirithPath: 'tirith', + tirithTimeout: 5, + tirithFailOpen: true, + }) +}) + +test('Hermes 安全扫描配置读取会规范化已有值', () => { + const values = buildHermesSecurityConfigValues({ + security: { + tirith_enabled: false, + tirith_path: 'C:/tools/tirith.exe', + tirith_timeout: 12, + tirith_fail_open: false, + }, + }) + + assert.equal(values.tirithEnabled, false) + assert.equal(values.tirithPath, 'C:/tools/tirith.exe') + assert.equal(values.tirithTimeout, 12) + assert.equal(values.tirithFailOpen, false) +}) + +test('Hermes 安全扫描配置保存会保留未知字段并写入 security.tirith', () => { + const next = mergeHermesSecurityConfig({ + model: { provider: 'anthropic' }, + security: { + allow_private_urls: false, + website_blocklist: { enabled: true, domains: ['example.com'] }, + custom_flag: 'keep-security', + }, + terminal: { backend: 'docker' }, + }, { + tirithEnabled: false, + tirithPath: '~/bin/tirith', + tirithTimeout: '9', + tirithFailOpen: false, + }) + + assert.deepEqual(next.model, { provider: 'anthropic' }) + assert.deepEqual(next.terminal, { backend: 'docker' }) + assert.equal(next.security.allow_private_urls, false) + assert.deepEqual(next.security.website_blocklist, { enabled: true, domains: ['example.com'] }) + assert.equal(next.security.custom_flag, 'keep-security') + assert.equal(next.security.tirith_enabled, false) + assert.equal(next.security.tirith_path, '~/bin/tirith') + assert.equal(next.security.tirith_timeout, 9) + assert.equal(next.security.tirith_fail_open, false) +}) + +test('Hermes 安全扫描配置保存会拒绝非法超时和空路径', () => { + assert.throws( + () => mergeHermesSecurityConfig({}, { tirithTimeout: '0' }), + /security\.tirith_timeout/, + ) + assert.throws( + () => mergeHermesSecurityConfig({}, { tirithPath: '' }), + /security\.tirith_path/, + ) +})