tools/approval.py,5,802 行。这是整个项目里对抗性思维最密集的一个文件。
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
「无条件」的意思是:用户配了什么都没用,这些命令永远不会被执行。
最朴素的实现是 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/*,多打了个空格)。
有两条红线规则没有命令名可以锚定 —— 重定向符号 > /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 到底怎么解析」反推。
_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」(加引号不是绕过手段)。
_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 |
除了危险命令,还有一组针对敏感文件写入的模式:
_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 密钥改成攻击者的。
一个能修改自己权限配置的系统,等于没有权限配置。这条边界必须是硬的。
另外两个正则值得注意:
_COMMAND_TAIL —— 匹配「命令后面还跟着更多命令」的情况(&&、||、;)。防止 echo ok && rm -rf / 这种把危险命令藏在后面的写法。_WRITE_TARGET_BOUNDARY —— 一个前瞻断言,要求写入目标后面必须是空白、分隔符或行尾。防止 .env 误匹配 .envrc 这类不同的文件。_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 表示「从标准输入读密码」,所以可以把密码通过管道喂进去,完全绕过交互式的密码确认。
而交互式密码确认本来是最后一道人工闸门。绕过它意味着智能体可以在用户毫不知情的情况下执行任意特权命令。
# 在模块加载时构建这些,可以消除每个进程第一次调用 terminal() 时
# 约 2.6 毫秒的冷缓存 re.compile 扇出开销
# (12 条 HARDLINE + 47 条 DANGEROUS 模式,每一条都可能因为程序其他
# 地方无关的正则操作,而被 Python 那个只有 512 项的 re._cache 挤出去)。
_RE_FLAGS = re.IGNORECASE | re.DOTALL
HARDLINE_PATTERNS_COMPILED = [...]
两层问题:
预编译成模块级常量之后,两个问题都消失了。
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(保存被拦截的载荷)把被拦下来的命令存起来。用途有两个:
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(裁决):这套机制被设计成可观测的,每次裁决都会被记录。这样才能评估「智能审批的准确率是多少、误判了哪些」。
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,802 行的对抗性代码,能拦住的是「一眼看去就是灾难」的命令。
它拦不住:
· 一条精心构造的、语法上无害而语义上有害的命令
· 通过合法工具组合达成的破坏(先 read_file 读密钥,再 web_extract 发出去)
· 利用某个具体程序的漏洞
真正的边界是隔离,不是模式匹配。下一章讲执行环境。