Hermes 架构全解Hermes Architecture, in Full第 5 章 · 14 章Chapter 5 of 14
全文目录Contents
  1. 0 · 项目全景与代码地图
    1. 0.1 这个软件是什么
    2. 0.2 目录地图
    3. 0.3 从这张地图能读出的三件事
    4. 0.4 一条消息的完整旅程
    5. 0.5 全文章节索引
  2. 1 · 网关层
    1. 1.1 它解决什么问题
    2. 1.2 平台适配器抽象基类
    3. 1.3 消息事件的归一化
    4. 1.4 网关主循环里的那些防御机制
    5. 1.5 状态消息与进度反馈
    6. 1.6 中断与恢复
    7. 1.7 网关内置钩子
    8. 1.8 这一层的代价
  3. 2 · 身份与会话路由
    1. 2.1 Profile:一个进程承载多个身份
    2. 2.2 四级路由与「具体度」打分
    3. 2.3 显式路由被拒绝的情况
    4. 2.4 SOUL.md:人格文件
    5. 2.5 会话路由:跨平台的连续性
    6. 2.6 智能体实例的缓存
  4. 3 · 主循环 ★
    1. 3.1 循环入口:三重预算闸门
    2. 3.2 每轮的准备工作:build_turn_context
    3. 3.3 中途插话:/steer
    4. 3.4 墙上时钟预算的收尾提醒
    5. 3.5 中断检查
    6. 3.6 步骤回调:给网关的观测点
    7. 3.7 循环里的其他状态
    8. 3.8 循环退出原因
    9. 3.9 一个可选的旁路:Codex 应用服务运行时
  5. 4 · 工具系统
    1. 4.1 最重要的设计:实现与投放分离
    2. 4.2 工具集的组合与解析
    3. 4.3 工具的中心分发
    4. 4.4 参数强制矫正层
    5. 4.5 工具错误消息的净化
    6. 4.6 工具结果的观测与钩子
    7. 4.7 委派上下文识别
    8. 4.8 工具定义的缓存
  6. 5 · 审批与安全红线 ★
    1. 5.1 整体结构
    2. 5.2 12 条硬红线
    3. 5.3 真正的难点:区分「命令」和「数据」
    4. 5.4 引号遮蔽:但要给「真会执行的部分」留后门
    5. 5.5 引号不能成为绕过手段
    6. 5.6 敏感路径与写入目标
    7. 5.7 sudo 标准输入守卫
    8. 5.8 性能:预编译的理由
    9. 5.9 被拦截命令的留存
    10. 5.10 智能审批:可选的模型判断
    11. 5.11 上下文感知:不同场景不同策略
    12. 5.12 这一层的定位:最外面也最薄
  7. 6 · 执行环境
    1. 6.1 七种可选环境
    2. 6.2 必须诚实说明的一件事
    3. 6.3 抽象基类里的共用逻辑
    4. 6.4 文件同步
    5. 6.5 环境的选择时机
  8. 7 · 上下文引擎 ★
    1. 7.1 它定义的是什么
    2. 7.2 生命周期
    3. 7.3 三个必须实现的方法
    4. 7.4 最精辟的设计:select 和 compress 是两个正交动词
    5. 7.5 后置观察钩子
    6. 7.6 其他可选钩子
    7. 7.7 默认参数值
    8. 7.8 用户可见状态的可控性
    9. 7.9 内置实现的体量对比
  9. 8 · 记忆系统 ★
    1. 8.1 记忆提供者接口
    2. 8.2 生命周期与钩子
    3. 8.3 接口的版本化契约
    4. 8.4 琐碎提问过滤器
    5. 8.5 记忆使用指示器
    6. 8.6 内置的全息记忆
    7. 8.7 存储层与信任分
    8. 8.8 SQLite 状态层
    9. 8.9 三种记忆的分工总结
  10. 9 · 插件系统
    1. 9.1 三个发现来源
    2. 9.2 插件能提供什么
    3. 9.3 最重要的设计:区分「可叠加能力」与「互斥策略」
    4. 9.4 插件的存储
    5. 9.5 插件与工具集的联动
    6. 9.6 插件钩子
    7. 9.7 MCP:另一条扩展路径
    8. 9.8 这套扩展体系的整体形状
  11. 10 · 委派与多智能体
    1. 10.1 为什么需要委派
    2. 10.2 深度限制:只允许一层
    3. 10.3 并发限制
    4. 10.4 子智能体的工具限制
    5. 10.5 子智能体的审批策略
    6. 10.6 运行中的控制
    7. 10.7 亲缘关系检查
    8. 10.8 看板:智能体之间的协作
    9. 10.9 委派系统为什么有 5,071 行
  12. 11 · 模型供应商与凭据池
    1. 11.1 为什么不能只支持一家
    2. 11.2 供应商适配器清单
    3. 11.3 凭据池:核心机制
    4. 11.4 PooledCredential:单个凭据的状态
    5. 11.5 「持久化」为什么必要
    6. 11.6 与其他机制的配合
    7. 11.7 供应商抽象要抽象到哪一层
  13. 12 · 定时任务
    1. 12.1 「cron」是什么
    2. 12.2 定时智能体的场景
    3. 12.3 最重要的一个类:CronPromptInjectionBlocked
    4. 12.4 定时任务的工具集收窄
    5. 12.5 失败处理
    6. 12.6 防重复执行
    7. 12.7 为什么 scheduler.py 有 367 KB
    8. 12.8 定时任务在整个架构里的位置
  14. 13 · 技能系统
    1. 13.1 技能是什么
    2. 13.2 前置元数据逐字段解释
    3. 13.3 渐进式披露:技能系统的核心机制
    4. 13.4 技能的支撑设施
    5. 13.5 技能 vs 工具 vs 插件
    6. 13.6 技能系统的一个隐含设计:可组合
    7. 13.7 15 个技能类别
    8. 13.8 全文回顾:Hermes 的整体形状
  1. 0 · The Project at a Glance, and a Map of the Code
    1. 0.1 What This Software Is
    2. 0.2 Directory Map
    3. 0.3 Three Things You Can Read Off This Map
    4. 0.4 The Full Journey of One Message
    5. 0.5 Chapter Index
  2. 1 · The Gateway Layer
    1. 1.1 The Problem It Solves
    2. 1.2 The Platform Adapter Abstract Base Class
    3. 1.3 Normalizing Message Events
    4. 1.4 The Defensive Mechanisms in the Gateway Main Loop
    5. 1.5 Status Messages and Progress Feedback
    6. 1.6 Interruption and Recovery
    7. 1.7 Built-in Gateway Hooks
    8. 1.8 What This Layer Costs
  3. 2 · Identity and Session Routing
    1. 2.1 Profiles: One Process, Many Identities
    2. 2.2 Four-Level Routing and the “Specificity” Score
    3. 2.3 When an Explicit Route Is Rejected
    4. 2.4 SOUL.md: The Persona File
    5. 2.5 Session Routing: Continuity Across Platforms
    6. 2.6 Caching Agent Instances
  4. 3 · The Main Loop ★
    1. 3.1 The Loop Entry: A Triple Budget Gate
    2. 3.2 Per-Turn Preparation: build_turn_context
    3. 3.3 Mid-Turn Interjection: /steer
    4. 3.4 The Wall-Clock Budget Wrap-Up Reminder
    5. 3.5 The Interrupt Check
    6. 3.6 The Step Callback: An Observation Point for the Gateway
    7. 3.7 Other State Inside the Loop
    8. 3.8 Why the Loop Exited
    9. 3.9 An Optional Bypass: The Codex App Server Runtime
  5. 4 · The Tool System
    1. 4.1 The Most Important Design Decision: Implementation Separated from Exposure
    2. 4.2 Composing and Resolving Toolsets
    3. 4.3 Central Tool Dispatch
    4. 4.4 The Argument Coercion Layer
    5. 4.5 Sanitizing Tool Error Messages
    6. 4.6 Observing Tool Results, and Hooks
    7. 4.7 Recognizing a Delegation Context
    8. 4.8 Caching Tool Definitions
  6. 5 · Approval and Safety Red Lines ★
    1. 5.1 The Overall Structure
    2. 5.2 The 12 Hard Red Lines
    3. 5.3 The Real Difficulty: Telling “Command” from “Data”
    4. 5.4 Quote Masking: But Leave a Way In for “the Part That Really Executes”
    5. 5.5 Quoting Is Not a Bypass
    6. 5.6 Sensitive Paths and Write Targets
    7. 5.7 The sudo Stdin Guard
    8. 5.8 Performance: Why Precompile
    9. 5.9 Retaining Blocked Commands
    10. 5.10 Smart Approval: An Optional Model Judgment
    11. 5.11 Context Awareness: Different Situations, Different Policies
    12. 5.12 Where This Layer Sits: Outermost, and Thinnest
  7. 6 · Execution Environments
    1. 6.1 Seven Environments to Choose From
    2. 6.2 One Thing That Has to Be Said Plainly
    3. 6.3 Shared Logic in the Abstract Base Class
    4. 6.4 File Synchronization
    5. 6.5 When the Environment Is Chosen
  8. 7 · The Context Engine ★
    1. 7.1 What It Defines
    2. 7.2 Lifecycle
    3. 7.3 The Three Methods You Must Implement
    4. 7.4 The Sharpest Design Decision: select and compress Are Two Orthogonal Verbs
    5. 7.5 The Post-Turn Observation Hook
    6. 7.6 Other Optional Hooks
    7. 7.7 Default Parameter Values
    8. 7.8 Control Over User-Visible Status
    9. 7.9 Size Comparison of the Built-in Implementation
  9. 8 · The Memory System ★
    1. 8.1 The Memory Provider Interface
    2. 8.2 Lifecycle and Hooks
    3. 8.3 The Interface's Versioned Contract
    4. 8.4 The Trivial-Prompt Filter
    5. 8.5 The Memory-Usage Indicator
    6. 8.6 The Built-in Holographic Memory
    7. 8.7 The Storage Layer and Trust Scores
    8. 8.8 The SQLite State Layer
    9. 8.9 Summing Up the Division of Labor Among Three Kinds of Memory
  10. 9 · The Plugin System
    1. 9.1 Three Discovery Sources
    2. 9.2 What a Plugin Can Provide
    3. 9.3 The Most Important Design Decision: “Stackable Capabilities” vs. “Mutually Exclusive Strategies”
    4. 9.4 Plugin Storage
    5. 9.5 How Plugins Tie into Toolsets
    6. 9.6 Plugin Hooks
    7. 9.7 MCP: The Other Extension Path
    8. 9.8 The Overall Shape of This Extension System
  11. 10 · Delegation and Multi-Agent
    1. 10.1 Why Delegation Is Needed
    2. 10.2 The Depth Limit: One Level Only
    3. 10.3 Concurrency Limits
    4. 10.4 Tool Restrictions on Subagents
    5. 10.5 Approval Policy for Subagents
    6. 10.6 In-Flight Control
    7. 10.7 The Lineage Check
    8. 10.8 Kanban: Collaboration Between Agents
    9. 10.9 Why the Delegation System Is 5,071 Lines
  12. 11 · Model Providers and the Credential Pool
    1. 11.1 Why You Can't Support Just One
    2. 11.2 The Provider Adapter Roster
    3. 11.3 The Credential Pool: The Core Mechanism
    4. 11.4 PooledCredential: The State of a Single Credential
    5. 11.5 Why “Persistent” Is Necessary
    6. 11.6 How It Works with Other Mechanisms
    7. 11.7 How Deep the Provider Abstraction Should Go
  13. 12 · Scheduled Tasks (Cron)
    1. 12.1 What “cron” Is
    2. 12.2 Scenarios for Scheduled Agents
    3. 12.3 The Single Most Important Class: CronPromptInjectionBlocked
    4. 12.4 Narrowing the Toolset for Scheduled Tasks
    5. 12.5 Failure Handling
    6. 12.6 Preventing Duplicate Runs
    7. 12.7 Why scheduler.py Is 367 KB
    8. 12.8 Where Scheduled Tasks Sit in the Overall Architecture
  14. 13 · The Skill System
    1. 13.1 What a Skill Is
    2. 13.2 The Front Matter, Field by Field
    3. 13.3 Progressive Disclosure: The Core Mechanism of the Skill System
    4. 13.4 The Infrastructure Around Skills
    5. 13.5 Skills vs. Tools vs. Plugins
    6. 13.6 An Implicit Design in the Skill System: Composability
    7. 13.7 The 15 Skill Categories
    8. 13.8 Looking Back Across the Book: The Overall Shape of Hermes

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 密钥改成攻击者的。

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

另外两个正则值得注意:

  • _COMMAND_TAIL —— 匹配「命令后面还跟着更多命令」的情况(&&||;)。防止 echo ok && rm -rf / 这种把危险命令藏在后面的写法。
  • _WRITE_TARGET_BOUNDARY —— 一个前瞻断言,要求写入目标后面必须是空白、分隔符或行尾。防止 .env 误匹配 .envrc 这类不同的文件。

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 发出去)
· 利用某个具体程序的漏洞

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

5 · Approval and Safety Red Lines ★

tools/approval.py, 5,802 lines. This is the single file in the whole project with the densest adversarial thinking.

5.1 The Overall Structure

For a command to be executed, it passes through, in order: ① Context determination — what situation are we in? _is_interactive_cli() is a person sitting at the terminal? _is_cron_approval_context() was this triggered by a scheduled task? _is_single_query_approval_context() is this a one-off query? _is_gateway_approval_context() did this come from a chat platform? ↓ the situation decides whether "a confirmation prompt is even possible" ② Hard red-line check — 12 unconditional blocks detect_hardline_command(command) ↓ a hit means rejection, regardless of anything the user configured ③ User deny rules _match_user_deny_rule(command) ↓ ④ Dangerous-pattern check — 47 patterns, confirmation required ↓ ⑤ Smart approval (optional) — let a model judge _prepare_smart_approval_observer / _observe_smart_approval_verdict ↓ ⑥ Actual execution (in the chosen environment; see Chapter 6)

5.2 The 12 Hard Red Lines

HARDLINE_PATTERNS = [
  # rm recursively deleting the root filesystem or a protected root directory
  (_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"),
  # formatting a filesystem
  (_CMDPOS + r'mkfs(\.[a-z0-9]+)?\b',     "format filesystem (mkfs)"),
  # writing straight to a raw disk device
  (_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"),
  # fork bomb: a process that replicates itself endlessly and locks up the machine
  (r':\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:',        "fork bomb"),
  # killing every process on the system
  (_CMDPOS + r'kill\s+(-[^\s]+\s+)*-1\b', "kill all processes"),
  # shutdown / reboot (four spellings)
  (_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

“Unconditional” means: nothing the user configures matters; these commands will never be executed.

5.3 The Real Difficulty: Telling “Command” from “Data”

A real incident

The most naive implementation is if "rm -rf /" in command: block. A source comment records the problem it caused:

“…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.”

In plain terms: …so the rule only fires when rm is actually in the “command word” position — not when the string "rm -rf /" shows up as data inside another command's argument, such as gh pr create --title "block rm -rf / spellings" (creating a pull request whose title contains that text) or git commit -m "…rm -rf /…". Before the position anchor was added, those commands hit the unconditional red line and could not run at all.

In other words: you couldn't make a commit whose message contained "rm -rf /".

The solution: command-position anchoring

_CMDPOS = (
    # start of line / after a command separator / after a subshell opener / after a sudo|env|exec wrapper
    ...
)

# rm plus its flag group, shared by the three rm rules. Kept as plain string concatenation
# (rather than an f-string) so the regex's backslashes never appear inside an f-string
# replacement field — which is unsupported on Python 3.11.
_RM_FLAG_PREFIX = _CMDPOS + r'rm\s+(-[^\s]*\s+)*'

_CMDPOS matches only in these positions:

PositionExample
Start of linerm -rf /
After a command separatorcd /tmp; rm -rf / · make && rm -rf / · a || rm -rf / · x | rm -rf /
After a subshell opener$(rm -rf /) · wrapped in backticks
After a wrapper commandsudo rm -rf / · env X=1 rm -rf / · exec rm -rf /

In --title "block rm -rf / spellings", what precedes rm is a space and a quote, which is none of the positions above — so there's no match, and the command runs normally.

Path normalization: which spellings actually mean the root directory

The regex in that root-directory rule is /(?:(?:\.\.?)?/)*(?:\.\.?)?\**|/ \*, and the comment explains its design:

“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…”

In plain terms: this path token matches any root-anchored path that “collapses back to the root directory in the shell”: a bare "/", doubled slashes "//", and current/parent segments like "." / ".." ("/.", "/./", "/..", "/../..") all resolve to root, optionally followed by a glob ("/*", "//*"). Every segment between two slashes must be exactly "." or "..", so a longer run of dots, or any real name, is a literal directory and not root — "/tmp", "/home", "/.ssh", "/.config", and even "/..." (a directory genuinely named “...”) fall through to the more lenient rules…

Treated as root (blocked)Not treated as root (passed on to the soft rules)
/ · // · /. · /./ · /.. · /../..
/* · //* · / * (the shell sees two arguments)
quoted "/", "$HOME", ${HOME}
/tmp · /home · /.ssh · /.config
/... (a directory genuinely named “...”)

The comment also notes: “the explicit "/ \*" branch preserves the ‘slash-space-glob’ spelling (rm -rf / *, which the shell sees as two arguments: / and the glob *)” — a classic slip-of-the-finger disaster (you meant to delete /tmp/* and typed one extra space).

5.4 Quote Masking: But Leave a Way In for “the Part That Really Executes”

Two of the red-line rules have no command name to anchor on — the redirect > /dev/sda and the fork bomb's function definition are effective at any position on the command line.

_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)     # ★ third field: whether to mask
    for pattern, description in HARDLINE_PATTERNS
]

The exact semantics of the masking function

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.
    """

In plain terms: for the positionless red-line rules, blank out the content inside quotes. This is a rewrite used only for detection… quoted text is data the shell passes as an argument, so echo "cat f > /dev/sda" must not hit the unconditional red line. Structure is preserved: the quote characters themselves stay, and inside double quotes, $(...) command substitutions and backtick spans are left unmasked, because the shell really does execute them. Unquoted text is untouched.

CommandBlock it?Why
cat f > /dev/sdaBlockIt really is writing to a disk device
echo "cat f > /dev/sda"AllowWhat's in the quotes is data; it just prints some text
echo "$(cat f > /dev/sda)"BlockIt's inside quotes, but the shell really executes $(...)

What this passage shows is precise modeling of shell semantics, not simple string handling.

Whoever wrote this had to know exactly: the difference between single and double quotes, which constructs get expanded inside double quotes, the two spellings of command substitution, and how they behave when nested.

You can't look up “how to write the safety check” for this kind of knowledge in any docs — you can only work backward from “how the shell actually parses.”

5.5 Quoting Is Not a Bypass

_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)     # ★ de-obfuscation
        ).lower()
        if name in _SHELL_CARRIER_NAMES:
            return True
    return False

The logic: if the command contains something like sh -c "...", bash -c "...", or eval "..." — a command that “hands quoted content to another shell to execute” — then what's inside the quotes is code, not prose. The masking rule is switched off entirely, and the raw string must be scanned.

The comment sums it up in one line: “quoting is not a bypass”.

Three layers of defense, stacked

When checking a command: ① First run every _CMDPOS-anchored rule against the raw string (these rules match only in command position, so data inside quotes can't trip them) ② For the positionless rules (redirect, fork bomb): if the command contains a shell carrier (sh -c / eval / …): scan the [raw string] ← what's in the quotes is code else: scan the [masked string] ← what's in the quotes is data ③ When masking, leave $(...) and backtick content unmasked ← even inside quotes, these really do get executed

And de-obfuscation

_deobfuscate_shell_word_for_detection handles spellings like these:

What the attacker writesWhat the shell actually parses
b''ash -c '...'bash -c '...' (the empty-string concatenation disappears)
ba\sh -c '...'bash -c '...' (a backslash escaping an ordinary character)
"bash" -c '...'bash -c '...'
/bin/bash -c '...'os.path.basename extracts bash

5.6 Sensitive Paths and Write Targets

Beyond dangerous commands, there is a set of patterns for writes to sensitive files:

_SSH_SENSITIVE_PATH   = r'(?:~|\$home|\$\{home\})/\.ssh(?:/|$)'
_HERMES_ENV_PATH      = ...        # ~/.hermes/.env (the agent's own credentials)
_HERMES_CONFIG_PATH   = ...        # ~/.hermes/config.yaml (the agent's own configuration)
_PROJECT_ENV_PATH     = r'(?:(?:/|\.{1,2}/)?(?:[^\s/"\'`]+/)*\.env(?:\.[^/\s"\'`]+)*)'
_PROJECT_CONFIG_PATH  = r'(?:(?:/|\.{1,2}/)?(?:[^\s/"\'`]+/)*config\.yaml)'
_SHELL_RC_FILES       = (...)      # .bashrc / .zshrc and other shell startup scripts
_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;&|<>"\']|$)'
Note _HERMES_ENV_PATH and _HERMES_CONFIG_PATH

These two protect the agent's own configuration and credential files.

Why must they be protected? Because if the agent can change its own configuration, it can change its own safety settings — switch the approval mode to “auto-approve everything,” turn off the red-line rules, or swap the API key for the attacker's.

A system that can modify its own permission configuration has no permission configuration. This boundary has to be hard.

Two other regexes deserve attention:

  • _COMMAND_TAIL — matches the case where “more commands follow this one” (&&, ||, ;). Prevents spellings like echo ok && rm -rf / that hide the dangerous command at the end.
  • _WRITE_TARGET_BOUNDARY — a lookahead assertion requiring the write target to be followed by whitespace, a separator, or end of line. Prevents .env from falsely matching a different file like .envrc.

5.7 The sudo Stdin Guard

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

This defends against spellings like echo PASSWORD | sudo -S DANGEROUS_COMMANDsudo -S means “read the password from standard input,” so the password can be piped in, bypassing the interactive password prompt entirely.

And the interactive password prompt was supposed to be the last human gate. Bypassing it means the agent can run arbitrary privileged commands without the user ever knowing.

5.8 Performance: Why Precompile

# Building these at module load eliminates the ~2.6 ms cold-cache re.compile fan-out
# on each process's first terminal() call
# (12 HARDLINE + 47 DANGEROUS patterns, any of which can be evicted from Python's
#  512-entry re._cache by unrelated regex work elsewhere in the program).
_RE_FLAGS = re.IGNORECASE | re.DOTALL
HARDLINE_PATTERNS_COMPILED = [...]

Two layers of problem:

  1. First-compile cost — 59 regexes compiled on first use, about 2.6 milliseconds
  2. Cache eviction — Python's built-in regex cache holds only 512 entries. Whenever the rest of the program gets busy (log formatting, text processing), these safety regexes get evicted, causing repeated recompilation

Precompiled into module-level constants, both problems disappear.

5.9 Retaining Blocked Commands

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 stores the commands that got blocked. Two uses:

  • Forensics — if an attack really happened, you can trace what the attacker tried to execute
  • False-positive analysis — if a user complains “a normal command got blocked,” you can see exactly which rule matched what

5.10 Smart Approval: An Optional Model Judgment

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

Beyond the deterministic rules, Hermes has an optional “smart approval” — using a model to judge whether an operation is safe.

Note the words observer and verdict in the function names: the mechanism is designed to be observable, and every verdict is recorded. That is what makes it possible to evaluate “how accurate is smart approval, and what did it get wrong.”

5.11 Context Awareness: Different Situations, Different Policies

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(...)

This uses Python's contextvarsa variable mechanism that “propagates automatically through an async call chain, with each coroutine isolated from the others.”

Why it has to be contextvars and not a global variable:

A gateway process may have dozens of sessions running at once. If a global variable held “is this interactive right now,” session A (a terminal session) and session B (a scheduled task) would overwrite each other — and the scheduled task would pop up a confirmation prompt nobody will ever see, then hang forever.

contextvars guarantees each async task sees its own value. It is the correct tool for “context-dependent decisions” in a concurrent environment.

And the policy differences across situations are:

SituationCan it prompt for confirmation?Policy
Interactive CLIYesDangerous operations prompt a human
Gateway (chat platform)Yes, via chat messagesSend the approval request to the chat window and wait for the user's reply
Scheduled taskNo (nobody is present)Either decide automatically by preset rules, or refuse outright
One-off queryDepends on the caller

5.12 Where This Layer Sits: Outermost, and Thinnest

5,802 lines of adversarial code can stop commands that are “obviously a disaster at a glance.”

It cannot stop:
· A carefully constructed command that is syntactically harmless and semantically harmful
· Damage achieved by combining legitimate tools (first read_file to read a key, then web_extract to send it out)
· Exploiting a vulnerability in some specific program

The real boundary is isolation, not pattern matching. The next chapter covers execution environments.