feat(hermes): add skills security controls

This commit is contained in:
晴天
2026-05-27 01:40:53 +08:00
parent 8f7f2a6e8e
commit ec0f7ec64a
6 changed files with 142 additions and 1 deletions

View File

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

View File

@@ -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"));
}
}

View File

@@ -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() {
<span class="hm-field-label">${t('engine.hermesSkillsConfigCreationNudgeInterval')}</span>
<input id="hm-skills-creation-nudge-interval" class="hm-input" type="number" inputmode="numeric" min="0" max="10000" step="1" value="${esc(skillsValues.creationNudgeInterval)}" ${disabled ? 'disabled' : ''}>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesSkillsConfigInlineShellTimeout')}</span>
<input id="hm-skills-inline-shell-timeout" class="hm-input" type="number" inputmode="numeric" min="1" max="86400" step="1" value="${esc(skillsValues.inlineShellTimeout)}" ${disabled ? 'disabled' : ''}>
</label>
<label class="hm-field hm-field--wide">
<span class="hm-field-label">${t('engine.hermesSkillsConfigExternalDirs')}</span>
<textarea id="hm-skills-external-dirs" class="hm-input" spellcheck="false" rows="3" ${disabled ? 'disabled' : ''}>${esc(skillsValues.externalDirs)}</textarea>
</label>
</div>
<div class="hm-config-check-grid">
<label class="hm-channel-check">
<input id="hm-skills-template-vars" type="checkbox" ${skillsValues.templateVars ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${t('engine.hermesSkillsConfigTemplateVars')}</span>
</label>
<label class="hm-channel-check">
<input id="hm-skills-inline-shell" type="checkbox" ${skillsValues.inlineShell ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${t('engine.hermesSkillsConfigInlineShell')}</span>
</label>
<label class="hm-channel-check">
<input id="hm-skills-guard-agent-created" type="checkbox" ${skillsValues.guardAgentCreated ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${t('engine.hermesSkillsConfigGuardAgentCreated')}</span>
</label>
</div>
<div class="hm-channel-footnote">${t('engine.hermesSkillsConfigFootnote')}</div>
</div>
</div>
@@ -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

View File

@@ -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 可关闭创建提醒。disabledcustom 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 可關閉建立提醒。disabledcustom flag 等進階欄位會保留在 raw YAML 中。'),
hermesSkillsConfigFootnote: _('提醒间隔按用户消息轮数计算,设为 0 可关闭创建提醒。内联命令会在本机执行,仅对可信技能源开启;外部目录、disabledcustom 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 可關閉建立提醒。內嵌命令會在本機執行,僅對可信技能來源開啟;外部目錄、disabledcustom 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'),

View File

@@ -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}`)

View File

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