feat(hermes): add tirith security settings

This commit is contained in:
晴天
2026-05-24 22:08:12 +08:00
parent 7be0ec66cc
commit b2146b54cb
8 changed files with 456 additions and 3 deletions

View File

@@ -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 {

View File

@@ -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<String>,
strict: bool,
@@ -5447,6 +5526,30 @@ pub fn hermes_unauthorized_dm_config_save(form: Value) -> Result<Value, String>
}))
}
#[tauri::command]
pub fn hermes_security_config_read() -> Result<Value, String> {
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<Value, String> {
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<Value, String> {
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::{

View File

@@ -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,

View File

@@ -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 `
<div class="hm-panel hm-config-runtime-panel hm-config-unauthorized-dm-panel">
<div class="hm-panel-header">
@@ -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 `
<div class="hm-panel hm-config-runtime-panel hm-config-security-panel">
<div class="hm-panel-header">
<div>
<div class="hm-panel-title">${t('engine.hermesSecurityConfigTitle')}</div>
<div class="hm-channel-panel-desc">${t('engine.hermesSecurityConfigDesc')}</div>
</div>
<div class="hm-panel-actions">
<span class="hm-muted">${securitySaving ? t('engine.hermesConfigStatusSaving') : securityLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesSecurityConfigStatusReady')}</span>
<button class="hm-btn hm-btn--cta hm-btn--sm" id="hm-security-save" ${disabled ? 'disabled' : ''}>${t('engine.hermesSecurityConfigSave')}</button>
</div>
</div>
<div class="hm-panel-body">
${renderError(securityError)}
<div class="hm-config-check-grid">
<label class="hm-channel-check">
<input id="hm-security-tirith-enabled" type="checkbox" ${securityValues.tirithEnabled ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${t('engine.hermesSecurityConfigTirithEnabled')}</span>
</label>
<label class="hm-channel-check hm-channel-check--danger">
<input id="hm-security-tirith-fail-open" type="checkbox" ${securityValues.tirithFailOpen ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${t('engine.hermesSecurityConfigTirithFailOpen')}</span>
</label>
</div>
<div class="hm-config-runtime-grid hm-config-security-grid">
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesSecurityConfigTirithPath')}</span>
<input id="hm-security-tirith-path" class="hm-input" value="${esc(securityValues.tirithPath)}" ${disabled ? 'disabled' : ''}>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesSecurityConfigTirithTimeout')}</span>
<input id="hm-security-tirith-timeout" class="hm-input" type="number" inputmode="numeric" min="1" max="300" step="1" value="${esc(securityValues.tirithTimeout)}" ${disabled ? 'disabled' : ''}>
</label>
</div>
<div class="hm-channel-footnote">${t('engine.hermesSecurityConfigFootnote')}</div>
</div>
</div>
`
}
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 `
<div class="hm-panel hm-config-runtime-panel hm-config-streaming-panel">
<div class="hm-panel-header">
@@ -688,6 +741,7 @@ export function render() {
${renderSkillsConfigPanel()}
${renderQuickCommandsConfigPanel()}
${renderUnauthorizedDmConfigPanel()}
${renderSecurityConfigPanel()}
<div class="hm-panel">
<div class="hm-panel-header">
@@ -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,

View File

@@ -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'),

View File

@@ -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', '已匯出'),

View File

@@ -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')

View File

@@ -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/,
)
})