From ec0f7ec64ac736180bea29a883087e0eafb66d44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Wed, 27 May 2026 01:40:53 +0800 Subject: [PATCH] feat(hermes): add skills security controls --- scripts/dev-api.js | 8 ++++ src-tauri/src/commands/hermes.rs | 71 +++++++++++++++++++++++++++++ src/engines/hermes/pages/config.js | 26 +++++++++++ src/locales/modules/engine.js | 6 ++- tests/hermes-config-page-ui.test.js | 4 ++ tests/hermes-skills-config.test.js | 28 ++++++++++++ 6 files changed, 142 insertions(+), 1 deletion(-) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index be1332c..2a7fe0d 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -4345,6 +4345,10 @@ export function buildHermesSkillsConfigValues(config = {}) { return { creationNudgeInterval: parseHermesInteger(skills.creation_nudge_interval, 'skills.creation_nudge_interval', 15, 0, 10000, false), externalDirs, + templateVars: readHermesBool(skills.template_vars, true), + inlineShell: readHermesBool(skills.inline_shell, false), + inlineShellTimeout: parseHermesInteger(skills.inline_shell_timeout, 'skills.inline_shell_timeout', 10, 1, 86400, false), + guardAgentCreated: readHermesBool(skills.guard_agent_created, false), } } @@ -4355,6 +4359,10 @@ export function mergeHermesSkillsConfig(config = {}, form = {}) { ? mergeConfigsPreservingFields(next.skills, {}) : {} skills.creation_nudge_interval = parseHermesInteger(Object.hasOwn(form, 'creationNudgeInterval') ? form.creationNudgeInterval : currentValues.creationNudgeInterval, 'skills.creation_nudge_interval', 15, 0, 10000, true) + skills.template_vars = formHermesBool(form, 'templateVars', currentValues.templateVars) + skills.inline_shell = formHermesBool(form, 'inlineShell', currentValues.inlineShell) + skills.inline_shell_timeout = parseHermesInteger(Object.hasOwn(form, 'inlineShellTimeout') ? form.inlineShellTimeout : currentValues.inlineShellTimeout, 'skills.inline_shell_timeout', 10, 1, 86400, true) + skills.guard_agent_created = formHermesBool(form, 'guardAgentCreated', currentValues.guardAgentCreated) const externalDirs = normalizeHermesMultilineList(Object.hasOwn(form, 'externalDirs') ? form.externalDirs : currentValues.externalDirs) if (externalDirs.length) skills.external_dirs = externalDirs else delete skills.external_dirs diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 5bdd107..ef06a73 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -4352,6 +4352,12 @@ fn build_hermes_skills_config_values(config: &serde_yaml::Value) -> Value { serde_json::json!({ "creationNudgeInterval": creation_nudge_interval, "externalDirs": external_dirs, + "templateVars": skills.and_then(|map| yaml_bool_field(map, "template_vars")).unwrap_or(true), + "inlineShell": skills.and_then(|map| yaml_bool_field(map, "inline_shell")).unwrap_or(false), + "inlineShellTimeout": skills + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "inline_shell_timeout"), 10, 1, 86400)) + .unwrap_or(10), + "guardAgentCreated": skills.and_then(|map| yaml_bool_field(map, "guard_agent_created")).unwrap_or(false), }) } @@ -4368,6 +4374,17 @@ fn merge_hermes_skills_config(config: &mut serde_yaml::Value, form: &Value) -> R 0, 10000, )?; + let inline_shell_timeout = validate_hermes_i64( + if form.get("inlineShellTimeout").is_some() { + form_i64(form, "inlineShellTimeout") + } else { + Some(current["inlineShellTimeout"].as_i64().unwrap_or(10)) + }, + "skills.inline_shell_timeout", + 10, + 1, + 86400, + )?; let external_dirs = normalize_hermes_multiline_list( form_string(form, "externalDirs") .or_else(|| current["externalDirs"].as_str().map(ToString::to_string)), @@ -4379,6 +4396,31 @@ fn merge_hermes_skills_config(config: &mut serde_yaml::Value, form: &Value) -> R yaml_key("creation_nudge_interval"), serde_yaml::Value::Number(creation_nudge_interval.into()), ); + skills.insert( + yaml_key("template_vars"), + serde_yaml::Value::Bool( + form_bool(form, "templateVars") + .unwrap_or_else(|| current["templateVars"].as_bool().unwrap_or(true)), + ), + ); + skills.insert( + yaml_key("inline_shell"), + serde_yaml::Value::Bool( + form_bool(form, "inlineShell") + .unwrap_or_else(|| current["inlineShell"].as_bool().unwrap_or(false)), + ), + ); + skills.insert( + yaml_key("inline_shell_timeout"), + serde_yaml::Value::Number(inline_shell_timeout.into()), + ); + skills.insert( + yaml_key("guard_agent_created"), + serde_yaml::Value::Bool( + form_bool(form, "guardAgentCreated") + .unwrap_or_else(|| current["guardAgentCreated"].as_bool().unwrap_or(false)), + ), + ); if external_dirs.is_empty() { skills.remove(yaml_key("external_dirs")); } else { @@ -17610,6 +17652,10 @@ mod hermes_skills_config_tests { let values = build_hermes_skills_config_values(&config); assert_eq!(values["creationNudgeInterval"], 15); assert_eq!(values["externalDirs"], ""); + assert_eq!(values["templateVars"], true); + assert_eq!(values["inlineShell"], false); + assert_eq!(values["inlineShellTimeout"], 10); + assert_eq!(values["guardAgentCreated"], false); } #[test] @@ -17621,6 +17667,10 @@ skills: external_dirs: - ~/.agents/skills - /home/shared/team-skills + template_vars: false + inline_shell: true + inline_shell_timeout: 25 + guard_agent_created: true "#, ) .unwrap(); @@ -17631,6 +17681,10 @@ skills: values["externalDirs"], "~/.agents/skills\n/home/shared/team-skills" ); + assert_eq!(values["templateVars"], false); + assert_eq!(values["inlineShell"], true); + assert_eq!(values["inlineShellTimeout"], 25); + assert_eq!(values["guardAgentCreated"], true); } #[test] @@ -17655,6 +17709,10 @@ memory: &json!({ "creationNudgeInterval": "0", "externalDirs": " ~/.agents/skills \n\n /home/shared/team-skills ", + "templateVars": false, + "inlineShell": true, + "inlineShellTimeout": "30", + "guardAgentCreated": true, }), ) .unwrap(); @@ -17673,6 +17731,13 @@ memory: config["skills"]["external_dirs"][1].as_str(), Some("/home/shared/team-skills") ); + assert_eq!(config["skills"]["template_vars"].as_bool(), Some(false)); + assert_eq!(config["skills"]["inline_shell"].as_bool(), Some(true)); + assert_eq!(config["skills"]["inline_shell_timeout"].as_i64(), Some(30)); + assert_eq!( + config["skills"]["guard_agent_created"].as_bool(), + Some(true) + ); assert_eq!( config["skills"]["disabled"][0].as_str(), Some("legacy-skill") @@ -17693,6 +17758,12 @@ memory: merge_hermes_skills_config(&mut config, &json!({ "creationNudgeInterval": 10001 })) .unwrap_err(); assert!(err.contains("skills.creation_nudge_interval")); + let err = merge_hermes_skills_config(&mut config, &json!({ "inlineShellTimeout": 0 })) + .unwrap_err(); + assert!(err.contains("skills.inline_shell_timeout")); + let err = merge_hermes_skills_config(&mut config, &json!({ "inlineShellTimeout": 86401 })) + .unwrap_err(); + assert!(err.contains("skills.inline_shell_timeout")); } } diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index fc27122..4b6168f 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -78,6 +78,10 @@ const MEMORY_DEFAULTS = { const SKILLS_DEFAULTS = { creationNudgeInterval: 15, externalDirs: '', + templateVars: true, + inlineShell: false, + inlineShellTimeout: 10, + guardAgentCreated: false, } const QUICK_COMMANDS_DEFAULTS = { @@ -935,11 +939,29 @@ export function render() { ${t('engine.hermesSkillsConfigCreationNudgeInterval')} + +
+ + + +
${t('engine.hermesSkillsConfigFootnote')}
@@ -3180,6 +3202,10 @@ export function render() { async function saveSkillsConfig() { const form = { creationNudgeInterval: el.querySelector('#hm-skills-creation-nudge-interval')?.value || '15', + templateVars: !!el.querySelector('#hm-skills-template-vars')?.checked, + inlineShell: !!el.querySelector('#hm-skills-inline-shell')?.checked, + inlineShellTimeout: el.querySelector('#hm-skills-inline-shell-timeout')?.value || '10', + guardAgentCreated: !!el.querySelector('#hm-skills-guard-agent-created')?.checked, externalDirs: el.querySelector('#hm-skills-external-dirs')?.value || '', } skillsSaving = true diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index db73fd5..60bfec5 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -834,8 +834,12 @@ export default { hermesSkillsConfigLoadFailed: _('加载技能配置失败', 'Load skill settings failed', '載入技能設定失敗'), hermesSkillsConfigSaveFailed: _('保存技能配置失败', 'Save skill settings failed', '儲存技能設定失敗'), hermesSkillsConfigCreationNudgeInterval: _('创建提醒间隔', 'Creation nudge interval', '建立提醒間隔'), + hermesSkillsConfigTemplateVars: _('展开技能模板变量', 'Expand skill template variables', '展開技能模板變數'), + hermesSkillsConfigInlineShell: _('允许执行 Skill.md 内联命令', 'Allow inline shell in Skill.md', '允許執行 Skill.md 內嵌命令'), + hermesSkillsConfigInlineShellTimeout: _('内联命令超时(秒)', 'Inline shell timeout, seconds', '內嵌命令逾時(秒)'), + hermesSkillsConfigGuardAgentCreated: _('扫描 Agent 创建的技能', 'Scan agent-created skills', '掃描 Agent 建立的技能'), hermesSkillsConfigExternalDirs: _('外部技能目录(每行一个)', 'External skill directories, one per line', '外部技能目錄(每行一個)'), - hermesSkillsConfigFootnote: _('提醒间隔按用户消息轮数计算,设为 0 可关闭创建提醒。disabled、custom flag 等高级字段会保留在 raw YAML 中。', 'The nudge interval is counted in user turns. Set it to 0 to disable creation nudges. Advanced fields such as disabled skills and custom flags are preserved in raw YAML.', '提醒間隔依使用者訊息輪數計算,設為 0 可關閉建立提醒。disabled、custom flag 等進階欄位會保留在 raw YAML 中。'), + hermesSkillsConfigFootnote: _('提醒间隔按用户消息轮数计算,设为 0 可关闭创建提醒。内联命令会在本机执行,仅对可信技能源开启;外部目录、disabled 和 custom flag 等字段会保留在 raw YAML 中。', 'The nudge interval is counted in user turns. Set it to 0 to disable creation nudges. Inline shell commands run on this machine, so enable them only for trusted skill sources. External dirs, disabled skills, and custom flags are preserved in raw YAML.', '提醒間隔依使用者訊息輪數計算,設為 0 可關閉建立提醒。內嵌命令會在本機執行,僅對可信技能來源開啟;外部目錄、disabled 和 custom flag 等欄位會保留在 raw YAML 中。'), hermesQuickCommandsConfigTitle: _('快捷命令', 'Quick commands', '快捷命令'), hermesQuickCommandsConfigDesc: _('配置消息平台和 CLI 可直接触发的零 token 运维命令,例如状态检查、磁盘空间和 Gateway 重启别名。', 'Configure zero-token operations commands that messaging platforms and the CLI can trigger directly, such as status checks, disk usage, and Gateway restart aliases.', '設定訊息平台和 CLI 可直接觸發的零 token 維運命令,例如狀態檢查、磁碟空間和 Gateway 重啟別名。'), hermesQuickCommandsConfigStatusReady: _('结构化 JSON', 'structured JSON', '結構化 JSON'), diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index cc22cf0..e921d4d 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -57,6 +57,10 @@ test('Hermes 配置页会暴露 Skills 结构化配置字段', () => { for (const id of [ 'hm-skills-config-save', 'hm-skills-creation-nudge-interval', + 'hm-skills-template-vars', + 'hm-skills-inline-shell', + 'hm-skills-inline-shell-timeout', + 'hm-skills-guard-agent-created', 'hm-skills-external-dirs', ]) { assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) diff --git a/tests/hermes-skills-config.test.js b/tests/hermes-skills-config.test.js index ab1e447..8b0acee 100644 --- a/tests/hermes-skills-config.test.js +++ b/tests/hermes-skills-config.test.js @@ -12,6 +12,10 @@ test('Hermes Skills 配置读取会提供上游默认值', () => { assert.deepEqual(values, { creationNudgeInterval: 15, externalDirs: '', + templateVars: true, + inlineShell: false, + inlineShellTimeout: 10, + guardAgentCreated: false, }) }) @@ -20,11 +24,19 @@ test('Hermes Skills 配置读取会回显创建提醒和外部目录', () => { skills: { creation_nudge_interval: 30, external_dirs: ['~/.agents/skills', '/home/shared/team-skills'], + template_vars: false, + inline_shell: true, + inline_shell_timeout: 25, + guard_agent_created: true, }, }) assert.equal(values.creationNudgeInterval, 30) assert.equal(values.externalDirs, '~/.agents/skills\n/home/shared/team-skills') + assert.equal(values.templateVars, false) + assert.equal(values.inlineShell, true) + assert.equal(values.inlineShellTimeout, 25) + assert.equal(values.guardAgentCreated, true) }) test('Hermes Skills 配置保存会保留未知字段并写入上游结构', () => { @@ -39,12 +51,20 @@ test('Hermes Skills 配置保存会保留未知字段并写入上游结构', () }, { creationNudgeInterval: '0', externalDirs: ' ~/.agents/skills \n\n /home/shared/team-skills ', + templateVars: false, + inlineShell: true, + inlineShellTimeout: '30', + guardAgentCreated: true, }) assert.deepEqual(next.model, { provider: 'anthropic' }) assert.deepEqual(next.memory, { memory_enabled: true }) assert.equal(next.skills.creation_nudge_interval, 0) assert.deepEqual(next.skills.external_dirs, ['~/.agents/skills', '/home/shared/team-skills']) + assert.equal(next.skills.template_vars, false) + assert.equal(next.skills.inline_shell, true) + assert.equal(next.skills.inline_shell_timeout, 30) + assert.equal(next.skills.guard_agent_created, true) assert.deepEqual(next.skills.disabled, ['legacy-skill']) assert.equal(next.skills.custom_flag, 'keep-skills') }) @@ -58,4 +78,12 @@ test('Hermes Skills 配置保存会拒绝非法提醒间隔', () => { () => mergeHermesSkillsConfig({}, { creationNudgeInterval: '10001' }), /skills\.creation_nudge_interval/, ) + assert.throws( + () => mergeHermesSkillsConfig({}, { inlineShellTimeout: '0' }), + /skills\.inline_shell_timeout/, + ) + assert.throws( + () => mergeHermesSkillsConfig({}, { inlineShellTimeout: '86401' }), + /skills\.inline_shell_timeout/, + ) })