mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-07-12 16:01:56 +08:00
feat(hermes): add tirith security settings
This commit is contained in:
@@ -3699,6 +3699,38 @@ export function mergeHermesUnauthorizedDmConfig(config = {}, form = {}) {
|
|||||||
return next
|
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 = {}) {
|
export function buildHermesStreamingConfigValues(config = {}) {
|
||||||
const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {}
|
const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {}
|
||||||
const streaming = hermesStreamingConfigSource(root)
|
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() {
|
hermes_streaming_config_read() {
|
||||||
const { configPath, exists, config } = readHermesConfigYamlObject()
|
const { configPath, exists, config } = readHermesConfigYamlObject()
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3905,6 +3905,85 @@ fn merge_hermes_unauthorized_dm_config(
|
|||||||
Ok(())
|
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(
|
fn normalize_hermes_streaming_transport(
|
||||||
value: Option<String>,
|
value: Option<String>,
|
||||||
strict: bool,
|
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]
|
#[tauri::command]
|
||||||
pub fn hermes_streaming_config_read() -> Result<Value, String> {
|
pub fn hermes_streaming_config_read() -> Result<Value, String> {
|
||||||
let (config_path, exists, config) = read_hermes_channel_yaml_config()?;
|
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)]
|
#[cfg(test)]
|
||||||
mod hermes_channel_tests {
|
mod hermes_channel_tests {
|
||||||
use super::{
|
use super::{
|
||||||
|
|||||||
@@ -271,6 +271,8 @@ pub fn run() {
|
|||||||
hermes::hermes_quick_commands_config_save,
|
hermes::hermes_quick_commands_config_save,
|
||||||
hermes::hermes_unauthorized_dm_config_read,
|
hermes::hermes_unauthorized_dm_config_read,
|
||||||
hermes::hermes_unauthorized_dm_config_save,
|
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_read,
|
||||||
hermes::hermes_streaming_config_save,
|
hermes::hermes_streaming_config_save,
|
||||||
hermes::hermes_execution_limits_config_read,
|
hermes::hermes_execution_limits_config_read,
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ const UNAUTHORIZED_DM_DEFAULTS = {
|
|||||||
unauthorizedDmBehavior: 'pair',
|
unauthorizedDmBehavior: 'pair',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SECURITY_DEFAULTS = {
|
||||||
|
tirithEnabled: true,
|
||||||
|
tirithPath: 'tirith',
|
||||||
|
tirithTimeout: 5,
|
||||||
|
tirithFailOpen: true,
|
||||||
|
}
|
||||||
|
|
||||||
const STREAMING_DEFAULTS = {
|
const STREAMING_DEFAULTS = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
transport: 'edit',
|
transport: 'edit',
|
||||||
@@ -109,6 +116,7 @@ export function render() {
|
|||||||
let skillsValues = { ...SKILLS_DEFAULTS }
|
let skillsValues = { ...SKILLS_DEFAULTS }
|
||||||
let quickCommandsValues = { ...QUICK_COMMANDS_DEFAULTS }
|
let quickCommandsValues = { ...QUICK_COMMANDS_DEFAULTS }
|
||||||
let unauthorizedDmValues = { ...UNAUTHORIZED_DM_DEFAULTS }
|
let unauthorizedDmValues = { ...UNAUTHORIZED_DM_DEFAULTS }
|
||||||
|
let securityValues = { ...SECURITY_DEFAULTS }
|
||||||
let streamingValues = { ...STREAMING_DEFAULTS }
|
let streamingValues = { ...STREAMING_DEFAULTS }
|
||||||
let executionLimitsValues = { ...EXECUTION_LIMITS_DEFAULTS }
|
let executionLimitsValues = { ...EXECUTION_LIMITS_DEFAULTS }
|
||||||
let terminalValues = { ...TERMINAL_DEFAULTS }
|
let terminalValues = { ...TERMINAL_DEFAULTS }
|
||||||
@@ -120,6 +128,7 @@ export function render() {
|
|||||||
let skillsLoading = true
|
let skillsLoading = true
|
||||||
let quickCommandsLoading = true
|
let quickCommandsLoading = true
|
||||||
let unauthorizedDmLoading = true
|
let unauthorizedDmLoading = true
|
||||||
|
let securityLoading = true
|
||||||
let streamingLoading = true
|
let streamingLoading = true
|
||||||
let executionLimitsLoading = true
|
let executionLimitsLoading = true
|
||||||
let terminalLoading = true
|
let terminalLoading = true
|
||||||
@@ -131,6 +140,7 @@ export function render() {
|
|||||||
let skillsSaving = false
|
let skillsSaving = false
|
||||||
let quickCommandsSaving = false
|
let quickCommandsSaving = false
|
||||||
let unauthorizedDmSaving = false
|
let unauthorizedDmSaving = false
|
||||||
|
let securitySaving = false
|
||||||
let streamingSaving = false
|
let streamingSaving = false
|
||||||
let executionLimitsSaving = false
|
let executionLimitsSaving = false
|
||||||
let terminalSaving = false
|
let terminalSaving = false
|
||||||
@@ -142,6 +152,7 @@ export function render() {
|
|||||||
let skillsError = null
|
let skillsError = null
|
||||||
let quickCommandsError = null
|
let quickCommandsError = null
|
||||||
let unauthorizedDmError = null
|
let unauthorizedDmError = null
|
||||||
|
let securityError = null
|
||||||
let streamingError = null
|
let streamingError = null
|
||||||
let executionLimitsError = null
|
let executionLimitsError = null
|
||||||
let terminalError = null
|
let terminalError = null
|
||||||
@@ -155,7 +166,7 @@ export function render() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isBusy() {
|
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) {
|
function option(labelKey, value, selected) {
|
||||||
@@ -440,7 +451,7 @@ export function render() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderUnauthorizedDmConfigPanel() {
|
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 `
|
return `
|
||||||
<div class="hm-panel hm-config-runtime-panel hm-config-unauthorized-dm-panel">
|
<div class="hm-panel hm-config-runtime-panel hm-config-unauthorized-dm-panel">
|
||||||
<div class="hm-panel-header">
|
<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() {
|
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 `
|
return `
|
||||||
<div class="hm-panel hm-config-runtime-panel hm-config-streaming-panel">
|
<div class="hm-panel hm-config-runtime-panel hm-config-streaming-panel">
|
||||||
<div class="hm-panel-header">
|
<div class="hm-panel-header">
|
||||||
@@ -688,6 +741,7 @@ export function render() {
|
|||||||
${renderSkillsConfigPanel()}
|
${renderSkillsConfigPanel()}
|
||||||
${renderQuickCommandsConfigPanel()}
|
${renderQuickCommandsConfigPanel()}
|
||||||
${renderUnauthorizedDmConfigPanel()}
|
${renderUnauthorizedDmConfigPanel()}
|
||||||
|
${renderSecurityConfigPanel()}
|
||||||
|
|
||||||
<div class="hm-panel">
|
<div class="hm-panel">
|
||||||
<div class="hm-panel-header">
|
<div class="hm-panel-header">
|
||||||
@@ -714,6 +768,7 @@ export function render() {
|
|||||||
el.querySelector('#hm-skills-config-save')?.addEventListener('click', saveSkillsConfig)
|
el.querySelector('#hm-skills-config-save')?.addEventListener('click', saveSkillsConfig)
|
||||||
el.querySelector('#hm-quick-commands-save')?.addEventListener('click', saveQuickCommandsConfig)
|
el.querySelector('#hm-quick-commands-save')?.addEventListener('click', saveQuickCommandsConfig)
|
||||||
el.querySelector('#hm-unauthorized-dm-save')?.addEventListener('click', saveUnauthorizedDmConfig)
|
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-streaming-save')?.addEventListener('click', saveStreaming)
|
||||||
el.querySelector('#hm-execution-limits-save')?.addEventListener('click', saveExecutionLimits)
|
el.querySelector('#hm-execution-limits-save')?.addEventListener('click', saveExecutionLimits)
|
||||||
el.querySelector('#hm-terminal-save')?.addEventListener('click', saveTerminal)
|
el.querySelector('#hm-terminal-save')?.addEventListener('click', saveTerminal)
|
||||||
@@ -759,6 +814,11 @@ export function render() {
|
|||||||
unauthorizedDmValues = { ...UNAUTHORIZED_DM_DEFAULTS, ...(data?.values || {}) }
|
unauthorizedDmValues = { ...UNAUTHORIZED_DM_DEFAULTS, ...(data?.values || {}) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSecurityConfig() {
|
||||||
|
const data = await api.hermesSecurityConfigRead()
|
||||||
|
securityValues = { ...SECURITY_DEFAULTS, ...(data?.values || {}) }
|
||||||
|
}
|
||||||
|
|
||||||
async function loadStreaming() {
|
async function loadStreaming() {
|
||||||
const data = await api.hermesStreamingConfigRead()
|
const data = await api.hermesStreamingConfigRead()
|
||||||
streamingValues = { ...STREAMING_DEFAULTS, ...(data?.values || {}) }
|
streamingValues = { ...STREAMING_DEFAULTS, ...(data?.values || {}) }
|
||||||
@@ -783,6 +843,7 @@ export function render() {
|
|||||||
skillsLoading = true
|
skillsLoading = true
|
||||||
quickCommandsLoading = true
|
quickCommandsLoading = true
|
||||||
unauthorizedDmLoading = true
|
unauthorizedDmLoading = true
|
||||||
|
securityLoading = true
|
||||||
streamingLoading = true
|
streamingLoading = true
|
||||||
executionLimitsLoading = true
|
executionLimitsLoading = true
|
||||||
terminalLoading = true
|
terminalLoading = true
|
||||||
@@ -794,6 +855,7 @@ export function render() {
|
|||||||
skillsError = null
|
skillsError = null
|
||||||
quickCommandsError = null
|
quickCommandsError = null
|
||||||
unauthorizedDmError = null
|
unauthorizedDmError = null
|
||||||
|
securityError = null
|
||||||
streamingError = null
|
streamingError = null
|
||||||
executionLimitsError = null
|
executionLimitsError = null
|
||||||
terminalError = null
|
terminalError = null
|
||||||
@@ -885,6 +947,14 @@ export function render() {
|
|||||||
unauthorizedDmLoading = false
|
unauthorizedDmLoading = false
|
||||||
draw()
|
draw()
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await loadSecurityConfig()
|
||||||
|
} catch (err) {
|
||||||
|
securityError = humanizeError(err, t('engine.hermesSecurityConfigLoadFailed') || 'Load security config failed')
|
||||||
|
} finally {
|
||||||
|
securityLoading = false
|
||||||
|
draw()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRawAfterStructuredSave() {
|
async function refreshRawAfterStructuredSave() {
|
||||||
@@ -927,6 +997,9 @@ export function render() {
|
|||||||
try {
|
try {
|
||||||
await loadUnauthorizedDmConfig()
|
await loadUnauthorizedDmConfig()
|
||||||
} catch {}
|
} catch {}
|
||||||
|
try {
|
||||||
|
await loadSecurityConfig()
|
||||||
|
} catch {}
|
||||||
try {
|
try {
|
||||||
await loadStreaming()
|
await loadStreaming()
|
||||||
} catch {}
|
} 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() {
|
async function saveStreaming() {
|
||||||
const form = {
|
const form = {
|
||||||
enabled: !!el.querySelector('#hm-streaming-enabled')?.checked,
|
enabled: !!el.querySelector('#hm-streaming-enabled')?.checked,
|
||||||
|
|||||||
@@ -523,6 +523,8 @@ export const api = {
|
|||||||
hermesQuickCommandsConfigSave: (form) => invoke('hermes_quick_commands_config_save', { form }),
|
hermesQuickCommandsConfigSave: (form) => invoke('hermes_quick_commands_config_save', { form }),
|
||||||
hermesUnauthorizedDmConfigRead: () => invoke('hermes_unauthorized_dm_config_read'),
|
hermesUnauthorizedDmConfigRead: () => invoke('hermes_unauthorized_dm_config_read'),
|
||||||
hermesUnauthorizedDmConfigSave: (form) => invoke('hermes_unauthorized_dm_config_save', { form }),
|
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'),
|
hermesStreamingConfigRead: () => invoke('hermes_streaming_config_read'),
|
||||||
hermesStreamingConfigSave: (form) => invoke('hermes_streaming_config_save', { form }),
|
hermesStreamingConfigSave: (form) => invoke('hermes_streaming_config_save', { form }),
|
||||||
hermesExecutionLimitsConfigRead: () => invoke('hermes_execution_limits_config_read'),
|
hermesExecutionLimitsConfigRead: () => invoke('hermes_execution_limits_config_read'),
|
||||||
|
|||||||
@@ -640,6 +640,18 @@ export default {
|
|||||||
hermesUnauthorizedDmConfigBehavior_pair: _('回复配对码', 'Reply with pairing code', '回覆配對碼'),
|
hermesUnauthorizedDmConfigBehavior_pair: _('回复配对码', 'Reply with pairing code', '回覆配對碼'),
|
||||||
hermesUnauthorizedDmConfigBehavior_ignore: _('静默忽略', 'Silently ignore', '靜默忽略'),
|
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 中單獨設定。'),
|
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: 会话导出
|
// Batch 1 §E: 会话导出
|
||||||
sessionsExport: _('导出', 'Export', '匯出'),
|
sessionsExport: _('导出', 'Export', '匯出'),
|
||||||
sessionsExportSuccess: _('已导出', 'Exported', '已匯出'),
|
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 配置页会暴露网关流式结构化配置字段', () => {
|
test('Hermes 配置页会暴露网关流式结构化配置字段', () => {
|
||||||
for (const id of [
|
for (const id of [
|
||||||
'hm-streaming-save',
|
'hm-streaming-save',
|
||||||
@@ -128,6 +140,7 @@ test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => {
|
|||||||
key.includes('SkillsConfig') ||
|
key.includes('SkillsConfig') ||
|
||||||
key.includes('QuickCommandsConfig') ||
|
key.includes('QuickCommandsConfig') ||
|
||||||
key.includes('UnauthorizedDmConfig') ||
|
key.includes('UnauthorizedDmConfig') ||
|
||||||
|
key.includes('SecurityConfig') ||
|
||||||
key.includes('StreamingConfig') ||
|
key.includes('StreamingConfig') ||
|
||||||
key.includes('ExecutionLimits') ||
|
key.includes('ExecutionLimits') ||
|
||||||
key.includes('TerminalConfig')
|
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