转载--Hermes Agent 10 | 7 层安全防线:从用户授权到输入净化

原文连接: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_PATTERNSapproval.py:76-139)定义了约 40 条正则,覆盖:

类别 示例模式 描述
文件删除 rm -rf /rm -rfind -delete 递归删除
权限 chmod 777chown -R root 危险权限变更
系统破坏 mkfsdd if=> /dev/sd 格式化/覆写
数据库 DROP TABLEDELETE FROM(无 WHERE)、TRUNCATE 数据丢失
系统配置 > /etc/sed -i /etc/systemctl stop 系统篡改
Shell 执行 bash -csh -lcpython -e、`curl sh`
文件覆写 tee /etc/>> ~/.ssh/>> ~/.hermes/.env 敏感路径写入
Git 破坏 git reset --hardgit push --forcegit clean -f 不可逆操作
自我保护 pkill hermeshermes gateway stophermes 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.yamlcommand_allowlist 永久
deny 拒绝本次 默认选项

消息平台上当前不是"回复 yes 就行"的老流程了。危险命令审批通过两类入口完成:

  • 显式命令/approve/approve session/approve always/deny

  • 平台按钮:Slack / Discord / Telegram / Feishu 等平台的交互按钮

gateway/run.py 里还专门写了保护逻辑:普通对话中的裸 yes 不会触发危险命令放行。

审批超时是 fail-closed,但 CLI 和 gateway 的默认值不同:

  • CLIapprovals.timeout,默认 60 秒

  • Gatewayapprovals.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_KEYANTHROPIC_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.mdCLAUDE.md.cursorrules 等)注入系统提示词。如果攻击者在你的代码仓库里提交了一个包含 ignore previous instructionsAGENTS.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)有严格的供应链验证:

  1. 从 GitHub Releases 下载对应平台的二进制

  2. SHA-256 校验和验证 ------对比下载文件和 checksums.txt

  3. 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 --- 输入净化 无法绕过 (白名单拒绝非法字符) ---

最后三层没有旁路------它们是架构级的安全保证,不是用户可以"选择关闭"的功能。

相关推荐
idolao3 小时前
Oligo 7.60 安装教程:引物设计+Java 环境配置
java·开发语言
做个文艺程序员6 小时前
第04篇:K8s 弹性伸缩实战:HPA、VPA、KEDA——Java SaaS 应对流量洪峰的秘密武器
java·容器·kubernetes·弹性伸缩·自动扩容·ai 推理伸缩
weelinking9 小时前
【产品】12_接入数据库——让数据永久保存
jvm·数据库·python·react.js·数据挖掘·前端框架·产品经理
稳联技术老娜9 小时前
DeviceNet主站怎么连接西门子PLC,Profinet网关配置手册(那智机器人)
服务器·网络·数据库
石山代码10 小时前
ArrayList / HashMap / ConcurrentHashMap
java·开发语言
这个DBA有点耶10 小时前
云上运维新挑战:当数据库不再“看得见摸得着”
数据库·sql·程序人生·云原生·运维开发·学习方法·dba
针叶10 小时前
Google Play加固保护导致的崩溃
android·安全·google
AskHarries11 小时前
系统提示词、开发者指令和用户输入的优先级
java·前端·数据库
黎阳之光11 小时前
视频孪生智护供水生命线:黎阳之光赋能医疗与园区水务高质量升级
运维·物联网·算法·安全·数字孪生