原文连接:Hermes Agent 10 | 7 层安全防线:从用户授权到输入净化
安全不是一堵墙,而是一系列让攻击者每一步都更难的台阶。
7 层模型:先看全景
security.md 开头就把 7 层列清楚了(第 13-21 行):
| 层 | 名称 | 保护目标 |
|---|---|---|
| 1 | 用户授权 | 谁能跟 Agent 对话 |
| 2 | 危险命令审批 | 破坏性命令的 human-in-the-loop |
| 3 | 容器隔离 | 执行环境与宿主机的隔离 |
| 4 | MCP 凭证过滤 | MCP 子进程的环境变量隔离 |
| 5 | 上下文文件扫描 | 项目文件中的提示词注入 |
| 6 | 跨会话隔离 | Session 之间不可见、cron 路径防穿越 |
| 7 | 输入净化 | 工作目录参数的白名单校验 |
这 7 层不是"第 1 层挡住了就不看后面"的串行链条------它们是纵深防御,任何一层都独立生效。攻击者需要同时突破多层才能造成实质损害。
第 1 层:用户授权------谁能跟 Agent 说话
问题
Gateway 模式下 Agent 通过 Telegram、Discord、Slack 等平台在线服务。任何人给你的 bot 发消息,它都会回应吗?
机制
授权检查的优先级链(security.md:167-211):
1. 平台级放行 → DISCORD_ALLOW_ALL_USERS=true(不推荐)
2. DM 配对通过 → approved.json 白名单
3. 平台级白名单 → TELEGRAM_ALLOWED_USERS=123456789,987654321
4. 全局白名单 → GATEWAY_ALLOWED_USERS=123456789
5. 全局放行 → GATEWAY_ALLOW_ALL_USERS=true(不推荐)
6. 默认:拒绝
默认是拒绝执行对话,但处理方式要分场景看:
-
未授权 DM :默认
unauthorized_dm_behavior: pair,会返回一次性 pairing code -
未授权 群聊/频道:默认静默忽略
-
显式把
unauthorized_dm_behavior设成ignore:未授权 DM 也会静默丢弃
DM 配对系统
对于不方便提前知道用户 ID 的场景(比如 Telegram),Hermes Agent 提供了一种一次性配对码机制:
-
码长 8 字符,从 32 字符的无歧义字母表(去掉 0/O/1/I)中选取
-
密码学随机(
secrets.choice()) -
有效期 1 小时
-
频率限制:每用户每 10 分钟最多请求 1 次
-
失败锁定:5 次错误尝试 → 锁定 1 小时
-
配对数据文件权限
0600 -
配对码永远不输出到日志
这套机制的安全性不亚于很多 2FA 实现。
第 2 层:危险命令审批------破坏前先问一声
问题
Agent 的 terminal 工具可以执行任意 shell 命令。如果模型决定执行 rm -rf /,怎么办?
三种审批模式
tools/approval.py 实现了三种模式(security.md:29-41):
| 模式 | 行为 |
|---|---|
manual (默认) |
匹配危险模式时总是弹确认 |
smart |
用辅助 LLM 评估风险------低风险自动放行、高风险自动拒绝、不确定则升级为手动 |
off |
关闭所有检查 = --yolo |
危险模式正则库
DANGEROUS_PATTERNS(approval.py:76-139)定义了约 40 条正则,覆盖:
| 类别 | 示例模式 | 描述 |
|---|---|---|
| 文件删除 | rm -rf / 、rm -r、find -delete |
递归删除 |
| 权限 | chmod 777 、chown -R root |
危险权限变更 |
| 系统破坏 | mkfs 、dd if=、> /dev/sd |
格式化/覆写 |
| 数据库 | DROP TABLE 、DELETE FROM(无 WHERE)、TRUNCATE |
数据丢失 |
| 系统配置 | > /etc/ 、sed -i /etc/、systemctl stop |
系统篡改 |
| Shell 执行 | bash -c 、sh -lc、python -e、`curl |
sh` |
| 文件覆写 | tee /etc/ 、>> ~/.ssh/、>> ~/.hermes/.env |
敏感路径写入 |
| Git 破坏 | git reset --hard 、git push --force、git clean -f |
不可逆操作 |
| 自我保护 | pkill hermes 、hermes gateway stop、hermes update |
防止 Agent 杀死自己 |
最后一类最有趣------Agent 不能杀死自己的宿主进程。 如果模型在 Gateway 模式下执行 hermes gateway stop,所有正在运行的 Agent 都会被终止。approval.py 把这类命令标记为需要审批。
审批 UX:CLI vs 消息平台
CLI 里的审批体验(approval.py:121-140):
⚠️ DANGEROUS COMMAND: recursive delete
rm -rf /tmp/old-project
[o]nce | [s]ession | [a]lways | [d]eny
Choice [o/s/a/D]:
四个选项的作用域不同:
| 选项 | 范围 | 持久性 |
|---|---|---|
once |
仅本次执行 | 不持久 |
session |
本 session 内同模式命令自动放行 | session 结束即失效 |
always |
写入 config.yaml 的 command_allowlist |
永久 |
deny |
拒绝本次 | 默认选项 |
消息平台上当前不是"回复 yes 就行"的老流程了。危险命令审批通过两类入口完成:
-
显式命令 :
/approve、/approve session、/approve always、/deny -
平台按钮:Slack / Discord / Telegram / Feishu 等平台的交互按钮
gateway/run.py 里还专门写了保护逻辑:普通对话中的裸 yes 不会触发危险命令放行。
审批超时是 fail-closed,但 CLI 和 gateway 的默认值不同:
-
CLI :
approvals.timeout,默认 60 秒 -
Gateway :
approvals.gateway_timeout,默认 300 秒
这避免了"Agent 提了一个危险操作,用户没看到,超时后就执行了"的风险。
YOLO 模式
--yolo / /yolo / HERMES_YOLO_MODE=1------三种方式激活,绕过所有危险命令审批。
> /yolo
⚡ YOLO mode ON --- all commands auto-approved. Use with caution.
> /yolo
⚠ YOLO mode OFF --- dangerous commands will require approval.
/yolo 是个 toggle------再敲一次就关掉。
从 7 层模型的口径看,YOLO 只绕过第 2 层(命令审批)。 容器隔离(第 3 层)、MCP 凭证过滤(第 4 层)、上下文文件扫描(第 5 层)、输入净化(第 7 层)------全部仍然生效。
但从当前运行时实现 看,还要补一句:YOLO 会让 check_all_command_guards() 提前返回,因此和审批链绑在一起的 Tirith 也会被短路,不会再参与本轮 terminal 命令的预检查。
第 3 层:容器隔离------让破坏性命令只在沙箱里生效
问题
即使审批机制被绕过(YOLO 模式或 approvals.mode: off),在容器里执行 rm -rf / 只会清空容器的文件系统------宿主机不受影响。
Docker 硬化
tools/environments/docker.py:146-163 定义了每个容器都会应用的安全参数:
_SECURITY_ARGS = [
"--cap-drop", "ALL", # 移除所有 Linux capabilities
"--cap-add", "DAC_OVERRIDE", # root 可写宿主挂载目录
"--cap-add", "CHOWN", # 包管理器需要改文件属主
"--cap-add", "FOWNER", # 同上
"--security-opt", "no-new-privileges", # 阻止权限提升
"--pids-limit", "256", # 限制进程数(防 fork bomb)
"--tmpfs", "/tmp:rw,nosuid,size=512m", # /tmp 有大小限制
"--tmpfs", "/var/tmp:rw,noexec,nosuid,size=256m", # /var/tmp 禁止执行
"--tmpfs", "/run:rw,noexec,nosuid,size=64m", # /run 禁止执行
]
-
--cap-drop ALL+ 选择性--cap-add:最小权限原则 -
--security-opt no-new-privileges:容器内进程不能通过 setuid 等手段提权 -
--pids-limit 256:fork bomb 在 256 个进程后就会被 OOM kill -
tmpfs 上的
noexec:禁止在/var/tmp和/run执行二进制------阻止"先写可执行文件到临时目录再运行"的攻击链
Singularity 隔离
tools/environments/singularity.py 使用 --containall --no-home:
-
--containall:隔离所有命名空间 -
--no-home:不挂载用户 home 目录
容器内的审批豁免
关键细节:当终端后端是 docker / singularity / modal / daytona 时,危险命令审批被跳过 (security.md:118-119)。因为容器本身就是安全边界------在容器里的破坏性命令不能影响宿主机。
这是一个很好的层次化设计:第 2 层(命令审批)和第 3 层(容器隔离)不是叠加的,而是互补的------用容器跑的时候,命令审批的工作由容器接管了。
第 4 层:MCP 凭证过滤------不把你的密钥泄露给 MCP 服务器
问题
MCP 服务器是独立的子进程------它们继承宿主进程的环境变量。如果你的 OPENROUTER_API_KEY、ANTHROPIC_API_KEY 都在环境变量里,MCP 子进程也能看到。一个恶意的 MCP 服务器可以读取这些变量并外泄。
机制
tools/mcp_tool.py:170-210 实现了环境变量白名单:
_SAFE_ENV_KEYS = frozenset({
"PATH", "HOME", "USER", "LANG", "LC_ALL", "TERM", "SHELL", "TMPDIR",
})
MCP 子进程只能看到这些基线变量 + XDG_* 变量。所有其他环境变量------API Key、Token、Secret------全部被过滤掉。
如果 MCP 服务器确实需要某个密钥(比如 GitHub MCP 服务器需要 GITHUB_PERSONAL_ACCESS_TOKEN),用户必须在 config.yaml 的 MCP 配置里显式声明:
mcp_servers:
github:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-github"]
env:
GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_..." # 只有这个会传递
凭证泄露兜底:错误消息脱敏
即使做了白名单过滤,MCP 工具的错误消息里也可能意外包含凭证。mcp_tool.py:174-187 对返回给模型的错误消息做正则脱敏:
_CREDENTIAL_PATTERN = re.compile(
r"(?:"
r"ghp_[A-Za-z0-9_]{1,255}" # GitHub PAT
r"|sk-[A-Za-z0-9_]{1,255}" # OpenAI-style key
r"|Bearer\s+\S+" # Bearer token
r"|token=[^\s&,;\"']{1,255}" # token=...
r"|key=[^\s&,;\"']{1,255}" # key=...
r"|password=[^\s&,;\"']{1,255}" # password=...
r"|secret=[^\s&,;\"']{1,255}" # secret=...
r")",
re.IGNORECASE,
)
匹配到的内容被替换为 [REDACTED]------模型永远看不到真实的凭证。
MCP 工具描述的注入检测
还有一层:mcp_tool.py:226-271 会扫描 MCP 服务器返回的工具描述 里是否包含提示词注入模式------比如 "ignore previous instructions" 藏在 tool description 里。
但这层要写得保守一些:当前它是告警型检查,不是阻断型检查。 _scan_mcp_description() 发现可疑内容后会记录 warning,帮助排查风险;它不会阻止工具注册,也不会像环境变量白名单那样直接切断能力。
第 5 层:上下文文件扫描------你的项目文件可能是一个攻击载体
问题
Hermes Agent 会加载项目目录下的上下文文件(AGENTS.md、CLAUDE.md、.cursorrules 等)注入系统提示词。如果攻击者在你的代码仓库里提交了一个包含 ignore previous instructions 的 AGENTS.md------Agent 打开这个项目时就会被注入。
机制
agent/prompt_builder.py:36-73 定义了一套上下文文件专用的扫描器:
_CONTEXT_THREAT_PATTERNS = [
(r'ignore\s+(previous|all|above|prior)\s+instructions', "prompt_injection"),
(r'do\s+not\s+tell\s+the\s+user', "deception_hide"),
(r'system\s+prompt\s+override', "sys_prompt_override"),
(r'disregard\s+(your|all|any)\s+(instructions|rules|guidelines)', "disregard_rules"),
(r'act\s+as\s+(if|though)\s+you\s+(have\s+no|don\'t\s+have)\s+(restrictions|limits|rules)', "bypass_restrictions"),
(r'<!--[^>]*(?:ignore|override|system|secret|hidden)[^>]*-->', "html_comment_injection"),
(r'<\s*div\s+style\s*=\s*["\'][\s\S]*?display\s*:\s*none', "hidden_div"),
(r'translate\s+.*\s+into\s+.*\s+and\s+(execute|run|eval)', "translate_execute"),
(r'curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)', "exfil_curl"),
(r'cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass)', "read_secrets"),
]
再加上 10 种不可见 Unicode 字符检测。
如果检测到威胁,文件内容不会被加载------替换为一条警告消息:
[BLOCKED: AGENTS.md contained potential prompt injection (prompt_injection). Content not loaded.]
文件大小也有限制:每个上下文文件最多 20,000 字符,超出部分做 head/tail 截断(70% 头部 + 20% 尾部)。
第 6 层:跨会话隔离------session 之间不可见
问题
Gateway 模式下,多个用户的 session 同时运行。一个用户的对话内容不应该泄露给另一个用户。
机制
-
ContextVar 隔离 :session 元信息、凭证文件挂载、env passthrough 等敏感状态通过 Python 的
contextvars模块绑定到当前执行上下文,防止跨 session 泄露 -
会话键隔离 :session key 由
build_session_key()按 chat type、chat_id、thread_id、user_id 动态组合生成;它不是一个固定模板
这里最容易写错的是 thread 的默认行为。当前实现里:
-
DM :默认按
platform:dm:chat[:thread]隔离 -
普通群聊/频道消息 :默认会把
user_id纳入 key,做到"同群不同人各自一条 session" -
线程消息 :默认是"线程内共享 session",只有显式开启
thread_sessions_per_user才会退回到按用户隔离
Cron 路径穿越防护
tools/cronjob_tools.py:153-189 对 cron 任务的脚本路径做严格校验:
def _validate_cron_script_path(script):
# 拒绝绝对路径和 ~ 开头
if raw.startswith(("/", "~")) or (len(raw) >= 2 and raw[1] == ":"):
return "Script path must be relative to ~/.hermes/scripts/..."
# 检查路径是否逃逸出 scripts 目录
from tools.path_security import validate_within_dir
scripts_dir = get_hermes_home() / "scripts"
containment_error = validate_within_dir(scripts_dir / raw, scripts_dir)
if containment_error:
return f"Script path escapes the scripts directory via traversal: {raw!r}"
Cron 任务的脚本只能是 ~/.hermes/scripts/** 下的相对路径 。攻击者不能通过 ../../etc/cron.d/evil 之类的路径穿越来执行任意脚本。
Cron 任务的 prompt 也会被扫描------包含 critical 级别的注入/外泄/破坏模式会被拒绝。
第 7 层:输入净化------连工作目录都不信任
问题
terminal 工具有一个 workdir 参数,指定命令的执行目录。如果模型被诱导传入 workdir: "/tmp; rm -rf /" 这样的值,shell 可能会执行注入的命令。
机制
tools/terminal_tool.py:150-176 用白名单正则验证 workdir:
_WORKDIR_SAFE_RE = re.compile(r'^[A-Za-z0-9/\:_\-.~ +@=,]+$')
def _validate_workdir(workdir: str) -> str | None:
if not workdir:
return None
if not _WORKDIR_SAFE_RE.match(workdir):
for ch in workdir:
if not _WORKDIR_SAFE_RE.match(ch):
return f"Blocked: workdir contains disallowed character {repr(ch)}."
return "Blocked: workdir contains disallowed characters."
return None
白名单而不是黑名单 ------只允许字母数字、路径分隔符、常见的安全字符。Shell 元字符(|、&、;、$、```、<、>、(、))全部被拒绝。
验证失败时,命令不会执行,返回 {"status": "blocked", "error": "..."} 给模型。
为什么用白名单不用黑名单? 因为 shell 元字符的集合不是固定的------不同 shell(bash、zsh、fish)有不同的特殊字符,未来的 shell 可能引入新的元字符。黑名单永远在追赶,白名单一劳永逸。
补充机制一:IterationBudget 的安全角色
IterationBudget(第 04 讲拆过)不在 7 层里,但它是一个重要的安全兜底。
| 场景 | 风险 | IterationBudget 的保护 |
|---|---|---|
| 模型陷入工具调用死循环 | 无限消耗 Token | 默认 90 轮上限 |
| 恶意 prompt 诱导 Agent 反复执行操作 | 放大攻击效果 | 预算耗尽后强制停止 |
| Gateway 模式下成本失控 | 单个用户消耗大量资源 | 子 Agent 独立预算(默认 50) |
MCP 工具也有独立的循环限制(mcp_tool.py:558-570)------max_tool_rounds 配置控制每个 MCP 服务器的工具循环上限。
补充机制二:Tirith 外部扫描器
tools/tirith_security.py 集成了一个名为 Tirith 的外部安全扫描器,专门检测正则模式匹配容易遗漏的攻击:
-
同形域名攻击(internationalized domain)
-
管道到解释器 (
curl | bash的变体) -
终端注入攻击
自动安装与供应链验证
Tirith 的安装(tirith_security.py:281-386)有严格的供应链验证:
-
从 GitHub Releases 下载对应平台的二进制
-
SHA-256 校验和验证 ------对比下载文件和
checksums.txt -
cosign 签名验证(可选)------验证发布是否来自预期的 GitHub Actions 工作流
_COSIGN_IDENTITY_REGEXP = f"^https://github.com/{_REPO}/\.github/workflows/release\.yml@refs/tags/v"
_COSIGN_ISSUER = "https://token.actions.githubusercontent.com"
cosign 验证失败(签名被拒绝)→ 中止安装。cosign 不可用(未安装)→ 退回到 SHA-256 校验。
运行时行为
check_command_security()(tirith_security.py:614-684)不是一个"对全系统所有命令无条件生效"的全局 hook。更准确地说,它运行在 terminal 工具的交互式 pre-exec 守卫链里:
-
退出码 0 → allow
-
退出码 1 → block
-
退出码 2 → warn
block 和 warn 的结果会进入审批流程(与危险命令检测的结果合并展示),不是硬拦截。
这也意味着它有几条明确的旁路:
-
force=True的已批准重放,不再重复跑 guard -
非 CLI / 非 gateway / 非 ask 的非交互路径,会直接返回 allow
-
container backend 与 YOLO 模式,会在更前面短路整个 guard 链
fail-open 设计 (默认):如果 tirith 不可用(未安装、超时、崩溃),默认放行------不让安全扫描器的故障阻塞正常工作。可以通过 security.tirith_fail_open: false 切换为 fail-closed。
一张表看 7 层的绕过条件
| 层 | 绕过方式 | 风险评估 |
|---|---|---|
| 1 --- 用户授权 | GATEWAY_ALLOW_ALL_USERS=true |
不推荐,仅限受信网络 |
| 2 --- 命令审批 | --yolo / approvals.mode: off |
需要用户主动操作 |
| 3 --- 容器隔离 | 切到 local backend | 绕过者承担风险 |
| 4 --- MCP 凭证 | 在 MCP 配置里显式传递变量 | 用户有意为之 |
| 5 --- 上下文扫描 | 无法绕过 (检测到即拒绝加载) | --- |
| 6 --- 跨会话隔离 | 无法绕过 (架构级隔离) | --- |
| 7 --- 输入净化 | 无法绕过 (白名单拒绝非法字符) | --- |
最后三层没有旁路------它们是架构级的安全保证,不是用户可以"选择关闭"的功能。