5 · 审批与安全红线 ★

tools/approval.py5,802 行。这是整个项目里对抗性思维最密集的一个文件。

5.1 整体结构

一条命令要被执行,依次经过: ① 上下文判定 —— 现在是什么场景? _is_interactive_cli() 有人坐在终端前吗 _is_cron_approval_context() 是定时任务触发的吗 _is_single_query_approval_context() 是一次性查询吗 _is_gateway_approval_context() 是从聊天平台来的吗 ↓ 场景决定了"能不能弹确认框" ② 硬红线检查 —— 12 条无条件拦截 detect_hardline_command(command) ↓ 命中即拒绝,用户配了什么都没用 ③ 用户拒绝规则 _match_user_deny_rule(command) ↓ ④ 危险模式检查 —— 47 条,需要确认 ↓ ⑤ 智能审批(可选)—— 用模型判断 _prepare_smart_approval_observer / _observe_smart_approval_verdict ↓ ⑥ 实际执行(在选定的环境里,见第 6 章)

5.2 12 条硬红线

HARDLINE_PATTERNS = [
  # rm 递归删除根文件系统或受保护的根目录
  (_RM_FLAG_PREFIX + _hardline_rm_path(r'/(?:(?:\.\.?)?/)*(?:\.\.?)?\**|/ \*'),
                                          "recursive delete of root filesystem"),
  (_RM_FLAG_PREFIX + _hardline_rm_path(_HARDLINE_SYSTEM_DIRS),
                                          "recursive delete of system directory"),
  (_RM_FLAG_PREFIX + _hardline_rm_path(r'(?:~|\$\{?HOME\}?)(?:/?|/\*)?'),
                                          "recursive delete of home directory"),
  # 格式化文件系统
  (_CMDPOS + r'mkfs(\.[a-z0-9]+)?\b',     "format filesystem (mkfs)"),
  # 直接往裸磁盘设备写数据
  (_CMDPOS + r'dd\b[^\n]*\bof=/dev/(sd|nvme|hd|mmcblk|vd|xvd)[a-z0-9]*',
                                          "dd to raw block device"),
  (r'>\s*/dev/(sd|nvme|hd|mmcblk|vd|xvd)[a-z0-9]*\b',
                                          "redirect to raw block device"),
  # 分叉炸弹:无限自我复制进程,直接卡死机器
  (r':\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:',        "fork bomb"),
  # 杀死系统所有进程
  (_CMDPOS + r'kill\s+(-[^\s]+\s+)*-1\b', "kill all processes"),
  # 关机 / 重启(四种写法)
  (_CMDPOS + r'(shutdown|reboot|halt|poweroff)\b',      "system shutdown/reboot"),
  (_CMDPOS + r'init\s+[06]\b',                          "init 0/6"),
  (_CMDPOS + r'systemctl\s+(poweroff|reboot|halt|kexec)\b', "systemctl poweroff/reboot"),
  (_CMDPOS + r'telinit\s+[06]\b',                       "telinit 0/6"),
]

_HARDLINE_SYSTEM_DIRS = (
    r'/home|/home/\*|/root|/root/\*|/etc|/etc/\*|/usr|/usr/\*|'
    r'/var|/var/\*|/bin|/bin/\*|/sbin|/sbin/\*|/boot|/boot/\*|/lib|/lib/\*'
)

hermes-agent/tools/approval.py

「无条件」的意思是:用户配了什么都没用,这些命令永远不会被执行。

5.3 真正的难点:区分「命令」和「数据」

一个真实的事故

最朴素的实现是 if "rm -rf /" in command: 拦截。源码注释记录了它造成的问题:

「…so the rule fires only when rm is an actual command word — not when the literal string "rm -rf /" appears as DATA inside another command's argument, e.g. gh pr create --title "block rm -rf / spellings" or git commit -m "…rm -rf /…". Those tripped the unconditional floor and could not run at all before the anchor.」

译:……所以这条规则只在 rm 确实处于「命令词」位置时才触发 —— 而不是当字符串 "rm -rf /" 作为数据出现在另一个命令的参数里时也触发,比如 gh pr create --title "block rm -rf / spellings"(创建一个标题里含这段文字的合并请求)或 git commit -m "…rm -rf /…"。在加上位置锚点之前,这些命令都会撞上无条件红线,完全无法执行。

也就是说:你没法提交一条说明文字里含 "rm -rf /" 的代码提交。

解法:命令位置锚点

_CMDPOS = (
    # 行首 / 命令分隔符之后 / 子 shell 开启符之后 / sudo|env|exec 包装之后
    ...
)

# rm 加上它的标志组,被三条 rm 规则共享。保持成普通的字符串拼接
# (而不是 f-string),这样正则里的反斜杠永远不会出现在 f-string 的
# 替换字段里 —— 那在 Python 3.11 下不支持。
_RM_FLAG_PREFIX = _CMDPOS + r'rm\s+(-[^\s]*\s+)*'

_CMDPOS 只在这几个位置匹配:

位置例子
行首rm -rf /
命令分隔符之后cd /tmp; rm -rf / · make && rm -rf / · a || rm -rf / · x | rm -rf /
子 shell 开启符之后$(rm -rf /) · 反引号包裹
包装命令之后sudo rm -rf / · env X=1 rm -rf / · exec rm -rf /

而在 --title "block rm -rf / spellings" 里,rm 前面是空格和引号,不属于上述任何一种位置 —— 所以不匹配,命令正常执行。

路径归一化:哪些写法其实等于根目录

那条根目录规则的正则是 /(?:(?:\.\.?)?/)*(?:\.\.?)?\**|/ \*,注释解释了它的设计:

「The path token matches any root-anchored path whose components collapse back to "/" in the shell: a bare "/", repeated slashes ("//"), and "."/".." current/parent segments ("/.", "/./", "/..", "/../..") all resolve to root, optionally followed by a trailing glob ("/*", "//*"). Each inter-slash segment must be exactly "." or "..", so a longer dot run or any real name is a literal directory, NOT root — "/tmp", "/home", "/.ssh", "/.config" and even "/..." (a dir literally named "...") fall through…」

译:这个路径词元匹配任何「在 shell 里会塌缩回根目录」的根锚定路径:裸的 "/"、重复斜杠 "//"、以及 "." / ".." 这样的当前/父目录段("/."、"/./"、"/.."、"/../..")全部解析为根目录,后面可以可选地跟一个通配符("/*"、"//*")。每两个斜杠之间的段必须恰好是 "." 或 "..",所以更长的点串或任何真实名字都是字面目录、不是根目录 —— "/tmp"、"/home"、"/.ssh"、"/.config"、甚至 "/..."(一个真的叫「...」的目录)都会落到更宽松的规则去处理……

判为根目录(拦)不判为根目录(放行到软规则)
/ · // · /. · /./ · /.. · /../..
/* · //* · / *(shell 看成两个参数)
带引号的 "/""$HOME"${HOME}
/tmp · /home · /.ssh · /.config
/...(真的叫「...」的目录)

注释里还提到:「显式的 "/ \*" 分支保留了「斜杠-空格-通配符」这种写法(rm -rf / *,shell 看到的是两个参数:/ 和通配符 *)」 —— 这是一个经典的手滑事故写法(本来想删 /tmp/*,多打了个空格)。

5.4 引号遮蔽:但要给「真会执行的部分」留后门

有两条红线规则没有命令名可以锚定 —— 重定向符号 > /dev/sda 和分叉炸弹的函数定义,它们在命令行任意位置都有效。

_QUOTE_MASKED_HARDLINE_DESCRIPTIONS = frozenset({
    "redirect to raw block device",
    "fork bomb",
})

HARDLINE_PATTERNS_COMPILED = [
    (re.compile(pattern, _RE_FLAGS),
     description,
     description in _QUOTE_MASKED_HARDLINE_DESCRIPTIONS)     # ★ 第三个字段:要不要遮蔽
    for pattern, description in HARDLINE_PATTERNS
]

遮蔽函数的精确语义

def _mask_quoted_prose(command: str) -> str:
    """Blank out quoted string CONTENT for positionless hardline matching.

    Detection-only rewrite used by the quote-masked hardline rules
    (redirect-to-block-device, fork bomb): text inside single or double
    quotes is data the shell passes as an argument, so `echo "cat f >
    /dev/sda"` must not trip the unconditional floor. Structure is
    preserved: the quote characters themselves stay, and inside double
    quotes `$(...)` command substitutions and backtick spans are kept RAW
    because the shell really executes them (`echo "$(cat f > /dev/sda)"`
    remains a true positive). Unquoted text is untouched.
    """

译:为那些无位置的红线规则,把引号里的内容清空。这是一次仅用于检测的重写……引号里的文字是 shell 作为参数传递的数据,所以 echo "cat f > /dev/sda" 不该撞上无条件红线。结构会被保留:引号字符本身留着,而且双引号里的 $(...) 命令替换和反引号片段保持原样不遮蔽,因为 shell 真的会执行它们。未加引号的文字不动。

命令该拦吗为什么
cat f > /dev/sda真的在往磁盘设备写数据
echo "cat f > /dev/sda"不拦引号里是数据,只是打印一段文字
echo "$(cat f > /dev/sda)"虽然在引号里,但 $(...) 会被 shell 真正执行

这一段体现的是对 shell 语义的精确建模,不是简单的字符串处理。

写这段代码的人必须准确知道:单引号和双引号的区别、双引号里哪些结构会被展开、命令替换的两种写法、以及它们嵌套时的行为。

这类知识没法从文档里查到「该怎么写安全检查」—— 只能从「shell 到底怎么解析」反推。

5.5 引号不能成为绕过手段

_SHELL_CARRIER_NAMES = frozenset({
    "eval", "sh", "bash", "zsh", "ksh", "dash", "source", ".",
})

def _contains_shell_carrier(command: str) -> bool:
    """Return whether any command-position word is a shell-carrying command."""
    for _, _, word in _iter_shell_command_word_spans(command):
        name = os.path.basename(
            _deobfuscate_shell_word_for_detection(word)     # ★ 反混淆
        ).lower()
        if name in _SHELL_CARRIER_NAMES:
            return True
    return False

逻辑:如果命令里出现了 sh -c "..."bash -c "..."eval "..." 这类「把引号内容交给另一个 shell 执行」的命令,那么引号里的东西就是代码而不是散文 —— 遮蔽规则整个失效,必须扫描原始字符串。

注释一句话总结:「quoting is not a bypass」(加引号不是绕过手段)。

三层防御的叠加

检查一条命令时: ① 先用原始字符串跑所有 _CMDPOS 锚定的规则 (这些规则本身就只在命令位置匹配,不会被引号里的数据误伤) ② 对无位置的规则(重定向、分叉炸弹): if 命令里有 shell 载体(sh -c / eval / …): 用【原始字符串】扫 ← 引号里是代码 else: 用【遮蔽后的字符串】扫 ← 引号里是数据 ③ 遮蔽时保留 $(...) 和反引号内容不遮蔽 ← 即使在引号里,这些也真的会被执行

还有反混淆

_deobfuscate_shell_word_for_detection(为检测目的对 shell 词做反混淆)处理的是这类写法:

攻击者写的shell 实际解析成
b''ash -c '...'bash -c '...'(空字符串拼接被消除)
ba\sh -c '...'bash -c '...'(反斜杠转义了一个普通字符)
"bash" -c '...'bash -c '...'
/bin/bash -c '...'os.path.basename 取出 bash

5.6 敏感路径与写入目标

除了危险命令,还有一组针对敏感文件写入的模式:

_SSH_SENSITIVE_PATH   = r'(?:~|\$home|\$\{home\})/\.ssh(?:/|$)'
_HERMES_ENV_PATH      = ...        # ~/.hermes/.env(智能体自己的凭据)
_HERMES_CONFIG_PATH   = ...        # ~/.hermes/config.yaml(智能体自己的配置)
_PROJECT_ENV_PATH     = r'(?:(?:/|\.{1,2}/)?(?:[^\s/"\'`]+/)*\.env(?:\.[^/\s"\'`]+)*)'
_PROJECT_CONFIG_PATH  = r'(?:(?:/|\.{1,2}/)?(?:[^\s/"\'`]+/)*config\.yaml)'
_SHELL_RC_FILES       = (...)      # .bashrc / .zshrc 等 shell 启动脚本
_CREDENTIAL_FILES     = (...)
_MACOS_PRIVATE_SYSTEM_PATH = r'/private/(?:etc|var|tmp|home)/'
_SYSTEM_CONFIG_PATH   = (...)

_SENSITIVE_WRITE_TARGET         = (...)
_USER_SENSITIVE_WRITE_TARGET    = (...)
_PROJECT_SENSITIVE_WRITE_TARGET = rf'(?:{_PROJECT_ENV_PATH}|{_PROJECT_CONFIG_PATH})'

_COMMAND_TAIL           = r'(?:\s*(?:&&|\|\||;).*)?$'
_WRITE_TARGET_BOUNDARY  = r'(?=[\s;&|<>"\']|$)'
注意 _HERMES_ENV_PATH_HERMES_CONFIG_PATH

这两条保护的是智能体自己的配置和凭据文件。

为什么必须保护?因为如果智能体能改自己的配置,它就能改掉自己的安全设置 —— 比如把审批模式改成「全部自动批准」、把红线规则关掉、或者把 API 密钥改成攻击者的。

一个能修改自己权限配置的系统,等于没有权限配置。这条边界必须是硬的。

另外两个正则值得注意:

5.7 sudo 标准输入守卫

_SUDO_STDIN_RE = re.compile(...)
def _check_sudo_stdin_guard(command: str) -> tuple: ...
def _sudo_stdin_block_result(description: str) -> dict: ...

这防的是 echo 密码 | sudo -S 危险命令 这种写法 —— sudo -S 表示「从标准输入读密码」,所以可以把密码通过管道喂进去,完全绕过交互式的密码确认

而交互式密码确认本来是最后一道人工闸门。绕过它意味着智能体可以在用户毫不知情的情况下执行任意特权命令。

5.8 性能:预编译的理由

# 在模块加载时构建这些,可以消除每个进程第一次调用 terminal() 时
# 约 2.6 毫秒的冷缓存 re.compile 扇出开销
# (12 条 HARDLINE + 47 条 DANGEROUS 模式,每一条都可能因为程序其他
#  地方无关的正则操作,而被 Python 那个只有 512 项的 re._cache 挤出去)。
_RE_FLAGS = re.IGNORECASE | re.DOTALL
HARDLINE_PATTERNS_COMPILED = [...]

两层问题:

  1. 首次编译开销 —— 59 条正则第一次使用时要现场编译,约 2.6 毫秒
  2. 缓存被挤出 —— Python 内置的正则缓存只有 512 项。程序其他地方一忙(比如日志格式化、文本处理),这些安全正则就会被挤出去,导致反复重新编译

预编译成模块级常量之后,两个问题都消失了。

5.9 被拦截命令的留存

def _save_blocked_payload(command: str) -> Optional[str]: ...
def _hardline_block_result(description: str, command: str = "") -> dict: ...
def _user_deny_block_result(pattern: str) -> dict: ...

_save_blocked_payload(保存被拦截的载荷)把被拦下来的命令存起来。用途有两个:

5.10 智能审批:可选的模型判断

def _prepare_smart_approval_observer(...)
def _observe_smart_approval_verdict(payload: dict | None, verdict: str) -> None
def _fire_approval_hook(hook_name: str, **kwargs) -> None

除了确定性规则,Hermes 还有一个可选的「智能审批」—— 用模型来判断某个操作安不安全。

注意函数名里的 observer(观察者)和 verdict(裁决):这套机制被设计成可观测的,每次裁决都会被记录。这样才能评估「智能审批的准确率是多少、误判了哪些」。

5.11 上下文感知:不同场景不同策略

def set_hermes_interactive_context(interactive: bool) -> contextvars.Token
def reset_hermes_interactive_context(token: contextvars.Token) -> None
def _is_interactive_cli() -> bool
def _is_cron_approval_context() -> bool
def _is_single_query_approval_context() -> bool
def _is_gateway_approval_context() -> bool
def _get_session_platform() -> str
def _resolve_cli_approval_callback(approval_callback=None)
def _should_fall_through_to_cli_approval(...)

def set_current_session_key(session_key: str) -> contextvars.Token[str]
def get_current_session_key(default: str = "default") -> str
def set_current_observability_context(...)

用的是 Python 的 contextvars(上下文变量)—— 一种「在异步调用链里自动传递、且各协程互不干扰」的变量机制。

为什么必须用 contextvars 而不是全局变量:

网关进程里同时可能有几十个会话在跑。如果用全局变量存「当前是不是交互式」,那么会话 A(终端交互)和会话 B(定时任务)会互相覆盖 —— 结果是定时任务弹出了一个没人会看到的确认框,然后永远卡住。

contextvars 保证每个异步任务看到的是自己的值。这是在并发环境下做「上下文相关决策」的正确工具。

而不同场景的策略差异是:

场景能不能弹确认框策略
交互式命令行危险操作弹框问人
网关(聊天平台)能,但走聊天消息把审批请求发到聊天窗口,等用户回复
定时任务不能(没人在场)要么按预设规则自动决定,要么直接拒绝
一次性查询取决于调用方

5.12 这一层的定位:最外面也最薄

5,802 行的对抗性代码,能拦住的是「一眼看去就是灾难」的命令

它拦不住:
· 一条精心构造的、语法上无害而语义上有害的命令
· 通过合法工具组合达成的破坏(先 read_file 读密钥,再 web_extract 发出去)
· 利用某个具体程序的漏洞

真正的边界是隔离,不是模式匹配。下一章讲执行环境。