mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 04:10:00 +08:00
feat(hermes): add tirith security settings
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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', '已匯出'),
|
||||
|
||||
@@ -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')
|
||||
|
||||
72
tests/hermes-security-config.test.js
Normal file
72
tests/hermes-security-config.test.js
Normal 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/,
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user