From 26d6ad18bc3689ba556f114ec4f0e752d4a9f668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sun, 24 May 2026 23:54:46 +0800 Subject: [PATCH] feat(hermes): add privacy redaction setting --- scripts/dev-api.js | 42 +++++++++++ src-tauri/src/commands/hermes.rs | 105 ++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 + src/engines/hermes/pages/config.js | 83 +++++++++++++++++++++- src/lib/tauri-api.js | 2 + src/locales/modules/engine.js | 9 +++ tests/hermes-config-page-ui.test.js | 10 +++ tests/hermes-privacy-config.test.js | 43 ++++++++++++ 8 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 tests/hermes-privacy-config.test.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 31e924e..c20d256 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3922,6 +3922,27 @@ export function mergeHermesIoSafetyConfig(config = {}, form = {}) { return next } +export function buildHermesPrivacyConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const privacy = root.privacy && typeof root.privacy === 'object' && !Array.isArray(root.privacy) + ? root.privacy + : {} + return { + redactPii: readHermesBool(privacy.redact_pii, false), + } +} + +export function mergeHermesPrivacyConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesPrivacyConfigValues(next) + const privacy = next.privacy && typeof next.privacy === 'object' && !Array.isArray(next.privacy) + ? mergeConfigsPreservingFields(next.privacy, {}) + : {} + privacy.redact_pii = formHermesBool(form, 'redactPii', currentValues.redactPii) + next.privacy = privacy + return next +} + export function buildHermesTerminalConfigValues(config = {}) { const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} const terminal = root.terminal && typeof root.terminal === 'object' && !Array.isArray(root.terminal) @@ -10524,6 +10545,27 @@ const handlers = { } }, + hermes_privacy_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesPrivacyConfigValues(config), + } + }, + + hermes_privacy_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesPrivacyConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesPrivacyConfigValues(next), + } + }, + hermes_terminal_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 64a4ca4..22f8223 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -4667,6 +4667,29 @@ fn merge_hermes_io_safety_config( Ok(()) } +fn build_hermes_privacy_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let privacy = root.and_then(|map| yaml_get_mapping(map, "privacy")); + let redact_pii = privacy + .and_then(|map| yaml_bool_field(map, "redact_pii")) + .unwrap_or(false); + + serde_json::json!({ + "redactPii": redact_pii, + }) +} + +fn merge_hermes_privacy_config(config: &mut serde_yaml::Value, form: &Value) -> Result<(), String> { + let current = build_hermes_privacy_config_values(config); + let redact_pii = form_bool(form, "redactPii") + .unwrap_or_else(|| current["redactPii"].as_bool().unwrap_or(false)); + + let root = ensure_yaml_object(config)?; + let privacy = yaml_child_object(root, "privacy")?; + privacy.insert(yaml_key("redact_pii"), serde_yaml::Value::Bool(redact_pii)); + Ok(()) +} + fn merge_hermes_execution_limits_config( config: &mut serde_yaml::Value, form: &Value, @@ -6114,6 +6137,30 @@ pub fn hermes_io_safety_config_save(form: Value) -> Result { })) } +#[tauri::command] +pub fn hermes_privacy_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_privacy_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_privacy_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_privacy_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_privacy_config_values(&config), + })) +} + #[tauri::command] pub fn hermes_terminal_config_read() -> Result { let (config_path, exists, config) = read_hermes_channel_yaml_config()?; @@ -11725,6 +11772,64 @@ streaming: } } +#[cfg(test)] +mod hermes_privacy_config_tests { + use super::{build_hermes_privacy_config_values, merge_hermes_privacy_config}; + use serde_json::json; + + #[test] + fn privacy_values_have_upstream_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_privacy_config_values(&config); + assert_eq!(values["redactPii"], false); + } + + #[test] + fn privacy_values_read_yaml_fields() { + let config: serde_yaml::Value = serde_yaml::from_str( + r#" +privacy: + redact_pii: true +"#, + ) + .unwrap(); + let values = build_hermes_privacy_config_values(&config); + assert_eq!(values["redactPii"], true); + } + + #[test] + fn merge_privacy_config_preserves_unknown_fields() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: anthropic +privacy: + redact_pii: false + custom_flag: keep-privacy +streaming: + enabled: true +"#, + ) + .unwrap(); + + merge_hermes_privacy_config( + &mut config, + &json!({ + "redactPii": true, + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("anthropic")); + assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true)); + assert_eq!(config["privacy"]["redact_pii"].as_bool(), Some(true)); + assert_eq!( + config["privacy"]["custom_flag"].as_str(), + Some("keep-privacy") + ); + } +} + #[cfg(test)] mod hermes_terminal_config_tests { use super::{build_hermes_terminal_config_values, merge_hermes_terminal_config}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 420ddf9..1e64af5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -283,6 +283,8 @@ pub fn run() { hermes::hermes_execution_limits_config_save, hermes::hermes_io_safety_config_read, hermes::hermes_io_safety_config_save, + hermes::hermes_privacy_config_read, + hermes::hermes_privacy_config_save, hermes::hermes_terminal_config_read, hermes::hermes_terminal_config_save, hermes::hermes_lazy_deps_features, diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index 0fcd527..cc97eb2 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -109,6 +109,10 @@ const IO_SAFETY_DEFAULTS = { toolOutputMaxLineLength: 2000, } +const PRIVACY_DEFAULTS = { + redactPii: false, +} + const TERMINAL_DEFAULTS = { terminalBackend: 'local', terminalCwd: '.', @@ -150,6 +154,7 @@ export function render() { let streamingValues = { ...STREAMING_DEFAULTS } let executionLimitsValues = { ...EXECUTION_LIMITS_DEFAULTS } let ioSafetyValues = { ...IO_SAFETY_DEFAULTS } + let privacyValues = { ...PRIVACY_DEFAULTS } let terminalValues = { ...TERMINAL_DEFAULTS } let loading = true let runtimeLoading = true @@ -165,6 +170,7 @@ export function render() { let streamingLoading = true let executionLimitsLoading = true let ioSafetyLoading = true + let privacyLoading = true let terminalLoading = true let saving = false let runtimeSaving = false @@ -180,6 +186,7 @@ export function render() { let streamingSaving = false let executionLimitsSaving = false let ioSafetySaving = false + let privacySaving = false let terminalSaving = false let error = null let runtimeError = null @@ -195,6 +202,7 @@ export function render() { let streamingError = null let executionLimitsError = null let ioSafetyError = null + let privacyError = null let terminalError = null function esc(value) { @@ -206,7 +214,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || terminalLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || ioSafetySaving || terminalSaving + return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || privacyLoading || terminalLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || ioSafetySaving || privacySaving || terminalSaving } function option(labelKey, value, selected) { @@ -828,6 +836,34 @@ export function render() { ` } + function renderPrivacyPanel() { + const disabled = loading || saving || privacyLoading || privacySaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving + return ` +
+
+
+
${t('engine.hermesPrivacyConfigTitle')}
+
${t('engine.hermesPrivacyConfigDesc')}
+
+
+ ${privacySaving ? t('engine.hermesConfigStatusSaving') : privacyLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesPrivacyConfigStatusReady')} + +
+
+
+ ${renderError(privacyError)} +
+ +
+
${t('engine.hermesPrivacyConfigFootnote')}
+
+
+ ` + } + function renderTerminalPanel() { const disabled = loading || saving || terminalLoading || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving return ` @@ -918,6 +954,7 @@ export function render() { ${renderStreamingPanel()} ${renderExecutionLimitsPanel()} ${renderIoSafetyPanel()} + ${renderPrivacyPanel()} ${renderCompressionPanel()} ${renderToolGuardrailsPanel()} ${renderMemoryPanel()} @@ -959,6 +996,7 @@ export function render() { el.querySelector('#hm-streaming-save')?.addEventListener('click', saveStreaming) el.querySelector('#hm-execution-limits-save')?.addEventListener('click', saveExecutionLimits) el.querySelector('#hm-io-safety-save')?.addEventListener('click', saveIoSafety) + el.querySelector('#hm-privacy-save')?.addEventListener('click', savePrivacyConfig) el.querySelector('#hm-terminal-save')?.addEventListener('click', saveTerminal) } @@ -1032,6 +1070,11 @@ export function render() { ioSafetyValues = { ...IO_SAFETY_DEFAULTS, ...(data?.values || {}) } } + async function loadPrivacyConfig() { + const data = await api.hermesPrivacyConfigRead() + privacyValues = { ...PRIVACY_DEFAULTS, ...(data?.values || {}) } + } + async function loadTerminal() { const data = await api.hermesTerminalConfigRead() terminalValues = { ...TERMINAL_DEFAULTS, ...(data?.values || {}) } @@ -1052,6 +1095,7 @@ export function render() { streamingLoading = true executionLimitsLoading = true ioSafetyLoading = true + privacyLoading = true terminalLoading = true error = null runtimeError = null @@ -1067,6 +1111,7 @@ export function render() { streamingError = null executionLimitsError = null ioSafetyError = null + privacyError = null terminalError = null draw() try { @@ -1124,6 +1169,14 @@ export function render() { ioSafetyLoading = false draw() } + try { + await loadPrivacyConfig() + } catch (err) { + privacyError = humanizeError(err, t('engine.hermesPrivacyConfigLoadFailed') || 'Load privacy config failed') + } finally { + privacyLoading = false + draw() + } try { await loadTerminal() } catch (err) { @@ -1248,6 +1301,9 @@ export function render() { try { await loadIoSafety() } catch {} + try { + await loadPrivacyConfig() + } catch {} try { await loadTerminal() } catch {} @@ -1636,6 +1692,31 @@ export function render() { } } + async function savePrivacyConfig() { + const form = { + redactPii: !!el.querySelector('#hm-privacy-redact-pii')?.checked, + } + privacySaving = true + privacyError = null + draw() + try { + const result = await api.hermesPrivacyConfigSave(form) + privacyValues = { ...PRIVACY_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesPrivacyConfigSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + privacyError = humanizeError(err, t('engine.hermesPrivacyConfigSaveFailed') || 'Save privacy config failed') + toast(privacyError, 'error') + } finally { + privacySaving = false + draw() + } + } + async function saveTerminal() { const form = { terminalBackend: el.querySelector('#hm-terminal-backend')?.value || 'local', diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 794ae4f..afb9a03 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -535,6 +535,8 @@ export const api = { hermesExecutionLimitsConfigSave: (form) => invoke('hermes_execution_limits_config_save', { form }), hermesIoSafetyConfigRead: () => invoke('hermes_io_safety_config_read'), hermesIoSafetyConfigSave: (form) => invoke('hermes_io_safety_config_save', { form }), + hermesPrivacyConfigRead: () => invoke('hermes_privacy_config_read'), + hermesPrivacyConfigSave: (form) => invoke('hermes_privacy_config_save', { form }), hermesTerminalConfigRead: () => invoke('hermes_terminal_config_read'), hermesTerminalConfigSave: (form) => invoke('hermes_terminal_config_save', { form }), hermesLazyDepsFeatures: () => cachedInvoke('hermes_lazy_deps_features', {}, 600000), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 7ea69ac..b656bc7 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -576,6 +576,15 @@ export default { hermesIoSafetyToolOutputMaxLines: _('文件分页最大行数', 'File page line cap', '檔案分頁最大行數'), hermesIoSafetyToolOutputMaxLineLength: _('单行显示字符上限', 'Per-line character cap', '單行顯示字元上限'), hermesIoSafetyFootnote: _('默认值适合大多数模型;小上下文模型可降低这些上限。其他 tool_output 高级字段会保留在 raw YAML 中。', 'Defaults fit most models. Lower these limits for small-context models. Other advanced tool_output fields are preserved in raw YAML.', '預設值適合多數模型;小上下文模型可降低這些上限。其他 tool_output 進階欄位會保留在 raw YAML 中。'), + hermesPrivacyConfigTitle: _('隐私脱敏', 'Privacy redaction', '隱私脫敏'), + hermesPrivacyConfigDesc: _('对支持的 Gateway 渠道在送入模型前脱敏用户 ID、手机号和会话标识,降低公网渠道泄露风险。', 'Redact user IDs, phone numbers, and chat identifiers before supported Gateway channels send context to the model, reducing public-channel exposure risk.', '對支援的 Gateway 渠道在送入模型前脫敏使用者 ID、電話號碼和會話識別,降低公開渠道外洩風險。'), + hermesPrivacyConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), + hermesPrivacyConfigSave: _('保存隐私配置', 'Save privacy settings', '儲存隱私設定'), + hermesPrivacyConfigSaveSuccess: _('隐私脱敏配置已保存,建议重启 Hermes Gateway 生效', 'Privacy redaction settings saved. Restart Hermes Gateway to take effect.', '隱私脫敏設定已儲存,建議重啟 Hermes Gateway 生效'), + hermesPrivacyConfigLoadFailed: _('加载隐私脱敏配置失败', 'Load privacy redaction failed', '載入隱私脫敏設定失敗'), + hermesPrivacyConfigSaveFailed: _('保存隐私脱敏配置失败', 'Save privacy redaction failed', '儲存隱私脫敏設定失敗'), + hermesPrivacyConfigRedactPii: _('送入模型前脱敏用户标识和手机号', 'Redact user identifiers and phone numbers before model context', '送入模型前脫敏使用者識別和電話號碼'), + hermesPrivacyConfigFootnote: _('目前主要作用于 WhatsApp、Signal 和 Telegram;Discord 与 Slack 为保持 mention 语义不会脱敏真实用户 ID。其他 privacy 高级字段会保留在 raw YAML 中。', 'Currently applies mainly to WhatsApp, Signal, and Telegram. Discord and Slack keep real user IDs to preserve mention semantics. Other advanced privacy fields are preserved in raw YAML.', '目前主要作用於 WhatsApp、Signal 和 Telegram;Discord 與 Slack 為保持 mention 語義不會脫敏真實使用者 ID。其他 privacy 進階欄位會保留在 raw YAML 中。'), hermesCompressionTitle: _('上下文压缩', 'Context compression', '上下文壓縮'), hermesCompressionDesc: _('控制长对话何时触发压缩、压缩目标和保留范围,降低上下文过长导致的失败与费用浪费。', 'Control when long conversations are compressed, the target size, and protected message ranges to reduce failures and wasted cost from oversized context.', '控制長對話何時觸發壓縮、壓縮目標和保留範圍,降低上下文過長導致的失敗與費用浪費。'), hermesCompressionStatusReady: _('结构化配置', 'structured settings', '結構化設定'), diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index a5fb81f..2f20110 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -150,6 +150,15 @@ test('Hermes 配置页会暴露输入输出保护结构化配置字段', () => { } }) +test('Hermes 配置页会暴露隐私脱敏结构化配置字段', () => { + for (const id of [ + 'hm-privacy-save', + 'hm-privacy-redact-pii', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + test('Hermes 配置页会暴露终端执行结构化配置字段', () => { for (const id of [ 'hm-terminal-save', @@ -184,6 +193,7 @@ test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => { key.includes('DisplayConfig') || key.includes('StreamingConfig') || key.includes('ExecutionLimits') || + key.includes('PrivacyConfig') || key.includes('TerminalConfig') ))) diff --git a/tests/hermes-privacy-config.test.js b/tests/hermes-privacy-config.test.js new file mode 100644 index 0000000..b70f8f2 --- /dev/null +++ b/tests/hermes-privacy-config.test.js @@ -0,0 +1,43 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesPrivacyConfigValues, + mergeHermesPrivacyConfig, +} from '../scripts/dev-api.js' + +test('Hermes 隐私配置读取会提供上游默认值', () => { + const values = buildHermesPrivacyConfigValues({}) + + assert.deepEqual(values, { + redactPii: false, + }) +}) + +test('Hermes 隐私配置读取会回显 YAML 字段', () => { + const values = buildHermesPrivacyConfigValues({ + privacy: { + redact_pii: true, + }, + }) + + assert.equal(values.redactPii, true) +}) + +test('Hermes 隐私配置保存会保留未知字段并写入上游结构', () => { + const next = mergeHermesPrivacyConfig({ + model: { provider: 'anthropic' }, + privacy: { + redact_pii: false, + custom_flag: 'keep-privacy', + }, + streaming: { enabled: true }, + }, { + redactPii: true, + }) + + assert.deepEqual(next.model, { provider: 'anthropic' }) + assert.deepEqual(next.streaming, { enabled: true }) + assert.equal(next.privacy.redact_pii, true) + assert.equal(next.privacy.custom_flag, 'keep-privacy') +})